Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-logs/src/auth.rs
atlas c2c61b6146 hive status publishing: read the hive's OIDC secret from the store, retry until it is there
hive-c0re publishes this hive's status to the swarm queue as the OIDC
client `hive-<name>`. Its secret came from
`deploy.hive-controller.statusPublish.clientSecretFile`, which defaulted
to authelia's minted file only on a host running both authelia and the
queue. Every other hive had no secret and never published, so the
controller showed it `never_reported`.

Now `swarm-secret-publish`, beside authelia, also copies each hive's
`hive-<name>` secret to `swarm/hives/<hive>/oidc/client`. Its policy
already allows `swarm/hives/*`. hive-c0re reads that path under the
hive's own store leaf, which the existing `swarm/hives/<hive>/*` grant
covers, and holds the value in memory.

The shared queue client no longer remembers a failed first connect for
the life of the process. A failed attempt is retried with a backoff
from 5s, doubling to a cap of 5 minutes; the dashboard banner stays up
until an attempt succeeds. The two status publishers wait for the
client instead of giving up on the first `None`, so a secret published
after boot is picked up without a restart.

Removed: the `statusPublish.clientSecretFile` option (both spellings,
now removed options with a message), its same-host default, its
LoadCredential, the assertion tying it to the other two coordinates,
and `swarm_queue_client::QueueConfig::from_env`, whose only caller was
this read. Publishing is now on wherever the queue URL and token
endpoint are set and the hive holds a store leaf.

Refs #5015
2026-10-10 01:05:33 +02:00

187 lines
8.7 KiB
Rust

//! The access token this CLI presents to the log store's gateway route.
//!
//! Minting it is **not** implemented here. `swarm-queue-client` already owns
//! the `client_credentials` request against authelia — the basic-auth form,
//! the per-call secret read, the CA-trust builder and the error type that
//! surfaces authelia's own `error_description` — and this crate calls
//! [`swarm_queue_client::mint_token_for_blocking`] rather than writing a
//! second copy of it. A token-endpoint fix has to be findable in one place.
//!
//! What *is* here is the shape of an agent's identity, which differs from the
//! daemons that crate was written for in exactly one way: **the client id
//! arrives as a file, not as a value.** It rides in the same systemd
//! credential pair as the secret so that nothing outside `queue.nix` ever
//! spells `hive-<name>-agent` again. That is the same problem
//! `hive-agent/src/swarm_queue.rs` solves, and [`Config::from_env`] below is
//! that module's `decide` restated over this binary's inputs — including its
//! rule that a *half*-set environment is a deployment bug rather than an
//! absent integration.
//!
//! 🩸 Every credential in here is a **path**. The secret's contents are read
//! by `swarm-queue-client` at the moment of the request and are never bound to
//! a name in this crate, never logged and never rendered into an error.
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use swarm_queue_client::{ClientSecret, QueueConfig};
/// Variable prefix for this binary's coordinates.
///
/// `HIVE_AGENT`, deliberately the **same** prefix the harness reads, because
/// it is the same identity: an agent authenticates as its hive's agent client
/// whether the caller is the harness or a CLI the agent typed. A prefix of its
/// own would be a second set of variables carrying one fact, which is the
/// disagreement `queue.nix`'s credential pair exists to prevent.
const ENV_PREFIX: &str = "HIVE_AGENT";
/// Where this CLI queries, and who it queries as.
pub struct Config {
/// Full URL of the LogsQL query endpoint, gateway side.
///
/// The whole URL rather than a host to build one from, because this same
/// string is **also the audience** the token is minted for — the rule
/// `swarm-otel.nix` states over its own push targets ("one binding
/// because the same string is both the address requested and the audience
/// the token is minted for — two spellings present as a valid token
/// refused at the store"). Splitting it would reintroduce exactly that.
pub query_url: String,
/// Everything needed to mint a token, in the form the minter wants it.
pub queue: QueueConfig,
}
impl Config {
/// Read the configuration out of the environment the wrapper sets.
///
/// All four coordinates or none, for the reason `swarm-queue-client`'s
/// README gives and `hive-agent`'s `swarm_queue` repeats: a half-set
/// environment produces a process that looks configured and never
/// authenticates. Unlike the harness, this binary *errors* on the absent
/// case rather than degrading — a CLI whose only
/// job is the query has nothing left to do without it, and an operator
/// running it wants to be told why rather than handed an empty result
/// they will read as "no logs matched".
pub fn from_env() -> Result<Self> {
let var = |suffix: &str| std::env::var(format!("{ENV_PREFIX}_{suffix}")).ok();
let query_url = var("LOGS_QUERY_URL");
let token_endpoint = var("OIDC_TOKEN_ENDPOINT");
let client_id_file = var("OIDC_CLIENT_ID_FILE");
let client_secret_file = var("OIDC_CLIENT_SECRET_FILE");
// Outside the all-or-none group on purpose: a swarm behind a
// publicly-trusted certificate needs no extra anchor, so a CA path
// with no endpoint is meaningless rather than half-configured. Nothing in this tree sets
// it for an agent — a container already trusts the store's root, which
// `hive_c0re::meta` embeds at build time.
let ca_file = var("OIDC_CA_FILE").map(PathBuf::from);
let (query_url, token_endpoint, client_id_file, client_secret_file) = match (
query_url,
token_endpoint,
client_id_file,
client_secret_file,
) {
(Some(u), Some(t), Some(i), Some(s)) => (u, t, i, s),
(None, None, None, None) => bail!(
"this agent has no swarm log store configured: \
{ENV_PREFIX}_LOGS_QUERY_URL, {ENV_PREFIX}_OIDC_TOKEN_ENDPOINT, \
{ENV_PREFIX}_OIDC_CLIENT_ID_FILE and \
{ENV_PREFIX}_OIDC_CLIENT_SECRET_FILE are all unset"
),
_ => bail!(
"swarm log store half-configured: {ENV_PREFIX}_LOGS_QUERY_URL, \
{ENV_PREFIX}_OIDC_TOKEN_ENDPOINT, {ENV_PREFIX}_OIDC_CLIENT_ID_FILE \
and {ENV_PREFIX}_OIDC_CLIENT_SECRET_FILE are set together or not \
at all — this is a deployment bug, not a missing feature"
),
};
let client_id = read_client_id(Path::new(&client_id_file))?;
Ok(Self {
query_url,
queue: QueueConfig {
// 🩸 Unused by the token mint and deliberately not read from
// the environment. `QueueConfig` is the queue's connect
// config as well as its token config, and this binary needs
// only the second half — taking `HIVE_AGENT_NATS_URL` for it
// would make a broker address a prerequisite for reading
// logs, which is a coupling with no cause. Named so a reader
// of a stack trace is not left wondering.
url: String::from("unused: swarm-logs mints a token and never connects"),
token_endpoint,
client_id,
client_secret: ClientSecret::File(PathBuf::from(client_secret_file)),
ca_file,
},
})
}
}
/// Read the OIDC client id out of the file the systemd credential landed at.
///
/// Trailing newline stripped and an empty file refused, both copied from
/// `hive-agent`'s `read_client_id`: the writing unit ends the file with a
/// newline, and an id carrying one authenticates as nobody. Where the harness
/// treats absence as the ordinary state of a hive whose credential has not
/// been published yet and carries on, this errors — see [`Config::from_env`]
/// for why a CLI has nothing to carry on with.
fn read_client_id(path: &Path) -> Result<String> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading the OIDC client id from {}", path.display()))?;
let id = raw.trim();
if id.is_empty() {
bail!(
"the OIDC client id at {} is empty — the queue credential has not \
been published to this hive yet",
path.display()
);
}
Ok(id.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_id_loses_its_trailing_newline() {
let dir = std::env::temp_dir().join(format!("swarm-logs-id-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("client-id");
std::fs::write(&path, "hive-alpha-agent\n").expect("write");
assert_eq!(read_client_id(&path).expect("read"), "hive-alpha-agent");
std::fs::remove_dir_all(&dir).expect("cleanup");
}
/// An empty credential file is the shape a hive has before its secret
/// store has published anything, and it must not become a token request
/// that authenticates as the empty string.
#[test]
fn an_empty_client_id_is_refused_by_name() {
let dir = std::env::temp_dir().join(format!("swarm-logs-empty-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("client-id");
std::fs::write(&path, "\n").expect("write");
let err = read_client_id(&path).expect_err("empty id must not be accepted");
assert!(
err.to_string().contains("is empty"),
"error should say the id is empty, got: {err}"
);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
/// A missing file names the path, because the operator's next move is to
/// look at whether the credential was delivered at all.
#[test]
fn a_missing_client_id_file_names_its_path() {
let path = Path::new("/nonexistent/swarm-logs/client-id");
let err = read_client_id(path).expect_err("missing file must not be accepted");
assert!(
err.to_string()
.contains("/nonexistent/swarm-logs/client-id"),
"error should name the path, got: {err}"
);
}
}