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 (
+
+ )
+ }
+
+ 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)
|