e6f64c39f3
- 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
373 lines
13 KiB
Python
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
|