diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 0cca5a5..3a86a2a 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -45,13 +45,16 @@ from src.services.config_profile_resolver import ( resolve_profile, ) from src.services.docker import ( + connect_container_to_network, ensure_instance_directory, execute_compose_command, find_free_port, get_backend_network_name, get_container_id, + get_container_ip_on_network, get_container_logs, get_container_status, + is_container_on_network, render_compose_template, sort_volumes_by_specificity, wait_for_container_running, @@ -2459,10 +2462,56 @@ async def recreate_tunnel_endpoint( detail="Tool type not found for this instance", ) + # Find the tool container — try stored ID first, then fall back to name lookup + tool_container_id = instance.container_id + if not tool_container_id: + tool_container_id = get_container_id(instance.name.lower()) + + if not tool_container_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Could not find running container for this instance", + ) + + # Ensure the tool container is on the backend network so the tunnel can reach it + network_name = get_backend_network_name() + if not is_container_on_network(tool_container_id, network_name): + logger.debug( + "Connecting container %s to network %s for tunnel access", + tool_container_id, + network_name, + ) + connected = connect_container_to_network(tool_container_id, network_name) + if not connected: + logger.warning( + "Failed to connect container %s to network %s", + tool_container_id, + network_name, + ) + + # Use the container's IP on the backend network as the tunnel target. + # This is more reliable than name-based DNS, especially for old instances + # whose container name may differ from instance.name.lower(). + target_ip = get_container_ip_on_network(tool_container_id, network_name) + if target_ip: + target_url = f"http://{target_ip}:{tool_type.default_port or 0}" + logger.debug( + "Using container IP %s as tunnel target for instance %s", + target_ip, + instance.id, + ) + else: + target_url = None + logger.warning( + "Could not get container IP for %s, falling back to name resolution", + tool_container_id, + ) + try: tunnel_info = recreate_tunnel( instance_name=instance.name, container_port=tool_type.default_port or 0, + target_url=target_url, ) instance.tunnel_id = tunnel_info["container_name"] instance.public_url = tunnel_info["url"] diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index a4d9178..fa482ea 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -286,6 +286,66 @@ def connect_container_to_network( return result.returncode == 0 +def get_container_ip_on_network( + container_id: str, network_name: str | None = None +) -> str | None: + """Get a container's IP address on a specific Docker network. + + Args: + container_id: Docker container ID or name. + network_name: Network name. If None, auto-detects from the API container. + + Returns: + IP address string, or None if the container is not on that network. + """ + if network_name is None: + network_name = get_backend_network_name() + result = subprocess.run( + [ + "docker", + "inspect", + "-f", + f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}", + container_id, + ], + capture_output=True, + text=True, + ) + if result.returncode == 0: + ip = result.stdout.strip() + if ip and ip != "": + return ip + return None + + +def is_container_on_network( + container_id: str, network_name: str | None = None +) -> bool: + """Check whether a container is already attached to a Docker network. + + Args: + container_id: Docker container ID or name. + network_name: Network name. If None, auto-detects from the API container. + + Returns: + True if the container is on the network. + """ + if network_name is None: + network_name = get_backend_network_name() + result = subprocess.run( + [ + "docker", + "inspect", + "-f", + f"{{{{.NetworkSettings.Networks.{network_name}}}}}", + container_id, + ], + capture_output=True, + text=True, + ) + return result.returncode == 0 and "" not in result.stdout + + def get_container_status(container_id: str) -> dict[str, Any]: """Get the status of a Docker container. diff --git a/apps/api/src/services/tunnel.py b/apps/api/src/services/tunnel.py index d021349..9b8bbf4 100644 --- a/apps/api/src/services/tunnel.py +++ b/apps/api/src/services/tunnel.py @@ -81,7 +81,10 @@ def _get_container_exit_code(tunnel_name: str) -> int | None: def start_tunnel( - instance_name: str, container_port: int, timeout: int = 30 + instance_name: str, + container_port: int, + timeout: int = 30, + target_url: str | None = None, ) -> dict[str, str]: """Start a temporary Cloudflare tunnel for an instance. @@ -89,6 +92,8 @@ def start_tunnel( instance_name: The tool instance name (used for tunnel naming). container_port: The port the tool container listens on internally. timeout: Seconds to wait for the tunnel URL. + target_url: Optional explicit URL to proxy to. If omitted, derives + http://{instance_name.lower()}:{container_port}. Returns: Dict with 'url' and 'container_name'. @@ -99,7 +104,8 @@ def start_tunnel( _cleanup_stale_tunnel(tunnel_name) # Target the tool container by name on the backend network - target_url = f"http://{instance_name.lower()}:{container_port}" + if target_url is None: + target_url = f"http://{instance_name.lower()}:{container_port}" cmd = [ "docker", @@ -180,10 +186,19 @@ def stop_tunnel(instance_name: str) -> None: logger.debug("Stopped and removed tunnel container %s", tunnel_name) -def recreate_tunnel(instance_name: str, container_port: int) -> dict[str, str]: - """Recreate a tunnel for an instance.""" +def recreate_tunnel( + instance_name: str, container_port: int, target_url: str | None = None +) -> dict[str, str]: + """Recreate a tunnel for an instance. + + Args: + instance_name: The tool instance name. + container_port: The port the tool container listens on internally. + target_url: Optional explicit origin URL. If omitted, derives + http://{instance_name.lower()}:{container_port}. + """ stop_tunnel(instance_name) - return start_tunnel(instance_name, container_port) + return start_tunnel(instance_name, container_port, target_url=target_url) def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: