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 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"): if not data.get("success"):
raise ValueError(f"Failed to create tunnel: {data.get('errors')}") errors = data.get('errors', [])
raise ValueError(f"Failed to create tunnel: {errors}")
tunnel = data["result"] tunnel = data["result"]
tunnel_id = tunnel["id"] tunnel_id = tunnel["id"]
logger.info("Step 1 complete: tunnel_id=%s", tunnel_id) logger.info("Step 1 complete: tunnel_id=%s", tunnel_id)
# Get tunnel token # Get tunnel token
logger.info("Step 2: Getting tunnel token...") logger.info("Step 2: Getting tunnel token...")
token_response = await client.get( token_response = await client.get(
f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel/{tunnel_id}/token", f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel/{tunnel_id}/token",
headers=headers, headers=headers,
) )
logger.info("Token response: status=%d", token_response.status_code) logger.info("Token response: status=%d", token_response.status_code)
token_response.raise_for_status()
token_data = token_response.json() if token_response.status_code != 200:
tunnel_token = token_data["result"] raise ValueError(f"Failed to get tunnel token: {token_response.status_code} - {token_response.text}")
logger.info("Step 2 complete: got tunnel token")
token_data = token_response.json()
tunnel_token = token_data["result"]
logger.info("Step 2 complete: got tunnel token")
# Create DNS record for the tunnel # Create DNS record for the tunnel
subdomain = f"instance-{instance_id[:8]}" subdomain = f"instance-{instance_id[:8]}"
hostname = f"{subdomain}.{settings.cloudflare_base_domain}" hostname = f"{subdomain}.{settings.cloudflare_base_domain}"
logger.info("Step 3: Creating DNS record for subdomain=%s, hostname=%s", subdomain, hostname) logger.info("Step 3: Creating DNS record for subdomain=%s, hostname=%s", subdomain, hostname)
dns_response = await client.post( dns_response = await client.post(
f"{CLOUDFLARE_API_BASE}/zones/{settings.cloudflare_zone_id}/dns_records", f"{CLOUDFLARE_API_BASE}/zones/{settings.cloudflare_zone_id}/dns_records",
headers=headers, headers=headers,
json={ json={
"type": "CNAME", "type": "CNAME",
"name": subdomain, "name": subdomain,
"content": f"{tunnel_id}.cfargotunnel.com", "content": f"{tunnel_id}.cfargotunnel.com",
"ttl": 1, "ttl": 1,
"proxied": True, "proxied": True,
}, },
) )
logger.info("DNS response: status=%d, body=%s", dns_response.status_code, dns_response.text[:500]) 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") 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 # Update cloudflared config
logger.info("Step 4: Updating cloudflared config...") logger.info("Step 4: Updating cloudflared config...")
await update_cloudflared_config( await update_cloudflared_config(
tunnel_id=tunnel_id, tunnel_id=tunnel_id,
tunnel_token=tunnel_token, tunnel_token=tunnel_token,
hostname=hostname, hostname=hostname,
instance_name=instance_name, instance_name=instance_name,
instance_port=instance_port, instance_port=instance_port,
settings=settings, settings=settings,
) )
logger.info("Step 4 complete: cloudflared config updated") logger.info("Step 4 complete: cloudflared config updated")
logger.info("Tunnel creation complete: tunnel_id=%s, public_url=https://%s", tunnel_id, hostname) logger.info("Tunnel creation complete: tunnel_id=%s, public_url=https://%s", tunnel_id, hostname)
return { return {
"tunnel_id": tunnel_id, "tunnel_id": tunnel_id,
"public_url": f"https://{hostname}", "public_url": f"https://{hostname}",
"subdomain": subdomain, "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