fix(api): allow every configured CORS origin, not just the last
`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:
26
CLAUDE.md
26
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user