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_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,
|
||||||
@@ -399,9 +400,32 @@ async def start_instance(
|
|||||||
|
|
||||||
instance.status = "running"
|
instance.status = "running"
|
||||||
instance.last_started_at = datetime.now()
|
instance.last_started_at = datetime.now()
|
||||||
instance.url = f"/instances/{instance.id}/proxy/"
|
|
||||||
await session.commit()
|
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}
|
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"
|
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):
|
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")
|
||||||
|
|
||||||
instance.status = "stopped"
|
instance.status = "stopped"
|
||||||
instance.last_stopped_at = datetime.now()
|
instance.last_stopped_at = datetime.now()
|
||||||
instance.url = None
|
instance.url = None
|
||||||
|
instance.public_url = None
|
||||||
|
instance.tunnel_id = None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
return {"status": instance.status}
|
return {"status": instance.status}
|
||||||
@@ -482,6 +519,17 @@ 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
|
||||||
|
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):
|
if instance.compose_path and os.path.exists(instance.compose_path):
|
||||||
returncode, stdout, stderr = execute_compose_command(
|
returncode, stdout, stderr = execute_compose_command(
|
||||||
instance.compose_path, "restart"
|
instance.compose_path, "restart"
|
||||||
@@ -490,7 +538,29 @@ async def restart_instance(
|
|||||||
if returncode == 0:
|
if returncode == 0:
|
||||||
instance.status = "running"
|
instance.status = "running"
|
||||||
instance.last_started_at = datetime.now()
|
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()
|
await session.commit()
|
||||||
return {"status": instance.status, "url": instance.url}
|
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"
|
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
|
# 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):
|
||||||
execute_compose_command(instance.compose_path, "down")
|
execute_compose_command(instance.compose_path, "down")
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ 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)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
url: Mapped[str | None] = mapped_column(
|
url: Mapped[str | None] = mapped_column(
|
||||||
String(1024), nullable=True
|
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(
|
port: Mapped[int | None] = mapped_column(
|
||||||
Integer, nullable=True
|
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">
|
<div className="instance-actions">
|
||||||
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
||||||
<a
|
<a
|
||||||
href={`${API_BASE_URL}${instance.url}`}
|
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
|
|||||||
@@ -156,7 +156,11 @@ export const SessionsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpen = (session: Session) => {
|
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 () => {
|
const handleResumeLast = async () => {
|
||||||
|
|||||||
@@ -91,11 +91,17 @@ 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
|
||||||
@@ -113,12 +119,26 @@ 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:
|
||||||
|
|||||||
@@ -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