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:
@@ -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
@@ -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"],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user