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
This commit is contained in:
2026-05-30 14:29:55 +02:00
parent 321b4e3d0e
commit 4cc433a1b8
3 changed files with 129 additions and 5 deletions
+49
View File
@@ -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"]
+60
View File
@@ -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 != "<no value>":
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 "<no value>" not in result.stdout
def get_container_status(container_id: str) -> dict[str, Any]:
"""Get the status of a Docker container.
+20 -5
View File
@@ -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]: