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 { class ApiError extends Error {
status: number status: number

View File

@@ -22,7 +22,7 @@ class DeploymentSummary(BaseModel):
runner: str | None = None runner: str | None = None
port: int | None = None port: int | None = None
health_path: str | 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 managed: bool = False
systemd: SystemdInfo | None = None systemd: SystemdInfo | None = None
version: str | 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, … run_target: str | None = None # what it runs: program name, argv, image, …
port: int | None = None port: int | None = None
health_path: str | None = None health_path: str | None = None
proxy_path: str | None = None subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
proxy_host: str | None = None
managed: bool = False managed: bool = False
systemd: SystemdInfo | None = None systemd: SystemdInfo | None = None
program: str | None = None # the program this deployment references, if any 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 entry["port"] = comp.port
if comp.health_path: if comp.health_path:
entry["health_path"] = comp.health_path entry["health_path"] = comp.health_path
if comp.proxy_path: if comp.subdomain:
entry["proxy_path"] = comp.proxy_path entry["subdomain"] = comp.subdomain
if comp.schedule: if comp.schedule:
entry["schedule"] = comp.schedule entry["schedule"] = comp.schedule
if comp.managed: if comp.managed:
@@ -85,7 +85,7 @@ def _json_to_registry(payload: str) -> NodeRegistry:
stack=comp_data.get("stack"), stack=comp_data.get("stack"),
port=comp_data.get("port"), port=comp_data.get("port"),
health_path=comp_data.get("health_path"), health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_path"), subdomain=comp_data.get("subdomain"),
schedule=comp_data.get("schedule"), schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False), 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, runner=d.runner,
port=d.port, port=d.port,
health_path=d.health_path, health_path=d.health_path,
proxy_path=d.proxy_path, subdomain=d.subdomain,
managed=d.managed, managed=d.managed,
schedule=d.schedule, schedule=d.schedule,
node=hostname, node=hostname,

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,7 +121,7 @@ def run_create(args: argparse.Namespace) -> int:
health_path="/health", health_path="/health",
) )
), ),
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")), proxy=ProxySpec(caddy=CaddySpec()), # expose at <name>.<gateway.domain>
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
# python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's # python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's
# computed port/data dir to those vars (explicit, no hidden injection). # 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: if not args.no_proxy:
path = args.path # Expose at <name>.<gateway.domain> (the subdomain is the service name).
if args.path: proxy = ProxySpec(caddy=CaddySpec())
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))
config.services[name] = ServiceSpec( config.services[name] = ServiceSpec(
id=name, id=name,
@@ -97,10 +91,7 @@ def run_service_create(args: argparse.Namespace) -> int:
if expose: if expose:
print(f" port: {args.port}") print(f" port: {args.port}")
if proxy and proxy.caddy: if proxy and proxy.caddy:
if proxy.caddy.path_prefix: print(f" subdomain: {name}.<gateway.domain>")
print(f" proxy: {proxy.caddy.path_prefix}")
if proxy.caddy.host:
print(f" host: {proxy.caddy.host}")
print(f"\nNext: castle service deploy {name} && castle service start {name}") print(f"\nNext: castle service deploy {name} && castle service start {name}")
return 0 return 0

View File

@@ -119,10 +119,8 @@ def run_info(args: argparse.Namespace) -> int:
print(f" {BOLD}port{RESET}: {http.internal.port}") print(f" {BOLD}port{RESET}: {http.internal.port}")
if http.health_path: if http.health_path:
print(f" {BOLD}health{RESET}: {http.health_path}") print(f" {BOLD}health{RESET}: {http.health_path}")
if service.proxy and service.proxy.caddy: if service.proxy and service.proxy.caddy and service.proxy.caddy.enable:
caddy = service.proxy.caddy print(f" {BOLD}subdomain{RESET}: {name}.<gateway.domain>")
if caddy.path_prefix:
print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
if service.manage and service.manage.systemd: if service.manage and service.manage.systemd:
sd = service.manage.systemd sd = service.manage.systemd
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}") 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": if kind == "service":
p.add_argument("--port", type=int, help="HTTP port") p.add_argument("--port", type=int, help="HTTP port")
p.add_argument("--health", default="/health", help="Health path (default: /health)") p.add_argument("--health", default="/health", help="Health path (default: /health)")
p.add_argument("--path", help="Gateway proxy prefix (default: /<name>)") p.add_argument(
p.add_argument("--host", help="Route by hostname instead of a path prefix") "--no-proxy",
p.add_argument("--no-proxy", action="store_true", help="Don't add a gateway route") action="store_true",
help="Port-only; don't expose at <name>.<gateway.domain>",
)
else: else:
p.add_argument("--schedule", default="0 2 * * *", help="Cron schedule (default: 0 2 * * *)") 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: if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False managed = False
# Routing fields (port/proxy_path/proxy_host/base_url) come from the shared # Routing comes from the shared deriver, so the registry written here and the
# deriver, so the registry written here and the Caddyfile computed from # Caddyfile computed from castle.yaml stay in lockstep. `expose` is the
# castle.yaml stay in lockstep. # checkbox; the subdomain is the service name.
proxy_path, proxy_host, port, base_url = service_proxy_targets(name, svc) expose, port, base_url = service_proxy_targets(name, svc)
health_path = None health_path = None
if svc.expose and svc.expose.http: if svc.expose and svc.expose.http:
health_path = svc.expose.http.health_path health_path = svc.expose.http.health_path
@@ -301,27 +301,11 @@ def _build_deployed_service(
) )
stop_cmd = _build_stop_cmd(name, run, source_dir) 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 # Resolve stack from referenced program
stack = None stack = None
if svc.program and svc.program in config.programs: if svc.program and svc.program in config.programs:
stack = config.programs[svc.program].stack stack = config.programs[svc.program].stack
# Remote services proxy to an external base_url
base_url = getattr(run, "base_url", None)
return Deployment( return Deployment(
runner=run.runner, runner=run.runner,
run_cmd=run_cmd, run_cmd=run_cmd,
@@ -333,8 +317,7 @@ def _build_deployed_service(
stack=stack, stack=stack,
port=port, port=port,
health_path=health_path, health_path=health_path,
proxy_path=proxy_path, subdomain=(name if expose else None),
proxy_host=proxy_host,
base_url=base_url, base_url=base_url,
managed=managed, managed=managed,
) )
@@ -576,8 +559,8 @@ def _format_deployed(name: str, deployed: Deployment) -> str:
parts.append(f"port={deployed.port}") parts.append(f"port={deployed.port}")
if deployed.schedule: if deployed.schedule:
parts.append(f"schedule={deployed.schedule}") parts.append(f"schedule={deployed.schedule}")
if deployed.proxy_path: if deployed.subdomain:
parts.append(f"proxy={deployed.proxy_path}") parts.append(f"subdomain={deployed.subdomain}")
return " ".join(parts) return " ".join(parts)

View File

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

View File

@@ -146,12 +146,10 @@ class ExposeSpec(BaseModel):
class CaddySpec(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 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) extra_snippets: list[str] = Field(default_factory=list)

View File

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

View File

@@ -71,11 +71,10 @@ async def _run(
def _vite_base(name: str) -> str: def _vite_base(name: str) -> str:
"""The base path a castle-served static frontend builds against. """The base path a castle-served static frontend builds against.
Matches the Caddyfile serve prefix: castle-app is the root app ('/'); every Every frontend now serves at the **root of its own subdomain**
other static frontend mounts at '/<name>/'. Exposed to the build as VITE_BASE (`<name>.<gateway.domain>`), so the base is always '/'. Exposed to the build
so the bundle's absolute asset URLs resolve at the gateway subpath (the as VITE_BASE (the vite.config reads `base: process.env.VITE_BASE ?? '/'`)."""
vite.config reads `base: process.env.VITE_BASE ?? '/'`).""" return "/"
return "/" if name == "castle-app" else f"/{name}/"
def _source_dir(comp: ProgramSpec, root: Path) -> Path: 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 from __future__ import annotations
import pytest import pytest
from castle_core.config import CastleConfig, GatewayConfig from castle_core.config import CastleConfig, GatewayConfig
@@ -11,10 +10,12 @@ from castle_core.generators.caddyfile import (
generate_caddyfile_from_registry, generate_caddyfile_from_registry,
) )
from castle_core.manifest import ( from castle_core.manifest import (
BuildSpec,
CaddySpec, CaddySpec,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
ProgramSpec,
ProxySpec, ProxySpec,
RunPython, RunPython,
ServiceSpec, ServiceSpec,
@@ -40,9 +41,7 @@ def _make_registry(
gateway_tls: str | None = None, gateway_tls: str | None = None,
gateway_domain: str | None = None, gateway_domain: str | None = None,
acme_email: str | None = None, acme_email: str | None = None,
acme_dns_provider: str = "cloudflare",
) -> NodeRegistry: ) -> NodeRegistry:
"""Create a test registry."""
return NodeRegistry( return NodeRegistry(
node=NodeConfig( node=NodeConfig(
hostname="test", hostname="test",
@@ -50,363 +49,151 @@ def _make_registry(
gateway_tls=gateway_tls, gateway_tls=gateway_tls,
gateway_domain=gateway_domain, gateway_domain=gateway_domain,
acme_email=acme_email, acme_email=acme_email,
acme_dns_provider=acme_dns_provider,
), ),
deployed=deployed or {}, deployed=deployed or {},
) )
class TestCaddyfileFromRegistry: def _dep(port: int, *, expose: bool, name: str | None = None, runner: str = "python") -> Deployment:
"""Tests for registry-based Caddyfile generation.""" """A deployed service; exposed at <name>.<domain> when expose=True."""
return Deployment(
def test_contains_gateway_port(self) -> None: runner=runner, run_cmd=["x"], port=port, subdomain=(name if expose else 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",
),
}
) )
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: def _acme(deployed: dict[str, Deployment], domain: str | None = "example.com") -> NodeRegistry:
"""A proxy_host becomes a host matcher inside the :9000 site (not a return _make_registry(
separate site block, which would split the listener into TLS).""" gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com", deployed=deployed
registry = _make_registry(
deployed={
"lake": Deployment(
runner="python",
run_cmd=["lake"],
port=8420,
proxy_host="lake.example.lan",
),
}
) )
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.""" class TestAcmeMode:
registry = _make_registry( """gateway.tls=acme → one *.domain wildcard site; every service is a subdomain."""
deployed={
"test-tool": Deployment( def test_global_acme_block(self) -> None:
runner="command", cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
run_cmd=["test-tool"], 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 "*.example.com {" in cf
assert "test-tool" not in caddyfile # 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: def test_port_9000_redirects_to_dashboard(self) -> None:
"""Uses fallback dashboard path when static dir doesn't exist.""" cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
registry = _make_registry() assert ":9000 {" in cf
caddyfile = generate_caddyfile_from_registry(registry) assert "redir https://castle-app.example.com{uri}" in cf
assert "handle / {" in caddyfile
assert "file_server" in caddyfile
def test_proxy_routes_before_dashboard(self) -> None: def test_no_path_routes(self) -> None:
"""Service proxy routes appear before the dashboard catch-all.""" cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
registry = _make_registry( assert "handle_path" not in cf
deployed={ assert "redir /" not in cf # no path-prefix trailing-slash redirects
"test-svc": Deployment( assert "auto_https off" not in cf
runner="python",
run_cmd=["uv", "run", "test-svc"], def test_unexposed_service_not_routed(self) -> None:
port=19000, cf = generate_caddyfile_from_registry(_acme({"pg": _dep(5432, expose=False, name="pg")}))
proxy_path="/test-svc", 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) monkeypatch.setattr(config_mod, "load_config", lambda *a, **k: cfg)
assert "handle_path /svc-a/*" in caddyfile cf = generate_caddyfile_from_registry(_acme({}))
assert "reverse_proxy localhost:9001" in caddyfile assert "@host_castle_app host castle-app.example.com" in cf
assert "handle_path /svc-b/*" in caddyfile assert "root * /data/repos/castle/app/dist" in cf
assert "reverse_proxy localhost:9002" in caddyfile 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: class TestOffMode:
"""A minimal python ServiceSpec with an HTTP port and a caddy proxy route.""" """No domain → HTTP-only control plane on :<port>: dashboard at / + /api → castle-api.
caddy = CaddySpec(path_prefix=path, host=host) 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( return ServiceSpec(
run=RunPython(runner="python", program="svc"), run=RunPython(runner="python", program="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))), 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( return CastleConfig(
root=None, # type: ignore[arg-type] root=None, # type: ignore[arg-type]
gateway=GatewayConfig(port=9000), gateway=GatewayConfig(port=9000),
repo=None, repo=None,
programs={}, programs=programs or {},
services=services, services=services,
jobs={}, jobs={},
) )
class TestLocalRoutesFromConfig: class TestConfigSourceOfTruth:
"""castle.yaml is the source of truth for local routes — a regenerated """compute_routes derives exposure/port from castle.yaml (the checkbox), so a
Caddyfile must track the spec even when the deployed registry is stale. regenerated Caddyfile tracks the spec, not a stale registry."""
This is the regression guard for the service-edit drift."""
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: def test_config_port_overrides_stale_registry(self) -> None:
# Registry was deployed with the OLD port; castle.yaml now says 8002.
registry = _make_registry( registry = _make_registry(
deployed={ deployed={"claw": _dep(8001, expose=True, name="claw")}
"app": Deployment(
runner="python", run_cmd=["app"], port=8001, proxy_path="/app"
),
}
) )
config = _config({"app": _service(port=8002, path="/app")}) config = _config({"claw": _service(8002, expose=True)})
routes = compute_routes(registry, config) routes = compute_routes(registry, config)
targets = {r.target for r in routes if r.address == "/app"} assert {r.target for r in routes if r.name == "claw"} == {"localhost:8002"}
assert targets == {"localhost:8002"} # config wins, not the stale 8001
def test_config_path_overrides_stale_registry(self) -> None: def test_fallback_to_registry_without_config(self) -> None:
registry = _make_registry( # load_config is isolated (raises) → generate uses the registry snapshot.
deployed={ cf = generate_caddyfile_from_registry(
"app": Deployment( _acme({"claw": _dep(8001, expose=True, name="claw")})
runner="python", run_cmd=["app"], port=8001, proxy_path="/old"
),
}
) )
config = _config({"app": _service(port=8001, path="/new")}) assert "@host_claw host claw.example.com" in cf
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

View File

@@ -46,7 +46,7 @@ class TestLoadConfig:
"""Service has correct proxy spec.""" """Service has correct proxy spec."""
config = load_config(castle_root) config = load_config(castle_root)
svc = config.services["test-svc"] 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: def test_service_run_spec(self, castle_root: Path) -> None:
"""Service has correct RunSpec.""" """Service has correct RunSpec."""

View File

@@ -92,9 +92,9 @@ class TestServiceSpec:
s = ServiceSpec( s = ServiceSpec(
id="svc", id="svc",
run=RunPython(runner="python", program="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: def test_service_with_manage(self) -> None:
"""Service with systemd management.""" """Service with systemd management."""
@@ -185,10 +185,10 @@ class TestModelSerialization:
internal=HttpInternal(port=9001), health_path="/health" internal=HttpInternal(port=9001), health_path="/health"
) )
), ),
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")), proxy=ProxySpec(caddy=CaddySpec()),
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
) )
data = s.model_dump(exclude_none=True, exclude={"id"}) data = s.model_dump(exclude_none=True, exclude={"id"})
assert data["run"]["runner"] == "python" assert data["run"]["runner"] == "python"
assert data["expose"]["http"]["internal"]["port"] == 9001 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 ## The gateway is the single ingress
Every reachable service goes through the Caddy **gateway** (`:9000` by default). A Every gateway-reachable service goes through the Caddy **gateway**. Exposure is a
gateway route maps a public **address** to a **target**. Two address shapes matter single **checkbox** (`proxy.caddy` on a service):
for DNS and TLS:
- **path prefix** (`/foo`) — reached at `http://<node>:9000/foo/`. Shares the - **unchecked** — the service is reachable only at its own `host:port` (no gateway
node's own name/port; needs no per-service DNS. Caddy **strips** the prefix, so route, no DNS name).
this only suits apps that don't assume they live at the origin root. - **checked** — the gateway routes the whole subdomain **`<service-name>.<domain>`**
- **host route** (`foo.lan`, `foo.example.com`) — reached at `https://foo.…/`. A to the backend root. The subdomain is always the service name.
whole hostname proxied to the backend root, nothing stripped. This is the shape
that gets its own DNS name and its own TLS cert.
> **Rule of thumb:** a service that needs HTTPS, a real origin, WebSockets, or There are **no path-prefix routes**: a whole subdomain maps to the backend root, so
> root-relative asset URLs wants a **host route**. Path prefixes are for simple, root-relative asset URLs and `window.location`-derived WebSocket URLs just work
> prefix-agnostic backends. See (Caddy proxies WebSocket upgrades transparently). Each checked service gets its own
> [registry.md](registry.md#path-prefix-vs-host-route--pick-by-whether-the-app-is-prefix-aware) DNS name and shares the gateway's TLS cert. The rest of this document is about
> for the failure modes of putting a root-based app under a stripped prefix. making those subdomains **resolve** (DNS) and be **trusted** (TLS).
Only host routes are the subject of the rest of this document — they're what DNS
and TLS act on.
## DNS: making a name resolve to the node ## DNS: making a name resolve to the node
A host route does nothing until `foo.…` resolves **to this node** on the clients A subdomain does nothing until it resolves **to this node** on the clients that
that will use it. Castle does **not** run DNS; it relies on whatever already serves browse it. Castle does **not** run DNS; you add one record to your **LAN's DNS
your LAN. Two facts shape the approach: 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 address=/<sub>.<domain>/<node-ip> # dnsmasq (e.g. on the router)
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 (or the equivalent wildcard `A` record in whatever your router's DNS uses). Pin
further DNS edits. `<node-ip>` with a **DHCP reservation** — the wildcard hardcodes it.
### Split-horizon: internal names, no public exposure ### Split-horizon: internal names, no public exposure
The names Castle serves resolve **only inside the LAN**. For a private zone this is The names resolve **only inside the LAN**. You own the domain publicly (needed so
automatic; for a public domain you own, it's deliberate split-horizon — your LAN DNS-01 can issue a real cert — below), but the **public** zone has no `A` records
resolver answers with the node's private IP, and the **public** zone has no `A` for the services — only your LAN's wildcard points at the node. So the services are
records for the services, so nothing is reachable from the internet. trusted (real cert) yet reachable only from the LAN, never 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.
## TLS: two trust modes ## TLS: two trust modes
@@ -126,11 +93,10 @@ How it stays internal:
LAN resolver answers `*.<domain>` with the private IP. Public internet sees LAN resolver answers `*.<domain>` with the private IP. Public internet sees
nothing. 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. **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 Every subdomain is the **service name** (`<name>.<domain>`), so services stay
service declares `host: claw` and is published at `claw.<domain>`. Only the label domain-agnostic — switching `gateway.domain` needs no service edits.
matters (the domain is the gateway's), so services stay domain-agnostic.
This is the recommended mode when you own a domain and want to reach services from This is the recommended mode when you own a domain and want to reach services from
arbitrary devices. arbitrary devices.
@@ -150,11 +116,10 @@ origin — moving a service onto HTTPS changes its origin.
## Putting a service on trusted HTTPS — the recipe ## Putting a service on trusted HTTPS — the recipe
1. **Give it a host route.** In the service's `proxy.caddy`, set `host:` to the 1. **Check the box.** Add `proxy: { caddy: {} }` to the service — it's now exposed
subdomain **label** you want (`host: claw`), and drop any `path_prefix`. In at `<service-name>.<gateway.domain>` (rename the service to change the name).
`acme` mode the published name is `<label>.<gateway.domain>`.
2. **Make the name resolve.** Add (or rely on) the LAN wildcard for the zone 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 3. **Set `gateway.tls: acme`** (with `domain`/`acme_email`), plus the operational
prerequisites (below). prerequisites (below).
4. **Deploy & reload:** `castle deploy` regenerates the Caddyfile and reloads Caddy. 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 | | 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 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: The last row is the sweet spot for a personal LAN: a `*.<sub>.<domain>` wildcard on
`*.civil.payne.io` (wild-central DNS) + a Let's Encrypt wildcard via Cloudflare the LAN resolver + a Let's Encrypt wildcard via DNS-01, so e.g.
DNS-01, so e.g. `https://claw.civil.payne.io/` is trusted on any device with `https://<service>.<sub>.<domain>/` is trusted on any device with nothing to
nothing to install. install.
## See also ## See also

View File

@@ -80,7 +80,7 @@ expose:
internal: { port: 9001 } internal: { port: 9001 }
health_path: /health health_path: /health
proxy: proxy:
caddy: { path_prefix: /my-service } caddy: {} # expose at my-service.<gateway.domain>
manage: manage:
systemd: {} systemd: {}
``` ```
@@ -270,58 +270,44 @@ expose:
health_path: /health # Used by health polling 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 ```yaml
proxy: proxy:
caddy: caddy: {} # expose at <service-name>.<gateway.domain>
path_prefix: /my-service # reachable at gateway:9000/my-service/
host: my-service.lan # …or by hostname (whole host → backend root)
``` ```
Castle generates the Caddyfile from these entries. Only needed for services The subdomain is always the service name — there's nothing to customize (rename the
reachable through the gateway. 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 **Gateway routes — one concept, three target kinds.** The gateway maps a public
a public **address** (a path prefix `/foo`, or a host `foo.lan`) to a **target**: **address** (always a subdomain host, `<name>.<domain>`) to a **target**:
| Kind | Target | Declared by | | Kind | Target | Declared by |
|------|--------|-------------| |------|--------|-------------|
| **proxy** | a local service on a port — Caddy `reverse_proxy localhost:PORT` | a service's `proxy.caddy` | | **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 (auto-exposed at `<name>.<domain>`) |
| **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 `/`) | | **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 — "Serving a frontend" and "proxying a service" are the same thing — a subdomain
differing only in whether the target is files on disk or a live process. The route — differing only in whether the target is files on disk or a live process.
complete table (all kinds) is shown by `castle gateway status`, the dashboard The table is shown by `castle gateway status`, the dashboard Gateway panel, and
Gateway panel, and `GET /gateway`; the Caddyfile is generated from it. `GET /gateway`; the Caddyfile is generated from it.
#### Path prefix vs host route — pick by whether the app is prefix-aware **The dashboard and its API.** `castle-app` (the dashboard frontend) and
`castle-api` are just two such subdomains (`castle-app.<domain>`,
A `path_prefix: /foo` route is generated as Caddy `handle_path /foo/*`, which `castle-api.<domain>`); the dashboard calls the API **cross-origin** (castle-api
**strips** the prefix before proxying — the backend sees requests at `/`. That's allows CORS `*`). The bare gateway port (`:9000`) redirects to the dashboard
right for a service that doesn't care what path it's mounted under. It **breaks** subdomain. On a node with **no domain** (`gateway.tls: off`), there are no
apps that assume they sit at the origin root, because the public path (`/foo/…`) subdomains, so `:9000` serves just the control plane — the dashboard at `/` plus a
and the path the backend sees (`/…`) no longer agree. Tell-tale symptoms: `/api` reverse-proxy to castle-api — and other services stay port-only.
- 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.)
#### Host routes need DNS, and the gateway is HTTP-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: 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. 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: gateway:
port: 9000 port: 9000
tls: acme 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_email: you@example.com
acme_dns_provider: cloudflare # default acme_dns_provider: cloudflare # default
``` ```
@@ -387,9 +373,9 @@ gateway:
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
} }
*.civil.payne.io { *.example.com {
@host_claw host claw.civil.payne.io @host_openclaw host openclaw.example.com
handle @host_claw { handle @host_openclaw {
reverse_proxy localhost:18789 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 **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.) 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 Every subdomain is the **service name**: a service checks the `proxy.caddy` box and
service declares `host: claw` (or a legacy `claw.civil.lan`) and is published at is published at `<name>.<domain>`. Services stay domain-agnostic (switching
`claw.<domain>`. Only the label matters — the domain is the gateway's, so services `gateway.domain` needs no service edits). One `*.<domain>` site means a single cert
stay domain-agnostic (switching `gateway.domain` needs no service edits). One covers every route — adding a service needs no new cert.
`*.<domain>` site means a single cert covers every host route — adding a service
needs no new cert.
Setup (the parts castle can't do for you): 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} CLOUDFLARE_API_TOKEN: ${secret:CLOUDFLARE_API_TOKEN}
``` ```
`castle deploy` warns if the domain, this env var, or the secret is missing. `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 - **LAN DNS.** Add a wildcard on your LAN's DNS server (usually the router)
resolver. For a `*.payne.io` subdomain that's **wild-central's dnsmasq** (the pointing `*.<domain>` at the gateway's private IP — `address=/<domain>/<gateway-ip>`
router already forwards `*.payne.io` there): `address=/civil.payne.io/<gateway-ip>`. (dnsmasq) or the equivalent A record. The public zone gets no A records, so
The public zone gets no A records, so services aren't externally reachable. services aren't externally reachable.
- **Staging first.** Set `CASTLE_ACME_STAGING=1` to use Let's Encrypt's staging CA - **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 (its rate limits are generous) while verifying issuance, then unset it and
redeploy to get a browser-trusted production cert. Verify with redeploy to get a browser-trusted production cert. Verify with
@@ -577,7 +561,7 @@ services:
internal: { port: 9001 } internal: { port: 9001 }
health_path: /health health_path: /health
proxy: proxy:
caddy: { path_prefix: /my-service } caddy: {} # expose at my-service.<gateway.domain>
manage: manage:
systemd: {} systemd: {}
``` ```