diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 1c4a56d..1f5df7e 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1962,12 +1962,11 @@ async def start_instance( "error": f"Tool type '{instance.tool_type_id}' not found", } - published_port = instance.port or 0 logger.debug( - "Tool type for instance %s: name=%s, published_port=%s, interface_type=%s", + "Tool type for instance %s: name=%s, container_port=%s, interface_type=%s", instance.id, tool_type.name, - published_port, + tool_type.default_port or 0, tool_type.interface_type, ) @@ -1976,13 +1975,13 @@ async def start_instance( # Create temporary Cloudflare tunnel for public access try: logger.debug( - "Creating tunnel for instance %s (port=%d)", + "Creating tunnel for instance %s (container_port=%d)", instance.id, - published_port, + tool_type.default_port or 0, ) tunnel_info = start_tunnel( instance_name=instance.name, - published_port=published_port, + container_port=tool_type.default_port or 0, ) instance.tunnel_id = tunnel_info["container_name"] instance.public_url = tunnel_info["url"] @@ -2204,7 +2203,7 @@ async def restart_instance( try: tunnel_info = start_tunnel( instance_name=instance.name, - published_port=instance.port or 0, + container_port=tool_type.default_port or 0, ) instance.tunnel_id = tunnel_info["container_name"] instance.public_url = tunnel_info["url"] @@ -2437,10 +2436,17 @@ async def recreate_tunnel_endpoint( "message": "Tunnel is already healthy", } + tool_type = await session.get(ToolType, instance.tool_type_id) + if not tool_type: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tool type not found for this instance", + ) + try: tunnel_info = recreate_tunnel( instance_name=instance.name, - published_port=instance.port or 0, + container_port=tool_type.default_port or 0, ) instance.tunnel_id = tunnel_info["container_name"] instance.public_url = tunnel_info["url"] diff --git a/apps/api/src/services/tunnel.py b/apps/api/src/services/tunnel.py index 40d6d7f..5432fdc 100644 --- a/apps/api/src/services/tunnel.py +++ b/apps/api/src/services/tunnel.py @@ -1,10 +1,10 @@ -"""Clean tunnel service using host-network cloudflared containers. +"""Clean tunnel service using cloudflared containers on the backend network. Design: -- Each tunnel runs as a Docker container on the host network. -- cloudflared connects to localhost:{published_port}, leveraging Docker's - port forwarding. No container name resolution is required. -- Tunnels are named predictably (tunnel-{instance_name}) for start/stop. +- Each tunnel runs as a Docker container on the same 'backend' network as the API. +- cloudflared connects to the tool container by its Docker Compose service name + (e.g. http://code-server-headquarter-34837cd3:8443). +- This avoids host port conflicts and DNS resolution issues. """ import logging @@ -15,6 +15,7 @@ from typing import Any logger = logging.getLogger(__name__) TUNNEL_IMAGE = "cloudflare/cloudflared:latest" +TUNNEL_NETWORK = "backend" def _tunnel_container_name(instance_name: str) -> str: @@ -79,13 +80,13 @@ def _get_container_exit_code(tunnel_name: str) -> int | None: def start_tunnel( - instance_name: str, published_port: int, timeout: int = 30 + instance_name: str, container_port: int, timeout: int = 30 ) -> dict[str, str]: """Start a temporary Cloudflare tunnel for an instance. Args: - instance_name: The tool instance name (used to derive tunnel container name). - published_port: The host port Docker forwards to the container. + 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. Returns: @@ -96,19 +97,22 @@ def start_tunnel( tunnel_name = _tunnel_container_name(instance_name) _cleanup_stale_tunnel(tunnel_name) + # Target the tool container by name on the backend network + target_url = f"http://{instance_name.lower()}:{container_port}" + cmd = [ "docker", "run", "-d", "--network", - "host", + TUNNEL_NETWORK, "--name", tunnel_name, TUNNEL_IMAGE, "tunnel", "--no-autoupdate", "--url", - f"http://localhost:{published_port}", + target_url, ] logger.debug("Running: %s", " ".join(cmd)) @@ -148,7 +152,6 @@ def start_tunnel( __import__("time").sleep(0.5) if not url: - # Capture final state for debugging stdout, stderr = _get_container_logs(tunnel_name) combined_logs = stdout + "\n" + stderr exit_code = _get_container_exit_code(tunnel_name) @@ -160,10 +163,10 @@ def start_tunnel( ) logger.info( - "Tunnel %s started for %s on port %d → %s", + "Tunnel %s started for %s → %s (%s)", tunnel_name, instance_name, - published_port, + target_url, url, ) return {"url": url, "container_name": tunnel_name} @@ -176,10 +179,10 @@ def stop_tunnel(instance_name: str) -> None: logger.debug("Stopped and removed tunnel container %s", tunnel_name) -def recreate_tunnel(instance_name: str, published_port: int) -> dict[str, str]: +def recreate_tunnel(instance_name: str, container_port: int) -> dict[str, str]: """Recreate a tunnel for an instance.""" stop_tunnel(instance_name) - return start_tunnel(instance_name, published_port) + return start_tunnel(instance_name, container_port) def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: