Watch
0
0
Fork
You've already forked hyperhive
0

swarm-metrics-auth: remember a failed lookup briefly so a store outage costs one lookup per burst

After a failed or timed-out store read the responder denies for LOOKUP_FAILURE_WINDOW (3 s) without a new lookup, so a burst of requests during an outage costs one lookup instead of one per request.

Three edits besides the code in main.rs belong to this change:
- swarm-metrics-auth/Cargo.toml gains tokio's test-util dev-dependency because the burst test runs on paused time (start_paused) to let a hanging store sleep past LOOKUP_TIMEOUT without a real 5 s wait.
- docs/swarm/services.md gains the sentence that a failed or timed-out read is denied for three seconds without a retry, which documents the window this change adds.
- docs/swarm/credentials.md gains the clause "every three seconds while the store fails" on the Grafana bearer row, which documents the same window in the secrets table.
This commit is contained in:
atlas 2026-10-17 15:30:39 +02:00 • committed by mara
commit 70a0435814
4 changed files with 101 additions and 18 deletions

View file

@ -76,7 +76,7 @@ of the cell says how.
| `swarm/hives/<hive>/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | hive-c0re, under the hive's own certificate, when it first connects to the queue | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ✅ read until the first connect succeeds, retried with a backoff of up to five minutes while it's missing; then held in memory and read again when authelia refuses it |
| `swarm/services/<clientId>/oidc/client` | authelia | that service's own reader, under its own certificate — the queue responder's under `swarm-nats-auth`'s, the homeserver's under `swarm-matrix-ctl`'s, the forge's under `swarm-forge-publisher`'s, the review classifier's under `swarm-review-classifier`'s, the CI runner's, Grafana's, the log store's, the metrics store's, the forge's and the homeserver's journal forwarders' under `swarm-ci-runner`'s, `swarm-grafana-oidc`'s, `swarm-logs-auth`'s, `swarm-metrics-auth`'s, `swarm-forge-publisher`'s and `swarm-matrix-ctl`'s, authelia's journal forwarder's under `swarm-secret-publisher`'s; the bao UI's by the store's granter | must be stated | must be stated |
| `swarm/services/ci-runner/registration-token` | `swarm-controller`, from the forge's admin API, at start and in a five-minute pass; its grant on the path is write-only | `swarm-bao-ci-runner` on the runner's host, under the runner's own certificate, into a file under the host's `/run` that only `swarm-ci-runner-register` in the runner's container loads; no other principal's policy reads it | ❌ the forge hands out one token until an admin resets it; the controller writes a reset token within five minutes | ✅ `swarm-bao-ci-runner` reads it at boot, on deploy and every ten minutes, and restarts the runner when the value changed |
| `swarm/services/<grafana client id>/metrics/bearer` | `swarm-bao-grafana-metrics-bearer` on Grafana's host, under Grafana's certificate, when the store holds none | `swarm-metrics-auth` on the metrics store's host, under its own certificate, at most once a minute, to check the bearer Grafana's datasource presents at the store's read API, and `swarm-logs-grafana-auth` on the log store's host, under `swarm-logs-auth`'s certificate, to check the same bearer at the log store's query API | ❌ nothing re-mints it; an operator deletes it and restarts `swarm-bao-grafana-metrics-bearer` | ✅ `swarm-metrics-auth` reads it again once its copy is a minute old. ❌ Grafana reads its file copy only as it starts; the bearer unit restarts Grafana when the copy changes |
| `swarm/services/<grafana client id>/metrics/bearer` | `swarm-bao-grafana-metrics-bearer` on Grafana's host, under Grafana's certificate, when the store holds none | `swarm-metrics-auth` on the metrics store's host, under its own certificate, at most once a minute (every three seconds while the store fails), to check the bearer Grafana's datasource presents at the store's read API, and `swarm-logs-grafana-auth` on the log store's host, under `swarm-logs-auth`'s certificate, to check the same bearer at the log store's query API | ❌ nothing re-mints it; an operator deletes it and restarts `swarm-bao-grafana-metrics-bearer` | ✅ `swarm-metrics-auth` reads it again once its copy is a minute old. ❌ Grafana reads its file copy only as it starts; the bearer unit restarts Grafana when the copy changes |
| `swarm/services/review-classifier/anthropic-api-key` | the operator, by hand in the store's UI, field `api_key` | `swarm-review-classifier` in its container, under its own certificate, at the start of every API key run; held for that run and handed to `claude`'s environment only; no other principal's policy reads it | ❌ an operator's key; replaced by writing a new one | ✅ read on every run, so a written or removed key takes effect on the next run |
| `swarm/services/review-classifier/acp-provider` | the operator, by hand in the store's UI, field `api_key` | `swarm-review-classifier` in its container, under its own certificate, at the start of every ACP run; held for that run and handed to `opencode`'s environment only; no other principal's policy reads it | ❌ an operator's key; replaced by writing a new one | ✅ read on every run, so a written or removed key takes effect on the next run |
| `swarm/services/review-classifier/forge/token` | `swarm-controller`, with the forge's admin API, for its `review-classifier` account, at start and in a five-minute pass; the secret publisher's policy denies it | `swarm-controller` under its own certificate, before it decides whether to mint, and on every review it posts as the account; the classifier service's policy doesn't read it | ✅ the controller's pass re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the controller reads the store on every review it posts |

View file

@ -238,7 +238,8 @@ Where the secret store runs on the same host, these default to the leaf it
mints. On any other host you place a leaf whose CN is
`deploy.bao.metricsAuthCommonName`, and evaluation refuses the config until
you set both. `swarm-metrics-auth` reads the stored bearer at most once a
minute, so a replaced bearer starts working up to a minute later.
minute, so a replaced bearer starts working up to a minute later. After a
failed or timed-out read it denies for three seconds without trying again.
`swarm-metrics-agent-auth` is the log store's `swarm-logs-auth` binary, with
the same grant TTL and rate limit, under `deploy.victoriametrics.agentAuth*`
(table above).

View file

@ -23,3 +23,8 @@ tracing-subscriber.workspace = true
[lints]
workspace = true
# `test-util` for `#[tokio::test(start_paused = true)]`: the lookup-timeout
# test waits out `LOOKUP_TIMEOUT` on paused time.
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }

View file

@ -12,8 +12,10 @@
//!
//! The stored value is kept for `--cache-ttl` seconds, and no request reaches
//! the store within that window: a replaced bearer is refused, and the one it
//! replaced still accepted, for up to that long. Lookups are serialised, so a
//! burst of requests against an expired copy costs one.
//! replaced still accepted, for up to that long. Lookups are serialised, and a
//! failed or timed-out one is remembered for `LOOKUP_FAILURE_WINDOW`, so a
//! burst of requests against an expired copy costs one lookup, success or
//! failure.
use std::future::Future;
use std::os::unix::fs::PermissionsExt as _;
@ -33,6 +35,13 @@ use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings};
/// rather than a gateway timeout.
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
/// How long requests are denied without a lookup after one failed or timed
/// out. Each request queued behind a failing lookup would otherwise run its
/// own, and the k-th would wait k × `LOOKUP_TIMEOUT`, past nginx's 60s read
/// timeout. Short against `--cache-ttl`, so a recovered store is used again
/// within seconds.
const LOOKUP_FAILURE_WINDOW: Duration = Duration::from_secs(3);
#[derive(Parser)]
#[command(about = "auth_request responder: checks Grafana's metrics bearer against the store")]
struct Args {
@ -47,7 +56,8 @@ struct Args {
#[arg(long)]
store_path: String,
/// Seconds the stored bearer is used without reading the store again;
/// 0 reads it on every request.
/// 0 reads it on every request, except for a few seconds after a read
/// failed.
#[arg(long, default_value_t = 60)]
cache_ttl: u64,
}
@ -97,10 +107,19 @@ struct Cached {
read_at: Instant,
}
#[derive(Default)]
struct Cache {
stored: Option<Cached>,
/// Until when requests are denied without a lookup, after one failed or
/// timed out. Stamped when that lookup ends: the requests queued behind
/// it carry the time they arrived, which is earlier.
failed_until: Option<Instant>,
}
struct Responder<S> {
source: S,
ttl: Duration,
cached: tokio::sync::Mutex<Option<Cached>>,
cached: tokio::sync::Mutex<Cache>,
}
/// The answer for one subrequest's `Authorization` header, at `now`.
@ -115,17 +134,23 @@ async fn decide(
};
// Held across the lookup, so concurrent requests wait for one read
// rather than each starting their own.
let mut cached = responder.cached.lock().await;
let fresh = cached
let mut cache = responder.cached.lock().await;
let fresh = cache
.stored
.as_ref()
.is_some_and(|c| now.saturating_duration_since(c.read_at) < responder.ttl);
if !fresh {
if cache.failed_until.is_some_and(|until| now < until) {
tracing::warn!("bearer lookup failed a moment ago; denying without another");
return StatusCode::UNAUTHORIZED;
}
match tokio::time::timeout(LOOKUP_TIMEOUT, responder.source.lookup()).await {
Ok(Ok(Some(value))) if !value.is_empty() => {
*cached = Some(Cached {
cache.stored = Some(Cached {
value,
read_at: now,
});
cache.failed_until = None;
}
Ok(Ok(_)) => {
tracing::warn!("no bearer is stored; denying");
@ -133,15 +158,17 @@ async fn decide(
}
Ok(Err(e)) => {
tracing::warn!(error = format!("{e:#}"), "bearer lookup failed; denying");
cache.failed_until = Some(Instant::now() + LOOKUP_FAILURE_WINDOW);
return StatusCode::UNAUTHORIZED;
}
Err(_) => {
tracing::warn!("bearer lookup timed out; denying");
cache.failed_until = Some(Instant::now() + LOOKUP_FAILURE_WINDOW);
return StatusCode::UNAUTHORIZED;
}
}
}
let Some(stored) = cached.as_ref() else {
let Some(stored) = cache.stored.as_ref() else {
return StatusCode::UNAUTHORIZED;
};
// Constant time, so the time to refuse says nothing about how much of the
@ -183,7 +210,7 @@ async fn main() -> anyhow::Result<()> {
path: args.store_path,
},
ttl: Duration::from_secs(args.cache_ttl),
cached: tokio::sync::Mutex::new(None),
cached: tokio::sync::Mutex::new(Cache::default()),
});
// `RuntimeDirectoryPreserve=yes` can leave the previous run's socket; the
@ -212,10 +239,11 @@ mod tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A store answering `stored` (or failing when it is `Err`), counting
/// lookups.
/// A store answering `stored` (or failing when it is `Err`) after `delay`,
/// counting lookups.
struct Fake {
stored: Mutex<Result<Option<String>, ()>>,
delay: Duration,
lookups: AtomicUsize,
}
@ -223,10 +251,18 @@ mod tests {
fn holding(value: &str) -> Self {
Self {
stored: Mutex::new(Ok(Some(value.to_owned()))),
delay: Duration::ZERO,
lookups: AtomicUsize::new(0),
}
}
fn hanging() -> Self {
Self {
delay: Duration::from_hours(1),
..Self::holding(SECRET)
}
}
fn set(&self, stored: Result<Option<&str>, ()>) {
*self.stored.lock().unwrap() = stored.map(|v| v.map(str::to_owned));
}
@ -239,6 +275,7 @@ mod tests {
impl BearerSource for &Fake {
async fn lookup(&self) -> anyhow::Result<Option<String>> {
self.lookups.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(self.delay).await;
self.stored
.lock()
.unwrap()
@ -247,6 +284,12 @@ mod tests {
}
}
impl BearerSource for Arc<Fake> {
async fn lookup(&self) -> anyhow::Result<Option<String>> {
(&**self).lookup().await
}
}
const TTL: Duration = Duration::from_mins(1);
const SECRET: &str = "0f1e2d3c4b5a";
const GOOD: &str = "Bearer 0f1e2d3c4b5a";
@ -255,7 +298,7 @@ mod tests {
Responder {
source: store,
ttl: TTL,
cached: tokio::sync::Mutex::new(None),
cached: tokio::sync::Mutex::new(Cache::default()),
}
}
@ -338,17 +381,51 @@ mod tests {
assert_eq!(store.lookups(), 2);
}
/// A failed read leaves nothing usable cached, so the next request reads
/// the store again rather than waiting out a TTL.
/// A failed read is remembered for `LOOKUP_FAILURE_WINDOW`, not for a TTL:
/// requests inside it are denied without a lookup, the first one after it
/// reads the store again.
#[tokio::test]
async fn a_failed_lookup_is_retried_on_the_next_request() {
async fn a_failed_lookup_is_retried_after_the_failure_window() {
let store = Fake::holding(SECRET);
store.set(Err(()));
let r = responder(&store);
let t0 = Instant::now();
assert_eq!(decide(&r, Some(GOOD), t0).await, StatusCode::UNAUTHORIZED);
store.set(Ok(Some(SECRET)));
assert_eq!(decide(&r, Some(GOOD), t0).await, StatusCode::OK);
assert_eq!(decide(&r, Some(GOOD), t0).await, StatusCode::UNAUTHORIZED);
assert_eq!(store.lookups(), 1);
let after = t0 + LOOKUP_FAILURE_WINDOW + Duration::from_secs(1);
assert_eq!(decide(&r, Some(GOOD), after).await, StatusCode::OK);
assert_eq!(store.lookups(), 2);
}
/// A burst against a store that errors, or that sleeps past
/// `LOOKUP_TIMEOUT`, costs one lookup, and every request is denied
/// within that one timeout rather than queueing behind one timeout each.
#[tokio::test(start_paused = true)]
async fn a_burst_against_a_failing_store_costs_one_lookup() {
const BURST: usize = 20;
let erroring = Fake::holding(SECRET);
erroring.set(Err(()));
for (name, store) in [("erroring", erroring), ("hanging", Fake::hanging())] {
let store = Arc::new(store);
let r = Arc::new(Responder {
source: Arc::clone(&store),
ttl: TTL,
cached: tokio::sync::Mutex::new(Cache::default()),
});
let started = tokio::time::Instant::now();
let now = Instant::now();
let mut burst = tokio::task::JoinSet::new();
for _ in 0..BURST {
let r = Arc::clone(&r);
burst.spawn(async move { decide(&r, Some(GOOD), now).await });
}
while let Some(status) = burst.join_next().await {
assert_eq!(status.unwrap(), StatusCode::UNAUTHORIZED, "{name}");
}
assert_eq!(store.lookups(), 1, "{name}");
assert!(started.elapsed() <= LOOKUP_TIMEOUT, "{name}");
}
}
}