swarm-logs reads `swarm/agents/<agent>/queue` from the swarm secret store with the agent's own store certificate (the read hive-agent makes for its queue connection) and presents it as `Bearer swarm-agent.<agent>.<secret>`, which the gateway's swarm-logs-auth checks. On a 401 it reads the secret again and retries once, so a secret re-minted between the read and the check does not fail the query. The OIDC mint against the hive's shared `hive-<h>-agent` client is gone from swarm-logs, along with the unused `HIVE_AGENT_OIDC_CA_FILE` anchor. logs.nix now gates on the store address instead of the queue's token endpoint and wraps the binary with the paths queue-identity.nix gives the harness; the host forwards the query URL wherever it forwards a store identity. The shared client is still delivered into containers; removing that is Part of #4459.
149 lines
6 KiB
Rust
149 lines
6 KiB
Rust
//! The credential this CLI presents to the log store's gateway route: the
|
|
//! agent's own queue secret, read from the swarm secret store with the agent's
|
|
//! store certificate.
|
|
//!
|
|
//! The read is `hive-agent`'s `swarm_queue` one: the same variables, the same
|
|
//! path (`swarm/agents/<agent>/queue`) and the same cert-auth role. The secret
|
|
//! goes out as `Bearer swarm-agent.<agent>.<secret>`, spelled by
|
|
//! `swarm_queue_client::agent_token`; the gateway's `swarm-logs-auth`
|
|
//! responder checks it against the store.
|
|
//!
|
|
//! 🩸 The secret is never logged and never rendered into an error.
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings};
|
|
use swarm_secret_client::queue::{self, AgentCredential};
|
|
use swarm_secret_client::{SecretStore, policy};
|
|
|
|
/// The full URL of the LogsQL query endpoint, gateway side.
|
|
const ENV_QUERY_URL: &str = "HIVE_AGENT_LOGS_QUERY_URL";
|
|
/// This agent's name, which the store path and the role are built from.
|
|
const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME";
|
|
/// The store's address; the rest of its coordinates are `Settings`'s.
|
|
const ENV_ADDR: &str = "BAO_ADDR";
|
|
/// The store's CA bundle, which may name an undelivered credential.
|
|
const ENV_CACERT: &str = "BAO_CACERT";
|
|
|
|
/// Where this CLI queries, and who it queries as.
|
|
pub struct Config {
|
|
/// Full URL of the LogsQL query endpoint, gateway side.
|
|
pub query_url: String,
|
|
agent: String,
|
|
settings: Settings,
|
|
role: String,
|
|
path: String,
|
|
}
|
|
|
|
impl Config {
|
|
/// Read the configuration out of the environment the wrapper sets.
|
|
///
|
|
/// Errors rather than degrading: a CLI whose only job is the query has
|
|
/// nothing to do without it, and an empty result would read as "no logs
|
|
/// matched".
|
|
pub fn from_env() -> Result<Self> {
|
|
Self::from_lookup(|k| std::env::var(k).ok())
|
|
}
|
|
|
|
fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
|
|
let set = |k: &str| get(k).filter(|v| !v.is_empty());
|
|
let Some(query_url) = set(ENV_QUERY_URL) else {
|
|
bail!("this agent has no swarm log store configured: {ENV_QUERY_URL} is unset");
|
|
};
|
|
if set(ENV_ADDR).is_none() {
|
|
bail!(
|
|
"{ENV_QUERY_URL} is set but {ENV_ADDR} is not, so there is no secret store \
|
|
to read this agent's credential from — this is a deployment bug"
|
|
);
|
|
}
|
|
let agent =
|
|
set(ENV_AGENT_NAME).with_context(|| format!("{ENV_AGENT_NAME} is unset or empty"))?;
|
|
let settings =
|
|
Settings::from_lookup(|k| set(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
|
|
.context("reading the swarm secret store's coordinates from the environment")?;
|
|
Ok(Self {
|
|
query_url,
|
|
role: policy::agent_object_name(&agent)?,
|
|
path: queue::agent_queue_path(&agent)?,
|
|
agent,
|
|
settings,
|
|
})
|
|
}
|
|
|
|
/// Read this agent's secret from the store and spell it as the bearer.
|
|
///
|
|
/// Read on every call, so a caller that was refused can call again and
|
|
/// present a secret re-minted since.
|
|
pub fn bearer(&self) -> Result<String> {
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.context("starting the runtime for the secret store read")?;
|
|
let stored: Option<AgentCredential> = runtime.block_on(async {
|
|
SecretStore::connect(&self.settings, &self.role, DEFAULT_CERT_MOUNT)
|
|
.await
|
|
.context("logging in to the swarm secret store with this agent's certificate")?
|
|
.read_optional(&self.path)
|
|
.await
|
|
.with_context(|| format!("reading {}", self.path))
|
|
})?;
|
|
let stored = stored.with_context(|| format!("nothing is stored at {}", self.path))?;
|
|
swarm_queue_client::agent_token::format_agent_token(&self.agent, &stored.value)
|
|
.with_context(|| format!("spelling the credential stored at {}", self.path))
|
|
}
|
|
}
|
|
|
|
/// Whether the CA bundle at `path` is a file with bytes in it. `BAO_CACERT`
|
|
/// names a systemd credential that may not have been delivered; absent means
|
|
/// the container's own trust store.
|
|
fn ca_is_usable(path: &str) -> bool {
|
|
std::fs::metadata(path).is_ok_and(|m| m.len() > 0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
|
let pairs: Vec<(String, String)> = pairs
|
|
.iter()
|
|
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
|
.collect();
|
|
move |k| pairs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone())
|
|
}
|
|
|
|
const FULL: &[(&str, &str)] = &[
|
|
(ENV_QUERY_URL, "https://logs.example/select/logsql/query"),
|
|
(ENV_AGENT_NAME, "atlas"),
|
|
(ENV_ADDR, "https://bao.example:8200"),
|
|
("BAO_CLIENT_CERT", "/nonexistent/cert"),
|
|
("BAO_CLIENT_KEY", "/nonexistent/key"),
|
|
];
|
|
|
|
#[test]
|
|
fn no_query_url_says_there_is_no_log_store() {
|
|
let err = Config::from_lookup(env(&[])).err().expect("refused");
|
|
assert!(err.to_string().contains("no swarm log store"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_query_url_without_a_store_is_a_deployment_bug() {
|
|
let err = Config::from_lookup(env(&FULL[..2])).err().expect("refused");
|
|
assert!(err.to_string().contains("deployment bug"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_agent_name_is_named() {
|
|
let mut pairs = FULL.to_vec();
|
|
pairs.retain(|(k, _)| *k != ENV_AGENT_NAME);
|
|
let err = Config::from_lookup(env(&pairs)).err().expect("refused");
|
|
assert!(err.to_string().contains(ENV_AGENT_NAME), "{err}");
|
|
}
|
|
|
|
/// The path and role are the agent's own, the ones its policy grants.
|
|
#[test]
|
|
fn the_read_is_the_agents_own_queue_secret() {
|
|
let cfg = Config::from_lookup(env(FULL)).expect("configured");
|
|
assert_eq!(cfg.path, "swarm/agents/atlas/queue");
|
|
assert_eq!(cfg.role, policy::agent_object_name("atlas").expect("valid"));
|
|
}
|
|
}
|