`hive-matrix-daemon.path` globbed `/agents/*/state/matrix-token*`. Every agent's state dir is visible from inside every container, so the condition is satisfied by a sibling's token. That is reachable, not cosmetic. The daemon deliberately exits 0 when it has no token of its own — `Restart = "on-failure"` therefore does not restart it, and the unit sits inactive, which is the state the path unit exists for. In that state a sibling's token keeps the glob satisfied: the path fires, the daemon exits 0, the unit deactivates, the path re-arms, the condition is still true. systemd.path(5) activates a `PathExists`-family condition that already holds immediately on arming, so it repeats until the start limit stops it. Scoped to this agent, the condition is false exactly when the daemon would have nothing to do. The glob is quoted in four other places, all of which would otherwise name a pattern that no longer exists — a doc, a Rust doc-comment in hive-c0re, a nix comment, and an assertion message an operator reads. Each is reworded to the basename (`matrix-token*` in this agent's state dir), which is what the assertion actually enforces via `baseNameOf`, so they stay true wherever the directory moves. Refs #4030.
175 lines
7.9 KiB
Rust
175 lines
7.9 KiB
Rust
//! 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 `matrix-token*` inside that agent's own state dir, which
|
|
//! 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.
|
|
///
|
|
/// Both names arrive off the queue and both become path components, so both
|
|
/// are refused here rather than trusted: `agent` by its [`Ident`] type, and
|
|
/// `account` by the same charset check the store path uses. They are checked
|
|
/// by different mechanisms because only one of them has a newtype — an account
|
|
/// name is an attribute name in `hyperhive.matrixAccounts`, so it is not an
|
|
/// `Ident` and cannot become one without narrowing what an operator may
|
|
/// configure.
|
|
///
|
|
/// # Errors
|
|
/// [`swarm_secret_client::Error::PathSegment`] when `account` is empty or
|
|
/// holds anything outside `[A-Za-z0-9_-]`.
|
|
pub fn token_path(agent: &Ident, account: &str) -> Result<PathBuf, swarm_secret_client::Error> {
|
|
path::checked_segment("account", account)?;
|
|
Ok(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")
|
|
.expect("a legal account name");
|
|
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")
|
|
.expect("a legal account name");
|
|
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*, and `token_path` cannot be called without the second.
|
|
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());
|
|
}
|
|
|
|
/// The account half of that same rule, which the agent half's type does
|
|
/// not cover: an account name is an attr name in `hyperhive.matrixAccounts`
|
|
/// and so has no newtype to lean on. Without this, a second caller reaching
|
|
/// `token_path` without going through `path::matrix_account` first would
|
|
/// build a filename out of an unchecked name.
|
|
#[test]
|
|
fn an_account_name_off_the_queue_is_refused_for_the_disk_path_too() {
|
|
let agent = Ident::parse("dmatrix").expect("a legal agent name");
|
|
for bad in ["../argus", "a/b", "a b", "a.b", ""] {
|
|
assert!(
|
|
token_path(&agent, bad).is_err(),
|
|
"account {bad:?} must be refused"
|
|
);
|
|
}
|
|
// Controls: the legal charset stays reachable, so the loop above is
|
|
// not passing because everything is refused. Uppercase and underscore
|
|
// are deliberate — `matrixAccounts` is an attrset, so both are names
|
|
// an operator can already write.
|
|
assert!(token_path(&agent, "ops-relay").is_ok());
|
|
assert!(token_path(&agent, "Ops_Relay9").is_ok());
|
|
}
|
|
}
|