feat(tunnels): switch to temporary Cloudflare tunnels
Replace persistent Cloudflare tunnels (API-based) with temporary tunnels using 'cloudflared tunnel --url'. This removes the need for Cloudflare API tokens, DNS records, and persistent tunnel management. Changes: - Install cloudflared binary in API Dockerfile - Add start_cloudflared_tunnel() and stop_cloudflared_tunnel() to docker.py - Update instance start/stop/restart/delete to use temporary tunnels - Store tunnel PID in tunnel_id field, temporary URL in url/public_url - Remove Cloudflare API service (cloudflare_tunnel.py) - Remove cloudflared container from docker-compose - Remove Cloudflare env vars (CLOUDFLARE_API_TOKEN, ZONE_ID, etc.) - Remove Cloudflare configuration from config.py - Remove Cloudflare startup check from main.py - Remove /health/cloudflare endpoint
This commit is contained in:
@@ -38,6 +38,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" > /etc/apt/sources.list.d/docker.list \
|
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" > /etc/apt/sources.list.d/docker.list \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||||
|
&& curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/cloudflared \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy dependencies from builder
|
# Copy dependencies from builder
|
||||||
|
|||||||
@@ -147,21 +147,3 @@ 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
|
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ from src.models.tool_config import ToolConfig
|
|||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.services.cloudflare_tunnel import create_tunnel, delete_tunnel
|
|
||||||
from src.services.docker import (
|
from src.services.docker import (
|
||||||
ensure_instance_directory,
|
ensure_instance_directory,
|
||||||
execute_compose_command,
|
execute_compose_command,
|
||||||
find_free_port,
|
find_free_port,
|
||||||
get_container_id,
|
get_container_id,
|
||||||
|
start_cloudflared_tunnel,
|
||||||
|
stop_cloudflared_tunnel,
|
||||||
get_container_name,
|
get_container_name,
|
||||||
get_container_logs,
|
get_container_logs,
|
||||||
get_container_status,
|
get_container_status,
|
||||||
@@ -420,24 +421,23 @@ async def start_instance(
|
|||||||
logger.info("Tool type for instance %s: name=%s, default_port=%s",
|
logger.info("Tool type for instance %s: name=%s, default_port=%s",
|
||||||
instance.id, tool_type.name if tool_type else "unknown", instance_port)
|
instance.id, tool_type.name if tool_type else "unknown", instance_port)
|
||||||
|
|
||||||
# Create Cloudflare tunnel for public access
|
# Create temporary Cloudflare tunnel for public access
|
||||||
try:
|
try:
|
||||||
logger.info("Creating Cloudflare tunnel for instance %s (name=%s, port=%d)",
|
logger.info("Creating temporary tunnel for instance %s (container=%s, port=%d)",
|
||||||
instance.id, instance.name, instance_port)
|
instance.id, instance.container_name, instance_port)
|
||||||
tunnel_info = await create_tunnel(
|
tunnel_info = start_cloudflared_tunnel(
|
||||||
instance_name=instance.name,
|
container_name=instance.container_name or instance.name,
|
||||||
instance_id=str(instance.id),
|
port=instance_port,
|
||||||
instance_port=instance_port,
|
|
||||||
)
|
)
|
||||||
instance.tunnel_id = tunnel_info["tunnel_id"]
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
instance.public_url = tunnel_info["public_url"]
|
instance.public_url = tunnel_info["url"]
|
||||||
instance.url = tunnel_info["public_url"]
|
instance.url = tunnel_info["url"]
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Created tunnel for instance %s: tunnel_id=%s, url=%s",
|
"Created temporary tunnel for instance %s: pid=%s, url=%s",
|
||||||
instance.id,
|
instance.id,
|
||||||
tunnel_info["tunnel_id"],
|
tunnel_info["pid"],
|
||||||
tunnel_info["public_url"],
|
tunnel_info["url"],
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
import traceback
|
import traceback
|
||||||
@@ -488,16 +488,13 @@ async def stop_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete Cloudflare tunnel if exists
|
# Stop Cloudflare tunnel if exists
|
||||||
if instance.tunnel_id:
|
if instance.tunnel_id:
|
||||||
try:
|
try:
|
||||||
await delete_tunnel(
|
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
tunnel_id=instance.tunnel_id,
|
logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id)
|
||||||
subdomain=f"instance-{str(instance.id)[:8]}",
|
|
||||||
)
|
|
||||||
logger.info("Deleted tunnel for instance %s", instance.id)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to delete tunnel for instance %s: %s", instance.id, exc)
|
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
execute_compose_command(instance.compose_path, "stop")
|
execute_compose_command(instance.compose_path, "stop")
|
||||||
@@ -545,16 +542,13 @@ async def restart_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete old tunnel if exists
|
# Stop old tunnel if exists
|
||||||
if instance.tunnel_id:
|
if instance.tunnel_id:
|
||||||
try:
|
try:
|
||||||
await delete_tunnel(
|
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
tunnel_id=instance.tunnel_id,
|
logger.info("Stopped old tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id)
|
||||||
subdomain=f"instance-{str(instance.id)[:8]}",
|
|
||||||
)
|
|
||||||
logger.info("Deleted old tunnel for instance %s", instance.id)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to delete old tunnel for instance %s: %s", instance.id, exc)
|
logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
@@ -569,20 +563,19 @@ async def restart_instance(
|
|||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
|
instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080
|
||||||
|
|
||||||
# Create new tunnel
|
# Create new temporary tunnel
|
||||||
try:
|
try:
|
||||||
tunnel_info = await create_tunnel(
|
tunnel_info = start_cloudflared_tunnel(
|
||||||
instance_name=instance.name,
|
container_name=instance.container_name or instance.name,
|
||||||
instance_id=str(instance.id),
|
port=instance_port,
|
||||||
instance_port=instance_port,
|
|
||||||
)
|
)
|
||||||
instance.tunnel_id = tunnel_info["tunnel_id"]
|
instance.tunnel_id = tunnel_info["pid"]
|
||||||
instance.public_url = tunnel_info["public_url"]
|
instance.public_url = tunnel_info["url"]
|
||||||
instance.url = tunnel_info["public_url"]
|
instance.url = tunnel_info["url"]
|
||||||
logger.info(
|
logger.info(
|
||||||
"Created new tunnel for instance %s: %s",
|
"Created new tunnel for instance %s: %s",
|
||||||
instance.id,
|
instance.id,
|
||||||
tunnel_info["public_url"],
|
tunnel_info["url"],
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -633,16 +626,13 @@ async def delete_instance(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete Cloudflare tunnel if exists
|
# Stop Cloudflare tunnel if exists
|
||||||
if instance.tunnel_id:
|
if instance.tunnel_id:
|
||||||
try:
|
try:
|
||||||
await delete_tunnel(
|
stop_cloudflared_tunnel(instance.tunnel_id)
|
||||||
tunnel_id=instance.tunnel_id,
|
logger.info("Stopped tunnel for instance %s (pid=%s)", instance.id, instance.tunnel_id)
|
||||||
subdomain=f"instance-{str(instance.id)[:8]}",
|
|
||||||
)
|
|
||||||
logger.info("Deleted tunnel for instance %s", instance.id)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to delete tunnel for instance %s: %s", instance.id, exc)
|
logger.warning("Failed to stop tunnel for instance %s: %s", instance.id, exc)
|
||||||
|
|
||||||
# Stop and remove container
|
# Stop and remove container
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
|
|||||||
@@ -53,12 +53,7 @@ class Settings(BaseSettings):
|
|||||||
# Tool instance storage
|
# Tool instance storage
|
||||||
instance_base_path: str = "/data/instances"
|
instance_base_path: str = "/data/instances"
|
||||||
|
|
||||||
# Cloudflare Tunnel configuration
|
|
||||||
cloudflare_api_token: str | None = None
|
|
||||||
cloudflare_zone_id: str | None = None
|
|
||||||
cloudflare_account_id: str | None = None
|
|
||||||
cloudflare_base_domain: str | None = None
|
|
||||||
cloudflared_config_dir: str = "/etc/cloudflared"
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
|
||||||
|
|
||||||
|
|||||||
@@ -229,18 +229,6 @@ async def on_startup():
|
|||||||
|
|
||||||
# Seed built-in data
|
# Seed built-in data
|
||||||
await seed_builtin_tool_types()
|
await seed_builtin_tool_types()
|
||||||
|
|
||||||
# Check Cloudflare configuration
|
|
||||||
settings = Settings()
|
|
||||||
if settings.cloudflare_api_token and settings.cloudflare_account_id and settings.cloudflare_zone_id and settings.cloudflare_base_domain:
|
|
||||||
logger.info("Cloudflare tunnel configuration detected: base_domain=%s", settings.cloudflare_base_domain)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Cloudflare tunnel configuration incomplete. "
|
|
||||||
"Tunnels will not be created. Set CLOUDFLARE_API_TOKEN, "
|
|
||||||
"CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ZONE_ID, and CLOUDFLARE_BASE_DOMAIN."
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
|||||||
@@ -1,376 +0,0 @@
|
|||||||
"""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()
|
|
||||||
|
|
||||||
logger.info("Settings loaded: api_token=%s, account_id=%s, zone_id=%s, base_domain=%s",
|
|
||||||
"set" if settings.cloudflare_api_token else "NOT SET",
|
|
||||||
settings.cloudflare_account_id or "NOT SET",
|
|
||||||
settings.cloudflare_zone_id or "NOT SET",
|
|
||||||
settings.cloudflare_base_domain or "NOT SET")
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -232,3 +232,79 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|||||||
return port
|
return port
|
||||||
|
|
||||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
|
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def start_cloudflared_tunnel(
|
||||||
|
container_name: str, port: int, timeout: int = 30
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Start a temporary Cloudflare tunnel for a container.
|
||||||
|
|
||||||
|
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
||||||
|
with a random trycloudflare.com URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name of the Docker container to tunnel to
|
||||||
|
port: Port number the container listens on
|
||||||
|
timeout: Maximum seconds to wait for tunnel URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Run cloudflared in background, capture output
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for the URL to appear in output
|
||||||
|
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||||
|
start_time = time.time()
|
||||||
|
url = None
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
# Read available output
|
||||||
|
import select
|
||||||
|
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||||
|
if readable:
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if line:
|
||||||
|
match = url_pattern.search(line)
|
||||||
|
if match:
|
||||||
|
url = match.group(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to get tunnel URL within {timeout}s. "
|
||||||
|
f"cloudflared output may contain errors."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"url": url, "pid": str(proc.pid)}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_cloudflared_tunnel(pid: str) -> None:
|
||||||
|
"""Stop a cloudflared tunnel process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid: Process ID of the cloudflared tunnel
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(int(pid), signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass # Already stopped
|
||||||
|
|||||||
@@ -91,17 +91,11 @@ services:
|
|||||||
AUTHENTIK_APPLICATION_SLUG: ${AUTHENTIK_APPLICATION_SLUG:-headquarter-web}
|
AUTHENTIK_APPLICATION_SLUG: ${AUTHENTIK_APPLICATION_SLUG:-headquarter-web}
|
||||||
AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-}
|
AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-}
|
||||||
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
|
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
|
||||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
|
|
||||||
CLOUDFLARE_ZONE_ID: ${CLOUDFLARE_ZONE_ID}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID}
|
|
||||||
CLOUDFLARE_BASE_DOMAIN: ${CLOUDFLARE_BASE_DOMAIN}
|
|
||||||
CLOUDFLARED_CONFIG_DIR: /etc/cloudflared
|
|
||||||
volumes:
|
volumes:
|
||||||
- repo_data:/data/repos
|
- repo_data:/data/repos
|
||||||
- instance_data:/data/instances
|
- instance_data:/data/instances
|
||||||
- avatar_uploads:/app/uploads
|
- avatar_uploads:/app/uploads
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- cloudflared_config:/etc/cloudflared
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -119,26 +113,12 @@ services:
|
|||||||
- "traefik.http.routers.headquarter-api.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
- "traefik.http.routers.headquarter-api.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
||||||
- "traefik.http.services.headquarter-api.loadbalancer.server.port=8000"
|
- "traefik.http.services.headquarter-api.loadbalancer.server.port=8000"
|
||||||
|
|
||||||
# Cloudflare Tunnel
|
|
||||||
cloudflared:
|
|
||||||
image: cloudflare/cloudflared:latest
|
|
||||||
container_name: hq-cloudflared
|
|
||||||
command: tunnel --config /etc/cloudflared/config.yml run
|
|
||||||
volumes:
|
|
||||||
- cloudflared_config:/etc/cloudflared
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- headquarter-api
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
repo_data:
|
repo_data:
|
||||||
instance_data:
|
instance_data:
|
||||||
avatar_uploads:
|
avatar_uploads:
|
||||||
cloudflared_config:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
Reference in New Issue
Block a user