refactor: rewrite tunnel system with host-network cloudflared containers
Replace the subprocess-based tunnel implementation with Docker containers
running on the host network. This eliminates all container name resolution
bugs that caused tunnel 502 errors.
New design:
- Each tunnel is a docker run --network host cloudflare/cloudflared container
- cloudflared connects to localhost:{published_port} (Docker port forwarding)
- No dependency on container names, backend network DNS, or binding diagnostics
- Tunnels named predictably: tunnel-{instance_name}
- Start/stop/recreate use container names instead of PIDs
Files changed:
- NEW: apps/api/src/services/tunnel.py — clean tunnel module (start/stop/recreate/health)
- apps/api/src/services/docker.py — removed 250 lines of old tunnel code
- apps/api/src/api/tool_instances.py — use new tunnel module, store container_name
- apps/api/src/services/health_monitor.py — updated import
- apps/web/src/components/session-card.tsx — Recreate Tunnel button always visible
Quality gates: ruff clean, 13 tests passed (health_monitor + notifications)
This commit is contained in:
@@ -45,7 +45,6 @@ from src.services.config_profile_resolver import (
|
||||
resolve_profile,
|
||||
)
|
||||
from src.services.docker import (
|
||||
check_tunnel_health,
|
||||
connect_container_to_network,
|
||||
ensure_instance_directory,
|
||||
execute_compose_command,
|
||||
@@ -53,16 +52,19 @@ from src.services.docker import (
|
||||
get_container_id,
|
||||
get_container_logs,
|
||||
get_container_status,
|
||||
recreate_tunnel,
|
||||
render_compose_template,
|
||||
sort_volumes_by_specificity,
|
||||
start_cloudflared_tunnel,
|
||||
stop_cloudflared_tunnel,
|
||||
wait_for_container_running,
|
||||
write_compose_file,
|
||||
write_config_files,
|
||||
write_env_file,
|
||||
)
|
||||
from src.services.tunnel import (
|
||||
check_tunnel_health,
|
||||
recreate_tunnel,
|
||||
start_tunnel,
|
||||
stop_tunnel,
|
||||
)
|
||||
from src.services.docker_build import build_image
|
||||
from src.services.manifest_compiler import (
|
||||
compile_compose,
|
||||
@@ -1960,12 +1962,12 @@ async def start_instance(
|
||||
"error": f"Tool type '{instance.tool_type_id}' not found",
|
||||
}
|
||||
|
||||
instance_port = tool_type.default_port or 0
|
||||
published_port = instance.port or 0
|
||||
logger.debug(
|
||||
"Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
|
||||
"Tool type for instance %s: name=%s, published_port=%s, interface_type=%s",
|
||||
instance.id,
|
||||
tool_type.name,
|
||||
instance_port,
|
||||
published_port,
|
||||
tool_type.interface_type,
|
||||
)
|
||||
|
||||
@@ -1974,23 +1976,22 @@ async def start_instance(
|
||||
# Create temporary Cloudflare tunnel for public access
|
||||
try:
|
||||
logger.debug(
|
||||
"Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
||||
"Creating tunnel for instance %s (port=%d)",
|
||||
instance.id,
|
||||
instance.container_name,
|
||||
instance_port,
|
||||
published_port,
|
||||
)
|
||||
tunnel_info = start_cloudflared_tunnel(
|
||||
container_name=instance.container_name or instance.name,
|
||||
port=instance_port,
|
||||
tunnel_info = start_tunnel(
|
||||
instance_name=instance.name,
|
||||
published_port=published_port,
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["pid"]
|
||||
instance.tunnel_id = tunnel_info["container_name"]
|
||||
instance.public_url = tunnel_info["url"]
|
||||
instance.url = tunnel_info["url"]
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"Created temporary tunnel for instance %s: pid=%s, url=%s",
|
||||
"Created tunnel for instance %s: container=%s, url=%s",
|
||||
instance.id,
|
||||
tunnel_info["pid"],
|
||||
tunnel_info["container_name"],
|
||||
tunnel_info["url"],
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -2060,9 +2061,9 @@ async def stop_instance(
|
||||
# Stop Cloudflare tunnel if exists
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
stop_tunnel(instance.name)
|
||||
logger.debug(
|
||||
"Stopped tunnel for instance %s (pid=%s)",
|
||||
"Stopped tunnel for instance %s (container=%s)",
|
||||
instance.id,
|
||||
instance.tunnel_id,
|
||||
)
|
||||
@@ -2129,9 +2130,9 @@ async def restart_instance(
|
||||
# Stop old tunnel if exists
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
stop_tunnel(instance.name)
|
||||
logger.debug(
|
||||
"Stopped old tunnel for instance %s (pid=%s)",
|
||||
"Stopped old tunnel for instance %s (container=%s)",
|
||||
instance.id,
|
||||
instance.tunnel_id,
|
||||
)
|
||||
@@ -2197,17 +2198,15 @@ async def restart_instance(
|
||||
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
|
||||
}
|
||||
|
||||
instance_port = tool_type.default_port
|
||||
|
||||
# Only create tunnel for web-enabled tools
|
||||
if tool_type.interface_type == "web":
|
||||
# Create new temporary tunnel
|
||||
# Create new tunnel
|
||||
try:
|
||||
tunnel_info = start_cloudflared_tunnel(
|
||||
container_name=instance.name.lower(),
|
||||
port=instance_port,
|
||||
tunnel_info = start_tunnel(
|
||||
instance_name=instance.name,
|
||||
published_port=instance.port or 0,
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["pid"]
|
||||
instance.tunnel_id = tunnel_info["container_name"]
|
||||
instance.public_url = tunnel_info["url"]
|
||||
instance.url = tunnel_info["url"]
|
||||
logger.debug(
|
||||
@@ -2306,9 +2305,9 @@ async def delete_instance(
|
||||
# Stop Cloudflare tunnel if exists
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||
stop_tunnel(instance.name)
|
||||
logger.debug(
|
||||
"Stopped tunnel for instance %s (pid=%s)",
|
||||
"Stopped tunnel for instance %s (container=%s)",
|
||||
instance.id,
|
||||
instance.tunnel_id,
|
||||
)
|
||||
@@ -2438,26 +2437,19 @@ async def recreate_tunnel_endpoint(
|
||||
"message": "Tunnel is already healthy",
|
||||
}
|
||||
|
||||
# Get tool type for default port
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
instance_port = (
|
||||
tool_type.default_port if tool_type and tool_type.default_port else 8080
|
||||
)
|
||||
|
||||
try:
|
||||
tunnel_info = recreate_tunnel(
|
||||
container_name=instance.container_name or instance.name,
|
||||
port=instance_port,
|
||||
old_pid=instance.tunnel_id,
|
||||
instance_name=instance.name,
|
||||
published_port=instance.port or 0,
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["pid"]
|
||||
instance.tunnel_id = tunnel_info["container_name"]
|
||||
instance.public_url = tunnel_info["url"]
|
||||
instance.url = tunnel_info["url"]
|
||||
await session.commit()
|
||||
logger.debug(
|
||||
"Recreated tunnel for instance %s: pid=%s, url=%s",
|
||||
"Recreated tunnel for instance %s: container=%s, url=%s",
|
||||
instance.id,
|
||||
tunnel_info["pid"],
|
||||
tunnel_info["container_name"],
|
||||
tunnel_info["url"],
|
||||
)
|
||||
return {"status": "healthy", "url": instance.url}
|
||||
|
||||
Reference in New Issue
Block a user