"""Cloudflare tunnel management using cloudflared Docker containers. 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). """ import logging import re import subprocess from typing import Any from src.services.docker.container import get_backend_network_name logger = logging.getLogger(__name__) TUNNEL_IMAGE = "cloudflare/cloudflared:latest" def _tunnel_container_name(instance_name: str) -> str: return f"tunnel-{instance_name.lower()}" def _ensure_image() -> None: """Pull cloudflared image if not already present.""" result = subprocess.run( ["docker", "images", "-q", TUNNEL_IMAGE], capture_output=True, text=True, ) if not result.stdout.strip(): logger.info("Pulling %s ...", TUNNEL_IMAGE) pull = subprocess.run( ["docker", "pull", TUNNEL_IMAGE], capture_output=True, text=True, ) if pull.returncode != 0: logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr) def _cleanup_stale_tunnel(tunnel_name: str) -> None: """Remove any existing tunnel container with this name.""" subprocess.run( ["docker", "stop", "-t", "3", tunnel_name], capture_output=True, text=True, ) subprocess.run( ["docker", "rm", "-f", tunnel_name], capture_output=True, text=True, ) def _get_tunnel_logs(tunnel_name: str) -> tuple[str, str]: """Get stdout and stderr logs from a container.""" result = subprocess.run( ["docker", "logs", tunnel_name], capture_output=True, text=True, ) return result.stdout, result.stderr def _get_tunnel_exit_code(tunnel_name: str) -> int | None: """Get exit code of a container if it has exited.""" result = subprocess.run( ["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name], capture_output=True, text=True, ) if result.returncode == 0: try: return int(result.stdout.strip()) except ValueError: pass return None def start_tunnel( 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. Args: 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'. """ _ensure_image() tunnel_name = _tunnel_container_name(instance_name) _cleanup_stale_tunnel(tunnel_name) # Target the tool container by name on the backend network if target_url is None: target_url = f"http://{instance_name.lower()}:{container_port}" cmd = [ "docker", "run", "-d", "--network", get_backend_network_name(), "--name", tunnel_name, TUNNEL_IMAGE, "tunnel", "--no-autoupdate", "--url", target_url, ] logger.debug("Running: %s", " ".join(cmd)) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: raise RuntimeError( f"Failed to start tunnel container {tunnel_name}: {proc.stderr}" ) container_id = proc.stdout.strip() logger.debug("Tunnel container started: %s", container_id) # Wait for URL to appear in logs # Exclude api.trycloudflare.com which is the Cloudflare API endpoint, # not a tunnel URL. Real tunnel URLs have random subdomains (10+ chars). url_pattern = re.compile(r"https://(?!api\.)[a-z0-9-]{10,}\.trycloudflare\.com") start_time = __import__("time").time() url: str | None = None combined_logs = "" while __import__("time").time() - start_time < timeout: stdout, stderr = _get_tunnel_logs(tunnel_name) combined_logs = stdout + "\n" + stderr match = url_pattern.search(combined_logs) if match: url = match.group(0) break # Check if container exited early exit_code = _get_tunnel_exit_code(tunnel_name) if exit_code is not None and exit_code != 0: _cleanup_stale_tunnel(tunnel_name) raise RuntimeError( f"Tunnel container {tunnel_name} exited with code {exit_code}. " f"Logs:\n{combined_logs[-3000:]}" ) __import__("time").sleep(0.5) if not url: stdout, stderr = _get_tunnel_logs(tunnel_name) combined_logs = stdout + "\n" + stderr exit_code = _get_tunnel_exit_code(tunnel_name) _cleanup_stale_tunnel(tunnel_name) raise RuntimeError( f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. " f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}" ) # Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain __import__("time").sleep(2) logger.info( "Tunnel %s started for %s → %s (%s)", tunnel_name, instance_name, target_url, url, ) return {"url": url, "container_name": tunnel_name} def stop_tunnel(instance_name: str) -> None: """Stop and remove the tunnel container for an instance.""" tunnel_name = _tunnel_container_name(instance_name) _cleanup_stale_tunnel(tunnel_name) logger.debug("Stopped and removed tunnel container %s", tunnel_name) 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, target_url=target_url) def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: """Check if a tunnel URL is healthy. Returns: Dict with 'tunnel_status', 'status_code', 'healthy', 'error'. """ try: result = subprocess.run( [ "curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", str(timeout), url, ], capture_output=True, text=True, timeout=timeout + 5, ) status_code = int(result.stdout.strip()) if 200 <= status_code < 400: return { "tunnel_status": "healthy", "status_code": status_code, "healthy": True, "error": None, } if status_code in (502, 503, 504): return { "tunnel_status": "error_response", "status_code": status_code, "healthy": False, "error": f"Application returned HTTP {status_code}", } return { "tunnel_status": "error_response", "status_code": status_code, "healthy": False, "error": f"HTTP {status_code}", } except subprocess.TimeoutExpired: return { "tunnel_status": "unreachable", "status_code": None, "healthy": False, "error": "Tunnel request timed out", } except (ValueError, Exception) as exc: error_str = str(exc).lower() if any( err in error_str for err in [ "connection refused", "econnrefused", "could not resolve", "nodename", ] ): return { "tunnel_status": "unreachable", "status_code": None, "healthy": False, "error": f"Tunnel unreachable: {exc}", } return { "tunnel_status": "unreachable", "status_code": None, "healthy": False, "error": str(exc), }