gateway: serve the dashboard at wild-pc.<domain>, 404 unrouted subdomains

The dashboard's deployment kept the pre-rename name `wildpc`, so it was
published at wildpc.<domain> while everything else had moved to Wild PC
naming. Rename the deployment and its program to `wild-pc` (the subdomain
is the deployment name) and follow the `_DASHBOARD` constant in both the
Caddyfile generator and doctor — that constant also drives the `:9000`
redirect and doctor's `wildpc program build` hint, which is why the
program is renamed alongside the deployment.

Browsing the old name exposed a second problem: in acme mode every route
lives inside one `*.<domain>` wildcard site, so an unrouted host still
matches the site and completes its TLS handshake. Caddy then ran off the
end of the block and returned a bare 200 with an empty body — a typo'd or
renamed subdomain looked *up* in a browser while rendering nothing. Each
wildcard site now ends with an unmatched `handle` that responds 404.

The API subdomain is untouched: the dashboard derives its API origin by
stripping the first label, so wild-pc.<domain> still resolves to
wildpc-api.<domain>, and the WebSocket origin gate matches on
gateway.domain rather than a hostname.

Also seed the bootstrap dashboard deployment with the `requires:
wildpc-api` block the live deployment already carried, so a fresh install
declares the dependency.
This commit is contained in:
2026-08-02 16:37:04 -07:00
parent b48535745b
commit 15957677ff
9 changed files with 49 additions and 11 deletions

View File

@@ -238,7 +238,7 @@ to Wild PC's computed values. Secrets live in `~/.wildpc/secrets/` (never in a r
Every gateway-exposed deployment gets its own subdomain — `<name>.<gateway.domain>` Every gateway-exposed deployment gets its own subdomain — `<name>.<gateway.domain>`
— routed to it by the Caddy gateway (there are no path-prefix routes). Exposure is a — routed to it by the Caddy gateway (there are no path-prefix routes). Exposure is a
single checkbox: `proxy: true` on a service, while a static deployment is inherently single checkbox: `proxy: true` on a service, while a static deployment is inherently
served. The dashboard is `wildpc.<domain>` and the API `wildpc-api.<domain>`; on a served. The dashboard is `wild-pc.<domain>` and the API `wildpc-api.<domain>`; on a
node with no domain, `:9000` serves the dashboard plus a `/api` proxy. node with no domain, `:9000` serves the dashboard plus a `/api` proxy.
`gateway.tls` is a per-node choice: `off` (plain HTTP on `:9000`) or `acme` (a real `gateway.tls` is a per-node choice: `off` (plain HTTP on `:9000`) or `acme` (a real
@@ -266,7 +266,7 @@ programs (CLI, API, gateway) know Wild PC internals.
## Dashboard & API ## Dashboard & API
The **dashboard** (`app/`, served at `wildpc.<domain>` or `http://localhost:9000`) The **dashboard** (`app/`, served at `wild-pc.<domain>` or `http://localhost:9000`)
lists programs and deployments, edits their config, drives lifecycle, shows the lists programs and deployments, edits their config, drives lifecycle, shows the
gateway route table and logs, and hosts the agent terminal dock. gateway route table and logs, and hosts the agent terminal dock.

View File

@@ -1,5 +1,5 @@
// Resolve the wildpc-api base URL. The gateway serves each service at its own // Resolve the wildpc-api base URL. The gateway serves each service at its own
// subdomain (<name>.<domain>), so when the dashboard runs at wildpc.<domain> // subdomain (<name>.<domain>), so when the dashboard runs at wild-pc.<domain>
// the API lives at wildpc-api.<domain> — a cross-origin call (wildpc-api allows // the API lives at wildpc-api.<domain> — a cross-origin call (wildpc-api allows
// CORS *). When served at a bare host (dev, or the off-mode :9000 gateway), the // CORS *). When served at a bare host (dev, or the off-mode :9000 gateway), the
// API is reachable same-origin at /api. // API is reachable same-origin at /api.

View File

@@ -0,0 +1,6 @@
program: wild-pc
description: Wild PC web app
requires:
- kind: deployment
ref: wildpc-api
manager: caddy

View File

@@ -1,3 +0,0 @@
program: wildpc
description: Wild PC web app
manager: caddy

View File

@@ -28,7 +28,7 @@ _ICON = {
_GATEWAY = "wildpc-gateway" _GATEWAY = "wildpc-gateway"
_API = "wildpc-api" _API = "wildpc-api"
_DASHBOARD = "wildpc" _DASHBOARD = "wild-pc"
@dataclass @dataclass

View File

@@ -254,6 +254,23 @@ def _host_static_block(label: str, host: str, serve_dir: str, spa: bool) -> list
] ]
def _no_route_fallback() -> list[str]:
"""The trailing catch-all for a wildcard site: an explicit 404 for a host that
matched `*.<domain>` but no route.
Without it Caddy runs off the end of the site block and returns a bare
`200 Content-Length: 0` — a typo'd or stale subdomain looks *up* to a browser
(TLS negotiates, the request succeeds) while rendering nothing. A `handle`
with no matcher is the fallback of its handle group, so it only fires when
every `@host_*` matcher above missed."""
return [
" handle {",
' respond "no wildpc route for this host" 404',
" }",
"",
]
def _public_site_block(host: str, kind: str, target: str, spa: bool = True) -> list[str]: def _public_site_block(host: str, kind: str, target: str, spa: bool = True) -> list[str]:
"""A standalone Caddy site for a custom ``public_host`` (apex or another zone). """A standalone Caddy site for a custom ``public_host`` (apex or another zone).
@@ -281,7 +298,7 @@ def _public_site_block(host: str, kind: str, target: str, spa: bool = True) -> l
# Wild PC's own control plane: the dashboard frontend and the API it calls. These # Wild PC'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 # 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). # on the :<port> site in off mode (no domain → no subdomains).
_DASHBOARD = "wildpc" _DASHBOARD = "wild-pc"
_API = "wildpc-api" _API = "wildpc-api"
@@ -346,6 +363,7 @@ def generate_caddyfile_from_registry(
lines += _host_remote_block(r.name or r.address, host, r.target) lines += _host_remote_block(r.name or r.address, host, r.target)
else: else:
lines += _host_matcher_block(r.name or r.address, host, r.target) lines += _host_matcher_block(r.name or r.address, host, r.target)
lines += _no_route_fallback()
lines.append("}") lines.append("}")
lines.append("") lines.append("")
# Public exposure: public deployments are also reachable by their public # Public exposure: public deployments are also reachable by their public
@@ -371,6 +389,7 @@ def generate_caddyfile_from_registry(
lines += _host_remote_block(label, host, r.target) lines += _host_remote_block(label, host, r.target)
else: else:
lines += _host_matcher_block(label, host, r.target) lines += _host_matcher_block(label, host, r.target)
lines += _no_route_fallback()
lines.append("}") lines.append("}")
lines.append("") lines.append("")
for r in custom_pub: for r in custom_pub:

View File

@@ -101,7 +101,16 @@ class TestAcmeMode:
def test_port_9000_redirects_to_dashboard(self) -> None: def test_port_9000_redirects_to_dashboard(self) -> None:
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")})) cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
assert ":9000 {" in cf assert ":9000 {" in cf
assert "redir https://wildpc.example.com{uri}" in cf assert "redir https://wild-pc.example.com{uri}" in cf
def test_unmatched_host_404s_instead_of_empty_200(self) -> None:
"""A host that matches `*.<domain>` but no route must 404. Without the
trailing catch-all Caddy falls off the end of the site block and returns
an empty 200, so a typo'd subdomain looks up but renders nothing."""
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))
wildcard = cf.split("*.example.com {", 1)[1].split("\n}", 1)[0]
assert "handle {" in wildcard
assert 'respond "no wildpc route for this host" 404' in wildcard
def test_no_path_routes(self) -> None: def test_no_path_routes(self) -> None:
cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")})) cf = generate_caddyfile_from_registry(_acme({"api": _dep(9020, expose=True, name="api")}))

View File

@@ -404,14 +404,21 @@ route — differing only in whether the target is files on disk or a live proces
The table is shown by `wildpc gateway status`, the dashboard Gateway panel, and The table is shown by `wildpc gateway status`, the dashboard Gateway panel, and
`GET /gateway`; the Caddyfile is generated from it. `GET /gateway`; the Caddyfile is generated from it.
**The dashboard and its API.** `wildpc` (the dashboard frontend) and `wildpc-api` **The dashboard and its API.** `wild-pc` (the dashboard frontend) and `wildpc-api`
are just two such subdomains (`wildpc.<domain>`, `wildpc-api.<domain>`); the are just two such subdomains (`wild-pc.<domain>`, `wildpc-api.<domain>`); the
dashboard calls the API **cross-origin** (wildpc-api allows CORS `*`). The bare dashboard calls the API **cross-origin** (wildpc-api allows CORS `*`). The bare
gateway port (`:9000`) redirects to the dashboard subdomain. On a node with **no gateway port (`:9000`) redirects to the dashboard subdomain. On a node with **no
domain** (`gateway.tls: off`), there are no subdomains, so `:9000` serves just the domain** (`gateway.tls: off`), there are no subdomains, so `:9000` serves just the
control plane — the dashboard at `/` plus a `/api` reverse-proxy to wildpc-api — control plane — the dashboard at `/` plus a `/api` reverse-proxy to wildpc-api —
and other services stay port-only. and other services stay port-only.
**Unrouted subdomains 404.** In `acme` mode every route lives inside one
`*.<domain>` wildcard site, so a host that resolves but has no route still matches
the site and completes its TLS handshake. Each wildcard site therefore ends with an
unmatched `handle` that responds `404` — without it Caddy runs off the end of the
block and returns a bare `200` with an empty body, which makes a typo'd or renamed
subdomain look **up** in a browser while rendering nothing.
#### Host routes need DNS, and the gateway is HTTP-only #### Host routes need DNS, and the gateway is HTTP-only
A host route only does something once `<host>` resolves **to this node**. For a A host route only does something once `<host>` resolves **to this node**. For a