Files
headquarter/apps/api/src/services/cloudflare_tunnel.py
T
Fusion e6f64c39f3 fix(cloudflare): add backend network to compose templates and improve tunnel diagnostics
- Add 'backend' external network to all compose templates so cloudflared can reach tool containers
- Add better error handling and logging to create_tunnel() with specific error messages for auth failures
- Add check_cloudflare_config() diagnostic function
- Add /health/cloudflare endpoint to verify Cloudflare configuration
- Import Any type for type hints
2026-05-20 15:55:20 +02:00

373 lines
13 KiB
Python

"""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()
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)
logger.info("Cloudflare config: account_id=%s, zone_id=%s, base_domain=%s",
settings.cloudflare_account_id, settings.cloudflare_zone_id, settings.cloudflare_base_domain)
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