From 43ef1cc588af7da0097162f2ea10465a8c940447 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 30 Jun 2026 20:47:03 -0700 Subject: [PATCH] Subdomain-only gateway routing; drop path prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two proxy shapes (path_prefix + host) to a single checkbox: a service is either port-only, or exposed at .. 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 *. site with a reverse_proxy matcher per service + a file_server matcher per frontend; the : site redirects to the dashboard. off mode = a HTTP control plane on : (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. 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). --- app/.env | 5 +- app/src/services/api/client.ts | 20 +- castle-api/src/castle_api/models.py | 5 +- castle-api/src/castle_api/mqtt_client.py | 6 +- castle-api/src/castle_api/nodes.py | 2 +- castle-api/src/castle_api/routes.py | 26 +- castle-api/tests/conftest.py | 2 +- castle-api/tests/test_health.py | 7 +- castle-api/tests/test_mqtt.py | 6 +- cli/src/castle_cli/commands/create.py | 2 +- cli/src/castle_cli/commands/deploy_create.py | 15 +- cli/src/castle_cli/commands/info.py | 6 +- cli/src/castle_cli/main.py | 8 +- core/src/castle_core/deploy.py | 31 +- core/src/castle_core/generators/caddyfile.py | 238 ++++------ core/src/castle_core/manifest.py | 8 +- core/src/castle_core/registry.py | 14 +- core/src/castle_core/stacks.py | 9 +- core/tests/test_caddyfile.py | 467 +++++-------------- core/tests/test_config.py | 2 +- core/tests/test_manifest.py | 8 +- docs/dns-and-tls.md | 109 ++--- docs/registry.md | 98 ++-- 23 files changed, 382 insertions(+), 712 deletions(-) diff --git a/app/.env b/app/.env index 14ea4ad..d88544d 100644 --- a/app/.env +++ b/app/.env @@ -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. -> castle-api. (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). diff --git a/app/src/services/api/client.ts b/app/src/services/api/client.ts index da0b7d3..19507ce 100644 --- a/app/src/services/api/client.ts +++ b/app/src/services/api/client.ts @@ -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 (.), so when the dashboard runs at castle-app. +// the API lives at castle-api. — 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 diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 34a1ff8..1005a89 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -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 ., 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 ., else None managed: bool = False systemd: SystemdInfo | None = None program: str | None = None # the program this deployment references, if any diff --git a/castle-api/src/castle_api/mqtt_client.py b/castle-api/src/castle_api/mqtt_client.py index 79a4d72..d2abf50 100644 --- a/castle-api/src/castle_api/mqtt_client.py +++ b/castle-api/src/castle_api/mqtt_client.py @@ -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), ) diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py index 2f601fe..11f45fc 100644 --- a/castle-api/src/castle_api/nodes.py +++ b/castle-api/src/castle_api/nodes.py @@ -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, diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index dbf8914..15d3509 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -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 . 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, diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 5a5fcd9..3f7dd66 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -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, ), }, diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index bb9dee3..6b1fba2 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -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" diff --git a/castle-api/tests/test_mqtt.py b/castle-api/tests/test_mqtt.py index 3079775..d322ba1 100644 --- a/castle-api/tests/test_mqtt.py +++ b/castle-api/tests/test_mqtt.py @@ -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 diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index f7af4f8..5faed89 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -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 . 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). diff --git a/cli/src/castle_cli/commands/deploy_create.py b/cli/src/castle_cli/commands/deploy_create.py index b13316b..f6c9d64 100644 --- a/cli/src/castle_cli/commands/deploy_create.py +++ b/cli/src/castle_cli/commands/deploy_create.py @@ -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 . (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}.") print(f"\nNext: castle service deploy {name} && castle service start {name}") return 0 diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 3f6dcd1..d56ac85 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -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}.") if service.manage and service.manage.systemd: sd = service.manage.systemd print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}") diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index cc6b867..5c6d783 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -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: /)") - 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 .", + ) else: p.add_argument("--schedule", default="0 2 * * *", help="Cron schedule (default: 0 2 * * *)") diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 8c9c29e..cbd931a 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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 /. - 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) diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index 96cbed1..caa2fc5 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -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 . 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 /. - 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 ``.``); + ``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 /. castle-app is the - # root app (/), others mount at /. + # service of its own; served in place from / at .. 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 : 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 `.`; there are no path routes. Two modes, set by `gateway.tls`: - - **off (default)** — HTTP-only. Everything (host matchers + path prefixes + - static) lives in one `:` 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 `*.` 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 `