fix: run tunnel containers on backend network with container name DNS

The host-network tunnel approach had issues because localhost inside
the tunnel container wasn't reaching the host-published ports correctly.

This reverts to running cloudflared as a Docker container on the
'backend' network, where Docker DNS resolves container names reliably.
The tunnel connects to http://{container_name}:{container_port}.

- apps/api/src/services/tunnel.py: use --network backend instead of host
- apps/api/src/api/tool_instances.py: pass container_port (default_port)
  instead of published_port (host port) to tunnel functions

Quality gates: ruff clean
This commit is contained in:
2026-05-30 13:52:05 +02:00
parent eeb7d9a1b2
commit 9cab8c7bc7
2 changed files with 32 additions and 23 deletions
+14 -8
View File
@@ -1962,12 +1962,11 @@ async def start_instance(
"error": f"Tool type '{instance.tool_type_id}' not found", "error": f"Tool type '{instance.tool_type_id}' not found",
} }
published_port = instance.port or 0
logger.debug( 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, instance.id,
tool_type.name, tool_type.name,
published_port, tool_type.default_port or 0,
tool_type.interface_type, tool_type.interface_type,
) )
@@ -1976,13 +1975,13 @@ async def start_instance(
# Create temporary Cloudflare tunnel for public access # Create temporary Cloudflare tunnel for public access
try: try:
logger.debug( logger.debug(
"Creating tunnel for instance %s (port=%d)", "Creating tunnel for instance %s (container_port=%d)",
instance.id, instance.id,
published_port, tool_type.default_port or 0,
) )
tunnel_info = start_tunnel( tunnel_info = start_tunnel(
instance_name=instance.name, instance_name=instance.name,
published_port=published_port, container_port=tool_type.default_port or 0,
) )
instance.tunnel_id = tunnel_info["container_name"] instance.tunnel_id = tunnel_info["container_name"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
@@ -2204,7 +2203,7 @@ async def restart_instance(
try: try:
tunnel_info = start_tunnel( tunnel_info = start_tunnel(
instance_name=instance.name, 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.tunnel_id = tunnel_info["container_name"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
@@ -2437,10 +2436,17 @@ async def recreate_tunnel_endpoint(
"message": "Tunnel is already healthy", "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: try:
tunnel_info = recreate_tunnel( tunnel_info = recreate_tunnel(
instance_name=instance.name, 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.tunnel_id = tunnel_info["container_name"]
instance.public_url = tunnel_info["url"] instance.public_url = tunnel_info["url"]
+18 -15
View File
@@ -1,10 +1,10 @@
"""Clean tunnel service using host-network cloudflared containers. """Clean tunnel service using cloudflared containers on the backend network.
Design: Design:
- Each tunnel runs as a Docker container on the host network. - Each tunnel runs as a Docker container on the same 'backend' network as the API.
- cloudflared connects to localhost:{published_port}, leveraging Docker's - cloudflared connects to the tool container by its Docker Compose service name
port forwarding. No container name resolution is required. (e.g. http://code-server-headquarter-34837cd3:8443).
- Tunnels are named predictably (tunnel-{instance_name}) for start/stop. - This avoids host port conflicts and DNS resolution issues.
""" """
import logging import logging
@@ -15,6 +15,7 @@ from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TUNNEL_IMAGE = "cloudflare/cloudflared:latest" TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
TUNNEL_NETWORK = "backend"
def _tunnel_container_name(instance_name: str) -> str: 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( def start_tunnel(
instance_name: str, published_port: int, timeout: int = 30 instance_name: str, container_port: int, timeout: int = 30
) -> dict[str, str]: ) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for an instance. """Start a temporary Cloudflare tunnel for an instance.
Args: Args:
instance_name: The tool instance name (used to derive tunnel container name). instance_name: The tool instance name (used for tunnel naming).
published_port: The host port Docker forwards to the container. container_port: The port the tool container listens on internally.
timeout: Seconds to wait for the tunnel URL. timeout: Seconds to wait for the tunnel URL.
Returns: Returns:
@@ -96,19 +97,22 @@ def start_tunnel(
tunnel_name = _tunnel_container_name(instance_name) tunnel_name = _tunnel_container_name(instance_name)
_cleanup_stale_tunnel(tunnel_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 = [ cmd = [
"docker", "docker",
"run", "run",
"-d", "-d",
"--network", "--network",
"host", TUNNEL_NETWORK,
"--name", "--name",
tunnel_name, tunnel_name,
TUNNEL_IMAGE, TUNNEL_IMAGE,
"tunnel", "tunnel",
"--no-autoupdate", "--no-autoupdate",
"--url", "--url",
f"http://localhost:{published_port}", target_url,
] ]
logger.debug("Running: %s", " ".join(cmd)) logger.debug("Running: %s", " ".join(cmd))
@@ -148,7 +152,6 @@ def start_tunnel(
__import__("time").sleep(0.5) __import__("time").sleep(0.5)
if not url: if not url:
# Capture final state for debugging
stdout, stderr = _get_container_logs(tunnel_name) stdout, stderr = _get_container_logs(tunnel_name)
combined_logs = stdout + "\n" + stderr combined_logs = stdout + "\n" + stderr
exit_code = _get_container_exit_code(tunnel_name) exit_code = _get_container_exit_code(tunnel_name)
@@ -160,10 +163,10 @@ def start_tunnel(
) )
logger.info( logger.info(
"Tunnel %s started for %s on port %d %s", "Tunnel %s started for %s %s (%s)",
tunnel_name, tunnel_name,
instance_name, instance_name,
published_port, target_url,
url, url,
) )
return {"url": url, "container_name": tunnel_name} 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) 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.""" """Recreate a tunnel for an instance."""
stop_tunnel(instance_name) 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]: def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: