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

@@ -1,10 +1,11 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Globe, RefreshCw, FileText, ExternalLink } from "lucide-react" import { Globe, RefreshCw, FileText, ExternalLink, Cable } from "lucide-react"
import type { GatewayInfo, HealthStatus } from "@/types" import type { GatewayInfo, HealthStatus } from "@/types"
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks" import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
import { subdomainUrl } from "@/lib/labels" import { subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge" import { HealthBadge } from "./HealthBadge"
import { GatewaySettings } from "./GatewaySettings"
interface GatewayPanelProps { interface GatewayPanelProps {
gateway: GatewayInfo gateway: GatewayInfo
@@ -58,6 +59,9 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</div> </div>
</div> </div>
{/* Routing + public-exposure settings (editable) */}
<GatewaySettings gateway={gateway} />
{/* Route table — every gateway route, of every kind */} {/* Route table — every gateway route, of every kind */}
{gateway.routes.length > 0 && ( {gateway.routes.length > 0 && (
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -98,6 +102,17 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
) : ( ) : (
route.address route.address
)} )}
{route.public_url && (
<a
href={route.public_url}
target="_blank"
rel="noopener noreferrer"
title={route.public_url}
className="ml-2 inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 rounded bg-green-500/15 text-green-500 hover:bg-green-500/25"
>
<Cable size={10} /> public
</a>
)}
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<KindBadge kind={route.kind} /> <KindBadge kind={route.kind} />

View File

@@ -0,0 +1,96 @@
import { useState } from "react"
import { Pencil, Check, X, Cable } from "lucide-react"
import type { GatewayInfo } from "@/types"
import { useSaveGatewayConfig } from "@/services/api/hooks"
/** Editable gateway routing + public-exposure settings (domain / public_domain /
* tunnel / tls). Saves to castle.yaml; changes take effect on the next deploy. */
export function GatewaySettings({ gateway }: { gateway: GatewayInfo }) {
const { mutate: save, isPending, data: saved } = useSaveGatewayConfig()
const [editing, setEditing] = useState(false)
const [form, setForm] = useState({
tls: gateway.tls ?? "",
domain: gateway.domain ?? "",
public_domain: gateway.public_domain ?? "",
tunnel_id: gateway.tunnel_id ?? "",
})
const field = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }))
const publicConfigured = !!gateway.public_domain && !!gateway.tunnel_id
if (editing) {
return (
<div className="px-4 py-3 border-b border-[var(--border)] grid gap-2 text-sm bg-black/10">
<Row label="TLS">
<select value={form.tls} onChange={field("tls")} className={INPUT}>
<option value="">off</option>
<option value="acme">acme</option>
<option value="internal">internal</option>
</select>
</Row>
<Row label="Domain"><input value={form.domain} onChange={field("domain")} placeholder="civil.payne.io" className={INPUT} /></Row>
<Row label="Public domain"><input value={form.public_domain} onChange={field("public_domain")} placeholder="domain0.org" className={INPUT} /></Row>
<Row label="Tunnel ID"><input value={form.tunnel_id} onChange={field("tunnel_id")} placeholder="cloudflared tunnel UUID" className={INPUT} /></Row>
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => save(form, { onSuccess: () => setEditing(false) })}
disabled={isPending}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--primary)] text-white disabled:opacity-40"
>
<Check size={12} /> Save
</button>
<button onClick={() => setEditing(false)} className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] text-[var(--muted)]">
<X size={12} /> Cancel
</button>
<span className="text-xs text-[var(--muted)]">Run <span className="font-mono">castle deploy</span> to apply.</span>
</div>
</div>
)
}
return (
<div className="px-4 py-2.5 border-b border-[var(--border)] flex items-center gap-x-6 gap-y-1 flex-wrap text-sm">
<span className="text-[var(--muted)]">
TLS <span className="text-[var(--foreground)] font-mono">{gateway.tls ?? "off"}</span>
</span>
<span className="text-[var(--muted)]">
Domain <span className="text-[var(--foreground)] font-mono">{gateway.domain ?? "—"}</span>
</span>
<span className="flex items-center gap-1.5 text-[var(--muted)]">
<Cable size={13} className={publicConfigured && gateway.tunnel_connected ? "text-green-500" : "text-[var(--muted)]"} />
Public
{publicConfigured ? (
<>
<span className="text-[var(--foreground)] font-mono">{gateway.public_domain}</span>
<span className={gateway.tunnel_connected ? "text-green-500 text-xs" : "text-red-500 text-xs"}>
{gateway.tunnel_connected ? "● tunnel up" : "○ tunnel down"}
</span>
</>
) : (
<span className="text-[var(--muted)]">not configured</span>
)}
</span>
<button
onClick={() => setEditing(true)}
className="ml-auto flex items-center gap-1 text-xs px-2 py-0.5 rounded text-[var(--muted)] hover:text-[var(--foreground)]"
>
<Pencil size={11} /> Edit
</button>
{saved && <span className="text-xs text-green-500">{saved.message}</span>}
</div>
)
}
const INPUT =
"bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-sm font-mono w-64 focus:outline-none focus:border-[var(--primary)]"
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex items-center gap-3">
<span className="w-28 text-[var(--muted)]">{label}</span>
{children}
</label>
)
}

View File

@@ -11,6 +11,7 @@ import type {
ProgramDetail, ProgramDetail,
StatusResponse, StatusResponse,
GatewayInfo, GatewayInfo,
GatewayConfigRequest,
ServiceActionResponse, ServiceActionResponse,
SSEHealthEvent, SSEHealthEvent,
MeshStatus, MeshStatus,
@@ -173,6 +174,17 @@ export function useGatewayReload() {
}) })
} }
export function useSaveGatewayConfig() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: GatewayConfigRequest) =>
apiClient.put<{ status: string; message: string }>("/gateway/config", body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["gateway"] })
},
})
}
export function useNodes() { export function useNodes() {
return useQuery({ return useQuery({
queryKey: ["nodes"], queryKey: ["nodes"],

View File

@@ -114,6 +114,7 @@ export interface GatewayRoute {
target: string // serve dir, "localhost:PORT", or "host:PORT" target: string // serve dir, "localhost:PORT", or "host:PORT"
name: string | null name: string | null
node: string node: string
public_url?: string | null // set when the service is public (via the tunnel)
} }
export interface GatewayInfo { export interface GatewayInfo {
@@ -124,6 +125,17 @@ export interface GatewayInfo {
managed_count: number managed_count: number
routes: GatewayRoute[] routes: GatewayRoute[]
tls?: string | null // "acme" → host routes served over HTTPS with a Let's Encrypt wildcard tls?: string | null // "acme" → host routes served over HTTPS with a Let's Encrypt wildcard
domain?: string | null // acme zone → <service>.<domain>
public_domain?: string | null // tunnel zone → <service>.<public_domain>
tunnel_id?: string | null
tunnel_connected?: boolean
}
export interface GatewayConfigRequest {
tls?: string | null
domain?: string | null
public_domain?: string | null
tunnel_id?: string | null
} }
export interface ServiceActionResponse { export interface ServiceActionResponse {

View File

@@ -144,6 +144,9 @@ class GatewayRoute(BaseModel):
target: str target: str
name: str | None = None name: str | None = None
node: str node: str
# Public exposure via the tunnel: the public URL, or None if this route is
# LAN-only. Set when the backing service has `public: true`.
public_url: str | None = None
class GatewayInfo(BaseModel): class GatewayInfo(BaseModel):
@@ -158,6 +161,20 @@ class GatewayInfo(BaseModel):
# TLS mode: None/"off" → HTTP-only; "acme" → Let's Encrypt wildcard (publicly # TLS mode: None/"off" → HTTP-only; "acme" → Let's Encrypt wildcard (publicly
# trusted, no client CA setup) for host routes. # trusted, no client CA setup) for host routes.
tls: str | None = None tls: str | None = None
# Routing/exposure config (editable from the dashboard).
domain: str | None = None # acme zone → <service>.<domain>
public_domain: str | None = None # tunnel zone → <service>.<public_domain>
tunnel_id: str | None = None
tunnel_connected: bool = False # cloudflared service active
class GatewayConfigRequest(BaseModel):
"""Editable gateway settings (saved to castle.yaml; deploy to apply)."""
tls: str | None = None
domain: str | None = None
public_domain: str | None = None
tunnel_id: str | None = None
class NodeSummary(BaseModel): class NodeSummary(BaseModel):

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import shutil import shutil
import subprocess
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -19,6 +20,7 @@ from castle_api.health import check_all_health
from castle_api.models import ( from castle_api.models import (
DeploymentDetail, DeploymentDetail,
DeploymentSummary, DeploymentSummary,
GatewayConfigRequest,
GatewayInfo, GatewayInfo,
GatewayRoute, GatewayRoute,
JobDetail, JobDetail,
@@ -874,6 +876,13 @@ def get_gateway() -> GatewayInfo:
except FileNotFoundError: except FileNotFoundError:
pass 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()} remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
routes = [ routes = [
GatewayRoute( GatewayRoute(
@@ -882,12 +891,25 @@ def get_gateway() -> GatewayInfo:
target=r.target, target=r.target,
name=r.name, name=r.name,
node=r.node or registry.node.hostname, 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) for r in compute_routes(registry, config, remote or None)
] ]
# Caddyfile order is precedence-sensitive; the displayed table is alphabetical. # Caddyfile order is precedence-sensitive; the displayed table is alphabetical.
routes.sort(key=lambda r: r.address) 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( return GatewayInfo(
port=registry.node.gateway_port, port=registry.node.gateway_port,
hostname=registry.node.hostname, hostname=registry.node.hostname,
@@ -896,9 +918,35 @@ def get_gateway() -> GatewayInfo:
managed_count=managed_count, managed_count=managed_count,
routes=routes, routes=routes,
tls=registry.node.gateway_tls, 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") @router.get("/gateway/caddyfile")
def get_caddyfile() -> dict[str, str]: def get_caddyfile() -> dict[str, str]:
"""Return the generated Caddyfile content.""" """Return the generated Caddyfile content."""

View File

@@ -427,6 +427,10 @@ def save_config(config: CastleConfig) -> None:
# Only persist the provider when non-default, to keep castle.yaml minimal. # Only persist the provider when non-default, to keep castle.yaml minimal.
if config.gateway.acme_dns_provider and config.gateway.acme_dns_provider != "cloudflare": if config.gateway.acme_dns_provider and config.gateway.acme_dns_provider != "cloudflare":
gateway_data["acme_dns_provider"] = config.gateway.acme_dns_provider gateway_data["acme_dns_provider"] = config.gateway.acme_dns_provider
if config.gateway.public_domain:
gateway_data["public_domain"] = config.gateway.public_domain
if config.gateway.tunnel_id:
gateway_data["tunnel_id"] = config.gateway.tunnel_id
data: dict = {"gateway": gateway_data} data: dict = {"gateway": gateway_data}
if config.repo: if config.repo:
data["repo"] = str(config.repo) data["repo"] = str(config.repo)