//! Delivering an agent's external-account credential from the swarm's secret //! store into that agent's own state dir. //! //! The controller publishes a [`CredentialNotice`] naming an agent and an //! account; this reads the value out of the store and writes it where the //! agent's matrix daemon already watches for it. Nothing here activates //! anything: `nix/agent-modules/matrix.nix` has a `systemd.paths` unit //! globbing `/agents/*/state/matrix-token*` that re-fires the daemon when a //! token appears, so arrival is the whole trigger. //! //! 🔑 The notice carries no secret — see [`swarm_queue_client::credential_subject`] //! for why that is a requirement rather than a preference. The value is read //! from the store under this hive's own identity. use std::os::unix::fs::PermissionsExt as _; use std::path::PathBuf; use anyhow::{Context, Result}; use hive_types::Ident; use swarm_queue_client::CredentialNotice; use swarm_secret_client::{SecretStore, path}; use crate::paths::agent_state_dir; /// The basename every matrix token must start with. /// /// `nix/agent-modules/matrix.nix` asserts the same prefix on every configured /// `tokenFile`, because its path-watcher globs for it. A name written here /// that did not match would land a file the daemon never notices — no error, /// just a credential that silently never arrives. const TOKEN_PREFIX: &str = "matrix-token"; /// Where `agent`'s credential for `account` is written. /// /// Takes an [`Ident`] rather than a `&str` because the name arrives off the /// queue: `agent_state_dir` addresses a directory, and an unvalidated name /// there is a path-traversal argument. The compiler refusing the `&str` is the /// check — nothing here has to remember to perform one. #[must_use] pub fn token_path(agent: &Ident, account: &str) -> PathBuf { agent_state_dir(agent).join(format!("{TOKEN_PREFIX}-{account}")) } /// Read the credential `notice` names and write it into the agent's state dir. /// /// `cert_role` is the role on the store's `cert` auth mount whose policy scopes /// what this hive may read. /// /// # Errors /// The store refusing, being unreachable, or holding nothing at that path; a /// name that is not a single path segment; or the write failing. pub async fn deliver(notice: &CredentialNotice, cert_role: &str) -> Result<()> { // Parsed before anything is read, so a malformed name costs a decode and // not a round trip to the store. let agent = Ident::parse(¬ice.agent) .map_err(|e| anyhow::anyhow!("agent name {:?} off the queue: {e}", notice.agent))?; let secret_path = path::matrix_account(¬ice.agent, ¬ice.account) .context("building the credential's path in the store")?; let store = SecretStore::from_env(cert_role) .await .context("connecting to the swarm secret store")?; let value = store .read(&secret_path) .await .with_context(|| format!("reading {secret_path} from the store"))?; write_token(&token_path(&agent, ¬ice.account), &value) } /// Write `value` to `dest` at `0600`, atomically. /// /// Atomic because the daemon's path-watcher fires on the file *appearing*: a /// token written in place would be visible while still partial, and the daemon /// would read a truncated credential exactly once, at the moment it is hardest /// to reproduce. The temp name is dot-prefixed so it cannot match the /// `matrix-token*` glob on its way past. fn write_token(dest: &PathBuf, value: &str) -> Result<()> { let dir = dest .parent() .context("a token path always has a parent state dir")?; let name = dest .file_name() .context("a token path always has a file name")?; let mut tmp = dir.join("."); tmp.as_mut_os_string().push(name); tmp.as_mut_os_string().push(".partial"); std::fs::write(&tmp, value).with_context(|| format!("writing {}", tmp.display()))?; // Before the rename, so the file is never briefly readable by others under // its final name. std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)) .with_context(|| format!("restricting {}", tmp.display()))?; std::fs::rename(&tmp, dest).with_context(|| format!("publishing {}", dest.display())) } #[cfg(test)] mod tests { use super::*; #[test] fn the_written_name_matches_the_glob_the_daemon_watches() { // The other end of this agreement is an assertion in // `nix/agent-modules/matrix.nix` and a `systemd.paths` glob — neither // reachable from a Rust test, so the prefix is pinned here. let p = token_path(&Ident::parse("dmatrix").expect("a legal agent name"), "ccc"); let name = p.file_name().unwrap().to_str().unwrap(); assert!(name.starts_with("matrix-token"), "got {name}"); assert_eq!(name, "matrix-token-ccc"); } #[test] fn the_partial_file_cannot_match_that_glob() { // A temp name starting with `matrix-token` would be picked up // mid-write; the dot prefix is what stops it. let p = token_path(&Ident::parse("dmatrix").expect("a legal agent name"), "ccc"); let name = p.file_name().unwrap().to_str().unwrap(); let tmp = format!(".{name}.partial"); assert!(!tmp.starts_with("matrix-token"), "got {tmp}"); } #[test] fn a_name_that_could_address_another_agent_is_refused() { // `path::matrix_account` owns this rule; asserted here because this is // the module that feeds it names off the wire. assert!(path::matrix_account("../argus", "ccc").is_err()); assert!(path::matrix_account("dmatrix", "../../etc/x").is_err()); assert!(path::matrix_account("dmatrix", "ccc").is_ok()); } #[test] fn an_agent_name_off_the_queue_must_pass_the_ident_parser_too() { // Two independent refusals, not one restated: `path::matrix_account` // guards the address in the *store*, `Ident` guards the address on // *disk*. `token_path` cannot even be called without the second, // which is why it takes an `Ident` rather than validating internally. for bad in ["../argus", "dmatrix/../argus", "Dmatrix", "d matrix", ""] { assert!(Ident::parse(bad).is_err(), "{bad:?} must be refused"); } // The control: without it, a parser that rejected everything would // satisfy the loop above. assert!(Ident::parse("dmatrix").is_ok()); } }