swarm-controller: stop minting the per-hive matrix sender account and token nothing reads
This commit is contained in:
parent
cb5fb3c730
commit
c66832febf
16 changed files with 40 additions and 556 deletions
|
|
@ -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-<hive>:<server_name>`, 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).
|
||||
|
|
|
|||
|
|
@ -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>/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/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/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 |
|
||||
|
|
|
|||
|
|
@ -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/<agent>/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-<hive>:<server_name>` 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/<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.
|
||||
Earlier versions created an `@hive-<hive>:<server_name>` sender account per
|
||||
hive and stored its token at `swarm/hives/<hive>/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:<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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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-<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-<hive>:` sender — which is the whole matrix localpart charset.
|
||||
# Every local user the swarm appservice may create — agents, and the
|
||||
# `@hive-<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-<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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@
|
|||
//! stored at `swarm/hives/<hive>/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.
|
||||
|
|
|
|||
|
|
@ -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-<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
|
||||
/// 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<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.
|
||||
/// `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<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
|
||||
/// hive rather than an agent.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Arc<str>> {
|
|||
}
|
||||
|
||||
/// 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<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||
homeserver: Option<Arc<str>>,
|
||||
hives: &[HiveEntry],
|
||||
auth: Option<Arc<auth::AuthBridge>>,
|
||||
) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
/// (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<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.
|
||||
#[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!(
|
||||
|
|
|
|||
|
|
@ -47,8 +47,7 @@ pub fn agent_token_path(agent: &str) -> Result<String, Error> {
|
|||
/// 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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/<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]
|
||||
fn the_swarm_appservice_token_lands_under_the_controller() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
Loading…
Reference in a new issue