"""Cloudflare Tunnel management service.""" import json import logging import os import uuid from pathlib import Path from typing import Any import httpx from src.config import Settings logger = logging.getLogger(__name__) CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4" def _get_headers(settings: Settings) -> dict[str, str]: """Get Cloudflare API headers.""" return { "Authorization": f"Bearer {settings.cloudflare_api_token}", "Content-Type": "application/json", } async def create_tunnel( instance_name: str, instance_id: str, instance_port: int = 8080, settings: Settings | None = None, ) -> dict[str, str]: """Create a Cloudflare tunnel for an instance. Args: instance_name: Name of the instance (used for tunnel name) instance_id: UUID of the instance instance_port: Internal port the container listens on settings: Optional settings override Returns: Dict with tunnel_id and public_url """ if settings is None: settings = Settings() logger.info("Settings loaded: api_token=%s, account_id=%s, zone_id=%s, base_domain=%s", "set" if settings.cloudflare_api_token else "NOT SET", settings.cloudflare_account_id or "NOT SET", settings.cloudflare_zone_id or "NOT SET", settings.cloudflare_base_domain or "NOT SET") if not settings.cloudflare_api_token: raise ValueError("CLOUDFLARE_API_TOKEN not configured") logger.info("Creating Cloudflare tunnel for instance_name=%s, instance_id=%s, port=%d", instance_name, instance_id, instance_port) headers = _get_headers(settings) account_id = settings.cloudflare_account_id try: # Create tunnel async with httpx.AsyncClient() as client: logger.info("Step 1: Creating tunnel via Cloudflare API...") response = await client.post( f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel", headers=headers, json={ "name": f"headquarter-{instance_name}", "config_src": "cloudflare", }, ) logger.info("Tunnel creation response: status=%d, body=%s", response.status_code, response.text[:500]) if response.status_code == 403: raise ValueError(f"Cloudflare API authentication failed. Check your API token permissions. Body: {response.text}") if response.status_code == 400: raise ValueError(f"Cloudflare API bad request: {response.text}") response.raise_for_status() data = response.json() if not data.get("success"): errors = data.get('errors', []) raise ValueError(f"Failed to create tunnel: {errors}") tunnel = data["result"] tunnel_id = tunnel["id"] logger.info("Step 1 complete: tunnel_id=%s", tunnel_id) # Get tunnel token logger.info("Step 2: Getting tunnel token...") token_response = await client.get( f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel/{tunnel_id}/token", headers=headers, ) logger.info("Token response: status=%d", token_response.status_code) if token_response.status_code != 200: raise ValueError(f"Failed to get tunnel token: {token_response.status_code} - {token_response.text}") token_data = token_response.json() tunnel_token = token_data["result"] logger.info("Step 2 complete: got tunnel token") # Create DNS record for the tunnel subdomain = f"instance-{instance_id[:8]}" hostname = f"{subdomain}.{settings.cloudflare_base_domain}" logger.info("Step 3: Creating DNS record for subdomain=%s, hostname=%s", subdomain, hostname) dns_response = await client.post( f"{CLOUDFLARE_API_BASE}/zones/{settings.cloudflare_zone_id}/dns_records", headers=headers, json={ "type": "CNAME", "name": subdomain, "content": f"{tunnel_id}.cfargotunnel.com", "ttl": 1, "proxied": True, }, ) logger.info("DNS response: status=%d, body=%s", dns_response.status_code, dns_response.text[:500]) if dns_response.status_code == 403: raise ValueError(f"Cloudflare DNS API authentication failed. Check token has Zone:Edit permission.") if dns_response.status_code == 400: raise ValueError(f"Cloudflare DNS bad request: {dns_response.text}") dns_response.raise_for_status() logger.info("Step 3 complete: DNS record created") # Update cloudflared config logger.info("Step 4: Updating cloudflared config...") await update_cloudflared_config( tunnel_id=tunnel_id, tunnel_token=tunnel_token, hostname=hostname, instance_name=instance_name, instance_port=instance_port, settings=settings, ) logger.info("Step 4 complete: cloudflared config updated") logger.info("Tunnel creation complete: tunnel_id=%s, public_url=https://%s", tunnel_id, hostname) return { "tunnel_id": tunnel_id, "public_url": f"https://{hostname}", "subdomain": subdomain, } except Exception as e: logger.error("Failed to create Cloudflare tunnel: %s", str(e), exc_info=True) raise async def delete_tunnel( tunnel_id: str, subdomain: str, settings: Settings | None = None, ) -> None: """Delete a Cloudflare tunnel and its DNS record. Args: tunnel_id: Cloudflare tunnel ID subdomain: Subdomain to remove DNS record for settings: Optional settings override """ if settings is None: settings = Settings() if not settings.cloudflare_api_token: raise ValueError("CLOUDFLARE_API_TOKEN not configured") headers = _get_headers(settings) account_id = settings.cloudflare_account_id async with httpx.AsyncClient() as client: # Delete DNS record # First find the DNS record ID dns_list = await client.get( f"{CLOUDFLARE_API_BASE}/zones/{settings.cloudflare_zone_id}/dns_records", headers=headers, params={"name": f"{subdomain}.{settings.cloudflare_base_domain}"}, ) dns_list.raise_for_status() dns_data = dns_list.json() if dns_data.get("success") and dns_data.get("result"): for record in dns_data["result"]: await client.delete( f"{CLOUDFLARE_API_BASE}/zones/{settings.cloudflare_zone_id}/dns_records/{record['id']}", headers=headers, ) # Delete tunnel await client.delete( f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel/{tunnel_id}", headers=headers, ) # Remove from cloudflared config await remove_tunnel_from_config(tunnel_id, settings) async def update_cloudflared_config( tunnel_id: str, tunnel_token: str, hostname: str, instance_name: str, instance_port: int = 8080, settings: Settings | None = None, ) -> None: """Update the cloudflared config.yml with a new tunnel. Args: tunnel_id: Cloudflare tunnel ID tunnel_token: Tunnel token for authentication hostname: Public hostname for the tunnel instance_name: Instance name for the service settings: Optional settings override """ if settings is None: settings = Settings() config_dir = Path(settings.cloudflared_config_dir) config_file = config_dir / "config.yml" credentials_file = config_dir / f"{tunnel_id}.json" # Ensure config directory exists config_dir.mkdir(parents=True, exist_ok=True) # Write tunnel credentials credentials = { "AccountTag": settings.cloudflare_account_id, "TunnelID": tunnel_id, "TunnelName": f"headquarter-{instance_name}", "TunnelSecret": tunnel_token, } with open(credentials_file, "w") as f: json.dump(credentials, f, indent=2) # Read existing config or create new one config: dict = {"tunnel": "", "credentials-file": "", "ingress": []} if config_file.exists(): import yaml with open(config_file, "r") as f: config = yaml.safe_load(f) or config # Update tunnel and credentials-file (should point to latest) config["tunnel"] = tunnel_id config["credentials-file"] = str(credentials_file) # Add ingress rule for this instance ingress_rule = { "hostname": hostname, "service": f"http://{instance_name}:{instance_port}", } # Remove existing rule for this hostname if present config["ingress"] = [ rule for rule in config.get("ingress", []) if rule.get("hostname") != hostname ] # Add new rule and catch-all config["ingress"].append(ingress_rule) config["ingress"].append({"service": "http_status:404"}) # Write updated config import yaml with open(config_file, "w") as f: yaml.dump(config, f, default_flow_style=False) logger.info("Updated cloudflared config for tunnel %s", tunnel_id) async def remove_tunnel_from_config( tunnel_id: str, settings: Settings | None = None, ) -> None: """Remove a tunnel from the cloudflared config. Args: tunnel_id: Cloudflare tunnel ID to remove settings: Optional settings override """ if settings is None: settings = Settings() config_dir = Path(settings.cloudflared_config_dir) config_file = config_dir / "config.yml" credentials_file = config_dir / f"{tunnel_id}.json" if not config_file.exists(): return import yaml with open(config_file, "r") as f: config = yaml.safe_load(f) or {} # Remove ingress rules for this tunnel config["ingress"] = [ rule for rule in config.get("ingress", []) if rule.get("hostname") != f"instance-{tunnel_id[:8]}.{settings.cloudflare_base_domain}" ] # Write updated config with open(config_file, "w") as f: yaml.dump(config, f, default_flow_style=False) # Remove credentials file if credentials_file.exists(): credentials_file.unlink() logger.info("Removed tunnel %s from cloudflared config", tunnel_id) async def check_cloudflare_config(settings: Settings | None = None) -> dict[str, Any]: """Check if Cloudflare configuration is valid and working. Args: settings: Optional settings override Returns: Dict with status and diagnostic information """ if settings is None: settings = Settings() result: dict[str, Any] = { "configured": False, "api_token_set": bool(settings.cloudflare_api_token), "account_id_set": bool(settings.cloudflare_account_id), "zone_id_set": bool(settings.cloudflare_zone_id), "base_domain_set": bool(settings.cloudflare_base_domain), "api_test": None, "errors": [], } if not all([ settings.cloudflare_api_token, settings.cloudflare_account_id, settings.cloudflare_zone_id, settings.cloudflare_base_domain, ]): result["errors"].append("Missing required Cloudflare configuration") return result result["configured"] = True # Test API connectivity try: headers = _get_headers(settings) async with httpx.AsyncClient() as client: # Test account access resp = await client.get( f"{CLOUDFLARE_API_BASE}/accounts/{settings.cloudflare_account_id}", headers=headers, ) if resp.status_code == 200: result["api_test"] = "ok" elif resp.status_code == 403: result["api_test"] = "auth_failed" result["errors"].append("API token authentication failed - check token permissions") else: result["api_test"] = f"error_{resp.status_code}" result["errors"].append(f"API test failed: {resp.status_code}") except Exception as e: result["api_test"] = "exception" result["errors"].append(f"API test exception: {str(e)}") # Check config directory config_dir = Path(settings.cloudflared_config_dir) result["config_dir_exists"] = config_dir.exists() result["config_dir_writable"] = os.access(config_dir, os.W_OK) if config_dir.exists() else False return result