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:
@@ -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} />
|
||||||
|
|||||||
96
app/src/components/GatewaySettings.tsx
Normal file
96
app/src/components/GatewaySettings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user