Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: stop minting the per-hive matrix sender account and token nothing reads

This commit is contained in:
damocles 2026-11-12 15:38:34 +01:00
commit c66832febf
16 changed files with 40 additions and 556 deletions

View file

@ -119,9 +119,8 @@ authelia and federation peers by name.
The homeserver refuses registration. **The swarm's appservice** creates every account: 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 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 `access_token`. `swarm-controller` creates each agent's accounts. A hive has
account per hive, `@hive-<hive>:<server_name>`, an ordinary account that no matrix account, because hive-c0re doesn't talk to matrix.
nothing promotes.
How the registration gets rendered, where its tokens live and how the 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). controller keeps an agent's token: [`matrix-provisioning.md`](../../4-implementation/swarm/matrix-provisioning.md).

View file

@ -36,7 +36,6 @@ the rest of the cell says how.
| `swarm/agents/<agent>/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/<agent>/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/<agent>/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/<agent>/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/<agent>/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/agents/<agent>/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/<hive>/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/<hive>/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/hives/<hive>/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/<agent>/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/agents/<agent>/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/<hive>/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 | | `swarm/hives/<hive>/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 |

View file

@ -42,8 +42,7 @@ a token.
not talk to matrix. not talk to matrix.
5. **`swarm-controller` creates every account**: each agent's, with its 5. **`swarm-controller` creates every account**: each agent's, with its
token stored at `swarm/agents/<agent>/matrix/main` for the agent's token stored at `swarm/agents/<agent>/matrix/main` for the agent's
`hive-matrix-daemon` to read as the agent itself, and each hive's `hive-matrix-daemon` to read as the agent itself.
sender account (below).
A homeserver whose `swarm-matrix-ctl` has no store identity A homeserver whose `swarm-matrix-ctl` has no store identity
(`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`) loads no (`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 The registration's id is `swarm`, its sender `@swarm`, and its user
namespace non-exclusive. It's the swarm's identity on the homeserver: namespace non-exclusive. It's the swarm's identity on the homeserver:
`swarm-controller` creates every agent's account and every hive's sender `swarm-controller` creates every agent's account with it.
account with it.
The swarm secret store holds the registration's tokens, at The swarm secret store holds the registration's tokens, at
`swarm/controller/swarm-controller/matrix/appservice-registration`. `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 agent's daemon picks a re-minted token up on its next start, and a timer
restarts it while it's down. restarts it while it's down.
### A hive's sender account, and why it isn't an admin ### Hives have no matrix account
`@hive-<hive>:<server_name>` is the account a hive acts as. Earlier versions created an `@hive-<hive>:<server_name>` sender account per
`swarm-controller` creates it with the swarm's appservice token, for every hive and stored its token at `swarm/hives/<hive>/matrix/sender-token`.
hive in its directory. It's an **ordinary account**: nothing promotes it; hive-c0re no longer talks to matrix, so `swarm-controller` no longer creates
the homeserver's `admin_execute` promotes only `@swarm`. or refreshes either. Existing accounts and the stored tokens are harmless
leftovers: nothing reads them, and the `swarm-controller` policy has no grant
**One account per hive.** The localpart carries the hive's name, so a swarm on the path.
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/<hive>/matrix/sender-token`, inside the hive's own
read grant (`swarm/hives/<hive>/*`). 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.
Promoting a user to homeserver admin and resetting a password both need an Promoting a user to homeserver admin and resetting a password both need an
admin **sender**: `!admin …` messages into `#admins:<server_name>`, and admin **sender**: `!admin …` messages into `#admins:<server_name>`, 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 `login_with_password` stays on, so the password fallback works
too. too.
- **The homeserver's `admin_execute` promotes only `@swarm` at boot** - **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 - **The value that matters lives at
`swarm/controller/swarm-controller/matrix/appservice-token`.** `swarm/controller/swarm-controller/matrix/appservice-token`.**
`appservice sync` writes it there whenever the store's differs from the `appservice sync` writes it there whenever the store's differs from the

View file

@ -161,8 +161,8 @@ let
# ── the swarm's own appservice ────────────────────────────────────── # ── the swarm's own appservice ──────────────────────────────────────
# #
# The homeserver's one appservice registration, and the swarm's identity on # The homeserver's one appservice registration, and the swarm's identity on
# it: `swarm-controller` creates every agent's account and every hive's # it: `swarm-controller` creates every agent's account with its token. The
# `@hive-<hive>` sender with its token. The store holds its tokens; # store holds its tokens;
# `swarm-matrix-ctl appservice render` fetches them into this container's # `swarm-matrix-ctl appservice render` fetches them into this container's
# tmpfs before each homeserver start, and `appservice sync` keeps that copy # 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 # 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"; swarmAppserviceLegacyDir = "/var/lib/swarm-matrix-appservice";
swarmAppserviceCredentialId = "swarm-appservice.yaml"; swarmAppserviceCredentialId = "swarm-appservice.yaml";
# Every local user the swarm appservice may create — agents and every # Every local user the swarm appservice may create — agents, and the
# hive's `@hive-<hive>:` sender — which is the whole matrix localpart charset. # `@hive-<hive>:` senders earlier versions made — which is the whole matrix
# localpart charset.
# #
# ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet` # ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet`
# and asks it for a MATCH, not a full match, so an unanchored # 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: # Exactly one account is promoted to homeserver admin at boot:
# the swarm appservice's sender. Its token is read by # the swarm appservice's sender. Its token is read by
# matrix-ctl and swarm-controller only. Each `@hive-<hive>` # matrix-ctl and swarm-controller only. Every other account,
# stays an ordinary user, because its hive reads its # agents' included, stays an ordinary user.
# credential; what it does (the invites) rides on its
# power in the room, not on admin.
# #
# The registration load creates the sender inside # The registration load creates the sender inside
# `Services::start()`, and `admin_execute` runs after that and # `Services::start()`, and `admin_execute` runs after that and

View file

@ -382,8 +382,8 @@ let
# instead of rotating it. `metadata/` is the revocation half: `delete` on # instead of rotating it. `metadata/` is the revocation half: `delete` on
# `data/` only soft-deletes the newest version, and `+` being one path segment # `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. # 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` # Each hive's forge token (`forge::hive_token`) grants `read` for the same
# for the same keep-if-live reason, and `+` for the same glob-only-as-last-segment reason. # 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), # 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) # its OIDC client secret (the publisher), its forge token (the forge's host)
@ -498,10 +498,6 @@ let
capabilities = ["delete"] capabilities = ["delete"]
} }
path "${credentialMountPath}/data/swarm/hives/+/matrix/sender-token" {
capabilities = ["create", "read", "update"]
}
path "${credentialMountPath}/data/swarm/hives/+/forge/token" { path "${credentialMountPath}/data/swarm/hives/+/forge/token" {
capabilities = ["create", "read", "update"] capabilities = ["create", "read", "update"]
} }

View file

@ -1862,21 +1862,19 @@ let
covers "hive-hives" && covers "hive-agent" && !(covers "swarm-hives"); covers "hive-hives" && covers "hive-agent" && !(covers "swarm-hives");
} }
{ {
# `matrix_account::hive_sender` writes every hive's sender token and # `forge::hive_token` writes every hive's forge token, and nothing else
# `forge::hive_token` every hive's forge token, and nothing else under # under `hives/`, where each hive's queue credential also lives. `proxy_credential`
# `hives/`, where each hive's queue credential also lives. `proxy_credential`
# creates each hive's squid self-check credential and reads it back, and # creates each hive's squid self-check credential and reads it back, and
# has no `update`, so it cannot replace one. `+` is one # has no `update`, so it cannot replace one. `+` is one
# segment, which keeps each stanza to its one leaf; a `*` is a glob only # 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 # at the end of a path. Pinned as whole stanzas, so an added capability
# fails. # 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 = ok =
let let
s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script; s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
in 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 "path \"secret/data/swarm/hives/+/squid-selfcheck\" {\n capabilities = [\"create\", \"read\"]\n}" s
&& !(lib.hasInfix "secret/data/swarm/hives/*" s) && !(lib.hasInfix "secret/data/swarm/hives/*" s)
# The only metadata it reaches under hives is the archive grants: list and delete. # The only metadata it reaches under hives is the archive grants: list and delete.

View file

@ -4,8 +4,7 @@
//! stored at `swarm/hives/<hive>/forge/token`, where hive-c0re reads it under //! stored at `swarm/hives/<hive>/forge/token`, where hive-c0re reads it under
//! the hive's own store identity. //! the hive's own store identity.
//! //!
//! The roster is the controller's hive directory, as for //! The roster is the controller's hive directory. The token decision is
//! `matrix_account::hive_sender`. The token decision is
//! [`super::agent_token`]'s, under this module's name and scopes, so a stored //! [`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 //! token the forge still lists with the same last eight characters and scopes
//! is kept, and a pass whose reads fail queues nothing. //! is kept, and a pass whose reads fail queues nothing.

View file

@ -91,10 +91,6 @@ pub(crate) enum SwarmNodeKind {
/// Carries no hive: the path it writes has no hive segment, so an agent /// Carries no hive: the path it writes has no hive segment, so an agent
/// that moves between hives keeps one matrix identity. /// that moves between hives keeps one matrix identity.
MintAgentMatrixAccount { agent: String }, MintAgentMatrixAccount { agent: String },
/// Make sure `hive`'s sender account (`@hive-<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-<hive>`) exists, is in /// Make sure `hive`'s read-only forge account (`hive-<hive>`) exists, is in
/// the `hives` team, and holds a live token in the swarm store, where /// the `hives` team, and holds a live token in the swarm store, where
/// that hive's hive-c0re reads it. See `forge::hive_token`. /// 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::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(), SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".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::MintHiveForgeToken { .. } => "mint_hive_forge_token".to_owned(),
SwarmNodeKind::MintClassifierForgeToken => "mint_classifier_forge_token".to_owned(), SwarmNodeKind::MintClassifierForgeToken => "mint_classifier_forge_token".to_owned(),
SwarmNodeKind::MintBuilderForgeToken => "mint_builder_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 } => { SwarmNodeKind::SeedArchetypeConfig { agent, archetype } => {
serde_json::json!({ "agent": agent, "archetype": archetype.name }) serde_json::json!({ "agent": agent, "archetype": archetype.name })
} }
SwarmNodeKind::MintHiveSenderToken { hive } SwarmNodeKind::MintHiveForgeToken { hive } | SwarmNodeKind::CopyAtticFacts { hive } => {
| SwarmNodeKind::MintHiveForgeToken { hive } serde_json::json!({ "hive": hive })
| SwarmNodeKind::CopyAtticFacts { hive } => serde_json::json!({ "hive": hive }), }
SwarmNodeKind::MintClassifierForgeToken SwarmNodeKind::MintClassifierForgeToken
| SwarmNodeKind::MintBuilderForgeToken | SwarmNodeKind::MintBuilderForgeToken
| SwarmNodeKind::MintCiMetricsForgeToken | SwarmNodeKind::MintCiMetricsForgeToken
@ -438,9 +433,6 @@ async fn run_node_work(
SwarmNodeKind::MintAgentMatrixAccount { agent } => { SwarmNodeKind::MintAgentMatrixAccount { agent } => {
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await 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::MintHiveForgeToken { hive } => mint_hive_forge_token(forge, &hive).await,
SwarmNodeKind::MintClassifierForgeToken => mint_classifier_forge_token(forge).await, SwarmNodeKind::MintClassifierForgeToken => mint_classifier_forge_token(forge).await,
SwarmNodeKind::MintBuilderForgeToken => mint_builder_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 /// The `SetAgentWanted` arm, lifted out so `run_node_work` stays under the line
/// limit: it fails without a wanted-state writer. /// limit: it fails without a wanted-state writer.
async fn set_agent_wanted( async fn set_agent_wanted(
@ -1724,27 +1699,6 @@ pub(crate) fn queue_matrix_account_mints(
Ok(ids) 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<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
hives: Vec<String>,
) -> Result<Vec<hive_jobq::NodeId>> {
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. /// Insert one `MintHiveForgeToken` job per hive and return the nodes' ids.
/// `forge::hive_token::spawn`'s periodic pass is the one caller. /// `forge::hive_token::spawn`'s periodic pass is the one caller.
pub(crate) fn queue_hive_forge_token_mints( pub(crate) fn queue_hive_forge_token_mints(
@ -3970,37 +3924,6 @@ mod tests {
assert_eq!(agents, ["a", "b"]); 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<String> = 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 /// The hive forge-token pass's queueing: one node per hive, naming the
/// hive rather than an agent. /// hive rather than an agent.
#[test] #[test]

View file

@ -172,7 +172,7 @@ async fn main() -> Result<()> {
hive_jobq::resources::ResourceTable::new(), hive_jobq::resources::ResourceTable::new(),
))); )));
let hives = load_hives(); 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); spawn_jobq_worker(Arc::clone(&jobq), deps);
// Bound to a named variable, not `_` — dropping the provider stops its // Bound to a named variable, not `_` — dropping the provider stops its
// `PeriodicReader`, so it must live as long as `main` does (which it // `PeriodicReader`, so it must live as long as `main` does (which it

View file

@ -4,7 +4,6 @@
//! the swarm's homeserver, minted with the swarm's appservice token and stored //! 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 //! 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. //! `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 //! [`operator_admin`] makes authelia's operators homeserver admins with the same
//! appservice token. //! appservice token.
//! [`recovery`] mints the passphrase that opens each account's server-side key //! [`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}; use crate::routes::{AppState, error_problem};
pub mod agent_token; pub mod agent_token;
pub mod hive_sender;
pub mod operator_admin; pub mod operator_admin;
pub mod recovery; pub mod recovery;

View file

@ -1,195 +0,0 @@
//! 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"
);
}
}

View file

@ -21,8 +21,7 @@ use utoipa_axum::{router::OpenApiRouter, routes};
use crate::jobq::{ use crate::jobq::{
Late, SwarmNodeKind, SwarmResourceKind, queue_agent_renewals, queue_builder_forge_token_mint, 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_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_forge_token_mints, queue_hive_forge_token_mints, queue_matrix_account_mints,
queue_matrix_account_mints,
}; };
use crate::routes::hives::{HiveEntry, ServiceLink}; use crate::routes::hives::{HiveEntry, ServiceLink};
use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph}; use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph};
@ -229,13 +228,12 @@ pub(crate) fn configured_matrix_homeserver() -> Option<Arc<str>> {
} }
/// Start the recovery passphrase pass, which needs only the store; the /// Start the recovery passphrase pass, which needs only the store; the
/// matrix-account backfill and the hive sender-token pass when a homeserver is /// matrix-account backfill when a homeserver is configured; and the operator
/// configured; and the operator admin pass when the bridge is too. Lifted out /// admin pass when the bridge is too. Lifted out of `main` for
/// of `main` for `clippy::too_many_lines`. /// `clippy::too_many_lines`.
pub(crate) fn spawn_matrix_account_backfill( pub(crate) fn spawn_matrix_account_backfill(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>, jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
homeserver: Option<Arc<str>>, homeserver: Option<Arc<str>>,
hives: &[HiveEntry],
auth: Option<Arc<auth::AuthBridge>>, auth: Option<Arc<auth::AuthBridge>>,
) { ) {
matrix_account::recovery::spawn(); matrix_account::recovery::spawn();
@ -246,18 +244,11 @@ pub(crate) fn spawn_matrix_account_backfill(
matrix_account::operator_admin::spawn(bridge, Arc::clone(&base)); matrix_account::operator_admin::spawn(bridge, Arc::clone(&base));
} }
let sched = Arc::clone(jobq); 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) { if let Err(e) = queue_matrix_account_mints(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed"); 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 /// Start the forge's periodic passes — the swarm-wide objects, the runner

View file

@ -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<Vec<String>> {
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` /// The content of `room_id`'s `event_type` state event under `state_key`
/// (empty for most), or `None` when the room has none. /// (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`. /// 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 { fn state_url(base: &str, room_id: &str, event_type: &str, state_key: &str) -> String {
format!( 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<String> {
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. /// What the homeserver did with an invite.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Invited { pub enum Invited {
@ -1153,23 +1060,11 @@ mod tests {
async fn every_room_call_refuses_without_quoting_the_body() { async fn every_room_call_refuses_without_quoting_the_body() {
let base = any_stub(500, LEAKY).await; let base = any_stub(500, LEAKY).await;
let c = client().expect("a client"); 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( assert_refusal_hides_body(
&room_state(&c, &base, "t", "!r:x", "m.room.name", "") &room_state(&c, &base, "t", "!r:x", "m.room.name", "")
.await .await
.expect_err("refused"), .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( assert_refusal_hides_body(
&invite(&c, &base, "t", "!r:x", "@a:x") &invite(&c, &base, "t", "!r:x", "@a:x")
.await .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] #[test]
fn a_state_url_encodes_room_and_key_and_keeps_an_empty_key_slash() { fn a_state_url_encodes_room_and_key_and_keeps_an_empty_key_slash() {
assert_eq!( assert_eq!(

View file

@ -47,8 +47,7 @@ pub fn agent_token_path(agent: &str) -> Result<String, Error> {
/// basic-auth header that token travels in. /// basic-auth header that token travels in.
/// ///
/// The `hive-` prefix keeps the account reading as a hive's rather than an /// 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. /// agent's. `swarm-controller` refuses a new agent named with this prefix, and tells an
/// `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. /// older one apart from the hive's account by the account's email.
#[must_use] #[must_use]
pub fn hive_login(hive: &str) -> String { pub fn hive_login(hive: &str) -> String {

View file

@ -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/<this hive>/*`, 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<String, Error> {
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 /// The name segment of the controller's own subtree, and the cert-auth role it
/// logs in under (`swarm-controller`'s `store::CERT_ROLE`). /// logs in under (`swarm-controller`'s `store::CERT_ROLE`).
pub(crate) const CONTROLLER: &str = "swarm-controller"; pub(crate) const CONTROLLER: &str = "swarm-controller";
@ -254,48 +216,6 @@ mod tests {
assert!(recovery_path("a-b_C9", "d-e_F0").is_ok()); 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/<this hive>/*`, 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/<name>/*` 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] #[test]
fn the_swarm_appservice_token_lands_under_the_controller() { fn the_swarm_appservice_token_lands_under_the_controller() {
assert_eq!( assert_eq!(

View file

@ -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 /// 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 /// that decides what a hive may fetch, so "the path is per-hive" only
/// means something if the stanza that reaches it is too. /// means something if the stanza that reaches it is too.
#[test] #[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 alpha = effective_hive("alpha");
let own = crate::matrix::sender_token_path("alpha").expect("legal"); let own = crate::forge::hive_token_path("alpha").expect("legal");
let other = crate::matrix::sender_token_path("beta").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. // Reachable: the hive's own stanza is the prefix of its own path.
assert!( assert!(