diff --git a/docs/2-handbook/swarm/matrix.md b/docs/2-handbook/swarm/matrix.md index 20faf2584..139b7fc70 100644 --- a/docs/2-handbook/swarm/matrix.md +++ b/docs/2-handbook/swarm/matrix.md @@ -119,9 +119,8 @@ authelia and federation peers by name. The homeserver refuses registration. **The swarm's appservice** creates every account: agents never see an appservice token, and an agent only ever receives its own -`access_token`. `swarm-controller` creates each agent's accounts and one sender -account per hive, `@hive-:`, an ordinary account that -nothing promotes. +`access_token`. `swarm-controller` creates each agent's accounts. A hive has +no matrix account, because hive-c0re doesn't talk to matrix. How the registration gets rendered, where its tokens live and how the controller keeps an agent's token: [`matrix-provisioning.md`](../../4-implementation/swarm/matrix-provisioning.md). diff --git a/docs/4-implementation/swarm/credentials-reference.md b/docs/4-implementation/swarm/credentials-reference.md index b01101d06..012732d1d 100644 --- a/docs/4-implementation/swarm/credentials-reference.md +++ b/docs/4-implementation/swarm/credentials-reference.md @@ -36,7 +36,6 @@ the rest of the cell says how. | `swarm/agents//github-token` | `swarm-controller`, when you link a GitHub account in the swarm UI | the agent | ❌ your token; delete it and link again | ✅ every 2 minutes | | `swarm/agents//backend-env` | `hive-agent-backend-env`, in the agent's container, from the agent's `backendEnvironmentFile` when the leaf holds nothing (field `env`, the file's exact text); or `swarm-controller`, when you set the API-key environment in the swarm UI | the agent | ❌ nothing replaces it without you; a save in the swarm UI overwrites it, including text an agent wrote into an empty leaf; the agent's file stays as it was | ❌ the file in `/run` refreshes every 2 minutes, and the harness and the subagent daemon load it at their next start | | `swarm/agents//claude-credentials` | `swarm-claude-login`, when you log the agent in from its card; one version per login | the agent, while it waits for a login | ❌ log in again | ✅ every 30 seconds while waiting | -| `swarm/hives//matrix/sender-token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or rejected by the homeserver | ✅ the hive re-reads on every sweep | | `swarm/hives//forge/token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or no longer matching the forge | ✅ read on every forge call | | `swarm/agents//proxy-credential` | `swarm-controller`: 32 random bytes, at start and every 5 minutes for every agent that holds a store identity | the hive (`hive-c0re`) and the agent, which fetches it | ❌ never replaced: written only while the path holds nothing | ✅ `hive-c0re` re-reads it every 30 seconds | | `swarm/hives//squid-selfcheck` | `swarm-controller`, in the same pass as the agents' credentials | the hive: `hive-c0re` and `squid-selfcheck-credential` (root) | ❌ never replaced: written only while the path holds nothing | ✅ `hive-c0re` every 30 seconds, the root unit every 10 minutes | diff --git a/docs/4-implementation/swarm/matrix-provisioning.md b/docs/4-implementation/swarm/matrix-provisioning.md index af2de73ba..de147f604 100644 --- a/docs/4-implementation/swarm/matrix-provisioning.md +++ b/docs/4-implementation/swarm/matrix-provisioning.md @@ -42,8 +42,7 @@ a token. not talk to matrix. 5. **`swarm-controller` creates every account**: each agent's, with its token stored at `swarm/agents//matrix/main` for the agent's - `hive-matrix-daemon` to read as the agent itself, and each hive's - sender account (below). + `hive-matrix-daemon` to read as the agent itself. A homeserver whose `swarm-matrix-ctl` has no store identity (`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`) loads no @@ -60,8 +59,7 @@ Until you do, `swarm-bao-matrix-oidc` fails naming both. The registration's id is `swarm`, its sender `@swarm`, and its user namespace non-exclusive. It's the swarm's identity on the homeserver: -`swarm-controller` creates every agent's account and every hive's sender -account with it. +`swarm-controller` creates every agent's account with it. The swarm secret store holds the registration's tokens, at `swarm/controller/swarm-controller/matrix/appservice-registration`. @@ -95,26 +93,14 @@ when it's missing, unknown to the homeserver, or someone else's. An agent's daemon picks a re-minted token up on its next start, and a timer restarts it while it's down. -### A hive's sender account, and why it isn't an admin +### Hives have no matrix account -`@hive-:` is the account a hive acts as. -`swarm-controller` creates it with the swarm's appservice token, for every -hive in its directory. It's an **ordinary account**: nothing promotes it; -the homeserver's `admin_execute` promotes only `@swarm`. - -**One account per hive.** The localpart carries the hive's name, so a swarm -whose hives share a homeserver gives each of them its own identity: the -homeserver attributes an action to the hive that took it, and revoking one -hive's standing leaves the others alone. - -Its access token is the **sender token**. `swarm-controller`, its only -minter, mints it for every hive with the swarm's appservice token and -publishes it to `swarm/hives//matrix/sender-token`, inside the hive's own -read grant (`swarm/hives//*`). Nothing on the hive uses it any more: -hive-c0re used to present it for its Space and invite calls and no longer -makes any. The name says what it authenticates as — an account the -swarm's appservice creates — rather than any privilege level, because it -carries none. +Earlier versions created an `@hive-:` sender account per +hive and stored its token at `swarm/hives//matrix/sender-token`. +hive-c0re no longer talks to matrix, so `swarm-controller` no longer creates +or refreshes either. Existing accounts and the stored tokens are harmless +leftovers: nothing reads them, and the `swarm-controller` policy has no grant +on the path. Promoting a user to homeserver admin and resetting a password both need an admin **sender**: `!admin …` messages into `#admins:`, and @@ -136,7 +122,7 @@ boot has both halves. With the store down or sealed, tuwunel waits for it. `login_with_password` stays on, so the password fallback works too. - **The homeserver's `admin_execute` promotes only `@swarm` at boot** - (above); a hive's sender account is never promoted. + (above); no other swarm account is promoted. - **The value that matters lives at `swarm/controller/swarm-controller/matrix/appservice-token`.** `appservice sync` writes it there whenever the store's differs from the diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 1b288340e..12e8a845b 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -161,8 +161,8 @@ let # ── the swarm's own appservice ────────────────────────────────────── # # The homeserver's one appservice registration, and the swarm's identity on - # it: `swarm-controller` creates every agent's account and every hive's - # `@hive-` sender with its token. The store holds its tokens; + # it: `swarm-controller` creates every agent's account with its token. The + # store holds its tokens; # `swarm-matrix-ctl appservice render` fetches them into this container's # tmpfs before each homeserver start, and `appservice sync` keeps that copy # in step with them. Both are gated on matrix-ctl's store identity: with @@ -184,8 +184,9 @@ let swarmAppserviceLegacyDir = "/var/lib/swarm-matrix-appservice"; swarmAppserviceCredentialId = "swarm-appservice.yaml"; - # Every local user the swarm appservice may create — agents and every - # hive's `@hive-:` sender — which is the whole matrix localpart charset. + # Every local user the swarm appservice may create — agents, and the + # `@hive-:` senders earlier versions made — which is the whole matrix + # localpart charset. # # ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet` # and asks it for a MATCH, not a full match, so an unanchored @@ -1022,10 +1023,8 @@ in } # Exactly one account is promoted to homeserver admin at boot: # the swarm appservice's sender. Its token is read by - # matrix-ctl and swarm-controller only. Each `@hive-` - # stays an ordinary user, because its hive reads its - # credential; what it does (the invites) rides on its - # power in the room, not on admin. + # matrix-ctl and swarm-controller only. Every other account, + # agents' included, stays an ordinary user. # # The registration load creates the sender inside # `Services::start()`, and `admin_execute` runs after that and diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index d629a115c..7671854d5 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -382,8 +382,8 @@ let # instead of rotating it. `metadata/` is the revocation half: `delete` on # `data/` only soft-deletes the newest version, and `+` being one path segment # keeps this to the queue leaf alone, and each linked account to its one leaf. - # Each hive's matrix sender token (`matrix_account::hive_sender`) grants `read` - # for the same keep-if-live reason, and `+` for the same glob-only-as-last-segment reason. + # Each hive's forge token (`forge::hive_token`) grants `read` for the same + # keep-if-live reason, and `+` for the same glob-only-as-last-segment reason. # # Four leaves it reads and never writes: the swarm appservice token (matrix-ctl), # its OIDC client secret (the publisher), its forge token (the forge's host) @@ -498,10 +498,6 @@ let capabilities = ["delete"] } - path "${credentialMountPath}/data/swarm/hives/+/matrix/sender-token" { - capabilities = ["create", "read", "update"] - } - path "${credentialMountPath}/data/swarm/hives/+/forge/token" { capabilities = ["create", "read", "update"] } diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index a909df6c9..afae55fb7 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -1862,21 +1862,19 @@ let covers "hive-hives" && covers "hive-agent" && !(covers "swarm-hives"); } { - # `matrix_account::hive_sender` writes every hive's sender token and - # `forge::hive_token` every hive's forge token, and nothing else under - # `hives/`, where each hive's queue credential also lives. `proxy_credential` + # `forge::hive_token` writes every hive's forge token, and nothing else + # under `hives/`, where each hive's queue credential also lives. `proxy_credential` # creates each hive's squid self-check credential and reads it back, and # has no `update`, so it cannot replace one. `+` is one # segment, which keeps each stanza to its one leaf; a `*` is a glob only # at the end of a path. Pinned as whole stanzas, so an added capability # fails. - name = "the controller writes each hive's sender and forge tokens, creates its squid self-check credential, manages its archive grants, and nothing else under hives"; + name = "the controller writes each hive's forge token, creates its squid self-check credential, manages its archive grants, and nothing else under hives"; ok = let s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script; in - lib.hasInfix "path \"secret/data/swarm/hives/+/matrix/sender-token\" {\n capabilities = [\"create\", \"read\", \"update\"]\n}" s - && lib.hasInfix "path \"secret/data/swarm/hives/+/forge/token\" {\n capabilities = [\"create\", \"read\", \"update\"]\n}" s + lib.hasInfix "path \"secret/data/swarm/hives/+/forge/token\" {\n capabilities = [\"create\", \"read\", \"update\"]\n}" s && lib.hasInfix "path \"secret/data/swarm/hives/+/squid-selfcheck\" {\n capabilities = [\"create\", \"read\"]\n}" s && !(lib.hasInfix "secret/data/swarm/hives/*" s) # The only metadata it reaches under hives is the archive grants: list and delete. diff --git a/swarm-controller/src/forge/hive_token.rs b/swarm-controller/src/forge/hive_token.rs index 8768f5b03..28310c685 100644 --- a/swarm-controller/src/forge/hive_token.rs +++ b/swarm-controller/src/forge/hive_token.rs @@ -4,8 +4,7 @@ //! stored at `swarm/hives//forge/token`, where hive-c0re reads it under //! the hive's own store identity. //! -//! The roster is the controller's hive directory, as for -//! `matrix_account::hive_sender`. The token decision is +//! The roster is the controller's hive directory. The token decision is //! [`super::agent_token`]'s, under this module's name and scopes, so a stored //! token the forge still lists with the same last eight characters and scopes //! is kept, and a pass whose reads fail queues nothing. diff --git a/swarm-controller/src/jobq.rs b/swarm-controller/src/jobq.rs index 0873a048d..02a170dff 100644 --- a/swarm-controller/src/jobq.rs +++ b/swarm-controller/src/jobq.rs @@ -91,10 +91,6 @@ pub(crate) enum SwarmNodeKind { /// Carries no hive: the path it writes has no hive segment, so an agent /// that moves between hives keeps one matrix identity. MintAgentMatrixAccount { agent: String }, - /// Make sure `hive`'s sender account (`@hive-:`) holds a live token - /// in the swarm store, where that hive's matrix sweep reads it. See - /// `matrix_account::hive_sender`. - MintHiveSenderToken { hive: String }, /// Make sure `hive`'s read-only forge account (`hive-`) exists, is in /// the `hives` team, and holds a live token in the swarm store, where /// that hive's hive-c0re reads it. See `forge::hive_token`. @@ -189,7 +185,6 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(), SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(), SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(), - SwarmNodeKind::MintHiveSenderToken { .. } => "mint_hive_sender_token".to_owned(), SwarmNodeKind::MintHiveForgeToken { .. } => "mint_hive_forge_token".to_owned(), SwarmNodeKind::MintClassifierForgeToken => "mint_classifier_forge_token".to_owned(), SwarmNodeKind::MintBuilderForgeToken => "mint_builder_forge_token".to_owned(), @@ -234,9 +229,9 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { SwarmNodeKind::SeedArchetypeConfig { agent, archetype } => { serde_json::json!({ "agent": agent, "archetype": archetype.name }) } - SwarmNodeKind::MintHiveSenderToken { hive } - | SwarmNodeKind::MintHiveForgeToken { hive } - | SwarmNodeKind::CopyAtticFacts { hive } => serde_json::json!({ "hive": hive }), + SwarmNodeKind::MintHiveForgeToken { hive } | SwarmNodeKind::CopyAtticFacts { hive } => { + serde_json::json!({ "hive": hive }) + } SwarmNodeKind::MintClassifierForgeToken | SwarmNodeKind::MintBuilderForgeToken | SwarmNodeKind::MintCiMetricsForgeToken @@ -438,9 +433,6 @@ async fn run_node_work( SwarmNodeKind::MintAgentMatrixAccount { agent } => { mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await } - SwarmNodeKind::MintHiveSenderToken { hive } => { - mint_hive_sender(deps.matrix_homeserver.as_deref(), &hive).await - } SwarmNodeKind::MintHiveForgeToken { hive } => mint_hive_forge_token(forge, &hive).await, SwarmNodeKind::MintClassifierForgeToken => mint_classifier_forge_token(forge).await, SwarmNodeKind::MintBuilderForgeToken => mint_builder_forge_token(forge).await, @@ -1119,23 +1111,6 @@ async fn mint_matrix_account( } } -/// The `MintHiveSenderToken` arm, lifted out for the same reason. -async fn mint_hive_sender(homeserver: Option<&str>, hive: &str) -> hive_jobq::scheduler::Outcome { - use hive_jobq::scheduler::Outcome; - - let Some(base) = homeserver else { - return Outcome::Failed(format!( - "no matrix homeserver configured on this host ({} unset), so no hive \ - sender token can be minted", - matrix_account::DEFAULT_HOMESERVER_ENV - )); - }; - match matrix_account::hive_sender::ensure_hive_sender_token(base, hive).await { - Ok(()) => Outcome::Done, - Err(e) => Outcome::Failed(format!("{e:#}")), - } -} - /// The `SetAgentWanted` arm, lifted out so `run_node_work` stays under the line /// limit: it fails without a wanted-state writer. async fn set_agent_wanted( @@ -1724,27 +1699,6 @@ pub(crate) fn queue_matrix_account_mints( Ok(ids) } -/// Insert one `MintHiveSenderToken` job per hive and return the nodes' ids. -/// `matrix_account::hive_sender::spawn`'s periodic pass is the one caller. -pub(crate) fn queue_hive_sender_mints( - sched: &Mutex>, - hives: Vec, -) -> Result> { - let mut sched = sched - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut ids = Vec::with_capacity(hives.len()); - for hive in hives { - let queued = sched - .insert_job(None, |b| { - vec![b.node(SwarmNodeKind::MintHiveSenderToken { hive }).guid()] - }) - .map_err(|e| anyhow::anyhow!("{e}"))?; - ids.extend(queued); - } - Ok(ids) -} - /// Insert one `MintHiveForgeToken` job per hive and return the nodes' ids. /// `forge::hive_token::spawn`'s periodic pass is the one caller. pub(crate) fn queue_hive_forge_token_mints( @@ -3970,37 +3924,6 @@ mod tests { assert_eq!(agents, ["a", "b"]); } - /// The hive sender-token pass's queueing: one mint node per hive, and - /// the node names the hive rather than an agent. - #[test] - fn a_queued_hive_sender_mint_is_one_node_per_hive() { - use hive_jobq_wire::WireNode as _; - - let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( - hive_jobq::Graph::new(), - hive_jobq::resources::ResourceTable::new(), - )); - let ids = - super::queue_hive_sender_mints(&sched, vec!["alpha".to_owned(), "beta".to_owned()]) - .expect("two jobs insert"); - assert_eq!(ids.len(), 2); - let guard = sched - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut hives: Vec = guard - .graph() - .nodes() - .map(|n| { - assert_eq!(n.payload.label(), "mint_hive_sender_token"); - let data = n.payload.data(n.id.get()); - assert!(data.get("agent").is_none(), "{data}"); - data["hive"].as_str().expect("hive is a string").to_owned() - }) - .collect(); - hives.sort(); - assert_eq!(hives, ["alpha", "beta"]); - } - /// The hive forge-token pass's queueing: one node per hive, naming the /// hive rather than an agent. #[test] diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index cdcd2fbfd..f3e1a5598 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -172,7 +172,7 @@ async fn main() -> Result<()> { hive_jobq::resources::ResourceTable::new(), ))); let hives = load_hives(); - spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), &hives, auth.clone()); + spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), auth.clone()); spawn_jobq_worker(Arc::clone(&jobq), deps); // Bound to a named variable, not `_` — dropping the provider stops its // `PeriodicReader`, so it must live as long as `main` does (which it diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index c7583bd57..fdcde6512 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -4,7 +4,6 @@ //! the swarm's homeserver, minted with the swarm's appservice token and stored //! where the agent's daemon reads it. See docs/4-implementation/swarm/credentials-reference.md for why //! `main` is the one name [`put_matrix_account`] refuses to write. -//! [`hive_sender`] mints each **hive's** sender account the same way, sharing this module's helpers with [`agent_token`]. //! [`operator_admin`] makes authelia's operators homeserver admins with the same //! appservice token. //! [`recovery`] mints the passphrase that opens each account's server-side key @@ -38,7 +37,6 @@ use crate::routes::agents::swarm_hive; use crate::routes::{AppState, error_problem}; pub mod agent_token; -pub mod hive_sender; pub mod operator_admin; pub mod recovery; diff --git a/swarm-controller/src/matrix_account/hive_sender.rs b/swarm-controller/src/matrix_account/hive_sender.rs deleted file mode 100644 index 3d09b2c5f..000000000 --- a/swarm-controller/src/matrix_account/hive_sender.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Each hive's sender account, `@hive-:`, on the swarm's homeserver: -//! created here with the **swarm's** appservice token and stored at -//! `swarm/hives//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-:` 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> { - 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, hives: Vec, enqueue: impl Fn(Vec) + 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" - ); - } -} diff --git a/swarm-controller/src/startup.rs b/swarm-controller/src/startup.rs index 3b7f94d47..88de9c67b 100644 --- a/swarm-controller/src/startup.rs +++ b/swarm-controller/src/startup.rs @@ -21,8 +21,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use crate::jobq::{ Late, SwarmNodeKind, SwarmResourceKind, queue_agent_renewals, queue_builder_forge_token_mint, queue_ci_metrics_forge_token_mint, queue_classifier_forge_token_mint, queue_docs_grants, - queue_forge_token_mints, queue_hive_forge_token_mints, queue_hive_sender_mints, - queue_matrix_account_mints, + queue_forge_token_mints, queue_hive_forge_token_mints, queue_matrix_account_mints, }; use crate::routes::hives::{HiveEntry, ServiceLink}; use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph}; @@ -229,13 +228,12 @@ pub(crate) fn configured_matrix_homeserver() -> Option> { } /// Start the recovery passphrase pass, which needs only the store; the -/// matrix-account backfill and the hive sender-token pass when a homeserver is -/// configured; and the operator admin pass when the bridge is too. Lifted out -/// of `main` for `clippy::too_many_lines`. +/// matrix-account backfill when a homeserver is configured; and the operator +/// admin pass when the bridge is too. Lifted out of `main` for +/// `clippy::too_many_lines`. pub(crate) fn spawn_matrix_account_backfill( jobq: &Arc>>, homeserver: Option>, - hives: &[HiveEntry], auth: Option>, ) { matrix_account::recovery::spawn(); @@ -246,18 +244,11 @@ pub(crate) fn spawn_matrix_account_backfill( matrix_account::operator_admin::spawn(bridge, Arc::clone(&base)); } let sched = Arc::clone(jobq); - matrix_account::agent_token::spawn(Arc::clone(&base), move |agents| { + matrix_account::agent_token::spawn(base, move |agents| { if let Err(e) = queue_matrix_account_mints(&sched, agents) { tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed"); } }); - let sched = Arc::clone(jobq); - let hive_names = hives.iter().map(|h| h.name.clone()).collect(); - matrix_account::hive_sender::spawn(base, hive_names, move |hives| { - if let Err(e) = queue_hive_sender_mints(&sched, hives) { - tracing::warn!(error = %format!("{e:#}"), "hive sender tokens: queueing failed"); - } - }); } /// Start the forge's periodic passes — the swarm-wide objects, the runner diff --git a/swarm-matrix-client/src/lib.rs b/swarm-matrix-client/src/lib.rs index bbcc0b4f0..4a021b5f8 100644 --- a/swarm-matrix-client/src/lib.rs +++ b/swarm-matrix-client/src/lib.rs @@ -480,37 +480,6 @@ fn whoami_session_verdict( } } -/// The ids of the rooms `token`'s user has joined. -/// -/// # Errors -/// When the request cannot be sent, the response will not decode, the -/// homeserver refuses, or the answer carries no `joined_rooms`. -pub async fn joined_rooms( - client: &reqwest::Client, - base: &str, - token: &str, -) -> Result> { - let (status, json) = get( - client, - &format!("{base}/_matrix/client/v3/joined_rooms"), - token, - ) - .await - .context("GET /joined_rooms")?; - if !status.is_success() { - bail!( - "the homeserver refused to list joined rooms: {}", - why(status, &json) - ); - } - Ok(json["joined_rooms"] - .as_array() - .context("the homeserver's answer carried no `joined_rooms`")? - .iter() - .filter_map(|room| room.as_str().map(str::to_owned)) - .collect()) -} - /// The content of `room_id`'s `event_type` state event under `state_key` /// (empty for most), or `None` when the room has none. /// @@ -546,37 +515,6 @@ pub async fn room_state( ) } -/// Set `room_id`'s `event_type` state event under `state_key` to `content`. -/// -/// # Errors -/// When the request cannot be sent, the response will not decode, or the -/// homeserver refuses. -pub async fn put_state( - client: &reqwest::Client, - base: &str, - token: &str, - room_id: &str, - event_type: &str, - state_key: &str, - content: &serde_json::Value, -) -> Result<()> { - let (status, json) = exchange( - client - .put(state_url(base, room_id, event_type, state_key)) - .bearer_auth(token) - .json(content), - ) - .await - .with_context(|| format!("PUT {event_type} state into {room_id}"))?; - if !status.is_success() { - bail!( - "the homeserver refused to set {event_type} in {room_id}: {}", - why(status, &json) - ); - } - Ok(()) -} - /// The state-event URL for `room_id`, `event_type` and `state_key`. fn state_url(base: &str, room_id: &str, event_type: &str, state_key: &str) -> String { format!( @@ -586,37 +524,6 @@ fn state_url(base: &str, room_id: &str, event_type: &str, state_key: &str) -> St ) } -/// Create a room from the `createRoom` request `body` and return its id. -/// -/// # Errors -/// When the request cannot be sent, the response will not decode, the -/// homeserver refuses, or the answer carries no `room_id`. -pub async fn create_room( - client: &reqwest::Client, - base: &str, - token: &str, - body: &serde_json::Value, -) -> Result { - let (status, json) = post( - client, - &format!("{base}/_matrix/client/v3/createRoom"), - token, - body, - ) - .await - .context("POST /createRoom")?; - if !status.is_success() { - bail!( - "the homeserver refused to create the room: {}", - why(status, &json) - ); - } - json["room_id"] - .as_str() - .map(str::to_owned) - .context("the homeserver's createRoom answer carried no `room_id`") -} - /// What the homeserver did with an invite. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Invited { @@ -1153,23 +1060,11 @@ mod tests { async fn every_room_call_refuses_without_quoting_the_body() { let base = any_stub(500, LEAKY).await; let c = client().expect("a client"); - let body = serde_json::json!({}); - assert_refusal_hides_body(&joined_rooms(&c, &base, "t").await.expect_err("refused")); assert_refusal_hides_body( &room_state(&c, &base, "t", "!r:x", "m.room.name", "") .await .expect_err("refused"), ); - assert_refusal_hides_body( - &put_state(&c, &base, "t", "!r:x", "m.room.name", "", &body) - .await - .expect_err("refused"), - ); - assert_refusal_hides_body( - &create_room(&c, &base, "t", &body) - .await - .expect_err("refused"), - ); assert_refusal_hides_body( &invite(&c, &base, "t", "!r:x", "@a:x") .await @@ -1204,29 +1099,6 @@ mod tests { ); } - #[tokio::test] - async fn joined_rooms_and_create_room_read_their_fields() { - let c = client().expect("a client"); - let base = any_stub(200, r#"{"joined_rooms":["!a:x","!b:x"]}"#).await; - assert_eq!( - joined_rooms(&c, &base, "t").await.expect("rooms"), - ["!a:x", "!b:x"] - ); - assert!( - create_room(&c, &base, "t", &serde_json::json!({})) - .await - .is_err() - ); - let base = any_stub(200, r#"{"room_id":"!new:x"}"#).await; - assert_eq!( - create_room(&c, &base, "t", &serde_json::json!({})) - .await - .expect("room"), - "!new:x" - ); - assert!(joined_rooms(&c, &base, "t").await.is_err()); - } - #[test] fn a_state_url_encodes_room_and_key_and_keeps_an_empty_key_slash() { assert_eq!( diff --git a/swarm-secret-client/src/forge.rs b/swarm-secret-client/src/forge.rs index 1244e38dc..977937e98 100644 --- a/swarm-secret-client/src/forge.rs +++ b/swarm-secret-client/src/forge.rs @@ -47,8 +47,7 @@ pub fn agent_token_path(agent: &str) -> Result { /// basic-auth header that token travels in. /// /// The `hive-` prefix keeps the account reading as a hive's rather than an -/// agent's, as [`crate::matrix::hive_localpart`] does on the homeserver. -/// `swarm-controller` refuses a new agent named with this prefix, and tells an +/// agent's. `swarm-controller` refuses a new agent named with this prefix, and tells an /// older one apart from the hive's account by the account's email. #[must_use] pub fn hive_login(hive: &str) -> String { diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index 0a8cc953f..f1e510bb6 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -70,44 +70,6 @@ impl std::fmt::Debug for Recovery { } } -/// The localpart `hive` acts as on the homeserver: the account -/// `swarm-controller` creates for it with the swarm's appservice token, and -/// the one hive-c0re's sender token belongs to. -/// -/// **Derived from the hive's name, which is what makes it one account per -/// hive.** It used to be the bare constant `hive`: one swarm runs one -/// homeserver, so every hive on it logged in as the same `@hive:` and the -/// homeserver could not tell them apart — no attribution, and no way to revoke -/// one hive without revoking all of them. -/// -/// The prefix is kept so the account still reads as a hive's rather than an -/// agent's. Both `swarm-controller` and hive-c0re derive it here, so the two -/// cannot disagree on which account a hive is. -/// -/// Infallible on purpose: a hive name is `[a-z0-9-]` (`hive_types::Ident`) and -/// the appservice namespace regex admits that charset, so there is no -/// rejection arm to write. [`sender_token_path`] below does the checking, and -/// it is the one that interpolates into a store path. -#[must_use] -pub fn hive_localpart(hive: &str) -> String { - format!("hive-{hive}") -} - -/// The path holding `hive`'s matrix sender account's homeserver access token. -/// -/// Keyed per **hive**, like [`crate::queue::hive_client_path`]. Under -/// [`Kind::Hive`] the grant that reaches it is the hive's **own** stanza, -/// `secret/data/swarm/hives//*`, which interpolates the name: hive -/// `alpha` reads its own token and gets a 403 on `beta`'s. -/// -/// # Errors -/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`, -/// which is what keeps one hive's name from addressing another hive's secret. -pub fn sender_token_path(hive: &str) -> Result { - let prefix = principal_prefix(Kind::Hive, hive)?; - Ok(format!("{prefix}/matrix/sender-token")) -} - /// The name segment of the controller's own subtree, and the cert-auth role it /// logs in under (`swarm-controller`'s `store::CERT_ROLE`). pub(crate) const CONTROLLER: &str = "swarm-controller"; @@ -254,48 +216,6 @@ mod tests { assert!(recovery_path("a-b_C9", "d-e_F0").is_ok()); } - #[test] - fn the_sender_token_lands_under_the_hive_prefix_the_grant_covers() { - // Spelled out rather than rebuilt from the same pieces the code uses, - // for the reason above — and with a second job here: the hive's own - // policy grants read on `secret/data/swarm/hives//*`, so - // this exact string is what makes the path reachable at all. - assert_eq!( - sender_token_path("pr1ma").expect("a plain name is legal"), - "swarm/hives/pr1ma/matrix/sender-token" - ); - } - - #[test] - fn one_hives_sender_token_is_not_another_hives() { - // The property the per-hive path exists for: two hives never name the - // same object, so the `hives//*` grant that reaches one cannot - // reach the other. - let a = sender_token_path("alpha").expect("legal"); - let b = sender_token_path("beta").expect("legal"); - assert_ne!(a, b); - } - - #[test] - fn a_traversal_in_the_hive_name_is_refused_by_the_sender_path_too() { - let e = sender_token_path("../beta").expect_err("a traversal is not legal"); - assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}"); - } - - #[test] - fn the_localpart_is_derived_from_the_hive_name() { - // The literal is the point: an existing hive's account on the - // homeserver carries this localpart, so a change here strands it. - assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma"); - assert_ne!( - hive_localpart("pr1ma"), - hive_localpart("secunda"), - "two hives must not land on one account" - ); - // And it is never the bare `hive` that used to be shared swarm-wide. - assert_ne!(hive_localpart("pr1ma"), "hive"); - } - #[test] fn the_swarm_appservice_token_lands_under_the_controller() { assert_eq!( diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index a437bd67e..ca9e7e769 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -377,15 +377,15 @@ mod tests { ); } - /// 🩸 The per-hive matrix sender-token scoping, asserted on the rendered + /// 🩸 The per-hive forge-token scoping, asserted on the rendered /// document rather than on the path function alone: it is the policy text /// that decides what a hive may fetch, so "the path is per-hive" only /// means something if the stanza that reaches it is too. #[test] - fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() { + fn a_hive_reaches_its_own_forge_token_and_no_other_hives() { let alpha = effective_hive("alpha"); - let own = crate::matrix::sender_token_path("alpha").expect("legal"); - let other = crate::matrix::sender_token_path("beta").expect("legal"); + let own = crate::forge::hive_token_path("alpha").expect("legal"); + let other = crate::forge::hive_token_path("beta").expect("legal"); // Reachable: the hive's own stanza is the prefix of its own path. assert!(