feat(cloudflare-tunnel): integrate Cloudflare tunnels for instance access
Backend: - Add cloudflare_tunnel.py service for creating/deleting tunnels via Cloudflare API - Add public_url and tunnel_id fields to ToolInstance model - Update start_instance to create Cloudflare tunnel after container starts - Update stop_instance to delete tunnel before stopping container - Update delete_instance to cleanup tunnel before deletion - Update restart_instance to recreate tunnel on restart - Create Alembic migration 0011 for tunnel fields - Add Cloudflare config settings (API token, zone ID, account ID, base domain) Infrastructure: - Add cloudflared service to docker-compose.traefik.yml - Mount shared cloudflared_config volume between API and cloudflared containers - Add Cloudflare env vars to API service Frontend: - Update instance Open button to handle both full URLs and proxy paths The instance URL is now set to the Cloudflare tunnel public URL when available, falling back to the API proxy path if tunnel creation fails.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
"""add tunnel fields to tool_instances
|
||||
|
||||
Revision ID: 0011_tool_instance_tunnel_fields
|
||||
Revises: 0010_tool_type_default_port
|
||||
Create Date: 2026-05-20 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0011_tool_instance_tunnel_fields"
|
||||
down_revision: Union[str, None] = "0010_tool_type_default_port"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("public_url", sa.String(1024), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("tunnel_id", sa.String(255), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tool_instances", "tunnel_id")
|
||||
op.drop_column("tool_instances", "public_url")
|
||||
@@ -22,6 +22,7 @@ from src.models.tool_config import ToolConfig
|
||||
from src.models.tool_instance import ToolInstance
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.services.cloudflare_tunnel import create_tunnel, delete_tunnel
|
||||
from src.services.docker import (
|
||||
ensure_instance_directory,
|
||||
execute_compose_command,
|
||||
@@ -399,9 +400,32 @@ async def start_instance(
|
||||
|
||||
instance.status = "running"
|
||||
instance.last_started_at = datetime.now()
|
||||
instance.url = f"/instances/{instance.id}/proxy/"
|
||||
await session.commit()
|
||||
|
||||
# Create Cloudflare tunnel for public access
|
||||
try:
|
||||
tunnel_info = await create_tunnel(
|
||||
instance_name=instance.name,
|
||||
instance_id=str(instance.id),
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["tunnel_id"]
|
||||
instance.public_url = tunnel_info["public_url"]
|
||||
instance.url = tunnel_info["public_url"]
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Created tunnel for instance %s: %s",
|
||||
instance.id,
|
||||
tunnel_info["public_url"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to create tunnel for instance %s: %s. Falling back to proxy URL.",
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
instance.url = f"/instances/{instance.id}/proxy/"
|
||||
await session.commit()
|
||||
|
||||
return {"status": instance.status, "url": instance.url}
|
||||
|
||||
|
||||
@@ -438,12 +462,25 @@ async def stop_instance(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
# Delete Cloudflare tunnel if exists
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
await delete_tunnel(
|
||||
tunnel_id=instance.tunnel_id,
|
||||
subdomain=f"instance-{instance.id}",
|
||||
)
|
||||
logger.info("Deleted tunnel for instance %s", instance.id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to delete tunnel for instance %s: %s", instance.id, exc)
|
||||
|
||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||
execute_compose_command(instance.compose_path, "stop")
|
||||
|
||||
instance.status = "stopped"
|
||||
instance.last_stopped_at = datetime.now()
|
||||
instance.url = None
|
||||
instance.public_url = None
|
||||
instance.tunnel_id = None
|
||||
await session.commit()
|
||||
|
||||
return {"status": instance.status}
|
||||
@@ -482,6 +519,17 @@ async def restart_instance(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
# Delete old tunnel if exists
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
await delete_tunnel(
|
||||
tunnel_id=instance.tunnel_id,
|
||||
subdomain=f"instance-{instance.id}",
|
||||
)
|
||||
logger.info("Deleted old tunnel for instance %s", instance.id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to delete old tunnel for instance %s: %s", instance.id, exc)
|
||||
|
||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||
returncode, stdout, stderr = execute_compose_command(
|
||||
instance.compose_path, "restart"
|
||||
@@ -490,7 +538,29 @@ async def restart_instance(
|
||||
if returncode == 0:
|
||||
instance.status = "running"
|
||||
instance.last_started_at = datetime.now()
|
||||
instance.url = f"http://localhost:{instance.port}"
|
||||
|
||||
# Create new tunnel
|
||||
try:
|
||||
tunnel_info = await create_tunnel(
|
||||
instance_name=instance.name,
|
||||
instance_id=str(instance.id),
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["tunnel_id"]
|
||||
instance.public_url = tunnel_info["public_url"]
|
||||
instance.url = tunnel_info["public_url"]
|
||||
logger.info(
|
||||
"Created new tunnel for instance %s: %s",
|
||||
instance.id,
|
||||
tunnel_info["public_url"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to create tunnel for instance %s: %s",
|
||||
instance.id,
|
||||
exc,
|
||||
)
|
||||
instance.url = f"/instances/{instance.id}/proxy/"
|
||||
|
||||
await session.commit()
|
||||
return {"status": instance.status, "url": instance.url}
|
||||
|
||||
@@ -532,6 +602,17 @@ async def delete_instance(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
|
||||
)
|
||||
|
||||
# Delete Cloudflare tunnel if exists
|
||||
if instance.tunnel_id:
|
||||
try:
|
||||
await delete_tunnel(
|
||||
tunnel_id=instance.tunnel_id,
|
||||
subdomain=f"instance-{instance.id}",
|
||||
)
|
||||
logger.info("Deleted tunnel for instance %s", instance.id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to delete tunnel for instance %s: %s", instance.id, exc)
|
||||
|
||||
# Stop and remove container
|
||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||
execute_compose_command(instance.compose_path, "down")
|
||||
|
||||
@@ -53,6 +53,13 @@ class Settings(BaseSettings):
|
||||
# Tool instance storage
|
||||
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)
|
||||
|
||||
@property
|
||||
|
||||
@@ -47,6 +47,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
url: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True
|
||||
)
|
||||
public_url: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True
|
||||
)
|
||||
tunnel_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
port: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
)
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Cloudflare Tunnel management service."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
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,
|
||||
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
|
||||
settings: Optional settings override
|
||||
|
||||
Returns:
|
||||
Dict with tunnel_id and public_url
|
||||
"""
|
||||
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
|
||||
|
||||
# Create tunnel
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": f"headquarter-{instance_name}",
|
||||
"config_src": "cloudflare",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
raise ValueError(f"Failed to create tunnel: {data.get('errors')}")
|
||||
|
||||
tunnel = data["result"]
|
||||
tunnel_id = tunnel["id"]
|
||||
|
||||
# Get tunnel token
|
||||
token_response = await client.get(
|
||||
f"{CLOUDFLARE_API_BASE}/accounts/{account_id}/cfd_tunnel/{tunnel_id}/token",
|
||||
headers=headers,
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
tunnel_token = token_data["result"]
|
||||
|
||||
# Create DNS record for the tunnel
|
||||
subdomain = f"instance-{instance_id[:8]}"
|
||||
hostname = f"{subdomain}.{settings.cloudflare_base_domain}"
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
dns_response.raise_for_status()
|
||||
|
||||
# Update cloudflared config
|
||||
await update_cloudflared_config(
|
||||
tunnel_id=tunnel_id,
|
||||
tunnel_token=tunnel_token,
|
||||
hostname=hostname,
|
||||
instance_name=instance_name,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
return {
|
||||
"tunnel_id": tunnel_id,
|
||||
"public_url": f"https://{hostname}",
|
||||
"subdomain": subdomain,
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
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}:8080",
|
||||
}
|
||||
|
||||
# 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)
|
||||
@@ -149,7 +149,7 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
||||
<div className="instance-actions">
|
||||
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
||||
<a
|
||||
href={`${API_BASE_URL}${instance.url}`}
|
||||
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
|
||||
@@ -91,11 +91,17 @@ services:
|
||||
AUTHENTIK_APPLICATION_SLUG: ${AUTHENTIK_APPLICATION_SLUG:-headquarter-web}
|
||||
AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_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:
|
||||
- repo_data:/data/repos
|
||||
- instance_data:/data/instances
|
||||
- avatar_uploads:/app/uploads
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- cloudflared_config:/etc/cloudflared
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -113,12 +119,26 @@ services:
|
||||
- "traefik.http.routers.headquarter-api.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-letsencrypt}"
|
||||
- "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:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
repo_data:
|
||||
instance_data:
|
||||
avatar_uploads:
|
||||
cloudflared_config:
|
||||
|
||||
networks:
|
||||
backend:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-20
|
||||
@@ -0,0 +1,142 @@
|
||||
## Context
|
||||
|
||||
Currently, tool instances are exposed via an API proxy endpoint that forwards requests from `/instances/{id}/proxy/` to the internal Docker container. This creates latency, adds load to the API service, and doesn't support WebSocket features well. Cloudflare Tunnel offers a better architecture where each instance gets its own HTTPS subdomain.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Each running tool instance gets a unique public HTTPS subdomain
|
||||
- No manual DNS or reverse proxy configuration per instance
|
||||
- Automatic cleanup when instances are stopped or deleted
|
||||
- Support for WebSocket and real-time features (code-server terminal, jupyter kernels)
|
||||
- Minimal latency compared to API proxy approach
|
||||
|
||||
**Non-Goals:**
|
||||
- Custom domains per instance (use Cloudflare zone's wildcard)
|
||||
- Advanced tunnel features (load balancing, failover, ingress rules)
|
||||
- Replacing Traefik for the main app (API + frontend)
|
||||
- Supporting non-HTTP protocols (TCP/UDP raw tunneling)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Cloudflare API vs cloudflared CLI
|
||||
|
||||
**Decision:** Use the Cloudflare REST API to create/manage tunnels, not the `cloudflared` CLI.
|
||||
|
||||
**Rationale:**
|
||||
- The API gives us programmatic control without parsing CLI output
|
||||
- We can use `httpx` (already a dependency) instead of subprocess calls
|
||||
- Easier to test and mock
|
||||
|
||||
**Alternative considered:** Running `cloudflared tunnel create` via subprocess
|
||||
- Rejected: Fragile, harder to test, requires cloudflared binary in API container
|
||||
|
||||
### Architecture: cloudflared as a separate container
|
||||
|
||||
**Decision:** Run `cloudflared` as a standalone Docker service that connects to Cloudflare and routes traffic.
|
||||
|
||||
**Rationale:**
|
||||
- Separation of concerns: API manages tunnels, cloudflared handles connectivity
|
||||
- The cloudflared container can access the Docker internal network where instances run
|
||||
- Easier to scale/restart independently
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Cloudflare Edge │
|
||||
└──────────────────────┬──────────────────────────────────────┘
|
||||
│ HTTPS
|
||||
┌──────────────────────▼──────────────────────────────────────┐
|
||||
│ cloudflared container │
|
||||
│ (connects to Cloudflare, receives traffic for *.zone) │
|
||||
└──────────┬──────────────────────────────────────────────────┘
|
||||
│ Docker network
|
||||
┌──────────▼──────────────────────────────────────────────────┐
|
||||
│ code-server container:8443 jupyter container:8888 │
|
||||
│ (tool instances on Docker network with DNS names) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Subdomain naming
|
||||
|
||||
**Decision:** Use `instance-{short-uuid}.{zone}` format (e.g., `instance-a1b2c3d4.headquarter.commumedia.org`)
|
||||
|
||||
**Rationale:**
|
||||
- Predictable and URL-safe
|
||||
- Short enough to be readable
|
||||
- UUID ensures uniqueness without exposing internal IDs
|
||||
|
||||
### Tunnel lifecycle
|
||||
|
||||
**Decision:** Create tunnel on instance start, delete on instance stop/delete.
|
||||
|
||||
**Flow:**
|
||||
1. User clicks "Start"
|
||||
2. Backend creates Cloudflare tunnel via API
|
||||
3. Backend creates DNS CNAME record: `instance-abc123` → `{tunnel-id}.cfargotunnel.com`
|
||||
4. Backend stores `tunnel_id` and `public_url` in ToolInstance
|
||||
5. cloudflared container routes traffic to container:port
|
||||
6. On stop: delete DNS record, delete tunnel
|
||||
|
||||
### cloudflared configuration
|
||||
|
||||
**Decision:** Use a single cloudflared container with dynamic config file updates.
|
||||
|
||||
**Approach:**
|
||||
- The cloudflared container reads an `config.yml` file mounted as a volume
|
||||
- The API writes ingress rules to this file when instances start/stop
|
||||
- cloudflared automatically reloads the config (or we restart the container)
|
||||
|
||||
```yaml
|
||||
# /etc/cloudflared/config.yml
|
||||
tunnel: {tunnel-token}
|
||||
credentials-file: /etc/cloudflared/credentials.json
|
||||
ingress:
|
||||
- hostname: instance-abc123.headquarter.commumedia.org
|
||||
service: http://code-server-repo-abc123:8443
|
||||
- hostname: instance-xyz789.headquarter.commumedia.org
|
||||
service: http://jupyter-repo-def:8888
|
||||
- service: http_status:404
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
**Decision:** Cloudflare tunnels provide HTTPS but do NOT handle app-level auth. Tool instances without built-in auth (like code-server) will be publicly accessible.
|
||||
|
||||
**Rationale:**
|
||||
- Cloudflare Access could add auth, but adds complexity
|
||||
- Many tools (code-server) have their own password/auth mechanisms
|
||||
- Users should configure tool-level auth via ToolConfig
|
||||
|
||||
**Mitigation:** Document that users must configure tool passwords via ToolConfig (e.g., `PASSWORD` env for code-server).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk]** Cloudflare API rate limits (1200 requests/5 min)
|
||||
→ **Mitigation:** Tunnel creation is infrequent (user-initiated), unlikely to hit limits
|
||||
|
||||
**[Risk]** cloudflared container becomes a single point of failure
|
||||
→ **Mitigation:** It's stateless; can be restarted quickly. All instances share one cloudflared.
|
||||
|
||||
**[Risk]** Subdomain enumeration exposes running instances
|
||||
→ **Mitigation:** UUID-based names are hard to guess. Consider adding Cloudflare Access in future.
|
||||
|
||||
**[Risk]** cloudflared config file updates require container restart
|
||||
→ **Mitigation:** Investigate `cloudflared --no-autoupdate` with config watch, or accept brief restart
|
||||
|
||||
**[Risk]** Tool instances publicly accessible without auth
|
||||
→ **Mitigation:** Document security best practices, recommend setting tool passwords
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Deploy cloudflared container with base config
|
||||
2. Add Cloudflare env vars to API container
|
||||
3. Deploy backend changes (tunnel service, updated lifecycle)
|
||||
4. Deploy frontend changes (use public_url instead of proxy)
|
||||
5. Test with code-server instance
|
||||
6. Remove old proxy endpoint code
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should we add Cloudflare Access (Zero Trust) to protect instances?
|
||||
- Do we need to support custom subdomains (e.g., `myproject.headquarter.commumedia.org`)?
|
||||
- Should we keep the proxy endpoint as a fallback?
|
||||
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
The current approach of proxying tool instances through the backend API is fragile and creates a bottleneck. Every HTTP request and WebSocket connection to a tool instance (code-server, jupyter, etc.) must pass through the FastAPI application, adding latency and consuming API resources. Cloudflare Tunnel provides a robust alternative: each instance gets its own public subdomain with automatic HTTPS, without exposing ports or requiring complex reverse proxy rules.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Replace the API proxy endpoint (`/instances/{id}/proxy/`) with Cloudflare Tunnel integration
|
||||
- Run a `cloudflared` container alongside the API that manages tunnels programmatically via the Cloudflare API
|
||||
- When a tool instance starts, create a unique Cloudflare Tunnel and DNS record pointing to the instance's internal container name and port
|
||||
- Store the public URL (e.g., `https://instance-abc123.headquarter.commumedia.org`) in the ToolInstance model
|
||||
- Update the frontend "Open" button to use the Cloudflare URL instead of the proxy path
|
||||
- Remove the proxy endpoint and related code (instance_proxy.py)
|
||||
- **BREAKING**: The `/instances/{id}/proxy/{path:path}` endpoint will be removed
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `cloudflare-tunnel-management`: Creating, deleting, and managing Cloudflare tunnels for tool instances via the Cloudflare API
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `instance-proxy`: The current proxy-based approach will be replaced by Cloudflare tunnels. The requirement that "The API SHALL expose an endpoint that forwards HTTP requests" is replaced by "The system SHALL provide a public URL for each running instance."
|
||||
|
||||
## Impact
|
||||
|
||||
- Backend: New Cloudflare tunnel service, updated instance lifecycle (create tunnel on start, delete on stop), removed proxy code
|
||||
- Frontend: Update "Open" links to use public Cloudflare URLs
|
||||
- Infrastructure: New `cloudflared` Docker service, Cloudflare API token required
|
||||
- Environment: New env vars: `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_ZONE_ID`
|
||||
- Docker: Cloudflared container must be on the same network as tool instances
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: System creates Cloudflare tunnel on instance start
|
||||
When a tool instance is started, the system SHALL create a Cloudflare tunnel and DNS record to expose it publicly.
|
||||
|
||||
#### Scenario: Start instance creates tunnel
|
||||
- **WHEN** a user starts a tool instance
|
||||
- **THEN** the system calls the Cloudflare API to create a tunnel
|
||||
- **AND** creates a CNAME DNS record for `instance-{id}.{zone}`
|
||||
- **AND** stores the tunnel ID and public URL in the database
|
||||
|
||||
#### Scenario: Tunnel points to correct container
|
||||
- **WHEN** a tunnel is created for an instance
|
||||
- **THEN** the tunnel ingress rule maps the subdomain to the container's internal DNS name and port
|
||||
|
||||
### Requirement: System deletes Cloudflare tunnel on instance stop
|
||||
When a tool instance is stopped or deleted, the system SHALL clean up the associated Cloudflare tunnel and DNS record.
|
||||
|
||||
#### Scenario: Stop instance deletes tunnel
|
||||
- **WHEN** a user stops a running instance
|
||||
- **THEN** the system deletes the DNS record
|
||||
- **AND** deletes the Cloudflare tunnel
|
||||
|
||||
#### Scenario: Delete instance cleans up tunnel
|
||||
- **WHEN** a user deletes an instance
|
||||
- **AND** the instance has an active tunnel
|
||||
- **THEN** the system deletes both the DNS record and the tunnel
|
||||
|
||||
### Requirement: Frontend uses public URL for instance access
|
||||
The frontend SHALL display and link to the public Cloudflare URL for running instances.
|
||||
|
||||
#### Scenario: Open button uses public URL
|
||||
- **WHEN** a user views a running instance
|
||||
- **THEN** the "Open" button links to the instance's public URL
|
||||
- **AND** the URL opens in a new tab
|
||||
|
||||
#### Scenario: Session list shows public URL
|
||||
- **WHEN** a user views their sessions
|
||||
- **THEN** each running session displays its public URL
|
||||
|
||||
### Requirement: Only instance owner can start/stop/delete tunnels
|
||||
The system SHALL verify that only the instance owner can trigger tunnel creation or deletion.
|
||||
|
||||
#### Scenario: Owner starts instance
|
||||
- **WHEN** the instance owner clicks "Start"
|
||||
- **THEN** the tunnel is created successfully
|
||||
|
||||
#### Scenario: Non-owner attempts to start
|
||||
- **WHEN** a non-owner attempts to start an instance
|
||||
- **THEN** the request returns 403 Forbidden
|
||||
@@ -0,0 +1,45 @@
|
||||
## 1. Infrastructure Setup
|
||||
|
||||
- [ ] 1.1 Add cloudflared service to docker-compose.traefik.yml
|
||||
- [ ] 1.2 Create cloudflared config directory and base config
|
||||
- [ ] 1.3 Add Cloudflare env vars (API token, account ID, zone ID) to .env.example
|
||||
- [ ] 1.4 Mount shared config volume between API and cloudflared containers
|
||||
|
||||
## 2. Backend - Cloudflare Tunnel Service
|
||||
|
||||
- [ ] 2.1 Create `src/services/cloudflare_tunnel.py` with tunnel CRUD operations
|
||||
- [ ] 2.2 Implement `create_tunnel(instance_name, container_name, port)` function
|
||||
- [ ] 2.3 Implement `delete_tunnel(tunnel_id)` function
|
||||
- [ ] 2.4 Implement `update_cloudflared_config()` to rewrite config.yml
|
||||
- [ ] 2.5 Add Cloudflare API token validation on startup
|
||||
|
||||
## 3. Backend - Instance Lifecycle Updates
|
||||
|
||||
- [ ] 3.1 Update ToolInstance model: add `tunnel_id` and `public_url` fields
|
||||
- [ ] 3.2 Create Alembic migration for new fields
|
||||
- [ ] 3.3 Update `start_instance` to create tunnel and store public_url
|
||||
- [ ] 3.4 Update `stop_instance` to delete tunnel and DNS record
|
||||
- [ ] 3.5 Update `delete_instance` to ensure tunnel cleanup
|
||||
- [ ] 3.6 Update `get_user_sessions` to include `public_url`
|
||||
|
||||
## 4. Backend - Cleanup
|
||||
|
||||
- [ ] 4.1 Remove `instance_proxy.py` router
|
||||
- [ ] 4.2 Remove proxy route registration from `main.py`
|
||||
- [ ] 4.3 Remove `default_port` from ToolType (no longer needed)
|
||||
- [ ] 4.4 Clean up any proxy-related code
|
||||
|
||||
## 5. Frontend Updates
|
||||
|
||||
- [ ] 5.1 Update Session interface to include `public_url`
|
||||
- [ ] 5.2 Update InstanceList "Open" button to use `public_url`
|
||||
- [ ] 5.3 Update SessionsPage "Open" button to use `public_url`
|
||||
- [ ] 5.4 Remove proxy URL construction logic
|
||||
|
||||
## 6. Testing and Deployment
|
||||
|
||||
- [ ] 6.1 Test tunnel creation with code-server instance
|
||||
- [ ] 6.2 Test tunnel deletion on instance stop
|
||||
- [ ] 6.3 Verify HTTPS and WebSocket support
|
||||
- [ ] 6.4 Run quality gates (ruff, mypy, typecheck, lint, build)
|
||||
- [ ] 6.5 Deploy and test end-to-end
|
||||
Reference in New Issue
Block a user