Compare commits
2 Commits
98795e31dd
...
e7804c0f58
| Author | SHA1 | Date | |
|---|---|---|---|
| e7804c0f58 | |||
| b40eb3e88c |
@@ -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"
|
||||
|
||||
@@ -156,7 +156,11 @@ export const SessionsPage = () => {
|
||||
};
|
||||
|
||||
const handleOpen = (session: Session) => {
|
||||
navigate(`/projects/${session.project_name}/repositories/${session.repository_name}`);
|
||||
if (session.url) {
|
||||
window.open(session.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeLast = async () => {
|
||||
|
||||
@@ -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