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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user