From 4cc433a1b838d67443d07cb5ba10575980c30a29 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sat, 30 May 2026 14:29:55 +0200 Subject: [PATCH] fix: recreate tunnel uses container IP directly for reliable connectivity Old instances may have auto-generated Docker Compose container names that don't match instance.name.lower(), causing DNS resolution failures for the tunnel. Also, old instances may not be on the backend network. - apps/api/src/services/docker.py: add get_container_ip_on_network() and is_container_on_network() helpers - apps/api/src/services/tunnel.py: start_tunnel() and recreate_tunnel() now accept an optional target_url parameter to override the default name-based URL - apps/api/src/api/tool_instances.py: recreate_tunnel_endpoint now: 1. Looks up the tool container (by stored container_id or name) 2. Ensures it's connected to the backend network 3. Gets the container's IP on that network 4. Passes the IP as the explicit tunnel target This guarantees the tunnel can reach the tool container regardless of naming or network state. Quality gates: ruff clean --- apps/api/src/api/tool_instances.py | 49 ++++++++++++++++++++++++ apps/api/src/services/docker.py | 60 ++++++++++++++++++++++++++++++ apps/api/src/services/tunnel.py | 25 ++++++++++--- 3 files changed, 129 insertions(+), 5 deletions(-) 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]: