//! 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--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::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 /// [`QueueConfig::from_env`] 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 { 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, exactly as in // `QueueConfig::from_env`: 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 swarm 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_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 { 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}" ); } }