From e6f64c39f32e51c7f9a0e2c7527169ab16db6de3 Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 20 May 2026 15:55:20 +0200 Subject: [PATCH] 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 --- apps/api/src/api/health.py | 18 ++ apps/api/src/main.py | 24 ++- apps/api/src/services/cloudflare_tunnel.py | 213 ++++++++++++++------- 3 files changed, 187 insertions(+), 68 deletions(-) diff --git a/apps/api/src/api/health.py b/apps/api/src/api/health.py index 380112e..e2bc382 100644 --- a/apps/api/src/api/health.py +++ b/apps/api/src/api/health.py @@ -147,3 +147,21 @@ async def health_check_db() -> dict[str, Any]: status="unhealthy", response_time_ms=0.0, ).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 diff --git a/apps/api/src/main.py b/apps/api/src/main.py index d1ee675..db98f1f 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -122,7 +122,13 @@ services: - {{REPO_PATH}}:/config/workspace ports: - "8443:8443" - restart: unless-stopped""", + networks: + - backend + restart: unless-stopped + +networks: + backend: + external: true""", "default_port": 8443, "required_variables": ["REPO_PATH", "TOOL_NAME"], }, @@ -144,7 +150,13 @@ services: - {{REPO_PATH}}:/home/jovyan/work ports: - "8888:8888" - restart: unless-stopped""", + networks: + - backend + restart: unless-stopped + +networks: + backend: + external: true""", "required_variables": ["REPO_PATH", "TOOL_NAME"], }, { @@ -170,10 +182,16 @@ services: tail -f /dev/null" stdin_open: true tty: true + networks: + - backend restart: unless-stopped volumes: - opencode_home:""", + opencode_home: + +networks: + backend: + external: true""", "required_variables": ["REPO_PATH", "TOOL_NAME"], }, ] diff --git a/apps/api/src/services/cloudflare_tunnel.py b/apps/api/src/services/cloudflare_tunnel.py index 389f033..e446ef1 100644 --- a/apps/api/src/services/cloudflare_tunnel.py +++ b/apps/api/src/services/cloudflare_tunnel.py @@ -5,6 +5,7 @@ import logging import os import uuid from pathlib import Path +from typing import Any import httpx from src.config import Settings @@ -53,78 +54,98 @@ async def create_tunnel( headers = _get_headers(settings) account_id = settings.cloudflare_account_id - # 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]) - response.raise_for_status() - data = response.json() + 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"): - raise ValueError(f"Failed to create tunnel: {data.get('errors')}") + 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) + 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) - token_response.raise_for_status() - token_data = token_response.json() - tunnel_token = token_data["result"] - logger.info("Step 2 complete: got tunnel token") + # 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) + # 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]) - dns_response.raise_for_status() - logger.info("Step 3 complete: DNS record created") + 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") + # 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, - } + 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( @@ -287,3 +308,65 @@ async def remove_tunnel_from_config( 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