Dashboard: editable gateway settings + public-exposure panel

Surfaces the DNS/Cloudflare/public state that previously required editing
castle.yaml + curl:

- API: GET /gateway now returns domain, public_domain, tunnel_id,
  tunnel_connected, and a public_url per public route; new PUT /gateway/config
  edits tls/domain/public_domain/tunnel_id in castle.yaml (deploy to apply).
  save_config now persists public_domain/tunnel_id (previously dropped).
- UI: a GatewaySettings row in the Gateway panel shows TLS / domain / public
  (tunnel up/down + public_domain) with inline edit; each public route gets a
  green "public" badge linking to its <name>.<public_domain> URL.
This commit is contained in:
2026-07-01 08:29:36 -07:00
parent fc83ce40c1
commit e9b197756b
7 changed files with 205 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import shutil
import subprocess
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
@@ -19,6 +20,7 @@ from castle_api.health import check_all_health
from castle_api.models import (
DeploymentDetail,
DeploymentSummary,
GatewayConfigRequest,
GatewayInfo,
GatewayRoute,
JobDetail,
@@ -874,6 +876,13 @@ def get_gateway() -> GatewayInfo:
except FileNotFoundError:
pass
# Which local services are public → their public URL (<name>.<public_domain>).
public_domain = registry.node.public_domain
public_names = {
name for name, svc in (config.services.items() if config else [])
if getattr(svc, "public", False)
}
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
routes = [
GatewayRoute(
@@ -882,12 +891,25 @@ def get_gateway() -> GatewayInfo:
target=r.target,
name=r.name,
node=r.node or registry.node.hostname,
public_url=(
f"https://{r.name}.{public_domain}"
if public_domain and r.name in public_names
else None
),
)
for r in compute_routes(registry, config, remote or None)
]
# Caddyfile order is precedence-sensitive; the displayed table is alphabetical.
routes.sort(key=lambda r: r.address)
tunnel_connected = (
subprocess.run(
["systemctl", "--user", "is-active", "castle-castle-tunnel.service"],
capture_output=True, text=True,
).stdout.strip()
== "active"
)
return GatewayInfo(
port=registry.node.gateway_port,
hostname=registry.node.hostname,
@@ -896,9 +918,35 @@ def get_gateway() -> GatewayInfo:
managed_count=managed_count,
routes=routes,
tls=registry.node.gateway_tls,
domain=registry.node.gateway_domain,
public_domain=public_domain,
tunnel_id=registry.node.tunnel_id,
tunnel_connected=tunnel_connected,
)
@router.put("/gateway/config")
def save_gateway_config(request: GatewayConfigRequest) -> dict[str, str]:
"""Update the gateway's routing/exposure settings in castle.yaml.
Saves only; the caller runs deploy to regenerate the Caddyfile + tunnel config.
An empty string clears a field.
"""
root = get_castle_root()
if root is None:
raise HTTPException(status_code=404, detail="No castle root found.")
from castle_core.config import load_config, save_config
config = load_config(root)
norm = lambda v: (v or None) # noqa: E731 — empty string clears
config.gateway.tls = norm(request.tls)
config.gateway.domain = norm(request.domain)
config.gateway.public_domain = norm(request.public_domain)
config.gateway.tunnel_id = norm(request.tunnel_id)
save_config(config)
return {"status": "saved", "message": "Saved. Run deploy to apply."}
@router.get("/gateway/caddyfile")
def get_caddyfile() -> dict[str, str]:
"""Return the generated Caddyfile content."""