From 36a10ac691468673cd40de890bd5595aece1fed5 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Fri, 4 Sep 2026 13:21:13 +0300 Subject: [PATCH] fix(api): allow every configured CORS origin, not just the last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_01MSDYiibCtELsrjQq6KXnoi --- CLAUDE.md | 26 ++++++++++++++++++++++++++ crates/blackbeard-api/src/routes.rs | 16 +++++++++++----- readme.md | 7 ++++++- script/infra-setup.sh | 7 ++++++- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 19f8399..6bf0f56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,32 @@ touching them: node /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 diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index f5507a1..914b70a 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -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 = 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)) diff --git a/readme.md b/readme.md index d52a89a..1902886 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/script/infra-setup.sh b/script/infra-setup.sh index 8759147..fbe0205 100755 --- a/script/infra-setup.sh +++ b/script/infra-setup.sh @@ -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 }