Subdomain-only gateway routing; drop path prefixes

Collapse the two proxy shapes (path_prefix + host) to a single checkbox: a
service is either port-only, or exposed at <service-name>.<gateway.domain>. Path
routes and their prefix-stripping foot-guns are gone; the subdomain is always the
service name (rename the service to change it).

- Schema: CaddySpec is just `enable` (presence = expose). service_proxy_targets
  returns (expose, port, base_url); Deployment carries `subdomain` (was
  proxy_path/proxy_host). Registry/deploy/mesh/CLI updated in lockstep.
- Generator: compute_routes emits one host route per exposed service/frontend
  (address = name). acme mode = one *.<domain> site with a reverse_proxy matcher
  per service + a file_server matcher per frontend; the :<port> site redirects to
  the dashboard. off mode = a HTTP control plane on :<port> (dashboard at / +
  /api → castle-api); other services are port-only.
- Dashboard/API: castle-app and castle-api are ordinary subdomains. The dashboard
  derives the API base at runtime (castle-api.<domain> on a subdomain, else /api)
  and calls it cross-origin — castle-api already allows CORS *. Frontends build
  with VITE_BASE=/ (served at their subdomain root).
- CLI: `service create` drops --path/--host; --no-proxy makes it port-only.
- Docs/tests reworked for the model; docs use generic placeholders only (no
  personal host/domain/IP details).
This commit is contained in:
2026-06-30 20:47:03 -07:00
parent a022a136fe
commit 43ef1cc588
23 changed files with 382 additions and 712 deletions

View File

@@ -1 +1,4 @@
VITE_API_BASE_URL=/api
# The API base URL is derived at runtime (see src/services/api/client.ts):
# - dashboard at castle-app.<domain> -> castle-api.<domain> (cross-origin, CORS)
# - dev / off-mode :9000 (bare host) -> /api (same-origin)
# Set VITE_API_BASE_URL only to force a specific base (rarely needed).

View File

@@ -1,4 +1,22 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"
// Resolve the castle-api base URL. The gateway serves each service at its own
// subdomain (<name>.<domain>), so when the dashboard runs at castle-app.<domain>
// the API lives at castle-api.<domain> — a cross-origin call (castle-api allows
// CORS *). When served at a bare host (dev, or the off-mode :9000 gateway), the
// API is reachable same-origin at /api.
function resolveApiBase(): string {
const configured = import.meta.env.VITE_API_BASE_URL
if (configured) return configured
if (typeof window !== "undefined") {
const { protocol, hostname } = window.location
const labels = hostname.split(".")
if (labels.length > 2) {
return `${protocol}//castle-api.${labels.slice(1).join(".")}`
}
}
return "/api"
}
const BASE_URL = resolveApiBase()
class ApiError extends Error {
status: number

View File

@@ -22,7 +22,7 @@ class DeploymentSummary(BaseModel):
runner: str | None = None
port: int | None = None
health_path: str | None = None
proxy_path: str | None = None
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
managed: bool = False
systemd: SystemdInfo | None = None
version: str | None = None
@@ -53,8 +53,7 @@ class ServiceSummary(BaseModel):
run_target: str | None = None # what it runs: program name, argv, image, …
port: int | None = None
health_path: str | None = None
proxy_path: str | None = None
proxy_host: str | None = None
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
managed: bool = False
systemd: SystemdInfo | None = None
program: str | None = None # the program this deployment references, if any

View File

@@ -54,8 +54,8 @@ def _registry_to_json(registry: NodeRegistry) -> str:
entry["port"] = comp.port
if comp.health_path:
entry["health_path"] = comp.health_path
if comp.proxy_path:
entry["proxy_path"] = comp.proxy_path
if comp.subdomain:
entry["subdomain"] = comp.subdomain
if comp.schedule:
entry["schedule"] = comp.schedule
if comp.managed:
@@ -85,7 +85,7 @@ def _json_to_registry(payload: str) -> NodeRegistry:
stack=comp_data.get("stack"),
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_path"),
subdomain=comp_data.get("subdomain"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
)

View File

@@ -53,7 +53,7 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSu
runner=d.runner,
port=d.port,
health_path=d.health_path,
proxy_path=d.proxy_path,
subdomain=d.subdomain,
managed=d.managed,
schedule=d.schedule,
node=hostname,

View File

@@ -86,7 +86,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
runner=deployed.runner,
port=deployed.port,
health_path=deployed.health_path,
proxy_path=deployed.proxy_path,
subdomain=deployed.subdomain,
managed=managed,
systemd=systemd_info,
schedule=deployed.schedule,
@@ -100,12 +100,10 @@ def _summary_from_service(
"""Build a DeploymentSummary from a ServiceSpec (non-deployed)."""
port = None
health_path = None
proxy_path = None
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
health_path = svc.expose.http.health_path
if svc.proxy and svc.proxy.caddy:
proxy_path = svc.proxy.caddy.path_prefix
subdomain = name if (svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable) else None
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
@@ -138,7 +136,7 @@ def _summary_from_service(
runner=runner,
port=port,
health_path=health_path,
proxy_path=proxy_path,
subdomain=subdomain,
managed=managed,
systemd=systemd_info,
source=source,
@@ -266,8 +264,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
run_target=run_target,
port=deployed.port,
health_path=deployed.health_path,
proxy_path=deployed.proxy_path,
proxy_host=deployed.proxy_host,
subdomain=deployed.subdomain,
managed=deployed.managed,
systemd=systemd_info,
)
@@ -277,12 +274,11 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
"""Build a ServiceSummary from a ServiceSpec."""
port = None
health_path = None
proxy_path = None
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
health_path = svc.expose.http.health_path
if svc.proxy and svc.proxy.caddy:
proxy_path = svc.proxy.caddy.path_prefix
# Exposed at <name>.<domain> when the proxy checkbox is on.
subdomain = name if (svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable) else None
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
systemd_info = _make_systemd_info(name) if managed else None
@@ -297,7 +293,6 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
source = comp.source
stack = comp.stack
proxy_host = svc.proxy.caddy.host if (svc.proxy and svc.proxy.caddy) else None
return ServiceSummary(
id=name,
description=description,
@@ -306,8 +301,7 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
run_target=_run_target(svc.run),
port=port,
health_path=health_path,
proxy_path=proxy_path,
proxy_host=proxy_host,
subdomain=subdomain,
managed=managed,
systemd=systemd_info,
program=svc.program,
@@ -512,7 +506,7 @@ def get_service(name: str) -> ServiceDetail:
"secret_env_keys": deployed.secret_env_keys,
"port": deployed.port,
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"subdomain": deployed.subdomain,
"managed": deployed.managed,
"behavior": deployed.behavior,
"stack": deployed.stack,
@@ -762,7 +756,7 @@ def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
runner=d.runner,
port=d.port,
health_path=d.health_path,
proxy_path=d.proxy_path,
subdomain=d.subdomain,
managed=d.managed,
schedule=d.schedule,
node=hostname,
@@ -809,7 +803,7 @@ def get_component(name: str) -> DeploymentDetail:
"secret_env_keys": deployed.secret_env_keys,
"port": deployed.port,
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"subdomain": deployed.subdomain,
"managed": deployed.managed,
"behavior": deployed.behavior,
"stack": deployed.stack,

View File

@@ -118,7 +118,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
behavior="daemon",
port=19000,
health_path="/health",
proxy_path="/test-svc",
subdomain="test-svc",
managed=True,
),
},

View File

@@ -32,7 +32,7 @@ class TestComponents:
svc = next(c for c in data if c["id"] == "test-svc")
assert svc["port"] == 19000
assert svc["health_path"] == "/health"
assert svc["proxy_path"] == "/test-svc"
assert svc["subdomain"] == "test-svc"
assert svc["managed"] is True
assert svc["behavior"] == "daemon"
@@ -89,7 +89,7 @@ class TestServicesList:
svc = next(s for s in data if s["id"] == "test-svc")
assert svc["port"] == 19000
assert svc["health_path"] == "/health"
assert svc["proxy_path"] == "/test-svc"
assert svc["subdomain"] == "test-svc"
assert svc["managed"] is True
def test_no_schedule_field(self, client: TestClient) -> None:
@@ -255,7 +255,8 @@ class TestGateway:
"""Returns the full route table, tagged with kind + target."""
response = client.get("/gateway")
data = response.json()
route = next(r for r in data["routes"] if r["address"] == "/test-svc")
# Address is the subdomain label (the service name), not a path.
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"

View File

@@ -20,7 +20,7 @@ def _make_registry() -> NodeRegistry:
stack="python-fastapi",
port=9001,
health_path="/health",
proxy_path="/my-svc",
subdomain="my-svc",
managed=True,
),
"my-job": Deployment(
@@ -54,7 +54,7 @@ class TestRegistrySerialization:
assert svc.runner == "python"
assert svc.port == 9001
assert svc.health_path == "/health"
assert svc.proxy_path == "/my-svc"
assert svc.subdomain == "my-svc"
assert svc.managed is True
assert svc.behavior == "daemon"
assert svc.stack == "python-fastapi"
@@ -82,7 +82,7 @@ class TestRegistrySerialization:
bare = restored.deployed["bare"]
assert bare.port is None
assert bare.health_path is None
assert bare.proxy_path is None
assert bare.subdomain is None
assert bare.schedule is None
assert bare.managed is False

View File

@@ -121,7 +121,7 @@ def run_create(args: argparse.Namespace) -> int:
health_path="/health",
)
),
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
proxy=ProxySpec(caddy=CaddySpec()), # expose at <name>.<gateway.domain>
manage=ManageSpec(systemd=SystemdSpec()),
# python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's
# computed port/data dir to those vars (explicit, no hidden injection).

View File

@@ -71,14 +71,8 @@ def run_service_create(args: argparse.Namespace) -> int:
)
)
if not args.no_proxy:
path = args.path
if args.path:
path = args.path if args.path.startswith("/") else f"/{args.path}"
elif args.host:
path = None
else:
path = f"/{name}"
proxy = ProxySpec(caddy=CaddySpec(path_prefix=path, host=args.host))
# Expose at <name>.<gateway.domain> (the subdomain is the service name).
proxy = ProxySpec(caddy=CaddySpec())
config.services[name] = ServiceSpec(
id=name,
@@ -97,10 +91,7 @@ def run_service_create(args: argparse.Namespace) -> int:
if expose:
print(f" port: {args.port}")
if proxy and proxy.caddy:
if proxy.caddy.path_prefix:
print(f" proxy: {proxy.caddy.path_prefix}")
if proxy.caddy.host:
print(f" host: {proxy.caddy.host}")
print(f" subdomain: {name}.<gateway.domain>")
print(f"\nNext: castle service deploy {name} && castle service start {name}")
return 0

View File

@@ -119,10 +119,8 @@ def run_info(args: argparse.Namespace) -> int:
print(f" {BOLD}port{RESET}: {http.internal.port}")
if http.health_path:
print(f" {BOLD}health{RESET}: {http.health_path}")
if service.proxy and service.proxy.caddy:
caddy = service.proxy.caddy
if caddy.path_prefix:
print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
if service.proxy and service.proxy.caddy and service.proxy.caddy.enable:
print(f" {BOLD}subdomain{RESET}: {name}.<gateway.domain>")
if service.manage and service.manage.systemd:
sd = service.manage.systemd
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")

View File

@@ -90,9 +90,11 @@ def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
if kind == "service":
p.add_argument("--port", type=int, help="HTTP port")
p.add_argument("--health", default="/health", help="Health path (default: /health)")
p.add_argument("--path", help="Gateway proxy prefix (default: /<name>)")
p.add_argument("--host", help="Route by hostname instead of a path prefix")
p.add_argument("--no-proxy", action="store_true", help="Don't add a gateway route")
p.add_argument(
"--no-proxy",
action="store_true",
help="Port-only; don't expose at <name>.<gateway.domain>",
)
else:
p.add_argument("--schedule", default="0 2 * * *", help="Cron schedule (default: 0 2 * * *)")

View File

@@ -267,10 +267,10 @@ def _build_deployed_service(
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False
# Routing fields (port/proxy_path/proxy_host/base_url) come from the shared
# deriver, so the registry written here and the Caddyfile computed from
# castle.yaml stay in lockstep.
proxy_path, proxy_host, port, base_url = service_proxy_targets(name, svc)
# Routing comes from the shared deriver, so the registry written here and the
# Caddyfile computed from castle.yaml stay in lockstep. `expose` is the
# checkbox; the subdomain is the service name.
expose, port, base_url = service_proxy_targets(name, svc)
health_path = None
if svc.expose and svc.expose.http:
health_path = svc.expose.http.health_path
@@ -301,27 +301,11 @@ def _build_deployed_service(
)
stop_cmd = _build_stop_cmd(name, run, source_dir)
# Proxy: a path prefix (handle_path on the gateway) and/or a hostname (a
# dedicated host site block, so a root-based app serves unchanged).
proxy_path = None
proxy_host = None
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
caddy = svc.proxy.caddy
proxy_host = caddy.host
if caddy.path_prefix:
proxy_path = caddy.path_prefix
elif not caddy.host:
# No explicit path and no host → default to /<name>.
proxy_path = f"/{name}"
# Resolve stack from referenced program
stack = None
if svc.program and svc.program in config.programs:
stack = config.programs[svc.program].stack
# Remote services proxy to an external base_url
base_url = getattr(run, "base_url", None)
return Deployment(
runner=run.runner,
run_cmd=run_cmd,
@@ -333,8 +317,7 @@ def _build_deployed_service(
stack=stack,
port=port,
health_path=health_path,
proxy_path=proxy_path,
proxy_host=proxy_host,
subdomain=(name if expose else None),
base_url=base_url,
managed=managed,
)
@@ -576,8 +559,8 @@ def _format_deployed(name: str, deployed: Deployment) -> str:
parts.append(f"port={deployed.port}")
if deployed.schedule:
parts.append(f"schedule={deployed.schedule}")
if deployed.proxy_path:
parts.append(f"proxy={deployed.proxy_path}")
if deployed.subdomain:
parts.append(f"subdomain={deployed.subdomain}")
return " ".join(parts)

View File

@@ -46,45 +46,34 @@ class GatewayRoute:
return not self.address.startswith("/")
# (proxy_path, proxy_host, port, base_url) for a service's gateway route(s).
ProxyTargets = tuple[str | None, str | None, int | None, str | None]
# (expose?, port, base_url) — expose=True → route <service-name>.<domain> here.
ProxyTargets = tuple[bool, int | None, str | None]
def service_proxy_targets(name: str, svc: ServiceSpec) -> ProxyTargets:
"""Derive a service's gateway routing fields from its spec.
"""Derive a service's gateway exposure from its spec.
The single source of truth shared by the registry build (``deploy``) and
route computation (``compute_routes``), so the deployed registry and a
freshly regenerated Caddyfile can never disagree about ports/paths/hosts.
route computation (``compute_routes``), so they never disagree. ``expose`` is
the checkbox (``proxy.caddy`` present and enabled); the subdomain is always the
service name, so there's nothing else to derive.
"""
port = None
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
proxy_path = None
proxy_host = None
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
caddy = svc.proxy.caddy
proxy_host = caddy.host
if caddy.path_prefix:
proxy_path = caddy.path_prefix
elif not caddy.host:
# No explicit path and no host → default to /<name>.
proxy_path = f"/{name}"
expose = bool(svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable)
base_url = getattr(svc.run, "base_url", None)
return proxy_path, proxy_host, port, base_url
return expose, port, base_url
def _local_proxy_targets(
def _local_exposures(
config: CastleConfig | None, registry: NodeRegistry
) -> list[tuple[str, ProxyTargets]]:
"""Local services' routing fields, name-sorted for deterministic output.
"""Each local service's (expose?, port, base_url), name-sorted.
Prefers ``castle.yaml`` (``config.services``) as the source of truth so a
regenerated Caddyfile always reflects the current spec — this is what closes
the registry-staleness drift. Falls back to the deployed registry snapshot
only when config isn't available (load failed, or a pure-registry context).
regenerated Caddyfile always reflects the current spec; falls back to the
deployed registry snapshot when config isn't available.
"""
services = getattr(config, "services", None)
if services is not None:
@@ -92,7 +81,7 @@ def _local_proxy_targets(
(name, service_proxy_targets(name, svc)) for name, svc in services.items()
)
return sorted(
(name, (d.proxy_path, d.proxy_host, d.port, d.base_url))
(name, (d.subdomain is not None, d.port, d.base_url))
for name, d in registry.deployed.items()
)
@@ -102,10 +91,11 @@ def compute_routes(
config: CastleConfig | 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)."""
"""Build the ordered list of gateway routes. Every route is a host route whose
address is the service/frontend **name** (published at ``<name>.<domain>``);
``proxy`` routes reverse-proxy a local port, ``static`` routes file-serve a
frontend's dist. Path routes no longer exist. ``remote_registries`` is accepted
for signature compatibility but cross-node routing is out of scope here."""
if config is None:
try:
from castle_core.config import load_config
@@ -116,38 +106,15 @@ def compute_routes(
node = registry.node.hostname
routes: list[GatewayRoute] = []
local_paths: set[str] = set()
# Local proxy routes, derived from castle.yaml when available (else the
# deployed registry) so a regenerated Caddyfile tracks the current spec.
local = _local_proxy_targets(config, registry)
# Host-based proxy routes (whole host → backend root).
for name, (proxy_path, proxy_host, port, base_url) in local:
if proxy_host and (port or base_url):
# Exposed services → a subdomain reverse-proxy (address = service name).
for name, (expose, port, base_url) in _local_exposures(config, registry):
if expose and (port or base_url):
target = base_url or f"localhost:{port}"
routes.append(GatewayRoute(proxy_host, "proxy", target, name, node))
# Path-prefix proxy routes.
for name, (proxy_path, proxy_host, port, base_url) in local:
if proxy_path and (port or base_url):
local_paths.add(proxy_path)
target = base_url or f"localhost:{port}"
routes.append(GatewayRoute(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)
)
routes.append(GatewayRoute(name, "proxy", target, name, node))
# 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>.
# service of its own; served in place from <source>/<dist> at <name>.<domain>.
if config is not None:
for name, prog in sorted(config.programs.items()):
if prog.behavior != "frontend" or not prog.source:
@@ -157,10 +124,7 @@ def compute_routes(
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))
routes.append(GatewayRoute(name, "static", serve_dir, name, node))
return routes
@@ -180,33 +144,51 @@ def _host_matcher_block(label: str, host: str, target: str) -> list[str]:
]
def _host_static_block(label: str, host: str, serve_dir: str) -> list[str]:
"""A host matcher that file-serves a frontend's dist (with SPA fallback)."""
matcher = f"@host_{label.replace('-', '_').replace('.', '_')}"
return [
f" {matcher} host {host}",
f" handle {matcher} {{",
f" root * {serve_dir}",
" try_files {path} /index.html",
" file_server",
" }",
"",
]
# Castle's own control plane: the dashboard frontend and the API it calls. These
# names are the subdomains they're published at in acme mode, and the pair served
# on the :<port> site in off mode (no domain → no subdomains).
_DASHBOARD = "castle-app"
_API = "castle-api"
def generate_caddyfile_from_registry(
registry: NodeRegistry,
remote_registries: dict[str, NodeRegistry] | None = None,
) -> str:
"""Render the route list to a Caddyfile.
"""Render the routes to a Caddyfile. Every exposed service/frontend is a
subdomain `<name>.<domain>`; there are no path routes.
Two modes, set by `gateway.tls`:
- **off (default)** — HTTP-only. Everything (host matchers + path prefixes +
static) lives in one `:<port>` site with `auto_https off`, so a named host
can't pull the listener into TLS or try to bind :80/:443.
- **acme** — host routes are served under a single `*.<domain>` site with a
real Let's Encrypt **wildcard** cert obtained via a DNS-01 challenge (one
cert for all of them). Publicly trusted → no CA install on clients. Each
host route is published at `<label>.<domain>`, where `<label>` is the first
DNS label of the service's `proxy.caddy.host` (so a bare `claw`, or a legacy
`claw.civil.lan`, both yield `claw.<domain>`). Requires `gateway.domain`;
the DNS provider token reaches Caddy via `{env.<TOKEN>}`.
- **acme** — one `*.<domain>` site (a single DNS-01 wildcard cert) with a host
matcher per route: `reverse_proxy` for services, `file_server` for frontends.
The `:<port>` site just redirects to the dashboard subdomain (the "browse to
the box by IP" entry).
- **off / no domain** — no subdomains available, so the `:<port>` site serves
castle's control plane only: the dashboard at `/` + `reverse_proxy /api/*` →
castle-api (the one surviving path, for the dashboard's own backend). Other
services are reachable at their `host:port` directly.
"""
routes = compute_routes(registry, None, remote_registries)
node = registry.node
gw_port = node.gateway_port
mode = (node.gateway_tls or "").lower()
domain = node.gateway_domain
tls_acme = mode == "acme" and bool(domain) # acme without a domain → off-mode
host_routes = [r for r in routes if r.is_host]
tls_acme = mode == "acme" and bool(domain)
lines: list[str] = []
if tls_acme:
@@ -220,80 +202,48 @@ def generate_caddyfile_from_registry(
if os.environ.get("CASTLE_ACME_STAGING") == "1":
lines.append(f" acme_ca {_ACME_STAGING_CA}")
lines += ["}", ""]
# One wildcard site → a single DNS-01 cert covers every host route, so a
# new host-routed service needs no new cert or challenge. The published
# subdomain is the host's first label (a bare `claw` or `claw.civil.lan`
# both → `claw.<domain>`), keeping services domain-agnostic.
if host_routes:
# One wildcard site → a single cert covers every subdomain; a new service
# needs no new cert or challenge.
if routes:
lines.append(f"*.{domain} {{")
for r in host_routes:
sub = (r.address.split(".")[0] if r.address else "") or r.name or ""
lines += _host_matcher_block(r.name or r.address, f"{sub}.{domain}", r.target)
for r in routes:
host = f"{r.address}.{domain}"
if r.kind == "static":
lines += _host_static_block(r.name or r.address, host, r.target)
else:
lines += _host_matcher_block(r.name or r.address, host, r.target)
lines.append("}")
lines.append("")
else:
# HTTP-only: keep auto-HTTPS off so the bare-port site stays plain HTTP
# and named hosts don't trigger cert provisioning.
# Redirect the bare gateway port to the dashboard subdomain.
lines += [
f":{gw_port} {{",
f" redir https://{_DASHBOARD}.{domain}{{uri}}",
"}",
]
return "\n".join(lines)
# off mode: HTTP-only control plane on :<port>.
if mode == "acme" and not domain:
lines.append("# gateway.tls=acme but gateway.domain is unset — host routes")
lines.append("# fall back to plain-HTTP matchers on the gateway port.")
lines += ["{", " auto_https off", "}", ""]
lines.append("# gateway.tls=acme but gateway.domain is unset — serving the")
lines.append("# control plane on the gateway port; services are port-only.")
lines += ["{", " auto_https off", "}", "", f":{gw_port} {{"]
lines.append(f":{gw_port} {{")
# Trailing-slash redirect: `handle_path /foo/*` doesn't match the bare `/foo`,
# so without this the no-slash form falls through to the root catch-all
# (serving the wrong app). Redirect /foo → /foo/ for each path-prefix route.
# (Caddy's bare path matcher is exact, so /foo/bar is unaffected.)
redirs = [r.address for r in routes if r.address.startswith("/") and r.address != "/"]
for prefix in redirs:
lines.append(f" redir {prefix} {prefix}/")
if redirs:
lines.append("")
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 r.kind == "static":
api = next((r for r in routes if r.name == _API and r.kind == "proxy"), None)
if api is not None:
lines += [
f" handle_path {r.address}/* {{",
f" root * {r.target}",
" try_files {path} /index.html",
" file_server",
" handle_path /api/* {",
f" reverse_proxy {api.target}",
" }",
"",
]
elif r.is_host: # host-based proxy
if tls_acme:
continue # emitted in the *.<domain> wildcard site above
lines += _host_matcher_block(r.name or r.address, r.address, 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:
app = next((r for r in routes if r.name == _DASHBOARD and r.kind == "static"), None)
root = app.target if app is not None else str(SPECS_DIR / "app")
lines += [
" handle {",
f" root * {root_static.target}",
f" root * {root}",
" 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

@@ -146,12 +146,10 @@ class ExposeSpec(BaseModel):
class CaddySpec(BaseModel):
# The "expose" checkbox: when present and enabled, the gateway routes
# <service-name>.<gateway.domain> to this service (whole host → backend root).
# The subdomain is always the service name — rename the service to change it.
enable: bool = True
path_prefix: str | None = None
# Route by hostname instead of (or alongside) a path prefix. The whole host
# maps to the backend root, so an app built with base="/" works unchanged —
# the fix for proxying a root-based SPA the gateway can't rebuild.
host: str | None = None
extra_snippets: list[str] = Field(default_factory=list)

View File

@@ -52,8 +52,9 @@ class Deployment:
stack: str | None = None
port: int | None = None
health_path: str | None = None
proxy_path: str | None = None
proxy_host: str | None = None
# Exposed at <subdomain>.<gateway.domain> (the subdomain is the service name),
# or None when the service is reachable only at its host:port.
subdomain: str | None = None
base_url: str | None = None
schedule: str | None = None
managed: bool = False
@@ -121,8 +122,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
stack=comp_data.get("stack"),
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_path"),
proxy_host=comp_data.get("proxy_host"),
subdomain=comp_data.get("subdomain"),
base_url=comp_data.get("base_url"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
@@ -178,10 +178,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["port"] = comp.port
if comp.health_path:
entry["health_path"] = comp.health_path
if comp.proxy_path:
entry["proxy_path"] = comp.proxy_path
if comp.proxy_host:
entry["proxy_host"] = comp.proxy_host
if comp.subdomain:
entry["subdomain"] = comp.subdomain
if comp.base_url:
entry["base_url"] = comp.base_url
if comp.schedule:

View File

@@ -71,11 +71,10 @@ async def _run(
def _vite_base(name: str) -> str:
"""The base path a castle-served static frontend builds against.
Matches the Caddyfile serve prefix: castle-app is the root app ('/'); every
other static frontend mounts at '/<name>/'. Exposed to the build as VITE_BASE
so the bundle's absolute asset URLs resolve at the gateway subpath (the
vite.config reads `base: process.env.VITE_BASE ?? '/'`)."""
return "/" if name == "castle-app" else f"/{name}/"
Every frontend now serves at the **root of its own subdomain**
(`<name>.<gateway.domain>`), so the base is always '/'. Exposed to the build
as VITE_BASE (the vite.config reads `base: process.env.VITE_BASE ?? '/'`)."""
return "/"
def _source_dir(comp: ProgramSpec, root: Path) -> Path:

View File

@@ -1,8 +1,7 @@
"""Tests for Caddyfile generation from registry."""
"""Tests for Caddyfile generation — subdomain-only routing model."""
from __future__ import annotations
import pytest
from castle_core.config import CastleConfig, GatewayConfig
@@ -11,10 +10,12 @@ from castle_core.generators.caddyfile import (
generate_caddyfile_from_registry,
)
from castle_core.manifest import (
BuildSpec,
CaddySpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
ProgramSpec,
ProxySpec,
RunPython,
ServiceSpec,
@@ -40,9 +41,7 @@ def _make_registry(
gateway_tls: str | None = None,
gateway_domain: str | None = None,
acme_email: str | None = None,
acme_dns_provider: str = "cloudflare",
) -> NodeRegistry:
"""Create a test registry."""
return NodeRegistry(
node=NodeConfig(
hostname="test",
@@ -50,363 +49,151 @@ def _make_registry(
gateway_tls=gateway_tls,
gateway_domain=gateway_domain,
acme_email=acme_email,
acme_dns_provider=acme_dns_provider,
),
deployed=deployed or {},
)
class TestCaddyfileFromRegistry:
"""Tests for registry-based Caddyfile generation."""
def test_contains_gateway_port(self) -> None:
"""Caddyfile uses the configured gateway port."""
registry = _make_registry(gateway_port=18000)
caddyfile = generate_caddyfile_from_registry(registry)
assert ":18000 {" in caddyfile
def test_contains_service_routes(self) -> None:
"""Caddyfile has reverse proxy routes for deployed services."""
registry = _make_registry(
deployed={
"test-svc": Deployment(
runner="python",
run_cmd=["uv", "run", "test-svc"],
port=19000,
proxy_path="/test-svc",
),
}
def _dep(port: int, *, expose: bool, name: str | None = None, runner: str = "python") -> Deployment:
"""A deployed service; exposed at <name>.<domain> when expose=True."""
return Deployment(
runner=runner, run_cmd=["x"], port=port, subdomain=(name if expose else None)
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "handle_path /test-svc/*" in caddyfile
assert "reverse_proxy localhost:19000" in caddyfile
def test_disables_auto_https(self) -> None:
"""The gateway is HTTP-only; auto_https must be off so named hosts don't
flip the listener to TLS or try to bind :80."""
caddyfile = generate_caddyfile_from_registry(_make_registry())
assert "auto_https off" in caddyfile
def test_host_route_uses_matcher_in_main_site(self) -> None:
"""A proxy_host becomes a host matcher inside the :9000 site (not a
separate site block, which would split the listener into TLS)."""
registry = _make_registry(
deployed={
"lake": Deployment(
runner="python",
run_cmd=["lake"],
port=8420,
proxy_host="lake.example.lan",
),
}
def _acme(deployed: dict[str, Deployment], domain: str | None = "example.com") -> NodeRegistry:
return _make_registry(
gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com", deployed=deployed
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "@host_lake host lake.example.lan" in caddyfile
assert "handle @host_lake {" in caddyfile
assert "reverse_proxy localhost:8420" in caddyfile
# No separate hostname site block on the gateway port.
assert "lake.example.lan:9000 {" not in caddyfile
def test_skips_non_proxied(self) -> None:
"""Components without proxy_path are not in Caddyfile."""
registry = _make_registry(
deployed={
"test-tool": Deployment(
runner="command",
run_cmd=["test-tool"],
),
}
class TestAcmeMode:
"""gateway.tls=acme → one *.domain wildcard site; every service is a subdomain."""
def test_global_acme_block(self) -> None:
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
assert "email p@e.com" in cf
assert "acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}" in cf
def test_service_is_a_subdomain_matcher(self) -> None:
cf = generate_caddyfile_from_registry(
_acme({"openclaw": _dep(18789, expose=True, name="openclaw")})
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "test-tool" not in caddyfile
assert "*.example.com {" in cf
# Subdomain is the service name.
assert "@host_openclaw host openclaw.example.com" in cf
assert "reverse_proxy localhost:18789" in cf
def test_fallback_when_no_static(self) -> None:
"""Uses fallback dashboard path when static dir doesn't exist."""
registry = _make_registry()
caddyfile = generate_caddyfile_from_registry(registry)
assert "handle / {" in caddyfile
assert "file_server" in caddyfile
def test_port_9000_redirects_to_dashboard(self) -> None:
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
assert ":9000 {" in cf
assert "redir https://castle-app.example.com{uri}" in cf
def test_proxy_routes_before_dashboard(self) -> None:
"""Service proxy routes appear before the dashboard catch-all."""
registry = _make_registry(
deployed={
"test-svc": Deployment(
runner="python",
run_cmd=["uv", "run", "test-svc"],
port=19000,
proxy_path="/test-svc",
),
}
def test_no_path_routes(self) -> None:
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
assert "handle_path" not in cf
assert "redir /" not in cf # no path-prefix trailing-slash redirects
assert "auto_https off" not in cf
def test_unexposed_service_not_routed(self) -> None:
cf = generate_caddyfile_from_registry(_acme({"pg": _dep(5432, expose=False, name="pg")}))
assert "pg.example.com" not in cf
def test_staging_toggle(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CASTLE_ACME_STAGING", "1")
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
assert "acme_ca https://acme-staging-v02.api.letsencrypt.org/directory" in cf
def test_static_frontend_is_a_file_server_subdomain(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Static frontends come from config.programs, so hand the generator a config.
import castle_core.config as config_mod
cfg = _config(
services={},
programs={
"castle-app": ProgramSpec(
behavior="frontend", source="/data/repos/castle/app",
build=BuildSpec(outputs=["dist"]),
)
caddyfile = generate_caddyfile_from_registry(registry)
proxy_pos = caddyfile.index("handle_path")
handle_pos = caddyfile.index("handle /")
assert proxy_pos < handle_pos
def test_multiple_services(self) -> None:
"""Multiple services get separate proxy routes."""
registry = _make_registry(
deployed={
"svc-a": Deployment(
runner="python",
run_cmd=["uv", "run", "svc-a"],
port=9001,
proxy_path="/svc-a",
),
"svc-b": Deployment(
runner="python",
run_cmd=["uv", "run", "svc-b"],
port=9002,
proxy_path="/svc-b",
),
}
},
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "handle_path /svc-a/*" in caddyfile
assert "reverse_proxy localhost:9001" in caddyfile
assert "handle_path /svc-b/*" in caddyfile
assert "reverse_proxy localhost:9002" in caddyfile
monkeypatch.setattr(config_mod, "load_config", lambda *a, **k: cfg)
cf = generate_caddyfile_from_registry(_acme({}))
assert "@host_castle_app host castle-app.example.com" in cf
assert "root * /data/repos/castle/app/dist" in cf
assert "try_files {path} /index.html" in cf
assert "file_server" in cf
def _service(port: int, path: str | None = None, host: str | None = None) -> ServiceSpec:
"""A minimal python ServiceSpec with an HTTP port and a caddy proxy route."""
caddy = CaddySpec(path_prefix=path, host=host)
class TestOffMode:
"""No domain → HTTP-only control plane on :<port>: dashboard at / + /api → castle-api.
Other services are port-only (not routed)."""
def test_control_plane(self) -> None:
cf = generate_caddyfile_from_registry(
_make_registry(deployed={"castle-api": _dep(9020, expose=True, name="castle-api")})
)
assert "auto_https off" in cf
assert "handle_path /api/* {" in cf
assert "reverse_proxy localhost:9020" in cf
assert "handle {" in cf # dashboard catch-all (SPA fallback)
def test_other_services_not_routed_in_off_mode(self) -> None:
cf = generate_caddyfile_from_registry(
_make_registry(deployed={"litellm": _dep(4000, expose=True, name="litellm")})
)
assert "litellm" not in cf # no subdomains without a domain
def _service(port: int, *, expose: bool) -> ServiceSpec:
return ServiceSpec(
run=RunPython(runner="python", program="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))),
proxy=ProxySpec(caddy=caddy),
proxy=ProxySpec(caddy=CaddySpec()) if expose else None,
)
def _config(services: dict[str, ServiceSpec]) -> CastleConfig:
def _config(
services: dict[str, ServiceSpec], programs: dict[str, ProgramSpec] | None = None
) -> CastleConfig:
return CastleConfig(
root=None, # type: ignore[arg-type]
gateway=GatewayConfig(port=9000),
repo=None,
programs={},
programs=programs or {},
services=services,
jobs={},
)
class TestLocalRoutesFromConfig:
"""castle.yaml is the source of truth for local routes — a regenerated
Caddyfile must track the spec even when the deployed registry is stale.
This is the regression guard for the service-edit drift."""
class TestConfigSourceOfTruth:
"""compute_routes derives exposure/port from castle.yaml (the checkbox), so a
regenerated Caddyfile tracks the spec, not a stale registry."""
def test_exposed_service_becomes_a_route(self) -> None:
routes = compute_routes(_make_registry(), _config({"claw": _service(18789, expose=True)}))
r = next(r for r in routes if r.name == "claw")
assert r.kind == "proxy"
assert r.address == "claw" # subdomain label = name
assert r.target == "localhost:18789"
def test_unexposed_service_has_no_route(self) -> None:
routes = compute_routes(_make_registry(), _config({"pg": _service(5432, expose=False)}))
assert not [r for r in routes if r.name == "pg"]
def test_config_port_overrides_stale_registry(self) -> None:
# Registry was deployed with the OLD port; castle.yaml now says 8002.
registry = _make_registry(
deployed={
"app": Deployment(
runner="python", run_cmd=["app"], port=8001, proxy_path="/app"
),
}
deployed={"claw": _dep(8001, expose=True, name="claw")}
)
config = _config({"app": _service(port=8002, path="/app")})
config = _config({"claw": _service(8002, expose=True)})
routes = compute_routes(registry, config)
targets = {r.target for r in routes if r.address == "/app"}
assert targets == {"localhost:8002"} # config wins, not the stale 8001
assert {r.target for r in routes if r.name == "claw"} == {"localhost:8002"}
def test_config_path_overrides_stale_registry(self) -> None:
registry = _make_registry(
deployed={
"app": Deployment(
runner="python", run_cmd=["app"], port=8001, proxy_path="/old"
),
}
def test_fallback_to_registry_without_config(self) -> None:
# load_config is isolated (raises) → generate uses the registry snapshot.
cf = generate_caddyfile_from_registry(
_acme({"claw": _dep(8001, expose=True, name="claw")})
)
config = _config({"app": _service(port=8001, path="/new")})
addrs = {r.address for r in compute_routes(registry, config) if r.kind == "proxy"}
assert addrs == {"/new"}
def test_falls_back_to_registry_without_config(self) -> None:
# No config available (load_config is isolated to raise) → use registry.
registry = _make_registry(
deployed={
"app": Deployment(
runner="python", run_cmd=["app"], port=8001, proxy_path="/app"
),
}
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "reverse_proxy localhost:8001" in caddyfile
class TestCaddyfileOffMode:
"""gateway.tls unset/off → HTTP-only: host matchers live on the :<port> site."""
def _host_registry(self) -> NodeRegistry:
return _make_registry(
deployed={
"claw": Deployment(
runner="node", run_cmd=["claw"], port=18789, proxy_host="claw.civil.lan"
),
"api": Deployment(
runner="python", run_cmd=["api"], port=9020, proxy_path="/api"
),
},
)
def test_host_matcher_and_auto_https_off(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._host_registry())
assert "auto_https off" in caddyfile
assert "@host_claw host claw.civil.lan" in caddyfile
assert "tls internal" not in caddyfile # internal mode is gone
assert "claw.civil.lan {" not in caddyfile # not a standalone TLS site
def test_path_routes_on_http_port(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._host_registry())
assert ":9000 {" in caddyfile
assert "handle_path /api/*" in caddyfile
def test_routing_is_runner_agnostic(self) -> None:
"""A compose-runner service with a host route is matched like any other."""
registry = _make_registry(
deployed={
"supabase": Deployment(
runner="compose",
run_cmd=["docker", "compose", "-p", "castle-supabase", "up"],
port=8000,
proxy_host="supabase.lan",
),
},
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "@host_supabase host supabase.lan" in caddyfile
assert "reverse_proxy localhost:8000" in caddyfile
class TestCaddyfileTlsAcme:
"""gateway.tls=acme → host routes served under one *.domain wildcard site
with a Let's Encrypt cert via DNS-01."""
def _acme_registry(self, domain: str | None = "civil.payne.io") -> NodeRegistry:
return _make_registry(
gateway_tls="acme",
gateway_domain=domain,
acme_email="paul@example.com",
deployed={
"claw": Deployment(
runner="node", run_cmd=["claw"], port=18789, proxy_host="claw.civil.lan"
),
"api": Deployment(
runner="python", run_cmd=["api"], port=9020, proxy_path="/api"
),
},
)
def test_global_email_and_acme_dns(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
assert "email paul@example.com" in caddyfile
assert "acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}" in caddyfile
def test_wildcard_site_with_derived_host_matcher(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
assert "*.civil.payne.io {" in caddyfile
# Published name = the host's first label under the gateway domain.
assert "@host_claw host claw.civil.payne.io" in caddyfile
assert "reverse_proxy localhost:18789" in caddyfile
def test_subdomain_from_host_label_not_service_name(self) -> None:
# Service is named "openclaw" but declares host label "claw" → the label
# wins (published as claw.<domain>), so the declared name is authoritative.
registry = _make_registry(
gateway_tls="acme",
gateway_domain="civil.payne.io",
acme_email="paul@example.com",
deployed={
"openclaw": Deployment(
runner="node", run_cmd=["c"], port=18789, proxy_host="claw"
),
},
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "host claw.civil.payne.io" in caddyfile
assert "openclaw.civil.payne.io" not in caddyfile
def test_path_routes_stay_on_http_port(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
assert ":9000 {" in caddyfile
assert "handle_path /api/*" in caddyfile
def test_no_auto_https_off_and_no_internal_ca(self) -> None:
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
assert "auto_https off" not in caddyfile
assert "tls internal" not in caddyfile
# The .lan host must not leak into the :9000 site as a plain matcher.
assert "claw.civil.lan" not in caddyfile
def test_staging_toggle(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CASTLE_ACME_STAGING", "1")
caddyfile = generate_caddyfile_from_registry(self._acme_registry())
assert "acme_ca https://acme-staging-v02.api.letsencrypt.org/directory" in caddyfile
def test_acme_without_domain_falls_back_to_http(self) -> None:
# No domain → no *.None site; host route degrades to an off-mode matcher.
caddyfile = generate_caddyfile_from_registry(self._acme_registry(domain=None))
assert "*." not in caddyfile
assert "auto_https off" in caddyfile
assert "@host_claw host claw.civil.lan" in caddyfile
class TestCaddyfileRemoteRegistries:
"""Tests for cross-node routing in Caddyfile."""
def test_remote_routes_included(self) -> None:
"""Remote services get reverse_proxy entries to their hostname."""
local = _make_registry(
deployed={
"local-svc": Deployment(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/local"
),
}
)
remote = _make_registry(
deployed={
"remote-svc": Deployment(
runner="python", run_cmd=["svc"], port=9050, proxy_path="/remote"
),
}
)
remote.node.hostname = "devbox"
caddyfile = generate_caddyfile_from_registry(local, remote_registries={"devbox": remote})
assert "reverse_proxy localhost:9001" in caddyfile
assert "reverse_proxy devbox:9050" in caddyfile
assert "handle_path /remote/*" in caddyfile
def test_local_takes_precedence(self) -> None:
"""If local and remote use the same path, local wins."""
local = _make_registry(
deployed={
"svc": Deployment(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
remote = _make_registry(
deployed={
"svc": Deployment(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
remote.node.hostname = "devbox"
caddyfile = generate_caddyfile_from_registry(local, remote_registries={"devbox": remote})
assert "reverse_proxy localhost:9001" in caddyfile
assert "devbox" not in caddyfile
def test_no_remote_when_none(self) -> None:
"""No remote routes when remote_registries is None."""
local = _make_registry(
deployed={
"svc": Deployment(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
caddyfile = generate_caddyfile_from_registry(local, remote_registries=None)
assert "reverse_proxy localhost:9001" in caddyfile
# Only one reverse_proxy line
assert caddyfile.count("reverse_proxy") == 1
assert "@host_claw host claw.example.com" in cf

View File

@@ -46,7 +46,7 @@ class TestLoadConfig:
"""Service has correct proxy spec."""
config = load_config(castle_root)
svc = config.services["test-svc"]
assert svc.proxy.caddy.path_prefix == "/test-svc"
assert svc.proxy.caddy.enable is True # exposed at <name>.<gateway.domain>
def test_service_run_spec(self, castle_root: Path) -> None:
"""Service has correct RunSpec."""

View File

@@ -92,9 +92,9 @@ class TestServiceSpec:
s = ServiceSpec(
id="svc",
run=RunPython(runner="python", program="svc"),
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
proxy=ProxySpec(caddy=CaddySpec()),
)
assert s.proxy.caddy.path_prefix == "/svc"
assert s.proxy.caddy.enable is True
def test_service_with_manage(self) -> None:
"""Service with systemd management."""
@@ -185,10 +185,10 @@ class TestModelSerialization:
internal=HttpInternal(port=9001), health_path="/health"
)
),
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
proxy=ProxySpec(caddy=CaddySpec()),
manage=ManageSpec(systemd=SystemdSpec()),
)
data = s.model_dump(exclude_none=True, exclude={"id"})
assert data["run"]["runner"] == "python"
assert data["expose"]["http"]["internal"]["port"] == 9001
assert data["proxy"]["caddy"]["path_prefix"] == "/svc"
assert data["proxy"]["caddy"]["enable"] is True

View File

@@ -17,73 +17,40 @@ strategy, and any working combination is fine.
## The gateway is the single ingress
Every reachable service goes through the Caddy **gateway** (`:9000` by default). A
gateway route maps a public **address** to a **target**. Two address shapes matter
for DNS and TLS:
Every gateway-reachable service goes through the Caddy **gateway**. Exposure is a
single **checkbox** (`proxy.caddy` on a service):
- **path prefix** (`/foo`) — reached at `http://<node>:9000/foo/`. Shares the
node's own name/port; needs no per-service DNS. Caddy **strips** the prefix, so
this only suits apps that don't assume they live at the origin root.
- **host route** (`foo.lan`, `foo.example.com`) — reached at `https://foo.…/`. A
whole hostname proxied to the backend root, nothing stripped. This is the shape
that gets its own DNS name and its own TLS cert.
- **unchecked** — the service is reachable only at its own `host:port` (no gateway
route, no DNS name).
- **checked** — the gateway routes the whole subdomain **`<service-name>.<domain>`**
to the backend root. The subdomain is always the service name.
> **Rule of thumb:** a service that needs HTTPS, a real origin, WebSockets, or
> root-relative asset URLs wants a **host route**. Path prefixes are for simple,
> prefix-agnostic backends. See
> [registry.md](registry.md#path-prefix-vs-host-route--pick-by-whether-the-app-is-prefix-aware)
> for the failure modes of putting a root-based app under a stripped prefix.
Only host routes are the subject of the rest of this document — they're what DNS
and TLS act on.
There are **no path-prefix routes**: a whole subdomain maps to the backend root, so
root-relative asset URLs and `window.location`-derived WebSocket URLs just work
(Caddy proxies WebSocket upgrades transparently). Each checked service gets its own
DNS name and shares the gateway's TLS cert. The rest of this document is about
making those subdomains **resolve** (DNS) and be **trusted** (TLS).
## DNS: making a name resolve to the node
A host route does nothing until `foo.…` resolves **to this node** on the clients
that will use it. Castle does **not** run DNS; it relies on whatever already serves
your LAN. Two facts shape the approach:
A subdomain does nothing until it resolves **to this node** on the clients that
browse it. Castle does **not** run DNS; you add one record to your **LAN's DNS
server** — usually the router. A single **wildcard** covers every subdomain, so new
services need no further DNS edits:
- **Resolve on the clients that matter.** A name only needs to resolve for the
devices that browse it. That's usually your LAN's DHCP/DNS authority — often the
router — not a central or mesh resolver.
- **One wildcard beats many records.** A single wildcard entry routes every
subdomain of a zone to the node, so each new host-routed service works with no
further DNS edits.
```
address=/<sub>.<domain>/<node-ip> # dnsmasq (e.g. on the router)
```
(or the equivalent wildcard `A` record in whatever your router's DNS uses). Pin
`<node-ip>` with a **DHCP reservation** — the wildcard hardcodes it.
### Split-horizon: internal names, no public exposure
The names Castle serves resolve **only inside the LAN**. For a private zone this is
automatic; for a public domain you own, it's deliberate split-horizon — your LAN
resolver answers with the node's private IP, and the **public** zone has no `A`
records for the services, so nothing is reachable from the internet.
### Two zone styles
| Zone style | Example | Who's authoritative | Wildcard record |
|------------|---------|---------------------|-----------------|
| **private TLD** | `*.civil.lan` | the LAN router/DHCP server (owns `.lan`) | `address=/civil.lan/<node-ip>` |
| **subdomain of a public domain** | `*.civil.payne.io` | your public DNS host (e.g. Cloudflare), but **answered internally** by a LAN resolver | `address=/civil.payne.io/<node-ip>` |
Both give the same result — every `*.<zone>` name resolves to the node's LAN IP.
The difference is which TLS modes each can use (below): a private TLD like `.lan`
**cannot** get a publicly-trusted cert (it isn't a real domain), while a real
subdomain can.
### Worked topology (this network)
- The **router** (`192.168.8.1`, a GL.iNet box) owns the `.lan` zone: it
auto-registers DHCP hostnames (`civil.lan`) and answers `*.lan`. Unknown `.lan`
names are **not** forwarded upstream — the router keeps that zone to itself. A
`*.civil.lan` wildcard therefore lives on the **router**.
- The router **forwards everything else** (including `*.payne.io`) to
**wild-central**'s dnsmasq. So a `*.civil.payne.io` wildcard lives on
**wild-central** (`/etc/dnsmasq.d/civil-payne.conf`,
`address=/civil.payne.io/192.168.8.222`), which the router already routes to.
`payne.io`'s **public** authority is Cloudflare, which holds no `A` records for
these names — so `civil.payne.io` services resolve on the LAN and nowhere else.
Pin the node's IP with a **DHCP reservation** — the wildcard hardcodes it, so a
drifting dynamic lease would break every host route at once.
The names resolve **only inside the LAN**. You own the domain publicly (needed so
DNS-01 can issue a real cert — below), but the **public** zone has no `A` records
for the services — only your LAN's wildcard points at the node. So the services are
trusted (real cert) yet reachable only from the LAN, never the internet.
## TLS: two trust modes
@@ -126,11 +93,10 @@ How it stays internal:
LAN resolver answers `*.<domain>` with the private IP. Public internet sees
nothing.
One `*.<domain>` site means a **single cert** covers every host route, and Caddy
One `*.<domain>` site means a **single cert** covers every subdomain, and Caddy
**auto-renews** it — adding a service needs no new cert and no DNS-01 round trip.
Host-route subdomains come from the **first label of `proxy.caddy.host`**: a
service declares `host: claw` and is published at `claw.<domain>`. Only the label
matters (the domain is the gateway's), so services stay domain-agnostic.
Every subdomain is the **service name** (`<name>.<domain>`), so services stay
domain-agnostic — switching `gateway.domain` needs no service edits.
This is the recommended mode when you own a domain and want to reach services from
arbitrary devices.
@@ -150,11 +116,10 @@ origin — moving a service onto HTTPS changes its origin.
## Putting a service on trusted HTTPS — the recipe
1. **Give it a host route.** In the service's `proxy.caddy`, set `host:` to the
subdomain **label** you want (`host: claw`), and drop any `path_prefix`. In
`acme` mode the published name is `<label>.<gateway.domain>`.
1. **Check the box.** Add `proxy: { caddy: {} }` to the service — it's now exposed
at `<service-name>.<gateway.domain>` (rename the service to change the name).
2. **Make the name resolve.** Add (or rely on) the LAN wildcard for the zone
(§DNS). Verify: `dig +short <label>.<domain>` → the node's IP.
(§DNS). Verify: `dig +short <name>.<domain>` → the node's IP.
3. **Set `gateway.tls: acme`** (with `domain`/`acme_email`), plus the operational
prerequisites (below).
4. **Deploy & reload:** `castle deploy` regenerates the Caddyfile and reloads Caddy.
@@ -187,14 +152,14 @@ a DNS token.
| You have… | Zone (DNS) | Trust (TLS) | Result |
|-----------|-----------|-------------|--------|
| a quick internal tool, HTTP is fine | path prefix, or a `.lan`/bare host | `off` | `http://node:9000/tool/` |
| a quick internal tool, HTTP is fine | a `.lan` host, or none | `off` | `http://<node>:9000/` (dashboard) + services by port |
| a node with no public domain, needs a secure context | — | `off` | reach it via `http://localhost` / direct port on the node |
| a domain you own + any devices (phones) | `*.sub.domain` on the LAN resolver | `acme` | HTTPS, **no client setup**, internal-only |
| a domain you own + any devices (phones) | `*.<sub>.<domain>` on the LAN resolver | `acme` | HTTPS, **no client setup**, internal-only |
The last row is the sweet spot for a personal LAN, and what this node runs today:
`*.civil.payne.io` (wild-central DNS) + a Let's Encrypt wildcard via Cloudflare
DNS-01, so e.g. `https://claw.civil.payne.io/` is trusted on any device with
nothing to install.
The last row is the sweet spot for a personal LAN: a `*.<sub>.<domain>` wildcard on
the LAN resolver + a Let's Encrypt wildcard via DNS-01, so e.g.
`https://<service>.<sub>.<domain>/` is trusted on any device with nothing to
install.
## See also

View File

@@ -80,7 +80,7 @@ expose:
internal: { port: 9001 }
health_path: /health
proxy:
caddy: { path_prefix: /my-service }
caddy: {} # expose at my-service.<gateway.domain>
manage:
systemd: {}
```
@@ -270,58 +270,44 @@ expose:
health_path: /health # Used by health polling
```
### `proxy` — How the gateway routes to it
### `proxy` — Expose the service at a subdomain
`proxy.caddy` is a **checkbox**: present (and `enable: true`, the default) means the
gateway routes **`<service-name>.<gateway.domain>`** to this service; absent means
the service is reachable only at its own `host:port`.
```yaml
proxy:
caddy:
path_prefix: /my-service # reachable at gateway:9000/my-service/
host: my-service.lan # …or by hostname (whole host → backend root)
caddy: {} # expose at <service-name>.<gateway.domain>
```
Castle generates the Caddyfile from these entries. Only needed for services
reachable through the gateway.
The subdomain is always the service name — there's nothing to customize (rename the
service to change it). There are **no path-prefix routes**: a whole subdomain maps
to the backend root, so root-relative asset URLs and `window.location`-derived
WebSocket URLs just work (the failure mode of the old prefix-stripping `handle_path`
routes is gone). Caddy proxies WebSocket upgrades transparently.
**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**:
**Gateway routes — one concept, three target kinds.** The gateway maps a public
**address** (always a subdomain host, `<name>.<domain>`) 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 `/`) |
| **static** | a built frontend's `dist/` — Caddy `file_server` (no process) | a `frontend` program with `build.outputs` and **no** service (auto-exposed at `<name>.<domain>`) |
| **remote** | a service on another node | mesh discovery (out of scope of the single-node gateway) |
"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.
"Serving a frontend" and "proxying a service" are the same thing — a subdomain
route — differing only in whether the target is files on disk or a live process.
The table is shown by `castle gateway status`, the dashboard Gateway panel, and
`GET /gateway`; the Caddyfile is generated from it.
#### Path prefix vs host route — pick by whether the app is prefix-aware
A `path_prefix: /foo` route is generated as Caddy `handle_path /foo/*`, which
**strips** the prefix before proxying — the backend sees requests at `/`. That's
right for a service that doesn't care what path it's mounted under. It **breaks**
apps that assume they sit at the origin root, because the public path (`/foo/…`)
and the path the backend sees (`/…`) no longer agree. Tell-tale symptoms:
- absolute asset URLs (`/assets/app.js`) 404 — they resolve at the gateway root,
not under `/foo/`, and fall through to the wrong handler;
- a **WebSocket** fails to connect: a browser app that derives its WS URL from
`window.location` will aim at `ws://host/foo` (no trailing slash), which hits
the `redir /foo → /foo/` rule — and a WS handshake can't follow a redirect.
For such an app, use a **host route** instead — `host: foo.lan`, no `path_prefix`:
```yaml
proxy:
caddy:
host: foo.lan # whole host → backend root; nothing is stripped
```
This proxies the whole hostname to the backend's root, so the public path and the
backend path match and root-relative assets/WS URLs just work. (Caddy proxies
WebSocket upgrades transparently in both modes — stripping, not the upgrade, is
what bites prefix-unaware apps.)
**The dashboard and its API.** `castle-app` (the dashboard frontend) and
`castle-api` are just two such subdomains (`castle-app.<domain>`,
`castle-api.<domain>`); the dashboard calls the API **cross-origin** (castle-api
allows CORS `*`). The bare gateway port (`:9000`) redirects to the dashboard
subdomain. On a node with **no domain** (`gateway.tls: off`), there are no
subdomains, so `:9000` serves just the control plane — the dashboard at `/` plus a
`/api` reverse-proxy to castle-api — and other services stay port-only.
#### Host routes need DNS, and the gateway is HTTP-only
@@ -332,7 +318,7 @@ dnsmasq wildcard routes every subdomain to the gateway, so each new host-routed
service works with no further DNS edits:
```
address=/<node>.lan/<node-ip> # e.g. address=/civil.lan/192.168.8.222
address=/<node>.lan/<node-ip> # e.g. address=/node.lan/192.0.2.10
```
Pin `<node-ip>` with a DHCP reservation — the wildcard hardcodes it.
@@ -376,7 +362,7 @@ every browser trusts it with **zero CA install** — while the services stay
gateway:
port: 9000
tls: acme
domain: civil.payne.io # wildcard cert *.civil.payne.io; host routes → <label>.civil.payne.io
domain: example.com # wildcard cert *.example.com; services → <name>.example.com
acme_email: you@example.com
acme_dns_provider: cloudflare # default
```
@@ -387,9 +373,9 @@ gateway:
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
*.civil.payne.io {
@host_claw host claw.civil.payne.io
handle @host_claw {
*.example.com {
@host_openclaw host openclaw.example.com
handle @host_openclaw {
reverse_proxy localhost:18789
}
}
@@ -401,12 +387,10 @@ it needs **no inbound exposure and no public A records** for the services. Only
**LAN DNS** resolves `*.<domain>` to the gateway's private IP. (HTTP-01 can't
validate a wildcard, so DNS-01 — and thus the provider token — is mandatory here.)
Host-route subdomains come from the **first label of `proxy.caddy.host`**: a
service declares `host: claw` (or a legacy `claw.civil.lan`) and is published at
`claw.<domain>`. Only the label matters — the domain is the gateway's, so services
stay domain-agnostic (switching `gateway.domain` needs no service edits). One
`*.<domain>` site means a single cert covers every host route — adding a service
needs no new cert.
Every subdomain is the **service name**: a service checks the `proxy.caddy` box and
is published at `<name>.<domain>`. Services stay domain-agnostic (switching
`gateway.domain` needs no service edits). One `*.<domain>` site means a single cert
covers every route — adding a service needs no new cert.
Setup (the parts castle can't do for you):
@@ -422,10 +406,10 @@ Setup (the parts castle can't do for you):
CLOUDFLARE_API_TOKEN: ${secret:CLOUDFLARE_API_TOKEN}
```
`castle deploy` warns if the domain, this env var, or the secret is missing.
- **LAN DNS.** Point `*.<domain>` at the gateway's private IP on your LAN
resolver. For a `*.payne.io` subdomain that's **wild-central's dnsmasq** (the
router already forwards `*.payne.io` there): `address=/civil.payne.io/<gateway-ip>`.
The public zone gets no A records, so services aren't externally reachable.
- **LAN DNS.** Add a wildcard on your LAN's DNS server (usually the router)
pointing `*.<domain>` at the gateway's private IP — `address=/<domain>/<gateway-ip>`
(dnsmasq) or the equivalent A record. The public zone gets no A records, so
services aren't externally reachable.
- **Staging first.** Set `CASTLE_ACME_STAGING=1` to use Let's Encrypt's staging CA
(its rate limits are generous) while verifying issuance, then unset it and
redeploy to get a browser-trusted production cert. Verify with
@@ -577,7 +561,7 @@ services:
internal: { port: 9001 }
health_path: /health
proxy:
caddy: { path_prefix: /my-service }
caddy: {} # expose at my-service.<gateway.domain>
manage:
systemd: {}
```