feat: unified gateway route table (static · proxy · remote)

The gateway does two things — reverse-proxy services and serve static
frontends — but the dashboard/API route table only ever showed the proxy
routes, so 'serving a frontend' and 'proxying a service' looked like unrelated
features and castle-app/power-graph-app were invisible in the route view.

Now there's one concept: a route maps an address (path '/foo' or host 'foo.lan')
to a target of one kind — static (a built dist served by file_server), proxy
(a local service port), or remote (a service on another node).

- core: caddyfile.py gains compute_routes() — the single source of truth for
  the route list; generate_caddyfile_from_registry renders it (output byte-for-
  byte identical, verified against the live Caddyfile). Adds a GatewayRoute
  dataclass.
- api: GatewayRoute model → {address, kind, target, name, node}; GET /gateway
  builds from compute_routes (incl. config for static frontends + mesh for
  remote), so the table matches what Caddy actually does.
- app: Gateway panel shows Address · Kind · Target for every route (static
  frontends + host routes now appear); program detail shows 'Reachable at
  /foo/ · served (static)' for static frontends.
- cli: 'castle gateway status' prints the full route table.
- docs: registry.md/design.md/CLAUDE.md describe routes as one concept with
  three target kinds.

core 94 / cli 24 / api 52 green; ruff + app build clean; Caddyfile unchanged.
This commit is contained in:
2026-06-14 17:48:35 -07:00
parent 7314b5cddb
commit c40f84104d
11 changed files with 304 additions and 194 deletions

View File

@@ -128,8 +128,12 @@ code, artifacts, secrets; default `~/.castle`) and `CASTLE_DATA_DIR` (program
data I/O on a dedicated volume; default `/data/castle`). Paths below use
`$CASTLE_HOME` and `$CASTLE_DATA_DIR` accordingly.
- **Gateway**: Caddy reverse proxy at port 9000, config generated from `castle.yaml`
into `$CASTLE_HOME/artifacts/specs/Caddyfile`. Dashboard served at root.
- **Gateway**: Caddy at port 9000 — both a reverse proxy (to local/remote
services) and a static file server (for built frontends, served in place from
`<source>/<dist>`). Config generated from `castle.yaml` into
`$CASTLE_HOME/artifacts/specs/Caddyfile`. A route maps an address (path or
host) to a target of kind static/proxy/remote; `castle gateway status` lists
them. Dashboard (castle-app) served at root.
- **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`.
Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy`
shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`).
@@ -165,7 +169,7 @@ Config:
- `POST /apply` — Apply registry changes; `POST /deploy` — Deploy to runtime
Gateway:
- `GET /gateway` — Gateway info with route table and hostname
- `GET /gateway` — Gateway info + full route table (every route tagged kind=static|proxy|remote, with its address and target)
- `GET /gateway/caddyfile` — Generated Caddyfile content
- `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy

View File

@@ -57,14 +57,14 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</div>
</div>
{/* Route table */}
{/* Route table — every gateway route, of every kind */}
{gateway.routes.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-4 py-2 font-medium text-[var(--muted)]">Path</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Program</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Port</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Address</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Kind</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Target</th>
{multiNode && (
<th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th>
)}
@@ -73,25 +73,26 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</thead>
<tbody>
{gateway.routes.map((route) => {
const health = statusMap.get(route.program)
// Health applies to proxy/remote targets (a running service);
// static targets are files on disk.
const health = route.kind !== "static" && route.name ? statusMap.get(route.name) : undefined
return (
<tr
key={route.path}
key={`${route.address}-${route.node}`}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-black/20 transition-colors"
>
<td className="px-4 py-2 font-mono text-[var(--primary)]">
{route.path}
</td>
<td className="px-4 py-2 font-mono text-[var(--primary)]">{route.address}</td>
<td className="px-4 py-2">
<Link
to={`/deployment/${route.program}`}
className="hover:text-[var(--primary)] transition-colors"
>
{route.program}
</Link>
<KindBadge kind={route.kind} />
</td>
<td className="px-4 py-2 font-mono text-[var(--muted)]">
{route.target_port}
<td className="px-4 py-2 font-mono text-xs text-[var(--muted)]">
{route.name ? (
<Link to={`/deployment/${route.name}`} className="hover:text-[var(--primary)]">
{route.kind === "static" ? shortDir(route.target) : route.target}
</Link>
) : (
route.target
)}
</td>
{multiNode && (
<td className="px-4 py-2">
@@ -104,10 +105,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</td>
)}
<td className="px-4 py-2">
{health ? (
<HealthBadge status={health.status} latency={health.latency_ms} />
{route.kind === "static" ? (
<span className="text-xs text-[var(--muted)]"></span>
) : (
<HealthBadge status="unknown" />
<HealthBadge status={health?.status ?? "unknown"} latency={health?.latency_ms} />
)}
</td>
</tr>
@@ -119,7 +120,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
{gateway.routes.length === 0 && (
<p className="px-4 py-6 text-center text-[var(--muted)] text-sm">
No proxy routes configured.
No gateway routes configured.
</p>
)}
@@ -134,3 +135,24 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</section>
)
}
const KIND_STYLE: Record<string, string> = {
static: "bg-cyan-900/30 text-cyan-300 border-cyan-800",
proxy: "bg-green-900/30 text-green-300 border-green-800",
remote: "bg-purple-900/30 text-purple-300 border-purple-800",
}
/** Caddy serves static files; proxy/remote forward to a process. */
function KindBadge({ kind }: { kind: string }) {
return (
<span className={`text-xs px-2 py-0.5 rounded border ${KIND_STYLE[kind] ?? "text-[var(--muted)]"}`}>
{kind}
</span>
)
}
/** Show the tail of a serve directory (…/app/dist). */
function shortDir(path: string): string {
const parts = path.split("/").filter(Boolean)
return parts.length <= 2 ? path : "…/" + parts.slice(-2).join("/")
}

View File

@@ -28,6 +28,16 @@ export function ProgramDetailPage() {
)
}
// A static frontend (frontend behavior, build outputs, no service) is served
// by the gateway in place — show where.
const buildOutputs = (deployment.manifest.build as { outputs?: string[] } | undefined)?.outputs
const servedAt =
deployment.behavior === "frontend" && deployment.services.length === 0 && buildOutputs?.length
? deployment.id === "castle-app"
? "/"
: `/${deployment.id}/`
: null
return (
<div className="max-w-3xl mx-auto px-6 py-8">
<DetailHeader
@@ -101,6 +111,14 @@ export function ProgramDetailPage() {
</span>
</>
)}
{servedAt && (
<>
<span className="text-[var(--muted)]">Reachable at</span>
<a href={servedAt} className="font-mono text-[var(--primary)] hover:underline">
{servedAt} <span className="text-[var(--muted)]">· served (static)</span>
</a>
</>
)}
</div>
{deployment.commands && Object.keys(deployment.commands).length > 0 && (

View File

@@ -110,9 +110,10 @@ export interface StatusResponse {
}
export interface GatewayRoute {
path: string
target_port: number
program: string
address: string // "/foo" (path prefix) or "foo.lan" (host)
kind: "static" | "proxy" | "remote"
target: string // serve dir, "localhost:PORT", or "host:PORT"
name: string | null
node: string
}

View File

@@ -133,11 +133,17 @@ class StatusResponse(BaseModel):
class GatewayRoute(BaseModel):
"""A single route in the gateway's reverse proxy table."""
"""One gateway route: a public address mapped to a target.
path: str
target_port: int
program: str
kind is `static` (Caddy serves a built dir), `proxy` (reverse-proxy a local
service), or `remote` (reverse-proxy another node). address is a path prefix
(`/foo`) or a host (`foo.lan`); target is the serve dir or `host:port`.
"""
address: str
kind: str
target: str
name: str | None = None
node: str

View File

@@ -844,40 +844,41 @@ async def get_status() -> StatusResponse:
@router.get("/gateway", response_model=GatewayInfo)
def get_gateway() -> GatewayInfo:
"""Get gateway configuration summary."""
"""Get gateway configuration summary, including the full route table.
Routes are computed by the same function that generates the Caddyfile, so
the table matches reality: static-served frontends, path/host proxies, and
cross-node routes all appear, each tagged with its kind and target.
"""
from castle_core.generators.caddyfile import compute_routes
registry = get_registry()
deployed_count = len(registry.deployed)
service_count = sum(1 for d in registry.deployed.values() if d.port is not None)
managed_count = sum(1 for d in registry.deployed.values() if d.managed)
# Local routes
routes: list[GatewayRoute] = [
config = None
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
except FileNotFoundError:
pass
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
routes = [
GatewayRoute(
path=d.proxy_path,
target_port=d.port,
program=name,
node=registry.node.hostname,
address=r.address,
kind=r.kind,
target=r.target,
name=r.name,
node=r.node or registry.node.hostname,
)
for name, d in registry.deployed.items()
if d.proxy_path and d.port
for r in compute_routes(registry, config, remote or None)
]
# Remote routes from mesh (local paths take precedence)
local_paths = {r.path for r in routes}
for hostname, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if d.proxy_path and d.port and d.proxy_path not in local_paths:
routes.append(
GatewayRoute(
path=d.proxy_path,
target_port=d.port,
program=name,
node=hostname,
)
)
routes.sort(key=lambda r: r.path)
return GatewayInfo(
port=registry.node.gateway_port,
hostname=registry.node.hostname,

View File

@@ -252,20 +252,19 @@ class TestGateway:
assert data["managed_count"] == 1
def test_gateway_routes(self, client: TestClient) -> None:
"""Returns proxy routes from deployed components."""
"""Returns the full route table, tagged with kind + target."""
response = client.get("/gateway")
data = response.json()
routes = data["routes"]
assert len(routes) == 1
route = routes[0]
assert route["path"] == "/test-svc"
assert route["target_port"] == 19000
assert route["program"] == "test-svc"
route = next(r for r in data["routes"] if r["address"] == "/test-svc")
assert route["kind"] == "proxy"
assert route["target"] == "localhost:19000"
assert route["name"] == "test-svc"
assert route["node"] == "test-node"
def test_gateway_routes_sorted(self, client: TestClient) -> None:
"""Routes are sorted by path."""
def test_gateway_route_kinds_valid(self, client: TestClient) -> None:
"""Every route declares a known kind."""
response = client.get("/gateway")
data = response.json()
paths = [r["path"] for r in data["routes"]]
assert paths == sorted(paths)
assert data["routes"]
for r in data["routes"]:
assert r["kind"] in ("static", "proxy", "remote")

View File

@@ -116,17 +116,30 @@ def _gateway_reload() -> int:
def _gateway_status() -> int:
"""Show gateway status via systemd."""
"""Show gateway status + the full route table (static, proxy, remote)."""
result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT],
capture_output=True,
text=True,
)
status = result.stdout.strip()
print(f"Gateway: {'running' if status == 'active' else status}")
if status == "active":
print("Gateway: running")
else:
print(f"Gateway: {status}")
if not REGISTRY_PATH.exists():
print(" (no registry — run 'castle deploy')")
return 0
from castle_core.generators.caddyfile import compute_routes
routes = compute_routes(load_registry())
if not routes:
print(" No routes configured.")
return 0
# Each route: address → target, tagged by kind. static = files served in
# place; proxy/remote = reverse-proxied to a process.
print(f"\n {'ADDRESS':24} {'KIND':7} TARGET")
for r in routes:
target = r.target.replace("localhost:", ":") if r.kind != "static" else r.target
print(f" {r.address:24} {r.kind:7} {target}")
return 0

View File

@@ -1,138 +1,165 @@
"""Caddyfile generation from node registry."""
"""Gateway routes + Caddyfile generation from the node registry.
A single source of truth: `compute_routes()` produces the structured list of
gateway routes; `generate_caddyfile_from_registry()` renders that list to a
Caddyfile, and the API serves the same list to the dashboard — so the route
table always matches what Caddy actually does.
A route maps a public **address** (a path prefix `/foo`, or a host `foo.lan`) to
a **target**, of one **kind**:
- ``static`` — a built frontend's `dist/`; Caddy serves files (`file_server`).
- ``proxy`` — a local service on a port; Caddy reverse-proxies.
- ``remote`` — a service on another node; Caddy reverse-proxies cross-node.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from castle_core.config import SPECS_DIR
from castle_core.registry import NodeRegistry
@dataclass
class GatewayRoute:
"""One gateway route: address → target."""
address: str # "/foo" (path prefix, served at /foo/*) or "foo.lan" (host)
kind: str # "static" | "proxy" | "remote"
target: str # static: serve dir; proxy: "localhost:PORT"/base_url; remote: "host:PORT"
name: str | None = None # backing program/service
node: str | None = None
@property
def is_host(self) -> bool:
return not self.address.startswith("/")
def compute_routes(
registry: NodeRegistry,
config: object | None = None,
remote_registries: dict[str, NodeRegistry] | None = None,
) -> list[GatewayRoute]:
"""Build the full ordered list of gateway routes (host, proxy, remote, static).
Order matters for Caddy precedence: host matchers first, then path proxies,
then cross-node, then static frontends (the root app last)."""
if config is None:
try:
from castle_core.config import load_config
config = load_config()
except Exception:
config = None
node = registry.node.hostname
routes: list[GatewayRoute] = []
local_paths: set[str] = set()
# Host-based proxy routes (whole host → backend root).
for name, d in registry.deployed.items():
if d.proxy_host and (d.port or d.base_url):
target = d.base_url or f"localhost:{d.port}"
routes.append(GatewayRoute(d.proxy_host, "proxy", target, name, node))
# Path-prefix proxy routes.
for name, d in registry.deployed.items():
if d.proxy_path and (d.port or d.base_url):
local_paths.add(d.proxy_path)
target = d.base_url or f"localhost:{d.port}"
routes.append(GatewayRoute(d.proxy_path, "proxy", target, name, node))
# Cross-node routes — local paths take precedence.
if remote_registries:
for hostname, remote_reg in remote_registries.items():
for name, d in remote_reg.deployed.items():
if d.proxy_path and d.port and d.proxy_path not in local_paths:
local_paths.add(d.proxy_path)
routes.append(
GatewayRoute(d.proxy_path, "remote", f"{hostname}:{d.port}", name, hostname)
)
# Static frontends — a behavior=frontend program with build outputs and no
# service of its own; served in place from <source>/<dist>. castle-app is the
# root app (/), others mount at /<name>.
if config is not None:
for name, prog in sorted(config.programs.items()):
if prog.behavior != "frontend" or not prog.source:
continue
if not (prog.build and prog.build.outputs):
continue
if name in config.services: # self-serving frontend → already a proxy route
continue
serve_dir = str(Path(prog.source) / prog.build.outputs[0])
address = "/" if name == "castle-app" else f"/{name}"
if address != "/" and address in local_paths:
continue
routes.append(GatewayRoute(address, "static", serve_dir, name, node))
return routes
def generate_caddyfile_from_registry(
registry: NodeRegistry,
remote_registries: dict[str, NodeRegistry] | None = None,
) -> str:
"""Generate Caddyfile from the node registry.
Static files served from ~/.castle/artifacts/content/castle-app/.
No repo-relative paths.
If remote_registries is provided, cross-node routes are added for
remote services whose proxy_path doesn't conflict with local ones.
"""
"""Render the route list to a Caddyfile."""
routes = compute_routes(registry, None, remote_registries)
gw_port = registry.node.gateway_port
# Global options: the gateway is an internal HTTP-only reverse proxy on a
# non-standard port. Disable automatic HTTPS so named hosts don't pull the
# listener into TLS or try to bind :80/:443 (which fails for a user service).
lines = ["{", " auto_https off", "}", ""]
# HTTP-only internal gateway on a non-standard port → disable auto-HTTPS so a
# named host doesn't pull the listener into TLS or try to bind :80/:443.
lines = ["{", " auto_https off", "}", "", f":{gw_port} {{"]
lines.append(f":{gw_port} {{")
# Track local proxy paths for precedence
local_paths: set[str] = set()
# Host-based routes: a `host` matcher inside the single :9000 site (NOT a
# separate site block — that would split the listener and flip it to TLS).
# The whole host maps to the backend root, so a root-based SPA (base="/")
# serves unchanged. Emitted first so a host match wins over path routes.
for name, deployed in registry.deployed.items():
if not deployed.proxy_host:
root_static: GatewayRoute | None = None
for r in routes:
if r.kind == "static" and r.address == "/":
root_static = r # the root app is the catch-all, emitted last
continue
if not deployed.port and not deployed.base_url:
continue
target = deployed.base_url or f"localhost:{deployed.port}"
matcher = f"@host_{name.replace('-', '_')}"
lines.append(f" {matcher} host {deployed.proxy_host}")
lines.append(f" handle {matcher} {{")
lines.append(f" reverse_proxy {target}")
lines.append(" }")
lines.append("")
if r.kind == "static":
lines += [
f" handle_path {r.address}/* {{",
f" root * {r.target}",
" try_files {path} /index.html",
" file_server",
" }",
"",
]
elif r.is_host: # host-based proxy
matcher = f"@host_{(r.name or r.address).replace('-', '_').replace('.', '_')}"
lines += [
f" {matcher} host {r.address}",
f" handle {matcher} {{",
f" reverse_proxy {r.target}",
" }",
"",
]
else: # path-prefix proxy (local or remote)
if r.kind == "remote":
lines.append(f" # {r.name} on {r.node}")
lines += [
f" handle_path {r.address}/* {{",
f" reverse_proxy {r.target}",
" }",
"",
]
for name, deployed in registry.deployed.items():
if not deployed.proxy_path:
continue
# Need either a local port or a remote base_url to proxy to
if not deployed.port and not deployed.base_url:
continue
local_paths.add(deployed.proxy_path)
target = deployed.base_url or f"localhost:{deployed.port}"
lines.append(f" handle_path {deployed.proxy_path}/* {{")
lines.append(f" reverse_proxy {target}")
lines.append(" }")
lines.append("")
# Remote routes (cross-node) — local paths take precedence
if remote_registries:
for hostname, remote_reg in remote_registries.items():
for name, deployed in remote_reg.deployed.items():
if not deployed.proxy_path or not deployed.port:
continue
if deployed.proxy_path in local_paths:
continue
local_paths.add(deployed.proxy_path)
lines.append(f" # {name} on {hostname}")
lines.append(f" handle_path {deployed.proxy_path}/* {{")
lines.append(f" reverse_proxy {hostname}:{deployed.port}")
lines.append(" }")
lines.append("")
# Static frontends — served IN PLACE from each program's repo build output.
# A behavior=frontend program with no service is static; Caddy roots directly
# at <source>/<build.outputs[0]> (no central copy). castle-app is the root app
# (served at /); other static frontends mount at /<name>.
root_serve = _root_static_serve(lines, local_paths)
if root_serve is not None:
lines.append(" handle {")
lines.append(f" root * {root_serve}")
lines.append(" try_files {path} /index.html")
lines.append(" file_server")
lines.append(" }")
if root_static is not None:
lines += [
" handle {",
f" root * {root_static.target}",
" try_files {path} /index.html",
" file_server",
" }",
]
else:
fallback = SPECS_DIR / "app"
lines.append(" handle / {")
lines.append(f" root * {fallback}")
lines.append(" file_server")
lines.append(" }")
lines += [
" handle / {",
f" root * {SPECS_DIR / 'app'}",
" file_server",
" }",
]
lines.append("}")
return "\n".join(lines)
def _root_static_serve(lines: list[str], local_paths: set[str]) -> Path | None:
"""Emit handle_path blocks for non-root static frontends; return the root app's
serve dir (castle-app), or None. Static frontends are served from their repo
build output in place — no copy into a central content dir."""
try:
from castle_core.config import load_config
config = load_config()
except Exception:
return None
root_serve: Path | None = None
for name, prog in sorted(config.programs.items()):
if prog.behavior != "frontend" or not prog.source:
continue
if not (prog.build and prog.build.outputs):
continue
if name in config.services: # self-serving frontend → handled as a proxy route
continue
serve_dir = Path(prog.source) / prog.build.outputs[0]
if name == "castle-app":
root_serve = serve_dir
continue
path_prefix = f"/{name}"
if path_prefix in local_paths:
continue
local_paths.add(path_prefix)
lines.append(f" handle_path {path_prefix}/* {{")
lines.append(f" root * {serve_dir}")
lines.append(" try_files {path} /index.html")
lines.append(" file_server")
lines.append(" }")
lines.append("")
return root_serve

View File

@@ -251,8 +251,12 @@ Coordination handles discovery and communication — both between
programs on a single node and across multiple Castle nodes.
**Intra-node coordination:**
- Components find each other through the gateway (path-based routing)
or direct port access via env vars.
- Programs find each other through the gateway or direct port access via env
vars. A gateway route maps an address (path prefix or host) to a target of one
kind: **proxy** (a local service port), **remote** (a service on another
node), or **static** (a built frontend's `dist/`, served as files). The same
computed route list drives the Caddyfile, `castle gateway status`, and the
dashboard, so they always agree.
- The registry (CLI/API) provides discoverability.
- No service mesh or message broker required for basic operation.

View File

@@ -221,16 +221,31 @@ expose:
health_path: /health # Used by health polling
```
### `proxy` — How to proxy it
### `proxy` — How the gateway routes to it
```yaml
proxy:
caddy:
path_prefix: /my-service # Proxied at gateway:9000/my-service/
path_prefix: /my-service # reachable at gateway:9000/my-service/
host: my-service.lan # …or by hostname (whole host → backend root)
```
Castle generates a Caddyfile from these entries. Only needed for services
accessible through the gateway.
Castle generates the Caddyfile from these entries. Only needed for services
reachable through the gateway.
**Gateway routes — one concept, three target kinds.** The gateway (`:9000`) maps
a public **address** (a path prefix `/foo`, or a host `foo.lan`) to a **target**:
| Kind | Target | Declared by |
|------|--------|-------------|
| **proxy** | a local service on a port — Caddy `reverse_proxy localhost:PORT` | a service's `proxy.caddy` |
| **remote** | a service on another node — `reverse_proxy host:PORT` | mesh discovery |
| **static** | a built frontend's `dist/` — Caddy `file_server` (no process) | a `frontend` program with `build.outputs` and **no** service (implicit; served at `/<name>/`, `castle-app` at `/`) |
"Serving a frontend" and "proxying a service" are the same thing — a route —
differing only in whether the target is files on disk or a live process. The
complete table (all kinds) is shown by `castle gateway status`, the dashboard
Gateway panel, and `GET /gateway`; the Caddyfile is generated from it.
### `manage` — How to manage it