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 data I/O on a dedicated volume; default `/data/castle`). Paths below use
`$CASTLE_HOME` and `$CASTLE_DATA_DIR` accordingly. `$CASTLE_HOME` and `$CASTLE_DATA_DIR` accordingly.
- **Gateway**: Caddy reverse proxy at port 9000, config generated from `castle.yaml` - **Gateway**: Caddy at port 9000 — both a reverse proxy (to local/remote
into `$CASTLE_HOME/artifacts/specs/Caddyfile`. Dashboard served at root. 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`. - **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`.
Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy` Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy`
shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`). shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`).
@@ -165,7 +169,7 @@ Config:
- `POST /apply` — Apply registry changes; `POST /deploy` — Deploy to runtime - `POST /apply` — Apply registry changes; `POST /deploy` — Deploy to runtime
Gateway: 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 - `GET /gateway/caddyfile` — Generated Caddyfile content
- `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy - `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy

View File

@@ -57,14 +57,14 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</div> </div>
</div> </div>
{/* Route table */} {/* 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">
<thead> <thead>
<tr className="border-b border-[var(--border)] text-left"> <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)]">Address</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)]">Kind</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)]">Target</th>
{multiNode && ( {multiNode && (
<th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th> <th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th>
)} )}
@@ -73,25 +73,26 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</thead> </thead>
<tbody> <tbody>
{gateway.routes.map((route) => { {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 ( return (
<tr <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" 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)]"> <td className="px-4 py-2 font-mono text-[var(--primary)]">{route.address}</td>
{route.path}
</td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<Link <KindBadge kind={route.kind} />
to={`/deployment/${route.program}`}
className="hover:text-[var(--primary)] transition-colors"
>
{route.program}
</Link>
</td> </td>
<td className="px-4 py-2 font-mono text-[var(--muted)]"> <td className="px-4 py-2 font-mono text-xs text-[var(--muted)]">
{route.target_port} {route.name ? (
<Link to={`/deployment/${route.name}`} className="hover:text-[var(--primary)]">
{route.kind === "static" ? shortDir(route.target) : route.target}
</Link>
) : (
route.target
)}
</td> </td>
{multiNode && ( {multiNode && (
<td className="px-4 py-2"> <td className="px-4 py-2">
@@ -104,10 +105,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</td> </td>
)} )}
<td className="px-4 py-2"> <td className="px-4 py-2">
{health ? ( {route.kind === "static" ? (
<HealthBadge status={health.status} latency={health.latency_ms} /> <span className="text-xs text-[var(--muted)]"></span>
) : ( ) : (
<HealthBadge status="unknown" /> <HealthBadge status={health?.status ?? "unknown"} latency={health?.latency_ms} />
)} )}
</td> </td>
</tr> </tr>
@@ -119,7 +120,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
{gateway.routes.length === 0 && ( {gateway.routes.length === 0 && (
<p className="px-4 py-6 text-center text-[var(--muted)] text-sm"> <p className="px-4 py-6 text-center text-[var(--muted)] text-sm">
No proxy routes configured. No gateway routes configured.
</p> </p>
)} )}
@@ -134,3 +135,24 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</section> </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 ( return (
<div className="max-w-3xl mx-auto px-6 py-8"> <div className="max-w-3xl mx-auto px-6 py-8">
<DetailHeader <DetailHeader
@@ -101,6 +111,14 @@ export function ProgramDetailPage() {
</span> </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> </div>
{deployment.commands && Object.keys(deployment.commands).length > 0 && ( {deployment.commands && Object.keys(deployment.commands).length > 0 && (

View File

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

View File

@@ -133,11 +133,17 @@ class StatusResponse(BaseModel):
class GatewayRoute(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 kind is `static` (Caddy serves a built dir), `proxy` (reverse-proxy a local
target_port: int service), or `remote` (reverse-proxy another node). address is a path prefix
program: str (`/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 node: str

View File

@@ -844,40 +844,41 @@ async def get_status() -> StatusResponse:
@router.get("/gateway", response_model=GatewayInfo) @router.get("/gateway", response_model=GatewayInfo)
def get_gateway() -> 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() registry = get_registry()
deployed_count = len(registry.deployed) deployed_count = len(registry.deployed)
service_count = sum(1 for d in registry.deployed.values() if d.port is not None) 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) managed_count = sum(1 for d in registry.deployed.values() if d.managed)
# Local routes config = None
routes: list[GatewayRoute] = [ 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( GatewayRoute(
path=d.proxy_path, address=r.address,
target_port=d.port, kind=r.kind,
program=name, target=r.target,
node=registry.node.hostname, name=r.name,
node=r.node or registry.node.hostname,
) )
for name, d in registry.deployed.items() for r in compute_routes(registry, config, remote or None)
if d.proxy_path and d.port
] ]
# 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( return GatewayInfo(
port=registry.node.gateway_port, port=registry.node.gateway_port,
hostname=registry.node.hostname, hostname=registry.node.hostname,

View File

@@ -252,20 +252,19 @@ class TestGateway:
assert data["managed_count"] == 1 assert data["managed_count"] == 1
def test_gateway_routes(self, client: TestClient) -> None: 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") response = client.get("/gateway")
data = response.json() data = response.json()
routes = data["routes"] route = next(r for r in data["routes"] if r["address"] == "/test-svc")
assert len(routes) == 1 assert route["kind"] == "proxy"
route = routes[0] assert route["target"] == "localhost:19000"
assert route["path"] == "/test-svc" assert route["name"] == "test-svc"
assert route["target_port"] == 19000
assert route["program"] == "test-svc"
assert route["node"] == "test-node" assert route["node"] == "test-node"
def test_gateway_routes_sorted(self, client: TestClient) -> None: def test_gateway_route_kinds_valid(self, client: TestClient) -> None:
"""Routes are sorted by path.""" """Every route declares a known kind."""
response = client.get("/gateway") response = client.get("/gateway")
data = response.json() data = response.json()
paths = [r["path"] for r in data["routes"]] assert data["routes"]
assert paths == sorted(paths) 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: def _gateway_status() -> int:
"""Show gateway status via systemd.""" """Show gateway status + the full route table (static, proxy, remote)."""
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT], ["systemctl", "--user", "is-active", GATEWAY_UNIT],
capture_output=True, capture_output=True,
text=True, text=True,
) )
status = result.stdout.strip() status = result.stdout.strip()
print(f"Gateway: {'running' if status == 'active' else status}")
if status == "active": if not REGISTRY_PATH.exists():
print("Gateway: running") print(" (no registry — run 'castle deploy')")
else: return 0
print(f"Gateway: {status}")
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 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 __future__ import annotations
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from castle_core.config import SPECS_DIR from castle_core.config import SPECS_DIR
from castle_core.registry import NodeRegistry from castle_core.registry import NodeRegistry
def generate_caddyfile_from_registry( @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, registry: NodeRegistry,
config: object | None = None,
remote_registries: dict[str, NodeRegistry] | None = None, remote_registries: dict[str, NodeRegistry] | None = None,
) -> str: ) -> list[GatewayRoute]:
"""Generate Caddyfile from the node registry. """Build the full ordered list of gateway routes (host, proxy, remote, static).
Static files served from ~/.castle/artifacts/content/castle-app/. Order matters for Caddy precedence: host matchers first, then path proxies,
No repo-relative paths. then cross-node, then static frontends (the root app last)."""
if config is None:
If remote_registries is provided, cross-node routes are added for
remote services whose proxy_path doesn't conflict with local ones.
"""
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", "}", ""]
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:
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("")
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(" }")
else:
fallback = SPECS_DIR / "app"
lines.append(" handle / {")
lines.append(f" root * {fallback}")
lines.append(" file_server")
lines.append(" }")
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: try:
from castle_core.config import load_config from castle_core.config import load_config
config = load_config() config = load_config()
except Exception: except Exception:
return None config = None
root_serve: Path | None = 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()): for name, prog in sorted(config.programs.items()):
if prog.behavior != "frontend" or not prog.source: if prog.behavior != "frontend" or not prog.source:
continue continue
if not (prog.build and prog.build.outputs): if not (prog.build and prog.build.outputs):
continue continue
if name in config.services: # self-serving frontend → handled as a proxy route if name in config.services: # self-serving frontend → already a proxy route
continue continue
serve_dir = Path(prog.source) / prog.build.outputs[0] serve_dir = str(Path(prog.source) / prog.build.outputs[0])
if name == "castle-app": address = "/" if name == "castle-app" else f"/{name}"
root_serve = serve_dir if address != "/" and address in local_paths:
continue continue
path_prefix = f"/{name}" routes.append(GatewayRoute(address, "static", serve_dir, name, node))
if path_prefix in local_paths:
return routes
def generate_caddyfile_from_registry(
registry: NodeRegistry,
remote_registries: dict[str, NodeRegistry] | None = None,
) -> str:
"""Render the route list to a Caddyfile."""
routes = compute_routes(registry, None, remote_registries)
gw_port = registry.node.gateway_port
# 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} {{"]
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 continue
local_paths.add(path_prefix) if r.kind == "static":
lines.append(f" handle_path {path_prefix}/* {{") lines += [
lines.append(f" root * {serve_dir}") f" handle_path {r.address}/* {{",
lines.append(" try_files {path} /index.html") f" root * {r.target}",
lines.append(" file_server") " try_files {path} /index.html",
lines.append(" }") " file_server",
lines.append("") " }",
return root_serve "",
]
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}",
" }",
"",
]
if root_static is not None:
lines += [
" handle {",
f" root * {root_static.target}",
" try_files {path} /index.html",
" file_server",
" }",
]
else:
lines += [
" handle / {",
f" root * {SPECS_DIR / 'app'}",
" file_server",
" }",
]
lines.append("}")
return "\n".join(lines)

View File

@@ -251,8 +251,12 @@ Coordination handles discovery and communication — both between
programs on a single node and across multiple Castle nodes. programs on a single node and across multiple Castle nodes.
**Intra-node coordination:** **Intra-node coordination:**
- Components find each other through the gateway (path-based routing) - Programs find each other through the gateway or direct port access via env
or direct port access via env vars. 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. - The registry (CLI/API) provides discoverability.
- No service mesh or message broker required for basic operation. - No service mesh or message broker required for basic operation.

View File

@@ -221,16 +221,31 @@ expose:
health_path: /health # Used by health polling health_path: /health # Used by health polling
``` ```
### `proxy` — How to proxy it ### `proxy` — How the gateway routes to it
```yaml ```yaml
proxy: proxy:
caddy: 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 Castle generates the Caddyfile from these entries. Only needed for services
accessible through the gateway. 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 ### `manage` — How to manage it