"""Cloudflare tunnel management for Docker instances.""" import logging import os import re import signal import subprocess import time from typing import Any logger = logging.getLogger(__name__) def start_cloudflared_tunnel( container_name: str, port: int, timeout: int = 30 ) -> dict[str, str]: """Start a temporary Cloudflare tunnel for a container. Uses 'cloudflared tunnel --url' to create a temporary tunnel with a random trycloudflare.com URL. Args: container_name: Name of the Docker container to tunnel to port: Port number the container listens on timeout: Maximum seconds to wait for tunnel URL Returns: Dict with 'url' (the public tunnel URL) and 'pid' (process ID) """ import select as sel # First verify the container is accessible logger.info("Checking connectivity to %s:%d...", container_name, port) for attempt in range(10): check = subprocess.run( ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"http://{container_name}:{port}"], capture_output=True, text=True, timeout=5, ) logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()) if check.returncode == 0: break time.sleep(1) else: logger.warning("Container %s:%d not responding to curl checks", container_name, port) # Run cloudflared in background, capture output logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port) proc = subprocess.Popen( ["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) # Wait for the URL to appear in output url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com") start_time = time.time() url = None while time.time() - start_time < timeout: # Read available output readable, _, _ = sel.select([proc.stdout], [], [], 1.0) if readable: line = proc.stdout.readline() if line: match = url_pattern.search(line) if match: url = match.group(0) break if not url: proc.terminate() proc.wait(timeout=5) raise RuntimeError( f"Failed to get tunnel URL within {timeout}s. " f"cloudflared output may contain errors." ) return {"url": url, "pid": str(proc.pid)} def stop_cloudflared_tunnel(pid: str) -> None: """Stop a cloudflared tunnel process. Args: pid: Process ID of the cloudflared tunnel """ try: os.kill(int(pid), signal.SIGTERM) except ProcessLookupError: pass # Already stopped def recreate_tunnel( container_name: str, port: int, old_pid: str | None = None ) -> dict[str, str]: """Recreate a temporary Cloudflare tunnel. Stops the old tunnel (if pid provided) and starts a new one. Args: container_name: Name of the Docker container to tunnel to port: Port number the container listens on old_pid: Optional PID of the old tunnel process to stop Returns: Dict with 'url' and 'pid' for the new tunnel """ if old_pid: stop_cloudflared_tunnel(old_pid) return start_cloudflared_tunnel(container_name, port) def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: """Check if a tunnel URL is healthy. Args: url: The tunnel URL to check timeout: Request timeout in seconds Returns: Dict with 'healthy' (bool) and 'status_code' (int or None) """ 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()) return { "healthy": 200 <= status_code < 400, "status_code": status_code, } except (ValueError, subprocess.TimeoutExpired, Exception) as e: return { "healthy": False, "status_code": None, "error": str(e), }