From e9b197756b93be27a2f51324bfa448dfc540fe35 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 08:29:36 -0700 Subject: [PATCH] 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 . URL. --- app/src/components/GatewayPanel.tsx | 17 ++++- app/src/components/GatewaySettings.tsx | 96 ++++++++++++++++++++++++++ app/src/services/api/hooks.ts | 12 ++++ app/src/types/index.ts | 12 ++++ castle-api/src/castle_api/models.py | 17 +++++ castle-api/src/castle_api/routes.py | 48 +++++++++++++ core/src/castle_core/config.py | 4 ++ 7 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 app/src/components/GatewaySettings.tsx diff --git a/app/src/components/GatewayPanel.tsx b/app/src/components/GatewayPanel.tsx index 4281e9d..ed2087d 100644 --- a/app/src/components/GatewayPanel.tsx +++ b/app/src/components/GatewayPanel.tsx @@ -1,10 +1,11 @@ import { useMemo, useState } from "react" 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 { useGatewayReload, useCaddyfile } from "@/services/api/hooks" import { subdomainUrl } from "@/lib/labels" import { HealthBadge } from "./HealthBadge" +import { GatewaySettings } from "./GatewaySettings" interface GatewayPanelProps { gateway: GatewayInfo @@ -58,6 +59,9 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { + {/* Routing + public-exposure settings (editable) */} + + {/* Route table — every gateway route, of every kind */} {gateway.routes.length > 0 && ( @@ -98,6 +102,17 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { ) : ( route.address )} + {route.public_url && ( + + public + + )}
diff --git a/app/src/components/GatewaySettings.tsx b/app/src/components/GatewaySettings.tsx new file mode 100644 index 0000000..f258857 --- /dev/null +++ b/app/src/components/GatewaySettings.tsx @@ -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) => + setForm((f) => ({ ...f, [k]: e.target.value })) + + const publicConfigured = !!gateway.public_domain && !!gateway.tunnel_id + + if (editing) { + return ( +
+ + + + + + +
+ + + Run castle deploy to apply. +
+
+ ) + } + + return ( +
+ + TLS {gateway.tls ?? "off"} + + + Domain {gateway.domain ?? "—"} + + + + Public + {publicConfigured ? ( + <> + {gateway.public_domain} + + {gateway.tunnel_connected ? "● tunnel up" : "○ tunnel down"} + + + ) : ( + not configured + )} + + + {saved && {saved.message}} +
+ ) +} + +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 ( + + ) +} diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index 814b24a..5bf6ff7 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -11,6 +11,7 @@ import type { ProgramDetail, StatusResponse, GatewayInfo, + GatewayConfigRequest, ServiceActionResponse, SSEHealthEvent, 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() { return useQuery({ queryKey: ["nodes"], diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 4938b1b..f6dff93 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -114,6 +114,7 @@ export interface GatewayRoute { target: string // serve dir, "localhost:PORT", or "host:PORT" name: string | null node: string + public_url?: string | null // set when the service is public (via the tunnel) } export interface GatewayInfo { @@ -124,6 +125,17 @@ export interface GatewayInfo { managed_count: number routes: GatewayRoute[] tls?: string | null // "acme" → host routes served over HTTPS with a Let's Encrypt wildcard + domain?: string | null // acme zone → . + public_domain?: string | null // tunnel zone → . + 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 { diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 1005a89..e7c73f3 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -144,6 +144,9 @@ class GatewayRoute(BaseModel): target: str name: str | None = None 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): @@ -158,6 +161,20 @@ class GatewayInfo(BaseModel): # TLS mode: None/"off" → HTTP-only; "acme" → Let's Encrypt wildcard (publicly # trusted, no client CA setup) for host routes. tls: str | None = None + # Routing/exposure config (editable from the dashboard). + domain: str | None = None # acme zone → . + public_domain: str | None = None # tunnel zone → . + 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): diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index adabf2a..2d47519 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -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 (.). + 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.""" diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index d272d0a..7e9632f 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -427,6 +427,10 @@ def save_config(config: CastleConfig) -> None: # 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": 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} if config.repo: data["repo"] = str(config.repo)