hive-matrix-daemon now learns which external matrix accounts it has from the swarm secret store, under the agent's own certificate, and the hive push chain for matrix is gone. The daemon lists swarm/agents/<agent>/matrix/ (the `list` its policy grants on its own metadata subtree), reads each account's homeserver from its credential, and brings the accounts up with their tokens from the store. Every two minutes it lists again and exits with 75 when the set of linked accounts changed; the unit restarts on 75 without counting a failure. A listed name whose credential reads as absent is skipped and logged once. At start it removes the matrix-token-<a> / matrix-account-<a>.json pairs a hive delivered (a sidecar marks a pair as delivered; a declared tokenFile keeps its token). Removed: CredentialNotice and the $SWARM.credential.* subject and NATS grant, the controller's publish and its queue precondition on the PUT route, hive-c0re's credential subscription arm and workers/credential.rs, priv_client::write_agent_matrix_token, hive-priv's WriteAgentMatrixToken and its helpers, and the daemon's state-dir account discovery. Kept: WriteAgentGithubToken and the external-forge path (WriteAgentExtraForgeAccount, extra_forges.rs) are untouched, and a declared matrixAccounts tokenFile is still read when the store has no token for that account. Refs #4348
500 lines
19 KiB
Rust
500 lines
19 KiB
Rust
//! The matrix access token as a value this process holds, and the two places
|
|
//! it comes from; and the accounts the store links to this agent.
|
|
//!
|
|
//! 🩸 **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.
|
|
//!
|
|
//! Which accounts exist is read the same way: [`linked_accounts`] lists this
|
|
//! agent's `matrix/` directory, which its policy grants `list` on
|
|
//! (`swarm_secret_client::policy::render_agent`).
|
|
//!
|
|
//! The file arm is a `tokenFile` an operator declared in `matrixAccounts`, or
|
|
//! the `main` token a hive minted before the swarm did.
|
|
|
|
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 `tokenFile` declared for this account.
|
|
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 container was given no store (no `BAO_ADDR`).
|
|
///
|
|
/// # 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 path = matrix::account_path(agent, account)
|
|
.context("building this agent's credential path in the store")?;
|
|
let Some(store) = connect(agent).await? else {
|
|
return Ok(None);
|
|
};
|
|
// The stored object also carries the account's homeserver, dropped
|
|
// here: the account's URL is settled before this runs
|
|
// (`AccountCfg::homeserver`), from config or from [`linked_accounts`].
|
|
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 an account's declared `tokenFile`.
|
|
///
|
|
/// `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)"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An account the store links to this agent: its name, and the homeserver
|
|
/// stored beside its token.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Linked {
|
|
/// The account name, as the store lists it.
|
|
pub name: String,
|
|
/// `None` for a credential stored without one.
|
|
pub homeserver: Option<String>,
|
|
}
|
|
|
|
/// What [`linked_accounts`] found.
|
|
#[derive(Debug, Default)]
|
|
pub struct LinkedAccounts {
|
|
/// The listed accounts the store holds a credential for.
|
|
pub found: Vec<Linked>,
|
|
/// Listed names whose newest version the store answers 404 for: an account
|
|
/// whose credential was deleted while its metadata stays listed.
|
|
pub missing: Vec<String>,
|
|
}
|
|
|
|
/// The accounts the store lists under `agent`'s `matrix/` directory, each read
|
|
/// for its homeserver.
|
|
///
|
|
/// `Ok(None)` means this container was given no store (no `BAO_ADDR`). An
|
|
/// empty directory is an agent with no linked accounts, not an error.
|
|
///
|
|
/// # Errors
|
|
/// A name that is not a single path segment, an environment naming an
|
|
/// identity that cannot be read, or a store that refuses the certificate, the
|
|
/// listing or a path. A policy minted without `list` on the agent's metadata
|
|
/// path is refused the listing.
|
|
pub async fn linked_accounts(agent: &str) -> Result<Option<LinkedAccounts>> {
|
|
let dir = matrix::accounts_dir(agent).context("building this agent's accounts path")?;
|
|
let Some(store) = connect(agent).await? else {
|
|
return Ok(None);
|
|
};
|
|
let keys = store
|
|
.list(&dir)
|
|
.await
|
|
.with_context(|| format!("listing {dir} in the store"))?;
|
|
let mut out = LinkedAccounts::default();
|
|
// A key ending in `/` is a directory below `dir`, not an account.
|
|
for name in keys.into_iter().filter(|k| !k.ends_with('/')) {
|
|
let path = matrix::account_path(agent, &name)
|
|
.with_context(|| format!("building the path of listed account {name:?}"))?;
|
|
let stored: Option<matrix::Credential> = store
|
|
.read_optional(&path)
|
|
.await
|
|
.with_context(|| format!("reading {path} from the store"))?;
|
|
match stored {
|
|
Some(credential) => out.found.push(Linked {
|
|
name,
|
|
homeserver: credential.homeserver,
|
|
}),
|
|
None => out.missing.push(name),
|
|
}
|
|
}
|
|
Ok(Some(out))
|
|
}
|
|
|
|
/// Log in to the store as `agent`, or `None` when this container was given no
|
|
/// store.
|
|
///
|
|
/// `None` is an absent integration, not a failure: the agent's hive was given
|
|
/// no `BAO_ADDR` to forward, so `nix/agent-modules/bao.nix` minted no identity
|
|
/// and there is nothing to log in to.
|
|
///
|
|
/// # Errors
|
|
/// An environment naming an identity that cannot be read, or a store that
|
|
/// refuses the certificate.
|
|
async fn connect(agent: &str) -> Result<Option<SecretStore>> {
|
|
let Some(settings) = store_settings(|k| std::env::var(k).ok())? else {
|
|
return Ok(None);
|
|
};
|
|
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")?;
|
|
Ok(Some(store))
|
|
}
|
|
|
|
/// 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 its declared
|
|
/// token files.
|
|
#[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();
|
|
}
|
|
}
|