From c40f84104dfd083b58e05bbc7c249ed9d9844db3 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 14 Jun 2026 17:48:35 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20unified=20gateway=20route=20table=20(st?= =?UTF-8?q?atic=20=C2=B7=20proxy=20=C2=B7=20remote)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 10 +- app/src/components/GatewayPanel.tsx | 64 +++-- app/src/pages/ProgramDetail.tsx | 18 ++ app/src/types/index.ts | 7 +- castle-api/src/castle_api/models.py | 14 +- castle-api/src/castle_api/routes.py | 51 ++-- castle-api/tests/test_health.py | 21 +- cli/src/castle_cli/commands/gateway.py | 23 +- core/src/castle_core/generators/caddyfile.py | 259 ++++++++++--------- docs/design.md | 8 +- docs/registry.md | 23 +- 11 files changed, 304 insertions(+), 194 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b47fdc..9e5c157 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + `/`). 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 diff --git a/app/src/components/GatewayPanel.tsx b/app/src/components/GatewayPanel.tsx index d77d527..5cec834 100644 --- a/app/src/components/GatewayPanel.tsx +++ b/app/src/components/GatewayPanel.tsx @@ -57,14 +57,14 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { - {/* Route table */} + {/* Route table — every gateway route, of every kind */} {gateway.routes.length > 0 && ( - - - + + + {multiNode && ( )} @@ -73,25 +73,26 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { {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 ( - + - {multiNode && ( )} @@ -119,7 +120,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { {gateway.routes.length === 0 && (

- No proxy routes configured. + No gateway routes configured.

)} @@ -134,3 +135,24 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { ) } + +const KIND_STYLE: Record = { + 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 ( + + {kind} + + ) +} + +/** 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("/") +} diff --git a/app/src/pages/ProgramDetail.tsx b/app/src/pages/ProgramDetail.tsx index b623211..169835c 100644 --- a/app/src/pages/ProgramDetail.tsx +++ b/app/src/pages/ProgramDetail.tsx @@ -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 (
)} + {servedAt && ( + <> + Reachable at + + {servedAt} · served (static) + + + )}
{deployment.commands && Object.keys(deployment.commands).length > 0 && ( diff --git a/app/src/types/index.ts b/app/src/types/index.ts index e3062ca..8fc9669 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -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 } diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index c0801da..92efced 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -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 diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 6b124e7..010b523 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -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, diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 3037b34..83c2fd5 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -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") diff --git a/cli/src/castle_cli/commands/gateway.py b/cli/src/castle_cli/commands/gateway.py index 317a1a0..06ae0e0 100644 --- a/cli/src/castle_cli/commands/gateway.py +++ b/cli/src/castle_cli/commands/gateway.py @@ -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 diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index 9a02d5e..a48cce7 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -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 /. castle-app is the + # root app (/), others mount at /. + 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 / (no central copy). castle-app is the root app - # (served at /); other static frontends mount at /. - 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 diff --git a/docs/design.md b/docs/design.md index e8021db..d3b93f4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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. diff --git a/docs/registry.md b/docs/registry.md index 92c0de3..1e2253c 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -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 `//`, `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
PathProgramPortAddressKindTargetNode
- {route.path} - {route.address} - - {route.program} - + - {route.target_port} + + {route.name ? ( + + {route.kind === "static" ? shortDir(route.target) : route.target} + + ) : ( + route.target + )} @@ -104,10 +105,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { - {health ? ( - + {route.kind === "static" ? ( + ) : ( - + )}