Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/matrix_account/hive_sender.rs
atlas ddb7d7196d matrix: swarm-controller is the only minter
Every hive is in a swarm and every swarm runs matrix, so every swarm has a
swarm-controller, and since #4810 its hive_sender pass mints each hive's
@hive-<hive>: sender token into the store every five minutes. The two
other minters of that token go:

- swarm-matrix-ctl mint: the systemd.services.swarm-matrix-ctl unit in the
  hive-matrix container, Command::Mint and src/mint.rs. The binary, its
  appservice render/publish verbs, ctlPackage, ctlActive and the ctl cert
  role stay. bao-matrix-reader's checks on the deleted unit are removed;
  the leaf-identity and no-token-in-env checks now look at
  swarm-matrix-appservice-publish, which runs under the same identity.
- the hive-side mint ladder in hive-c0re's ensure_hive_user
  (register/appservice-login/password-login with the local as_token), with
  read_appservice_token, paths::matrix_appservice_token and the helpers
  only it used. ensure_hive_user now takes the store's token, keeps the
  file when the store has none or can't be reached, and fails otherwise.
- hivectl matrix sync-admin: the verb, HostRequest::MatrixSyncAdmin and
  handle_matrix_sync_admin. The periodic MatrixSweep (ensure_all) is
  unchanged apart from no longer reading the local as_token.

This removes the double-mint race #4810's review flagged: two minters
logging in on one pinned device could leave a dead token in the store
until the next pass.

Closes #4813
Closes #4814
2026-09-30 00:46:46 +02:00

195 lines
7.2 KiB
Rust

//! Each hive's sender account, `@hive-<hive>:`, on the swarm's homeserver:
//! created here with the **swarm's** appservice token and stored at
//! `swarm/hives/<hive>/matrix/sender-token`, where hive-c0re's matrix sweep
//! reads it under the hive's own store identity.
//!
//! Runs for every hive in the directory, local or remote, and decides the
//! same way [`super::agent_token`] does: a stored token that `whoami`
//! confirms as `@hive-<hive>:` is kept, so this writes only when the path is
//! empty or its token is dead.
//!
//! This is the only writer of that path: hive-c0re never mints, it takes
//! whatever the store holds.
use std::sync::Arc;
use anyhow::{Context, Result};
use swarm_matrix_client as homeserver;
use swarm_secret_client::matrix;
use super::agent_token::{Decision, Observed, appservice_token, classify, mint_at, plan, probe_at};
/// How often [`spawn`] re-checks every hive's sender token.
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
/// Decide what to do about `hive`'s sender token from what the stored one is.
fn classify_hive(hive: &str, probe: &super::agent_token::Probe) -> Decision {
classify(&matrix::hive_localpart(hive), probe)
}
/// Make sure `hive`'s sender account holds a live token in the store,
/// creating the account or logging in to it when it does not. The whole job
/// of the `MintHiveSenderToken` node.
///
/// # Errors
/// When the store or the homeserver refuses a step, the swarm appservice
/// token has not been published, or the new token authenticates as someone
/// else.
pub async fn ensure_hive_sender_token(base: &str, hive: &str) -> Result<()> {
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
let as_token = appservice_token(&store).await?;
let http = homeserver::client()?;
let path = matrix::sender_token_path(hive)?;
let reason = match classify_hive(hive, &probe_at(&store, &http, base, &path).await?) {
Decision::Keep => {
tracing::debug!(hive, "hive sender token is current; left as it is");
return Ok(());
}
Decision::Mint(reason) => reason,
};
let localpart = matrix::hive_localpart(hive);
mint_at(&store, &http, base, &localpart, &as_token, &path).await?;
tracing::info!(hive, ?reason, %path, "hive sender token minted and stored");
Ok(())
}
/// One pass: every hive in `hives`, observed.
///
/// Fails as a whole when the swarm appservice token is not published, for
/// the reason `agent_token`'s pass does.
async fn observe_all(base: &str, hives: &[String]) -> Result<Vec<(String, Observed)>> {
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
appservice_token(&store).await?;
let http = homeserver::client()?;
let mut observed = Vec::with_capacity(hives.len());
for hive in hives {
let o = match matrix::sender_token_path(hive) {
Ok(path) => match probe_at(&store, &http, base, &path).await {
Ok(p) => Observed::Decided(classify_hive(hive, &p)),
Err(e) => {
tracing::warn!(hive, error = %format!("{e:#}"), "hive sender token: read failed");
Observed::Unknown
}
},
Err(e) => {
tracing::warn!(hive, error = %e, "hive sender token: the hive name forms no store path");
Observed::Unknown
}
};
observed.push((hive.clone(), o));
}
Ok(observed)
}
/// Check every hive's sender token now and every [`RECONCILE_INTERVAL`]
/// after, and hand the hives that need one to `enqueue`, which inserts a
/// `MintHiveSenderToken` node for each.
///
/// The roster is the controller's hive directory. A pass that fails is
/// logged and retried on the next tick; it never stops the daemon.
pub fn spawn(base: Arc<str>, hives: Vec<String>, enqueue: impl Fn(Vec<String>) + Send + 'static) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop {
ticker.tick().await;
match observe_all(&base, &hives).await {
Ok(observed) => {
let minting = plan(&observed);
if minting.is_empty() {
tracing::debug!(
checked = observed.len(),
"hive sender tokens: all current"
);
} else {
tracing::info!(
checked = observed.len(),
minting = minting.len(),
"hive sender tokens: queueing mints"
);
enqueue(minting);
}
}
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
retry_in_s = RECONCILE_INTERVAL.as_secs(),
"hive sender tokens: pass failed; retrying next tick"
),
}
}
});
}
#[cfg(test)]
mod tests {
use super::super::agent_token::{MintReason, Probe};
use super::*;
use swarm_matrix_client::Whoami;
fn user(id: &str) -> Probe {
Probe::Whoami(Whoami::User(id.to_owned()))
}
#[test]
fn a_live_token_for_the_hive_account_is_kept() {
assert_eq!(
classify_hive("pr1ma", &user("@hive-pr1ma:t.local")),
Decision::Keep
);
}
#[test]
fn nothing_stored_mints() {
// A hive whose homeserver is remote: nothing has ever written its path.
assert_eq!(
classify_hive("pr1ma", &Probe::NotStored),
Decision::Mint(MintReason::NotStored)
);
}
#[test]
fn a_dead_token_mints() {
// A token the homeserver revoked, or a device a manual login replaced.
assert_eq!(
classify_hive("pr1ma", &Probe::Whoami(Whoami::UnknownToken)),
Decision::Mint(MintReason::Revoked)
);
}
#[test]
fn a_token_for_another_account_mints() {
// The bare hive name is an agent's localpart, not the hive's account,
// and another hive's account is not this one's.
for other in ["@pr1ma:t.local", "@hive-beta:t.local", "@hive:t.local"] {
assert_eq!(
classify_hive("pr1ma", &user(other)),
Decision::Mint(MintReason::OtherUser),
"{other}"
);
}
}
#[test]
fn an_outage_plans_nothing() {
// A mint replaces the token the hive is running on, so a pass that
// could not read must not mint for every hive.
let observed = [
("alpha".to_owned(), Observed::Unknown),
("beta".to_owned(), Observed::Unknown),
];
assert!(plan(&observed).is_empty());
}
#[test]
fn the_published_path_is_the_one_the_hive_reads() {
// hive-c0re's `stored_sender_token` resolves this path; the literal is
// what the bao grant names.
assert_eq!(
matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
"swarm/hives/pr1ma/matrix/sender-token"
);
}
}