The daemon reads each account's token from `swarm/agents/<agent>/matrix/` as the agent itself, inside its own container, and falls back to the file only when the store has none. This is #4519's read, without its `main` carve-out: the swarm now mints `main` there and no hive writes the file. The daemon unit gets the agent's store identity, spelled the way forge-token.nix spells it. A timer re-starts it while it is down: a token the swarm mints or replaces in the store changes no file, so the path watcher never fires for it, and a daemon that exited on a replaced token would otherwise stay down until the container restarts.
429 lines
17 KiB
Rust
429 lines
17 KiB
Rust
//! The matrix access token as a value this process holds, and the two places
|
|
//! it comes from.
|
|
//!
|
|
//! 🩸 **A secret is a path, not a value.** Nothing here writes the token
|
|
//! anywhere, interpolates it into a command, or lets it reach a log line or an
|
|
//! error message: [`Token`] has no `Debug` derive, and every message below
|
|
//! names an [`Origin`] — which is a path in the store or a path on disk, never
|
|
//! the bytes at either.
|
|
//!
|
|
//! The store read is this agent reading **its own** credential under **its own
|
|
//! identity, from inside its own container** — the path
|
|
//! `swarm/agents/<agent>/matrix/<account>` is already agent-scoped, and the
|
|
//! certificate the login presents is the one this agent's hive delivered as a
|
|
//! systemd credential (`nix/agent-modules/bao.nix`). The hive is not in the
|
|
//! path of the value at all.
|
|
//!
|
|
//! The file arm is the hive-side delivery that still runs beside this one for
|
|
//! extra accounts (`hive_c0re::workers::credential`), and the `main` token a
|
|
//! hive minted before the swarm did. That is what this replaces, not something
|
|
//! it depends on, and it is the arm that goes when the hive-side loop does.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
use swarm_secret_client::{
|
|
SecretStore,
|
|
client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings},
|
|
matrix, policy,
|
|
};
|
|
|
|
/// Names the agent this container belongs to, set by
|
|
/// `nix/agent-modules/matrix.nix` from the agent's own unix user name.
|
|
///
|
|
/// The name and nothing else: `swarm_secret_client` owns both spellings
|
|
/// derived from it — the credential's path ([`matrix::account_path`]) and the
|
|
/// cert-auth role the login selects ([`policy::agent_object_name`]) — so a
|
|
/// path or a role name forwarded from nix would be a second copy of a string
|
|
/// whose mismatch is a 403 that names neither.
|
|
pub const ENV_AGENT: &str = "HIVE_AGENT_NAME";
|
|
|
|
/// Where a token came from.
|
|
///
|
|
/// A **path** in both arms, which is what makes it safe to put in a log line:
|
|
/// the whole point of this module is that the thing beside it never is.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Origin {
|
|
/// A file in this agent's state dir, written by the hive-side delivery.
|
|
File(PathBuf),
|
|
/// A path in the swarm secret store, read by this agent as itself.
|
|
Store(String),
|
|
}
|
|
|
|
impl std::fmt::Display for Origin {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::File(p) => write!(f, "{}", p.display()),
|
|
Self::Store(p) => write!(f, "the store's {p}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A matrix access token, held in memory for as long as it takes to restore a
|
|
/// session with it.
|
|
///
|
|
/// ⚠️ **No `Debug` derive**, for the reason
|
|
/// [`swarm_secret_client::mtls::Credential`] states for the private key it
|
|
/// carries: this value is threaded through `anyhow` context chains and
|
|
/// `tracing` fields, both of which format whatever they are handed. The
|
|
/// hand-written impl below reports the origin and the length, which is every
|
|
/// question a reader of a log line actually has.
|
|
pub struct Token {
|
|
value: String,
|
|
origin: Origin,
|
|
}
|
|
|
|
impl std::fmt::Debug for Token {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("Token")
|
|
.field("origin", &self.origin)
|
|
.field(
|
|
"value",
|
|
&format_args!("<redacted, {} bytes>", self.value.len()),
|
|
)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Token {
|
|
/// The bytes, for the one caller that has to present them to the
|
|
/// homeserver.
|
|
#[must_use]
|
|
pub fn expose(&self) -> &str {
|
|
&self.value
|
|
}
|
|
|
|
/// Where this token came from — the only half of it that may be printed.
|
|
#[must_use]
|
|
pub fn origin(&self) -> &Origin {
|
|
&self.origin
|
|
}
|
|
|
|
/// Read this agent's credential for `account` out of the swarm secret
|
|
/// store, under this agent's own certificate.
|
|
///
|
|
/// `Ok(None)` means this deployment has no store: the agent's hive was
|
|
/// given no `BAO_ADDR` to forward, so `nix/agent-modules/bao.nix` minted no
|
|
/// identity check and there is nothing here to log in to. That is an absent
|
|
/// integration, not a failure — the caller falls back to the file the hive
|
|
/// delivered.
|
|
///
|
|
/// # Errors
|
|
/// A name that is not a single path segment, an environment naming an
|
|
/// identity that cannot be read, a store that refuses the certificate or
|
|
/// the path, or a credential stored empty.
|
|
pub async fn from_store(agent: &str, account: &str) -> Result<Option<Self>> {
|
|
let Some(settings) = store_settings(|k| std::env::var(k).ok())? else {
|
|
return Ok(None);
|
|
};
|
|
let path = matrix::account_path(agent, account)
|
|
.context("building this agent's credential path in the store")?;
|
|
let role = policy::agent_object_name(agent)
|
|
.context("building this agent's cert-auth role name")?;
|
|
|
|
let store = SecretStore::connect(&settings, &role, DEFAULT_CERT_MOUNT)
|
|
.await
|
|
.context("logging in to the swarm secret store as this agent")?;
|
|
// The stored object also carries the account's homeserver, and it is
|
|
// deliberately dropped here: the account's URL is already settled by
|
|
// the time this runs (`AccountCfg::homeserver`), and taking it from the
|
|
// store instead would change which accounts come up at all — that is
|
|
// the discovery half of this move, which goes with the hive-side loop
|
|
// rather than with the read.
|
|
let credential: matrix::Credential = store
|
|
.read(&path)
|
|
.await
|
|
.with_context(|| format!("reading {path} from the store"))?;
|
|
|
|
let value = credential.value.trim().to_owned();
|
|
if value.is_empty() {
|
|
return Err(anyhow!("the credential at {path} in the store is empty"));
|
|
}
|
|
Ok(Some(Self {
|
|
value,
|
|
origin: Origin::Store(path),
|
|
}))
|
|
}
|
|
|
|
/// Read a token out of the file the hive-side delivery wrote.
|
|
///
|
|
/// `Ok(None)` when the file is not there — the account has not been
|
|
/// provisioned yet, which the caller treats as "skip", not "fail".
|
|
///
|
|
/// # Errors
|
|
/// The file existing but being unreadable or empty.
|
|
pub async fn from_file(path: &Path) -> Result<Option<Self>> {
|
|
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
|
|
return Ok(None);
|
|
}
|
|
let value = tokio::fs::read_to_string(path)
|
|
.await
|
|
.with_context(|| format!("read matrix token from {}", path.display()))?
|
|
.trim()
|
|
.to_owned();
|
|
if value.is_empty() {
|
|
return Err(anyhow!("matrix token at {} is empty", path.display()));
|
|
}
|
|
Ok(Some(Self {
|
|
value,
|
|
origin: Origin::File(path.to_owned()),
|
|
}))
|
|
}
|
|
|
|
/// Drop this token after the homeserver rejected it, so a restart does not
|
|
/// loop on the same dead value.
|
|
///
|
|
/// Only a file can be dropped. A credential in the store is not this
|
|
/// agent's to delete — its policy grants `read` and nothing else — and
|
|
/// replacing it is renewal, a different job from this one. Saying so is the
|
|
/// whole of what the store arm does.
|
|
pub async fn discard_stale(&self) {
|
|
match &self.origin {
|
|
Origin::File(p) => {
|
|
let _ = tokio::fs::remove_file(p).await;
|
|
}
|
|
Origin::Store(path) => tracing::warn!(
|
|
credential = %path,
|
|
"the stored matrix credential was rejected; leaving it alone (this agent may only read it)"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// This agent's name, or `None` when the harness did not say.
|
|
///
|
|
/// `None` is not a failure: it is what an agent whose harness predates
|
|
/// [`ENV_AGENT`] looks like, and such an agent keeps working off the file its
|
|
/// hive delivers.
|
|
#[must_use]
|
|
pub fn agent_name() -> Option<String> {
|
|
std::env::var(ENV_AGENT).ok().filter(|v| !v.is_empty())
|
|
}
|
|
|
|
/// The store's coordinates as this container was given them, or `None` when it
|
|
/// was given none.
|
|
///
|
|
/// Split out of [`Token::from_store`] and taken through a lookup so the two
|
|
/// decisions it makes — "is there a store at all" and "is the delivered CA
|
|
/// usable" — can be asserted without a store to talk to or an environment to
|
|
/// mutate.
|
|
///
|
|
/// # Errors
|
|
/// [`swarm_secret_client::Error::MissingEnv`] when an address was forwarded but
|
|
/// the identity beside it was not, which is a half-delivered container rather
|
|
/// than one without a store.
|
|
fn store_settings(get: impl Fn(&str) -> Option<String>) -> Result<Option<Settings>> {
|
|
if get(ENV_ADDR).is_none_or(|v| v.is_empty()) {
|
|
return Ok(None);
|
|
}
|
|
let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
|
|
.context("reading the swarm secret store's coordinates from the environment")?;
|
|
Ok(Some(settings))
|
|
}
|
|
|
|
/// Whether the CA bundle at `path` is a file with bytes in it.
|
|
///
|
|
/// `BAO_CACERT` names a systemd credential, and the bare `LoadCredential=` form
|
|
/// is non-fatal when the manager received no such credential — so the variable
|
|
/// can name a file that is not there. Absent means "verify the store's listener
|
|
/// against the container's own trust store", which is what a deployment with a
|
|
/// real CA wants; handing the path through regardless would fail the TLS
|
|
/// handshake on a file that was never meant to exist and blame the 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::*;
|
|
|
|
/// A lookup standing in for a container that was handed a store.
|
|
fn with_store(k: &str) -> Option<String> {
|
|
match k {
|
|
ENV_ADDR => Some("https://bao.t.local:8200".to_owned()),
|
|
"BAO_CLIENT_CERT" => Some("/run/credentials/x/cert".to_owned()),
|
|
"BAO_CLIENT_KEY" => Some("/run/credentials/x/key".to_owned()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The property the hand-written `Debug` exists for: a token that reaches a
|
|
/// log line is a token in a journal somebody else can read.
|
|
#[test]
|
|
fn formatting_a_token_does_not_reveal_it() {
|
|
let token = Token {
|
|
value: "syt_SUPER_SECRET_ACCESS_TOKEN".to_owned(),
|
|
origin: Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()),
|
|
};
|
|
let rendered = format!("{token:?}");
|
|
assert!(
|
|
!rendered.contains("syt_SUPER_SECRET_ACCESS_TOKEN"),
|
|
"the token must not survive formatting, got {rendered}"
|
|
);
|
|
assert!(rendered.contains("<redacted"), "got {rendered}");
|
|
// The control: the origin IS meant to survive, so the assertion above
|
|
// is not passing because the impl prints nothing at all.
|
|
assert!(
|
|
rendered.contains("swarm/agents/a1/matrix/ccc"),
|
|
"got {rendered}"
|
|
);
|
|
}
|
|
|
|
/// The same property for the arm a reader actually reaches for: `Display`
|
|
/// on the origin is what every message in this crate interpolates.
|
|
#[test]
|
|
fn an_origin_renders_as_a_path_in_both_arms() {
|
|
assert_eq!(
|
|
Origin::File(PathBuf::from("/agents/a1/state/matrix-token-ccc")).to_string(),
|
|
"/agents/a1/state/matrix-token-ccc"
|
|
);
|
|
assert_eq!(
|
|
Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()).to_string(),
|
|
"the store's swarm/agents/a1/matrix/ccc"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_container_with_no_store_address_reads_no_store() {
|
|
// The absence arm, and what the file fallback exists for: a hive that
|
|
// was given no store forwards nothing, and this must be "there is no
|
|
// store" rather than an error about a missing certificate.
|
|
assert!(
|
|
store_settings(|_| None)
|
|
.expect("an absent store is not a failure")
|
|
.is_none()
|
|
);
|
|
// Empty is how systemd delivers an unset nix option, so it has to read
|
|
// the same as unset.
|
|
assert!(
|
|
store_settings(|k| if k == ENV_ADDR {
|
|
Some(String::new())
|
|
} else {
|
|
with_store(k)
|
|
})
|
|
.expect("an empty address is not an address")
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_address_without_an_identity_is_a_half_delivered_container() {
|
|
// Not the same thing as having no store: the hive said where the store
|
|
// is and then delivered nothing to present to it, which is a deployment
|
|
// bug and must be named rather than silently degraded.
|
|
let e = store_settings(|k| if k == ENV_ADDR { with_store(k) } else { None })
|
|
.expect_err("an address with no certificate beside it is a failure");
|
|
assert!(e.to_string().contains("coordinates"), "got {e:#}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_store_address_with_an_identity_is_usable() {
|
|
// The control for the two arms above.
|
|
assert!(
|
|
store_settings(with_store)
|
|
.expect("a full environment")
|
|
.is_some()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_ca_that_was_never_delivered_is_dropped_rather_than_handed_on() {
|
|
// `LoadCredential=` in its bare form is non-fatal when the credential
|
|
// is absent, so this variable routinely names a file that is not there.
|
|
// The resulting settings must be the ones a container with no CA at all
|
|
// gets — anything else fails the handshake and blames the store.
|
|
let named = store_settings(|k| {
|
|
if k == ENV_CACERT {
|
|
Some("/nonexistent/server-ca.pem".to_owned())
|
|
} else {
|
|
with_store(k)
|
|
}
|
|
})
|
|
.expect("a full environment")
|
|
.expect("a store is configured");
|
|
let absent = store_settings(with_store)
|
|
.expect("a full environment")
|
|
.expect("a store is configured");
|
|
assert_eq!(named, absent);
|
|
}
|
|
|
|
#[test]
|
|
fn a_ca_with_bytes_in_it_is_kept() {
|
|
// The control: without it, the test above would pass against a
|
|
// correction that dropped every CA it was ever given.
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"hh-cred-ca-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let ca = dir.join("server-ca.pem");
|
|
std::fs::write(&ca, "-----BEGIN CERTIFICATE-----\n").unwrap();
|
|
assert!(ca_is_usable(ca.to_str().unwrap()));
|
|
|
|
let kept = store_settings(|k| {
|
|
if k == ENV_CACERT {
|
|
Some(ca.to_string_lossy().into_owned())
|
|
} else {
|
|
with_store(k)
|
|
}
|
|
})
|
|
.expect("a full environment")
|
|
.expect("a store is configured");
|
|
assert_ne!(
|
|
kept,
|
|
store_settings(with_store)
|
|
.expect("a full environment")
|
|
.expect("configured"),
|
|
"a delivered CA must change the settings it is delivered into"
|
|
);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unprovisioned_account_has_no_token_rather_than_an_error() {
|
|
// The daemon skips such an account (a secondary) or waits on the path
|
|
// watcher (the primary); neither is a failure.
|
|
let missing = std::env::temp_dir().join("hh-cred-definitely-not-here/matrix-token");
|
|
assert!(Token::from_file(&missing).await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_empty_token_file_names_the_file_and_not_its_contents() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"hh-cred-empty-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("matrix-token");
|
|
std::fs::write(&path, " \n").unwrap();
|
|
|
|
let e = Token::from_file(&path)
|
|
.await
|
|
.expect_err("whitespace is not a token");
|
|
assert!(
|
|
e.to_string().contains(&path.display().to_string()),
|
|
"got {e:#}"
|
|
);
|
|
|
|
// And the control: a real token comes back, trimmed, with the file as
|
|
// its origin.
|
|
std::fs::write(&path, "syt_abc\n").unwrap();
|
|
let token = Token::from_file(&path)
|
|
.await
|
|
.expect("a readable file")
|
|
.expect("a non-empty one");
|
|
assert_eq!(token.expose(), "syt_abc");
|
|
assert_eq!(token.origin(), &Origin::File(path.clone()));
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
}
|