fix(api): allow every configured CORS origin, not just the last
Some checks failed
deploy / build (push) Failing after 5s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped

`CorsLayer::allow_origin` replaces rather than appends, so folding over the
configured list left only `blackbeard.internal` allowed and silently refused
`blackbeard.observer` — the site's own public origin. It broke nothing, because
the frontend is served same-origin and never consults CORS, which is precisely
why it would have gone unnoticed until something else called the API.

Verified on the deployed vhost: both configured origins are now echoed back and
an unlisted one is refused.

Also records the deployment gotchas this session turned up (exact-argument
sudoers matching, the runas spec for the config check, the cross-site hop the
loopback probe cannot see, and why a WebSocket upgrade test needs --http1.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi
This commit is contained in:
2026-09-04 13:21:13 +03:00
parent 6a7253956a
commit 36a10ac691
4 changed files with 49 additions and 7 deletions

View File

@@ -125,6 +125,32 @@ touching them:
node <skill>/scripts/validate_palette.js "#bd8829" --mode dark --surface "#14110d"
```
## Deployment gotchas learned the hard way
**A sudoers grant matches the whole argument vector.** The `restorecon` grant
names three paths; running it with one path is a different command and asks for
a password. Run the command exactly as `infra-setup.sh` grants it, which is what
the workflow does.
**The config check runs as `blackbeard`, not root** — runas `(blackbeard)` in
sudoers. A root-run check passes on a config the service account cannot read,
which is the failure it exists to catch.
**`CorsLayer::allow_origin` replaces, it does not append.** Folding over a list
of origins leaves only the last one allowed. It does not break the site — the
frontend is same-origin and never consults CORS — so it is invisible until
something else tries to call the API. Use `AllowOrigin::list`.
**The edge proxy is at a different site from the API.** oolon (kosherinata)
fronts the name; the API is on bob (hanzalova) because that is where the Planck
node lives. The deploy's loopback health probe cannot see that hop, so there is
a separate check that curls the API *from oolon*. A firewalld service scoped to
bob's own /16 would leave a live site with a dead `/v1` and nothing would fail.
**WebSocket upgrade tests need `--http1.1`.** The vhost serves HTTP/2, and curl
negotiates h2, where the `Connection: Upgrade` handshake is not how WebSockets
work — you get a 400 from axum that looks like a proxy misconfiguration.
## Verifying a change
`systemctl is-active` is not evidence this works. A daemon with a node it cannot

View File

@@ -27,13 +27,19 @@ use crate::state::AppState;
/// Build the router.
pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
let cors = allowed_origins
// `allow_origin` REPLACES rather than appends, so folding over the list
// leaves only the last one allowed — and the served response advertised
// `blackbeard.internal` while the public origin was silently refused. It
// did not break the site (same-origin requests never consult CORS), which
// is exactly why it would have gone unnoticed. `AllowOrigin::list` takes
// them all.
let origins: Vec<HeaderValue> = allowed_origins
.iter()
.filter_map(|o| HeaderValue::from_str(o).ok())
.fold(
CorsLayer::new().allow_methods([Method::GET]),
|layer, origin| layer.allow_origin(origin),
);
.collect();
let cors = CorsLayer::new()
.allow_methods([Method::GET])
.allow_origin(tower_http::cors::AllowOrigin::list(origins));
Router::new()
.route("/v1/healthz", get(healthz))

View File

@@ -186,7 +186,12 @@ routers' Unbound or it `NXDOMAIN`s everywhere but one site.
| Port | What | Where |
| --- | --- | --- |
| `25864` | `blackbeard-api` REST + WebSocket | `bob`, mesh address only, plain HTTP behind nginx |
| `25864` | `blackbeard-api` REST + WebSocket | `bob` (hanzalova), mesh address only, plain HTTP behind nginx |
The edge proxy is **`oolon`** (kosherinata) and the API is on **`bob`**
(hanzalova), so `/v1` is a cross-site hop over the mesh. That is deliberate —
the API has to sit beside the node whose loopback RPC it reads — and it is the
one hop a loopback health probe cannot see, so the deploy checks it explicitly.
Registered in `architecture/port-allocations.md` §5; derived from the service
name per §3.

View File

@@ -123,7 +123,12 @@ gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable blackbeard-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now blackbeard-api-cert.path
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart blackbeard-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl is-active blackbeard-api.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/sudo -u blackbeard /usr/local/bin/blackbeard-api --config /etc/blackbeard/config.toml --check
# Runas is (blackbeard), not (root): the deploy validates the config AS the
# service account, which is the only way to prove the account can actually read
# the config and the certificate key it names. A (root) grant would not permit
# `sudo -u blackbeard` at all, and a root-run check would pass on a config the
# service cannot read.
gitea_ci ALL=(blackbeard) NOPASSWD: /usr/local/bin/blackbeard-api --config /etc/blackbeard/config.toml --check
EOF
}