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
This commit is contained in:
Fusion
2026-05-20 15:55:20 +02:00
parent e7c42c17b9
commit e6f64c39f3
3 changed files with 187 additions and 68 deletions
+18
View File
@@ -147,3 +147,21 @@ async def health_check_db() -> dict[str, Any]:
status="unhealthy", status="unhealthy",
response_time_ms=0.0, response_time_ms=0.0,
).model_dump() ).model_dump()
@router.get(
"/health/cloudflare",
summary="Cloudflare tunnel health check",
description="Returns Cloudflare tunnel configuration status and diagnostics.",
tags=["Health"],
)
async def health_check_cloudflare() -> dict[str, Any]:
"""Check Cloudflare tunnel configuration.
Returns:
Dict with Cloudflare configuration status.
"""
from src.services.cloudflare_tunnel import check_cloudflare_config
result = await check_cloudflare_config()
return result
+21 -3
View File
@@ -122,7 +122,13 @@ services:
- {{REPO_PATH}}:/config/workspace - {{REPO_PATH}}:/config/workspace
ports: ports:
- "8443:8443" - "8443:8443"
restart: unless-stopped""", networks:
- backend
restart: unless-stopped
networks:
backend:
external: true""",
"default_port": 8443, "default_port": 8443,
"required_variables": ["REPO_PATH", "TOOL_NAME"], "required_variables": ["REPO_PATH", "TOOL_NAME"],
}, },
@@ -144,7 +150,13 @@ services:
- {{REPO_PATH}}:/home/jovyan/work - {{REPO_PATH}}:/home/jovyan/work
ports: ports:
- "8888:8888" - "8888:8888"
restart: unless-stopped""", networks:
- backend
restart: unless-stopped
networks:
backend:
external: true""",
"required_variables": ["REPO_PATH", "TOOL_NAME"], "required_variables": ["REPO_PATH", "TOOL_NAME"],
}, },
{ {
@@ -170,10 +182,16 @@ services:
tail -f /dev/null" tail -f /dev/null"
stdin_open: true stdin_open: true
tty: true tty: true
networks:
- backend
restart: unless-stopped restart: unless-stopped
volumes: volumes:
opencode_home:""", opencode_home:
networks:
backend:
external: true""",
"required_variables": ["REPO_PATH", "TOOL_NAME"], "required_variables": ["REPO_PATH", "TOOL_NAME"],
}, },
] ]
+148 -65
View File
@@ -5,6 +5,7 @@ import logging
import os import os
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any
import httpx import httpx
from src.config import Settings from src.config import Settings
@@ -53,78 +54,98 @@ async def create_tunnel(
headers = _get_headers(settings) headers = _get_headers(settings)
account_id = settings.cloudflare_account_id account_id = settings.cloudflare_account_id
# Create tunnel try:
async with httpx.AsyncClient() as client: # Create tunnel
logger.info("Step 1: Creating tunnel via Cloudflare API...") async with httpx.AsyncClient() as client:
response = await client.post( logger.info("Step 1: Creating tunnel via Cloudflare API...")
f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel", response = await client.post(
headers=headers, f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel",
json={ headers=headers,
"name": f"headquarter-{instance_name}", json={
"config_src": "cloudflare", "name": f"headquarter-{instance_name}",
}, "config_src": "cloudflare",
) },
logger.info("Tunnel creation response: status=%d, body=%s", response.status_code, response.text[:500]) )
response.raise_for_status() logger.info("Tunnel creation response: status=%d, body=%s", response.status_code, response.text[:500])
data = response.json()
if not data.get("success"): if response.status_code == 403:
raise ValueError(f"Failed to create tunnel: {data.get('errors')}") 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}")
tunnel = data["result"] response.raise_for_status()
tunnel_id = tunnel["id"] data = response.json()
logger.info("Step 1 complete: tunnel_id=%s", tunnel_id)
# Get tunnel token if not data.get("success"):
logger.info("Step 2: Getting tunnel token...") errors = data.get('errors', [])
token_response = await client.get( raise ValueError(f"Failed to create tunnel: {errors}")
f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel/{tunnel_id}/token",
headers=headers,
)
logger.info("Token response: status=%d", token_response.status_code)
token_response.raise_for_status()
token_data = token_response.json()
tunnel_token = token_data["result"]
logger.info("Step 2 complete: got tunnel token")
# Create DNS record for the tunnel tunnel = data["result"]
subdomain = f"instance-{instance_id[:8]}" tunnel_id = tunnel["id"]
hostname = f"{subdomain}.{settings.cloudflare_base_domain}" logger.info("Step 1 complete: tunnel_id=%s", tunnel_id)
logger.info("Step 3: Creating DNS record for subdomain=%s, hostname=%s", subdomain, hostname)
dns_response = await client.post( # Get tunnel token
f"{CLOUDFLARE_API_BASE}/zones/{settings.cloudflare_zone_id}/dns_records", logger.info("Step 2: Getting tunnel token...")
headers=headers, token_response = await client.get(
json={ f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel/{tunnel_id}/token",
"type": "CNAME", headers=headers,
"name": subdomain, )
"content": f"{tunnel_id}.cfargotunnel.com", logger.info("Token response: status=%d", token_response.status_code)
"ttl": 1,
"proxied": True,
},
)
logger.info("DNS response: status=%d, body=%s", dns_response.status_code, dns_response.text[:500])
dns_response.raise_for_status()
logger.info("Step 3 complete: DNS record created")
# Update cloudflared config if token_response.status_code != 200:
logger.info("Step 4: Updating cloudflared config...") raise ValueError(f"Failed to get tunnel token: {token_response.status_code} - {token_response.text}")
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) token_data = token_response.json()
return { tunnel_token = token_data["result"]
"tunnel_id": tunnel_id, logger.info("Step 2 complete: got tunnel token")
"public_url": f"https://{hostname}",
"subdomain": subdomain, # 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( async def delete_tunnel(
@@ -287,3 +308,65 @@ async def remove_tunnel_from_config(
credentials_file.unlink() credentials_file.unlink()
logger.info("Removed tunnel %s from cloudflared config", tunnel_id) 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