Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/swarm_queue.rs
atlas a6935e57c3 swarm-queue-client: re-read a held OIDC client secret when the IdP refuses it
hive-c0re (`hive-<name>`), swarm-controller and swarm-review-classifier
read their queue client secret from the store once and held it as
`ClientSecret::Value` for the life of the process. After authelia
re-minted a secret and swarm-secret-publish stored the new one, every
later token mint presented the old value and was refused until the
daemon restarted.

`ClientSecret::Stored` replaces `Value`: a `StoredSecret` holds the
secret in memory with a caller-supplied read callback, so the crate
takes no store dependency. When the token endpoint answers 400 or 401,
`mint_token` (and its blocking sibling, used by the controller's OTLP
exporters) re-reads it and, if the store holds a different secret,
mints once more with it. One re-read runs at a time and is spawned so
it outlives a caller that gives up. A re-read that fails or finds the
refused value delays the next one, 5 s doubling to 5 min, so a secret
that stays wrong costs one store read per delay. Logs name the client
id and never the secret.

swarm-nats-auth's `oidc_secret` was the template; its re-read machinery
now sits on `StoredSecret`, so the tree has one copy. The responder
gains the same re-read backoff.

The controller keeps its `IDENTITY` OnceLock: it now holds a
`QueueConfig` whose secret is re-read, and the lazily built vcs-metrics
exporter reads the identity from it.

agent_renewal.rs's module doc said a re-minted agent queue secret only
reaches the agent at its next start; hive-agent reads the store on every
connection attempt after its first, so the doc now says that.

Part of #5017 (slice Q).
2026-10-13 19:16:04 +02:00

414 lines
15 KiB
Rust

//! One swarm-queue connection, shared by every consumer in this process.
//!
//! [`swarm_status`](crate::swarm_status) and
//! [`swarm_notices`](crate::swarm_notices) both need the swarm queue, and
//! both authenticate as the *same* identity (`hive-<name>`, minted for
//! this hive — see `swarm-authelia.nix`). Two independent `connect()`
//! calls would be two token mints and two live connections for one
//! identity, not two different credentials — the same shape that turned
//! `swarm-controller`'s own connect into the shared `swarm-queue-client`
//! crate in the first place, one layer up. This module is that same move
//! made again, this time between two consumers *inside* one process.
//!
//! The queue's address, the token endpoint and the client id come from the
//! environment. The client secret comes from the swarm's secret store, at
//! `swarm_secret_client::queue::hive_client_path`, read under this hive's own
//! certificate, held only in memory and read again when authelia refuses it
//! (`swarm_queue_client::stored_secret`). `swarm-secret-publish`, on the host
//! that runs authelia, puts it there.
//!
//! Connects lazily on first use rather than at boot — nothing here
//! blocks `hive-c0re` starting up on hosts with no queue configured,
//! which is the ordinary case. A failed attempt is retried after a
//! backoff capped at [`MAX_RETRY_DELAY`], so a secret published after this
//! daemon started is picked up without a restart.
use std::future::Future;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use swarm_queue_client::stored_secret::StoredSecret;
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::queue::{self, HiveClientCredential};
use tokio::sync::Mutex;
use tokio::time::Instant;
/// Env var prefix for this daemon's swarm-queue coordinates — see
/// [`coordinates_from`].
const ENV_PREFIX: &str = "HIVE_C0RE";
/// Wait after the first failed attempt; doubled after each further one.
const FIRST_RETRY_DELAY: Duration = Duration::from_secs(5);
/// The longest wait between two attempts.
const MAX_RETRY_DELAY: Duration = Duration::from_mins(5);
/// The banner kind a failed attempt raises, cleared by the next success.
const BANNER: &str = "swarm_queue_config";
static CLIENT: Retrying<async_nats::Client> = Retrying::new(FIRST_RETRY_DELAY, MAX_RETRY_DELAY);
/// The shared swarm-queue client, or `None` when there is none right now.
///
/// `None` covers "no queue configured" (the ordinary case, logged once at
/// `info`), a deployment bug in the coordinates (bannered, and permanent for
/// this process), and an attempt that failed or is still backing off. A
/// caller with `None` should just skip whatever it was about to publish.
/// Never waits out a backoff: see [`wait_for_client`] for that.
pub async fn client() -> Option<async_nats::Client> {
match CLIENT.get(connect_once).await {
Outcome::Ready(client) => Some(client),
Outcome::Off | Outcome::NotBefore(_) => None,
}
}
/// The shared swarm-queue client, waiting through every backoff until there
/// is one. `None` only when this hive publishes nothing to a queue at all.
pub async fn wait_for_client() -> Option<async_nats::Client> {
loop {
match CLIENT.get(connect_once).await {
Outcome::Ready(client) => return Some(client),
Outcome::Off => return None,
Outcome::NotBefore(at) => tokio::time::sleep_until(at).await,
}
}
}
/// What one connection attempt came to.
enum Attempt<T> {
/// Nothing to connect to, for the life of the process.
Off,
Ready(T),
/// Worth trying again later.
Failed(anyhow::Error),
}
/// What [`Retrying::get`] answers.
enum Outcome<T> {
Off,
Ready(T),
/// The last attempt failed; the next one is not tried before this.
NotBefore(Instant),
}
enum Slot<T> {
Off,
Ready(T),
/// `failures` consecutive attempts have failed. `banner` holds the
/// dashboard warning until the slot leaves this state.
Failing {
failures: u32,
not_before: Instant,
banner: Option<crate::warnings::WarningGuard>,
},
}
/// A value made by an attempt that can fail: a success or an `Off` is kept
/// for the life of the process, a failure only until its backoff runs out.
struct Retrying<T> {
slot: Mutex<Option<Slot<T>>>,
first_delay: Duration,
max_delay: Duration,
}
impl<T: Clone> Retrying<T> {
const fn new(first_delay: Duration, max_delay: Duration) -> Self {
Self {
slot: Mutex::const_new(None),
first_delay,
max_delay,
}
}
/// The kept value, or one new `attempt` if the last one's backoff has run
/// out. Concurrent callers wait on one attempt rather than each making
/// their own.
async fn get<F, Fut>(&self, attempt: F) -> Outcome<T>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Attempt<T>>,
{
let mut slot = self.slot.lock().await;
let failures = match &*slot {
None => 0,
Some(Slot::Off) => return Outcome::Off,
Some(Slot::Ready(value)) => return Outcome::Ready(value.clone()),
Some(Slot::Failing {
failures,
not_before,
..
}) => {
if Instant::now() < *not_before {
return Outcome::NotBefore(*not_before);
}
*failures
}
};
match attempt().await {
Attempt::Off => {
*slot = Some(Slot::Off);
Outcome::Off
}
Attempt::Ready(value) => {
if failures > 0 {
tracing::info!(failures, "swarm queue: connected after earlier failures");
}
*slot = Some(Slot::Ready(value.clone()));
Outcome::Ready(value)
}
Attempt::Failed(e) => {
let failures = failures.saturating_add(1);
let delay = self.delay_after(failures);
let not_before = Instant::now() + delay;
let message = format!("swarm queue is off, retrying in {delay:?}: {e:#}");
tracing::warn!(failures, "{message}");
// Updating the held guard keeps the banner up without a
// moment where it is cleared and re-raised.
let banner = match slot.take() {
Some(Slot::Failing {
banner: Some(banner),
..
}) => {
banner.update("warn", message);
banner
}
_ => crate::warnings::set_warning(BANNER, "warn", message),
};
*slot = Some(Slot::Failing {
failures,
not_before,
banner: Some(banner),
});
Outcome::NotBefore(not_before)
}
}
}
/// `first_delay` doubled per failure after the first, capped at
/// `max_delay`.
fn delay_after(&self, failures: u32) -> Duration {
let doublings = failures.saturating_sub(1).min(16);
self.first_delay
.saturating_mul(1 << doublings)
.min(self.max_delay)
}
}
async fn connect_once() -> Attempt<async_nats::Client> {
let coordinates = match coordinates_from(|name| std::env::var(name).ok()) {
Ok(Some(c)) => c,
Ok(None) => {
tracing::info!("no swarm queue configured; this hive offers nothing upward");
return Attempt::Off;
}
Err(e) => {
// A deployment bug, which no retry clears.
crate::warnings::set_boot_warning(BANNER, "warn", format!("swarm queue is off: {e:#}"));
return Attempt::Off;
}
};
let secret = match fetch_secret().await {
Ok(secret) => secret,
Err(e) => return Attempt::Failed(e),
};
match swarm_queue_client::connect(coordinates.with_secret(secret)).await {
Ok(client) => Attempt::Ready(client),
// `chain`, not `{:#}`: this is `swarm_queue_client::Error`, whose
// `Display` ignores the alternate flag.
Err(e) => Attempt::Failed(anyhow!(swarm_queue_client::chain(&e))),
}
}
/// Everything in a [`QueueConfig`] except the secret.
#[derive(Debug, PartialEq, Eq)]
struct Coordinates {
url: String,
token_endpoint: String,
client_id: String,
ca_file: Option<PathBuf>,
}
impl Coordinates {
fn with_secret(self, secret: String) -> QueueConfig {
let stored = StoredSecret::new(self.client_id.clone(), secret, || async {
fetch_secret().await.map_err(Into::into)
});
QueueConfig {
url: self.url,
token_endpoint: self.token_endpoint,
client_id: self.client_id,
client_secret: ClientSecret::Stored(stored),
ca_file: self.ca_file,
}
}
}
/// `<ENV_PREFIX>_NATS_URL`, `_OIDC_TOKEN_ENDPOINT` and `_OIDC_CLIENT_ID`, all
/// or none; `_OIDC_CA_FILE` optionally.
fn coordinates_from(var: impl Fn(&str) -> Option<String>) -> Result<Option<Coordinates>> {
let url = var(&format!("{ENV_PREFIX}_NATS_URL"));
let token_endpoint = var(&format!("{ENV_PREFIX}_OIDC_TOKEN_ENDPOINT"));
let client_id = var(&format!("{ENV_PREFIX}_OIDC_CLIENT_ID"));
let ca_file = var(&format!("{ENV_PREFIX}_OIDC_CA_FILE")).map(PathBuf::from);
match (url, token_endpoint, client_id) {
(None, None, None) => Ok(None),
(Some(url), Some(token_endpoint), Some(client_id)) => Ok(Some(Coordinates {
url,
token_endpoint,
client_id,
ca_file,
})),
_ => bail!(
"swarm queue is half-configured: {ENV_PREFIX}_NATS_URL, \
{ENV_PREFIX}_OIDC_TOKEN_ENDPOINT and {ENV_PREFIX}_OIDC_CLIENT_ID must be set \
together or not at all"
),
}
}
/// One login and one read, under this hive's own certificate. An empty value
/// is refused like an absent one: it would reach authelia as the secret and
/// come back as `invalid_client`, which names neither the store nor the
/// publisher.
async fn fetch_secret() -> Result<String> {
let Some(hive) = crate::container_view::hive_swarm_names().0 else {
bail!("HYPERHIVE_HIVE_NAME is unset, so there is no store path for this hive's secret");
};
let path = queue::hive_client_path(&hive)?;
let store = swarm_secret_client::SecretStore::from_env(&hive)
.await
.context("logging in to the swarm secret store")?;
let stored: Option<HiveClientCredential> = store
.read_optional(&path)
.await
.with_context(|| format!("reading {path}"))?;
match stored {
Some(c) if !c.value.trim().is_empty() => Ok(c.value.trim().to_owned()),
_ => bail!(
"nothing at {path}: swarm-secret-publish, on the host that runs authelia, has not \
published this hive's client secret"
),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
move |name| map.get(name).cloned()
}
const FULL: [(&str, &str); 3] = [
("HIVE_C0RE_NATS_URL", "tls://nats.example:4222"),
(
"HIVE_C0RE_OIDC_TOKEN_ENDPOINT",
"https://auth.example/api/oidc/token",
),
("HIVE_C0RE_OIDC_CLIENT_ID", "hive-alpha"),
];
#[test]
fn no_coordinates_is_no_queue() {
assert_eq!(
coordinates_from(env(&[])).expect("absent is not an error"),
None
);
}
#[test]
fn a_half_set_environment_is_an_error_naming_the_variables() {
let err = coordinates_from(env(&FULL[..2])).expect_err("a partial set is not absent");
assert!(
format!("{err}").contains("HIVE_C0RE_OIDC_CLIENT_ID"),
"{err}"
);
}
#[tokio::test]
async fn a_full_environment_carries_the_stored_secret_in_memory() {
let cfg = coordinates_from(env(&FULL))
.expect("complete")
.expect("configured")
.with_secret("s3cret".to_owned());
assert_eq!(cfg.client_id, "hive-alpha");
assert!(matches!(&cfg.client_secret, ClientSecret::Stored(s) if s.current() == "s3cret"));
}
/// The secret is not in the store when this daemon first asks, and is
/// later: the later value is used, with no restart in between.
#[tokio::test]
async fn a_failed_first_read_is_retried_and_its_later_secret_used() {
let shared = Retrying::<String>::new(Duration::ZERO, Duration::ZERO);
let first = shared
.get(|| async { Attempt::Failed(anyhow!("nothing at the path yet")) })
.await;
assert!(
matches!(first, Outcome::NotBefore(_)),
"the first read failed"
);
let second = shared
.get(|| async { Attempt::Ready("published-later".to_owned()) })
.await;
assert!(
matches!(&second, Outcome::Ready(s) if s == "published-later"),
"the read after the failure is made and its secret used"
);
let kept = shared
.get(|| async { panic!("a success is kept, not re-read") })
.await;
assert!(matches!(&kept, Outcome::Ready(s) if s == "published-later"));
}
#[tokio::test(start_paused = true)]
async fn a_failure_is_not_retried_before_its_backoff_runs_out() {
let shared = Retrying::<String>::new(Duration::from_secs(5), Duration::from_mins(5));
let failed = shared
.get(|| async { Attempt::Failed(anyhow!("down")) })
.await;
let Outcome::NotBefore(at) = failed else {
panic!("a failure backs off");
};
assert_eq!(at - Instant::now(), Duration::from_secs(5));
let early = shared
.get(|| async { panic!("no attempt inside the backoff") })
.await;
assert!(matches!(early, Outcome::NotBefore(t) if t == at));
tokio::time::advance(Duration::from_secs(5)).await;
let due = shared
.get(|| async { Attempt::Ready("up".to_owned()) })
.await;
assert!(matches!(&due, Outcome::Ready(s) if s == "up"));
}
#[test]
fn the_backoff_doubles_up_to_its_cap() {
let shared = Retrying::<String>::new(Duration::from_secs(5), Duration::from_mins(5));
let delays: Vec<u64> = (1..=8).map(|n| shared.delay_after(n).as_secs()).collect();
assert_eq!(delays, [5, 10, 20, 40, 80, 160, 300, 300]);
assert_eq!(shared.delay_after(u32::MAX), Duration::from_mins(5));
}
#[tokio::test]
async fn off_is_kept_for_the_life_of_the_process() {
let shared = Retrying::<String>::new(Duration::ZERO, Duration::ZERO);
assert!(matches!(
shared.get(|| async { Attempt::Off }).await,
Outcome::Off
));
let again = shared.get(|| async { panic!("off is not re-tried") }).await;
assert!(matches!(again, Outcome::Off));
}
}