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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
6
bootstrap/deployments/statics/wild-pc.yaml
Normal file
6
bootstrap/deployments/statics/wild-pc.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
program: wild-pc
|
||||||
|
description: Wild PC web app
|
||||||
|
requires:
|
||||||
|
- kind: deployment
|
||||||
|
ref: wildpc-api
|
||||||
|
manager: caddy
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
program: wildpc
|
|
||||||
description: Wild PC web app
|
|
||||||
manager: caddy
|
|
||||||
@@ -28,7 +28,7 @@ _ICON = {
|
|||||||
|
|
||||||
_GATEWAY = "wildpc-gateway"
|
_GATEWAY = "wildpc-gateway"
|
||||||
_API = "wildpc-api"
|
_API = "wildpc-api"
|
||||||
_DASHBOARD = "wildpc"
|
_DASHBOARD = "wild-pc"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")}))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user