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 }