diff --git a/Cargo.lock b/Cargo.lock index 64c1b82b..feee92c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4924,6 +4924,19 @@ dependencies = [ "swarm-queue-client", ] +[[package]] +name = "swarm-matrix-minter" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest", + "serde_json", + "swarm-secret-client", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "swarm-nats-auth" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a56fe0f0..693a7dee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "swarm-authelia-bridge", "swarm-authelia-bridge-sock", "swarm-controller", + "swarm-matrix-minter", "swarm-nats-auth", "swarm-queue-client", "swarm-logs", diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index cd284449..740d6367 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md). ### 6 · Matrix ```bash -# Ensure the hive-internal admin account exists first +# Ensure the hive's own `@hive:` account exists first hivectl matrix sync-admin # Provision ruth's own matrix account — same bootstrap-bypass reasoning @@ -293,7 +293,7 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb. - **No forge admin token is stored in any agent state dir.** Agents hold a regular agent token in their `forge-token` file; sensitive - creds (the core token, the matrix admin token) live on the host. + creds (the core token, the `@hive:` matrix access token) live on the host. - All config changes (forge PRs on `agent-configs/`) go through operator approval — agents can't unilaterally rebuild containers, by design. See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md). diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index cb3870ef..8847a098 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -147,29 +147,31 @@ a token. (`hive-matrix-daemon.path` watching for `matrix-token` appearance) brings the daemon up on the same boot cycle anyway. -### The admin account, and why it needs no first-user luck +### The `@hive:` account, and why it is not an admin `@hive:` is the appservice's own `sender_localpart`, which the homeserver creates itself when it loads the registration — on a zero-user database, inside startup, before the HTTP listener accepts -anything. Its **admin rights** then come from an explicit -`make_user_admin`, run by tuwunel's `admin_execute` in the same startup -and likewise before the listener — so a fresh hive has a joined, -power-level-100 admin on its first boot. +anything. It is an **ordinary account**: nothing promotes it, and the +homeserver runs no `admin_execute` for it. -This replaces a dependency on being the first account ever registered, -which was fragile in both directions: the design excludes an -appservice-created account from that automatic grant, and on a homeserver that -already had users the rule never fired at all. +It needs no promotion for what the hive does with it. Creating the hive +Space and the chat room, writing their hierarchy and join rules, and +inviting agents into them are all ordinary client calls that ride on +being the rooms' own creator at power level 100 — there is no homeserver +admin in any of it. (There is no Synapse admin API here either; tuwunel +has none.) -Tuwunel doesn't support bootstrapping promotion over the API, and -that's upstream's design rather than a gap: it only treats an admin-room message as a -command when its sender is already an admin. `admin_execute` is the one -lever with no sender to check. hive-c0re re-checks the result on every -sweep by reading the admin account's own joined-rooms list; if the rights -are missing it says so, names -`systemctl restart container@hive-matrix` as the fix, and carries on — -agent accounts, the hive Space and the chat room need no admin. +Two operations do need an admin **sender**, and neither works today: +`hivectl matrix promote-user` and `hivectl matrix reset-password`. Both +are `!admin …` messages into `#admins:`, and tuwunel only +treats a message as a command when its sender is already an admin. They +are swarm-level operations and are being rehomed as such; until then +they fail with the admin room's refusal rather than being served by an +over-privileged credential that every other call site would also carry. +The one hive-side path that depends on them is the password-reset +auto-recovery for an agent whose stored password is gone — the ordinary +appservice re-login above is unaffected.
Upgrading a hive that used the registration token @@ -185,10 +187,10 @@ restarts, so the first boot after the switch already has both halves. - **The per-agent sweep honours existing token files.** It skips any agent that already has a `matrix-token`, so it re-registers no account and displaces no session. -- **The admin account is already admin** on such a hive (it won the - first-user grant when the hive was new), so the startup promotion is a - no-op — upstream's `make_user_admin` short-circuits when the user is - already joined at power level 100. +- **`@hive:` may already be an admin** on such a hive (it won the + first-user grant when the hive was new). Nothing here demotes it; the + homeserver simply no longer promotes it, so a hive built fresh has an + ordinary account and an older one keeps whatever standing it acquired. - **`/var/lib/hyperhive/matrix-register-token` stays on disk**, read by nothing. Delete it or leave it; neither does any harm. - **`registrationTokenFile` is a removed option.** A config that still @@ -237,7 +239,7 @@ Initial rollout settings: ## Hive Matrix Space On first boot, after hive-c0re provisions all agent accounts, it -creates a private **Matrix Space** named `"hive"` using the admin +creates a private **Matrix Space** named `"hive"` using the `@hive:` account (`@hive:`) and invites every provisioned agent into it. This gives the operator a single Space in FluffyChat or any Matrix client that groups all agent-to-agent + operator rooms in one diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 12ecc67a..5870819f 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -193,9 +193,9 @@ For an existing agent, persists the token to its state dir; for a human/other ac ## `hivectl matrix sync-admin` -Provision (or re-provision) the hive system admin matrix account. +Provision (or re-provision) the hive's own `@hive:` matrix account. -Runs automatically on startup; run manually to recover a missing admin token. +Runs automatically on startup; run manually to recover a missing access token. **Usage:** `hivectl matrix sync-admin` diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 280afe08..ce7d4864 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -64,7 +64,7 @@ running (`services.hyperhive.deploy.matrix.enable = true`). hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris` hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout hivectl matrix create-user mara --password hunter2 # set a client-login password -hivectl matrix sync-admin # provision / refresh the hive internal admin account +hivectl matrix sync-admin # provision / refresh the hive's own `@hive:` account hivectl matrix promote-user mara # promote an existing matrix user to homeserver admin hivectl matrix reset-password iris # generate and set a new random password for `iris`; prints it hivectl matrix invite mara # invite a user to the hive Space @@ -74,19 +74,21 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec - `create-user`: for agents, persists the `access_token` to `/matrix-token`. Skips registration when the file already exists — delete it first to force re-registration. -- `sync-admin`: ensures the hive's internal admin matrix user exists - (used by `hive-c0re` for admin-room commands). Token persisted to the - admin token path. Safe to run again — idempotent. +- `sync-admin`: ensures the hive's own `@hive:` matrix user exists + (the account `hive-c0re` provisions rooms with). Token persisted to the + access token path. Safe to run again — idempotent. - `promote-user`: promotes an already-registered user to homeserver - admin via the matrix admin API. Requires `sync-admin` to have run - first (needs a valid admin token). -- `reset-password`: calls the matrix admin API to set a new random - password and prints it to stdout. Useful if an agent or human lost + admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**, + which `@hive:` is not — this operation is being rehomed at swarm level + and does not work from the hive today. +- `reset-password`: asks the admin room to set a new random + password and prints it to stdout. ⚠️ Needs an admin **sender** too, so + it does not work from the hive today either. Useful if an agent or human lost credentials. - `invite`: invites a matrix user (full `@user:server` or a bare localpart, qualified with the homeserver's `server_name`) to the hive - Space by default, or to a `--room` id / `#alias`. Uses the hive admin - token; the admin account must be a member of the target room with + Space by default, or to a `--room` id / `#alias`. Uses the `@hive:` + token; the account must be a member of the target room with invite power (it owns the hive Space, so that case always works). Idempotent — already-member / already-invited is a no-op. diff --git a/flake.nix b/flake.nix index 870a4b2e..ec910de8 100644 --- a/flake.nix +++ b/flake.nix @@ -156,6 +156,9 @@ services.hyperhive.deploy.nats.authPackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; + services.hyperhive.deploy.matrix.minterPackage = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-minter; services.hyperhive.deploy.authelia.bridgePackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge; diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index b4c09558..e3cd205d 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -71,12 +71,15 @@ const PASSWORD_BYTES: usize = 32; /// Also the `sender_localpart` of the hive's appservice registration, /// which is what creates this account on a homeserver that has never had /// one: the homeserver creates a registration's sender user itself, at -/// startup, before it accepts a request. The account's *admin rights* -/// then come from the `admin_execute` promotion beside that registration -/// — see `nix/host-modules/hive-matrix.nix`, where this same literal -/// appears as `adminLocalpart`. **The two must match**; nothing wires an -/// override across. -pub const HIVE_ADMIN_LOCALPART: &str = "hive"; +/// startup, before it accepts a request. See +/// `nix/host-modules/hive-matrix.nix`, where this same literal appears as +/// `hiveLocalpart`. **The two must match**; nothing wires an override +/// across. +/// +/// An ordinary account, with no homeserver-admin standing: what it +/// provisions — the Space, the chat room, the invites — it provisions as +/// the creator of those rooms. +pub const HIVE_LOCALPART: &str = "hive"; /// Display name of the hive Space. Plain text, no special characters, so /// the Space stays rediscoverable by name (no room alias needed) even when @@ -94,11 +97,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat"; const HIVE_CHAT_ROOM_TOPIC: &str = "Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re."; -/// Host path for the hive admin matrix access token. Outside every +/// Host path for the `@hive:` account's matrix access token. Outside every /// purgeable path — not deleted by `destroy --purge` on any agent. #[must_use] -pub fn admin_token_path() -> PathBuf { - crate::paths::matrix_admin_token() +pub fn hive_token_path() -> PathBuf { + crate::paths::matrix_hive_token() } /// Token file inside the agent's bind-mounted state dir (visible as @@ -382,21 +385,26 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re extract_access_token(&json) } -/// Auto-recovery helper: reset a user's matrix password via the admin API -/// when the locally stored password is missing. Requires a valid hive admin -/// token at [`admin_token_path()`]. Returns the new password (already -/// persisted to [`password_path`]) on success. +/// Auto-recovery helper: reset a user's matrix password through the admin +/// room when the locally stored password is missing. Returns the new +/// password (already persisted to [`password_path`]) on success. +/// +/// ⚠️ Needs an **admin sender**, which `@hive:` is not — the reset is a +/// `!admin` command and tuwunel only treats a message as a command when +/// its sender is an admin in that room. So this recovery path fails until +/// the two admin operations are rehomed at swarm level; the ordinary +/// route (the stored password, or an appservice login) is unaffected. /// /// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE` /// but the password file is absent — covers the case where agent state dirs /// were wiped but the homeserver still has the accounts. async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result { - let admin_token = read_admin_token() - .context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?; + let hive_token = read_hive_token() + .context("matrix: the @hive: access token is unavailable for auto-recovery")?; let server_name = discover_server_name(client) .await .context("matrix: discover_server_name for auto-recovery")?; - let effective_password = reset_user_password(client, &admin_token, name, &server_name) + let effective_password = reset_user_password(client, &hive_token, name, &server_name) .await .with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?; tracing::info!(%name, "matrix: auto-recovered password via admin-room reset"); @@ -416,7 +424,7 @@ fn encode_room_id_for_url(room_id: &str) -> String { /// Look up the room ID for the `#admins:` alias. async fn discover_admin_room_id( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, server_name: &str, ) -> Result { let base = matrix_base()?; @@ -425,7 +433,7 @@ async fn discover_admin_room_id( let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}"); let resp = client .get(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .context("matrix: GET admin room alias")?; @@ -553,7 +561,7 @@ mod extract_new_password_tests { /// Generic over `T` so both password-returning and `()` callers share the loop. async fn admin_room_send_and_poll( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, server_name: &str, room_url: &str, command: &str, @@ -566,7 +574,7 @@ async fn admin_room_send_and_poll( format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); let send_resp = client .put(&send_url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(&serde_json::json!({"msgtype": "m.text", "body": command})) .send() .await @@ -587,13 +595,13 @@ async fn admin_room_send_and_poll( // Poll for bot response: fetch the 20 most recent events (newest-first) // on each tick. Walk the list until we hit our own command event_id; // everything *before* that marker arrived after our command. - let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}"); + let own_user_id = format!("@{HIVE_LOCALPART}:{server_name}"); let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20"); for _ in 0..15_u8 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; let poll_json = client .get(&poll_url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .context("matrix: admin room poll")? @@ -647,16 +655,16 @@ async fn admin_room_send_and_poll( /// Returns the new password; caller is responsible for persisting it. async fn admin_room_reset_password( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, server_name: &str, localpart: &str, ) -> Result { - let room_id = discover_admin_room_id(client, admin_token, server_name).await?; + let room_id = discover_admin_room_id(client, hive_token, server_name).await?; let room_url = encode_room_id_for_url(&room_id); let command = format!("!admin users reset-password @{localpart}:{server_name}"); admin_room_send_and_poll( client, - admin_token, + hive_token, server_name, &room_url, &command, @@ -761,13 +769,15 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st { pw } else { - // Password file missing — attempt auto-recovery via admin API. + // Password file missing — attempt auto-recovery through the + // admin room. // This covers the case where agent state dirs were wiped but the - // homeserver still has the accounts. Requires the hive admin - // token at /var/lib/hyperhive/matrix/admin-token. + // homeserver still has the accounts. Requires the `@hive:` + // token at /var/lib/hyperhive/matrix/access-token, and an admin + // sender, which `@hive:` no longer is. tracing::info!( %name, - "matrix: stored password missing, attempting admin-API auto-recovery" + "matrix: stored password missing, attempting admin-room auto-recovery" ); match auto_reset_password(client, name).await { Ok(new_pw) => new_pw, @@ -883,42 +893,49 @@ pub async fn sync_agent_standalone(name: &str) { sync_agent(&client, name, &as_token).await; } -/// Ensure the hive system admin matrix user exists, that its token is -/// persisted at [`admin_token_path()`], and that it actually holds admin -/// rights. +/// Ensure the `@hive:` matrix user exists and that its access token is +/// persisted at [`hive_token_path()`]. /// -/// **Nothing here depends on registration order any more.** The account -/// used to have to be the first ever registered, to win tuwunel's -/// automatic first-user grant — a rule that cannot fire for an -/// appservice-created account at all, and one that silently did nothing -/// on a homeserver that already had users. Admin rights now come from an -/// explicit `make_user_admin`: the `admin_execute` entry beside the -/// appservice registration performs it at homeserver startup, and -/// [`ensure_admin_rights`] checks the result and says so when it is -/// missing. +/// **Nothing here depends on registration order, and nothing here is +/// privileged.** The account used to have to be the first ever +/// registered, to win tuwunel's automatic first-user grant — a rule that +/// cannot fire for an appservice-created account at all. It is now an +/// ordinary account: the homeserver creates it because it is the +/// appservice registration's `sender_localpart`, and everything the hive +/// provisions with it, it provisions as the creator of those rooms. /// /// Idempotent — skips the account work when the token file already exists /// and is non-empty. -pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Result<()> { +/// +/// The token is taken from the **swarm secret store** when it is there: +/// `swarm-matrix-minter`, the oneshot inside the matrix container, publishes +/// it under an identity of its own, and taking it from there is what lets a +/// hive that holds no `as_token` have an admin at all. The mint ladder below +/// stays as the fallback for a store that is empty, unconfigured or +/// unreachable — which is every swarm whose matrix container predates that +/// minter. +pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; - let path = admin_token_path(); + let path = hive_token_path(); if path.exists() && let Ok(existing) = std::fs::read_to_string(&path) && !existing.trim().is_empty() { - tracing::debug!("matrix: hive admin token already present"); + tracing::debug!("matrix: the @hive: access token is already present"); return Ok(()); } + if let Some(token) = stored_hive_token().await { + return persist_hive_token(&path, &token); + } let password = random_password()?; - let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await - { + let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await { Ok(token) => { - let pw_path = password_path(HIVE_ADMIN_LOCALPART); + let pw_path = password_path(HIVE_LOCALPART); if let Some(parent) = pw_path.parent() { std::fs::create_dir_all(parent).ok(); } if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) { - tracing::warn!(error = ?e, "matrix: failed to persist hive admin password"); + tracing::warn!(error = ?e, "matrix: failed to persist the @hive: account password"); } else { let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); } @@ -931,125 +948,104 @@ pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Resu // hive-c0re gets a chance to ask. An appservice login needs // no password, which is just as well since an account the // homeserver created has none. - tracing::info!("matrix: hive admin user already exists, logging in as the appservice"); - match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await { + tracing::info!("matrix: the @hive: user already exists, logging in as the appservice"); + match appservice_login(client, as_token, HIVE_LOCALPART).await { Ok(token) => token, Err(e) => { - tracing::warn!(error = ?e, "matrix: appservice login for the hive admin failed; falling back to the stored password"); - let pw_path = password_path(HIVE_ADMIN_LOCALPART); + tracing::warn!(error = ?e, "matrix: appservice login for @hive: failed; falling back to the stored password"); + let pw_path = password_path(HIVE_LOCALPART); let stored = std::fs::read_to_string(&pw_path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .with_context(|| { format!( - "matrix: hive admin user exists, appservice login failed, and no \ + "matrix: the @hive: user exists, appservice login failed, and no \ password is stored at {} — check that the registration file's \ - namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \ + namespace covers @{HIVE_LOCALPART} and that the homeserver \ loaded it", pw_path.display() ) })?; - login_user(client, HIVE_ADMIN_LOCALPART, &stored).await? + login_user(client, HIVE_LOCALPART, &stored).await? } } } Err(other) => return Err(other), }; + persist_hive_token(&path, &access_token) +} + +/// Write the `@hive:` account's access token to `path`, 0600, creating the +/// directory if it is not there. +/// +/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into +/// the store one: the file's mode is the only thing keeping an unprivileged +/// reader off the hive's matrix credential, and a second copy of that decision +/// is one that can be edited alone. +fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } - std::fs::write(&path, format!("{access_token}\n")) - .with_context(|| format!("matrix: write hive admin token to {}", path.display()))?; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); - tracing::info!(path = %path.display(), "matrix: provisioned hive admin token"); + std::fs::write(path, format!("{access_token}\n")).with_context(|| { + format!( + "matrix: write the @hive: access token to {}", + path.display() + ) + })?; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + tracing::info!(path = %path.display(), "matrix: provisioned the @hive: access token"); Ok(()) } -/// Check that the hive admin account holds admin rights, and repair it -/// through the admin room when it does not. +/// Fetch the `@hive:` access token `swarm-matrix-minter` published, under +/// this hive's own store identity. /// -/// Admin-ness in tuwunel is membership of the admin room, so that is what -/// this reads: the account's own joined-rooms list, which needs no -/// privileges to fetch. When the admin room is in it there is nothing to -/// do and nothing is sent — worth insisting on, because the repair is a -/// message in a room and this runs on every sweep. +/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — +/// the same role string `workers::credential` logs in with, and already in +/// this process's environment, so the store read costs no plumbing through +/// [`ensure_all`]. No new grant either: a hive's policy already covers the +/// whole `swarm/services/*` tree the minter writes into. /// -/// When it is absent, the repair is attempted anyway (a stale or failed -/// read is cheaper to retry than to reason about) and a failure is -/// reported rather than raised: agent accounts, the hive Space and the -/// chat room all work without an admin, so a hive with an unpromoted -/// admin is degraded, not broken. Only `hivectl matrix promote-user` / -/// `reset-password` need it. +/// `None`, never an error, for every way this can come up empty — no hive +/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All +/// four mean the same thing to the caller ("mint it the old way"), and three +/// of them are the ordinary state of a swarm that has not deployed the minter +/// yet, so raising would turn a supported deployment into a warning every +/// sweep. /// -/// Returns whether the account holds admin rights now. A `false` has -/// already been logged, with what to do about it. -async fn ensure_admin_rights( - client: &reqwest::Client, - admin_token: &str, - server_name: &str, -) -> bool { - let room_id = match discover_admin_room_id(client, admin_token, server_name).await { - Ok(id) => id, +/// 🩸 Logs the store **path** and never the value. +async fn stored_hive_token() -> Option { + let hive = std::env::var("HYPERHIVE_HIVE_NAME") + .ok() + .filter(|h| !h.is_empty())?; + let path = swarm_secret_client::matrix::hive_token_path(); + let store = match swarm_secret_client::SecretStore::from_env(&hive).await { + Ok(store) => store, Err(e) => { - tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights"); - return false; + tracing::debug!(error = %e, "matrix: no swarm secret store to read the @hive: access token from"); + return None; } }; - match joined_rooms(client, admin_token).await { - Some(rooms) if rooms.iter().any(|r| r == &room_id) => { - tracing::debug!("matrix: hive admin is in the admin room"); - return true; - } - Some(_) => { - tracing::warn!( - "matrix: hive admin is not in the admin room — attempting to promote it" - ); - } - None => tracing::debug!( - "matrix: could not read the hive admin's joined rooms; attempting to promote it" - ), - } - if let Err(e) = - promote_user_to_admin(client, admin_token, HIVE_ADMIN_LOCALPART, server_name).await + match store + .read::(&path) + .await { - // The honest message. Promoting through the admin room requires - // being admin already, so the one lever that works on a - // homeserver with no admin at all is the `admin_execute` entry in - // the hive-matrix module — which runs at startup, which means a - // restart is the fix rather than another sweep. - tracing::warn!( - error = ?e, - "matrix: hive admin '@{HIVE_ADMIN_LOCALPART}' holds no admin rights. \ - The homeserver promotes it at startup (admin_execute in hive-matrix.nix), \ - so `systemctl restart container@hive-matrix` grants it. Agent accounts and \ - room provisioning are unaffected; `hivectl matrix promote-user` and \ - `reset-password` need it." - ); - return false; + Ok(credential) if !credential.value.trim().is_empty() => { + tracing::info!(%path, "matrix: taking the @hive: access token from the swarm store"); + Some(credential.value) + } + Ok(_) => { + tracing::warn!(%path, "matrix: the stored @hive: credential is empty; minting instead"); + None + } + Err(e) => { + tracing::debug!(%path, error = %e, "matrix: no @hive: credential in the store; minting instead"); + None + } } - tracing::info!("matrix: promoted the hive admin through the admin room"); - true -} - -/// The rooms `token`'s account has joined, or `None` when the list could -/// not be read. `None` is deliberately not an empty list: "no rooms" and -/// "no answer" lead to different decisions in [`ensure_admin_rights`]. -async fn joined_rooms(client: &reqwest::Client, token: &str) -> Option> { - let base = matrix_http()?; - let url = format!("{base}/_matrix/client/v3/joined_rooms"); - let resp = client.get(&url).bearer_auth(token).send().await.ok()?; - if !resp.status().is_success() { - return None; - } - let body = resp.json::().await.ok()?; - Some( - body["joined_rooms"] - .as_array()? - .iter() - .filter_map(|r| r.as_str().map(ToOwned::to_owned)) - .collect(), - ) } /// Whether an admin-room reply says a `make-user-admin` succeeded. @@ -1116,16 +1112,16 @@ mod is_make_admin_success_tests { /// intended long-term mechanism, not a stopgap awaiting an upstream fix. pub async fn promote_user_to_admin( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, localpart: &str, server_name: &str, ) -> Result<()> { - let room_id = discover_admin_room_id(client, admin_token, server_name).await?; + let room_id = discover_admin_room_id(client, hive_token, server_name).await?; let room_url = encode_room_id_for_url(&room_id); let command = format!("!admin users make-user-admin @{localpart}:{server_name}"); admin_room_send_and_poll( client, - admin_token, + hive_token, server_name, &room_url, &command, @@ -1151,11 +1147,11 @@ pub async fn promote_user_to_admin( /// Returns the new password for use in subsequent `login_user` calls. pub async fn reset_user_password( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, localpart: &str, server_name: &str, ) -> Result { - let pw = admin_room_reset_password(client, admin_token, server_name, localpart) + let pw = admin_room_reset_password(client, hive_token, server_name, localpart) .await .with_context(|| { format!("matrix: admin-room password reset for @{localpart}:{server_name}") @@ -1205,19 +1201,19 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result { }) } -/// Read the hive admin access token from disk. Returns an error if it -/// is absent — callers should gate their admin-API calls on this. -pub fn read_admin_token() -> Result { - let path = admin_token_path(); +/// Read the `@hive:` access token from disk. Returns an error if it +/// is absent — callers should gate their homeserver calls on this. +pub fn read_hive_token() -> Result { + let path = hive_token_path(); std::fs::read_to_string(&path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .with_context(|| { format!( - "hive admin matrix token not found at {} — \ + "the @hive: matrix access token was not found at {} — \ ensure hive-c0re has started at least once with matrix enabled \ - (it provisions the admin account on boot)", + (it provisions the @hive: account on boot)", path.display() ) }) @@ -1244,12 +1240,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> { /// /// Name-based (not alias-based) rediscovery keeps the Space free of any /// special-char room alias — the hardcoded plain name is the anchor. -async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option { +async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Option { let base = matrix_http()?; let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .ok()? @@ -1264,12 +1260,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti let encoded = encode_room_id_for_url(room_id); // Must be an m.space (m.room.create `type`). let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); - let is_space = match client - .get(&create_url) - .bearer_auth(admin_token) - .send() - .await - { + let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1282,7 +1273,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti } // …and named HIVE_SPACE_NAME (m.room.name `name`). let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); - let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { + let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1313,7 +1304,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti /// /// Returns an error if the homeserver is unreachable, `createRoom` fails, /// or the room-ID file cannot be written. -pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result { +pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Result { let base = matrix_base()?; // 1. Stored room id wins (fast path). if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) { @@ -1326,7 +1317,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R // 2. No stored id — rediscover the existing space by its hardcoded name // before creating a new one (prevents duplicate spaces after a wipe). - if let Some(room_id) = find_space_by_name(client, admin_token).await { + if let Some(room_id) = find_space_by_name(client, hive_token).await { persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: recovered hive space by name"); return Ok(room_id); @@ -1342,7 +1333,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R }); let resp = client .post(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(&body) .send() .await @@ -1382,12 +1373,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> { /// [`find_space_by_name`] so a lost room-id file recovers the existing chat /// room instead of spawning a duplicate. `None` if the homeserver is /// unreachable or no match exists. -async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option { +async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> Option { let base = matrix_http()?; let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .ok()? @@ -1402,12 +1393,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> let encoded = encode_room_id_for_url(room_id); // Skip the Space itself (and any other m.space). let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); - let is_space = match client - .get(&create_url) - .bearer_auth(admin_token) - .send() - .await - { + let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1420,7 +1406,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> } // …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`). let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); - let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { + let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1460,10 +1446,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json:: /// too, loudly — and a 404 is the expected first-setup case. async fn current_room_state( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, url: &str, ) -> Option { - let resp = match client.get(url).bearer_auth(admin_token).send().await { + let resp = match client.get(url).bearer_auth(hive_token).send().await { Ok(resp) => resp, Err(e) => { tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing"); @@ -1491,7 +1477,7 @@ async fn current_room_state( } } -/// PUT a state event into `room_id` using the admin token, **skipping the +/// PUT a state event into `room_id` using the `@hive:` token, **skipping the /// write when the room already carries identical content**. /// /// The read is not an optimisation. A PUT of identical content is a no-op @@ -1506,7 +1492,7 @@ async fn current_room_state( /// missing or divergent link still gets written. async fn set_room_state( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, room_id: &str, event_type: &str, state_key: &str, @@ -1517,7 +1503,7 @@ async fn set_room_state( let encoded_key = encode_room_id_for_url(state_key); let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}"); - let current = current_room_state(client, admin_token, &url).await; + let current = current_room_state(client, hive_token, &url).await; if !state_needs_write(current.as_ref(), content) { tracing::debug!( %room_id, @@ -1528,7 +1514,7 @@ async fn set_room_state( } let resp = client .put(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(content) .send() .await @@ -1565,7 +1551,7 @@ async fn set_room_state( /// link is logged but not fatal (the room still exists + is joinable). pub async fn ensure_hive_chat_room( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, space_room_id: &str, server_name: &str, ) -> Result { @@ -1579,7 +1565,7 @@ pub async fn ensure_hive_chat_room( { tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned"); id - } else if let Some(id) = find_chat_room_by_name(client, admin_token).await { + } else if let Some(id) = find_chat_room_by_name(client, hive_token).await { persist_chat_room_id(&id)?; tracing::info!(room_id = %id, "matrix: recovered hive chat room by name"); id @@ -1619,7 +1605,7 @@ pub async fn ensure_hive_chat_room( }); let resp = client .post(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(&body) .send() .await @@ -1650,7 +1636,7 @@ pub async fn ensure_hive_chat_room( }); if let Err(e) = set_room_state( client, - admin_token, + hive_token, space_room_id, "m.space.child", &room_id, @@ -1668,21 +1654,21 @@ pub async fn ensure_hive_chat_room( /// account. Idempotent — treats already-member responses as success. async fn invite_to_room( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, room_id: &str, localpart: &str, server_name: &str, ) -> Result<()> { let user_id = format!("@{localpart}:{server_name}"); - invite_user_id(client, admin_token, room_id, &user_id).await + invite_user_id(client, hive_token, room_id, &user_id).await } -/// Fetch a user's current membership in a room via the admin token, or +/// Fetch a user's current membership in a room via the `@hive:` token, or /// `None` if there is no membership event (never invited) or the lookup /// fails. Returns the raw membership string (`invite`, `join`, `leave`, …). async fn room_membership( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, encoded_room_id: &str, user_id: &str, ) -> Option { @@ -1693,12 +1679,7 @@ async fn room_membership( let url = format!( "{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" ); - let resp = client - .get(&url) - .bearer_auth(admin_token) - .send() - .await - .ok()?; + let resp = client.get(&url).bearer_auth(hive_token).send().await.ok()?; if !resp.status().is_success() { // 404 = no membership event yet; anything else we treat as "unknown" // and let the caller fall through to the invite attempt. @@ -1709,13 +1690,13 @@ async fn room_membership( } /// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id` -/// using the admin token. Idempotent: a user who is already a member or +/// using the `@hive:` token. Idempotent: a user who is already a member or /// already has a pending invite is left untouched (no fresh invite is sent, /// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE` /// from a racing invite is still treated as success. async fn invite_user_id( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, room_id: &str, user_id: &str, ) -> Result<()> { @@ -1727,7 +1708,7 @@ async fn invite_user_id( // Skip the invite entirely when the user is already invited or joined. // Re-POSTing an invite to a pending member re-sends the invite event, // which re-notifies the agent on every provisioning sweep. - if let Some(membership) = room_membership(client, admin_token, &encoded_room_id, user_id).await + if let Some(membership) = room_membership(client, hive_token, &encoded_room_id, user_id).await && matches!(membership.as_str(), "invite" | "join") { tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)"); @@ -1737,7 +1718,7 @@ async fn invite_user_id( let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); let resp = client .post(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(&serde_json::json!({ "user_id": user_id })) .send() .await @@ -1770,13 +1751,13 @@ async fn invite_user_id( /// /// # Errors /// -/// Returns an error if the admin token or `server_name` can't be read, +/// Returns an error if the `@hive:` token or `server_name` can't be read, /// the target room can't be resolved (no `--room` and no persisted hive /// space), or the invite POST fails for a reason other than the user /// already being a member / invited. pub async fn invite_user( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, user: &str, room_override: Option<&str>, server_name: &str, @@ -1790,7 +1771,7 @@ pub async fn invite_user( // Resolve the room: explicit override (id or #alias) wins; otherwise // the persisted hive Space. let room_id = match room_override { - Some(r) if r.starts_with('#') => resolve_room_alias(client, admin_token, r).await?, + Some(r) if r.starts_with('#') => resolve_room_alias(client, hive_token, r).await?, Some(r) if r.starts_with('!') => r.to_owned(), Some(r) => anyhow::bail!( "matrix: --room {r:?} is neither a room id nor an alias; \ @@ -1804,14 +1785,14 @@ pub async fn invite_user( (run the hive-c0re matrix sweep first)", )?, }; - invite_user_id(client, admin_token, &room_id, &user_id).await?; + invite_user_id(client, hive_token, &room_id, &user_id).await?; Ok(room_id) } /// Resolve a `#alias:server` to its room id via the directory API. async fn resolve_room_alias( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, alias: &str, ) -> Result { let base = matrix_base()?; @@ -1819,7 +1800,7 @@ async fn resolve_room_alias( let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}"); let resp = client .get(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .with_context(|| format!("matrix: GET directory for {alias}"))?; @@ -1875,13 +1856,12 @@ pub async fn ensure_all() -> bool { return false; } }; - // The hive admin first, because everything below provisions THROUGH - // it (the Space, the chat room and every invite are sent with its - // token). Not, any more, so that it wins a first-registered-user - // grant: it holds admin rights by explicit promotion, checked in - // `provision_space` once the server name is known. - if let Err(e) = ensure_admin_user(&client, &as_token).await { - tracing::warn!(error = ?e, "matrix: ensure_admin_user failed"); + // The `@hive:` account first, because everything below provisions + // THROUGH it (the Space, the chat room and every invite are sent with + // its token) — as an ordinary user that created those rooms, not as a + // homeserver admin. + if let Err(e) = ensure_hive_user(&client, &as_token).await { + tracing::warn!(error = ?e, "matrix: ensure_hive_user failed"); ok = false; } let Ok(containers) = crate::lifecycle::list().await else { @@ -1911,10 +1891,10 @@ pub async fn ensure_all() -> bool { async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool { let mut ok = true; // server_name is needed to form full Matrix user IDs for invites. - let admin_token = match read_admin_token() { + let hive_token = match read_hive_token() { Ok(t) => t, Err(e) => { - tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)"); + tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no @hive: access token)"); return false; } }; @@ -1926,33 +1906,22 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo return false; } }; - // Before the rooms: the one thing in this sweep that is about the - // admin account itself rather than about what it provisions. - if !ensure_admin_rights(client, &admin_token, &server_name).await { - ok = false; - } - let room_id = match ensure_hive_space(client, &admin_token).await { + let room_id = match ensure_hive_space(client, &hive_token).await { Ok(id) => id, Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_hive_space failed"); return false; } }; - // Invite @hive admin first, then all agents. - if let Err(e) = invite_to_room( - client, - &admin_token, - &room_id, - HIVE_ADMIN_LOCALPART, - &server_name, - ) - .await + // Invite @hive first, then all agents. + if let Err(e) = + invite_to_room(client, &hive_token, &room_id, HIVE_LOCALPART, &server_name).await { tracing::warn!(error = ?e, "matrix: invite @hive to space failed"); ok = false; } for name in agent_names { - if let Err(e) = invite_to_room(client, &admin_token, &room_id, name, &server_name).await { + if let Err(e) = invite_to_room(client, &hive_token, &room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed"); ok = false; } @@ -1963,13 +1932,13 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo // rooms to chat in (Matrix semantics — children aren't auto-joined), so // without this the Space is empty. The restricted join rule additionally // lets the operator (a Space member) join from the Space hierarchy. - match ensure_hive_chat_room(client, &admin_token, &room_id, &server_name).await { + match ensure_hive_chat_room(client, &hive_token, &room_id, &server_name).await { Ok(chat_room_id) => { if let Err(e) = invite_to_room( client, - &admin_token, + &hive_token, &chat_room_id, - HIVE_ADMIN_LOCALPART, + HIVE_LOCALPART, &server_name, ) .await @@ -1979,7 +1948,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo } for name in agent_names { if let Err(e) = - invite_to_room(client, &admin_token, &chat_room_id, name, &server_name).await + invite_to_room(client, &hive_token, &chat_room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed"); ok = false; diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 5ad8066b..72ebb64f 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -2,7 +2,7 @@ //! `/run/hyperhive` + `/run/hive-agent` runtime roots). //! //! Historically these were flat string literals scattered across many -//! modules (`broker.sqlite`, `matrix-admin-token`, `agent-sockets.json`, +//! modules (`broker.sqlite`, `matrix-hive-token`, `agent-sockets.json`, //! …). This module is the **single Rust-side source** for every host //! path — the strictly host-side ones grouped into subdirs (`db/`, //! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots @@ -128,7 +128,7 @@ pub fn agent_identity_dir(name: &str) -> PathBuf { agent_identity_root().join(name) } -/// `matrix/` — host-side matrix provisioning state (admin token, hive +/// `matrix/` — host-side matrix provisioning state (the `@hive:` access token, hive /// Space room id, per-agent password creds). The shared registration /// token is bind-mounted into the tuwunel container via nix and stays /// at its own path (tracked separately). @@ -137,10 +137,10 @@ pub fn matrix_dir() -> PathBuf { state_root().join("matrix") } -/// `matrix/admin-token` — hive system admin access token. +/// `matrix/access-token` — the `@hive:` account's matrix access token. #[must_use] -pub fn matrix_admin_token() -> PathBuf { - matrix_dir().join("admin-token") +pub fn matrix_hive_token() -> PathBuf { + matrix_dir().join("access-token") } /// `matrix/space-room-id` — persisted hive Space room id. @@ -327,7 +327,7 @@ pub fn relocate_legacy_state() { "forge-agent-configs-avatar-set", forge_config_org_avatar_marker(), ), - ("matrix-admin-token", matrix_admin_token()), + ("matrix-hive-token", matrix_hive_token()), ("matrix-space-room-id", matrix_space_room_id()), ("matrix-creds", matrix_creds_dir()), ("agent-sockets.json", agent_sockets_file()), diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 803682e2..339be2fb 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -455,7 +455,7 @@ async fn stream_agent_status( // The `hivectl matrix` subcommands used to run these in-process, which forced // the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …). // They now run daemon-side over the host socket: the daemon already holds the -// register + admin tokens and the matrix creds dir. Each op returns the +// register + `@hive:` tokens and the matrix creds dir. Each op returns the // operator-facing lines hivectl used to `println!` in `HostResponse::messages` // for the client to print verbatim. // --------------------------------------------------------------------------- @@ -777,14 +777,14 @@ async fn handle_matrix_sync_admin() -> Result { let as_token = crate::matrix::read_appservice_token().context("read matrix appservice token")?; let client = matrix_http_client()?; - crate::matrix::ensure_admin_user(&client, &as_token) + crate::matrix::ensure_hive_user(&client, &as_token) .await .context("matrix sync-admin")?; - let path = crate::matrix::admin_token_path(); + let path = crate::matrix::hive_token_path(); Ok(HostResponse::messages(vec![ format!( - "matrix: hive admin user '@{}' provisioned", - crate::matrix::HIVE_ADMIN_LOCALPART + "matrix: the @{}: user is provisioned", + crate::matrix::HIVE_LOCALPART ), format!("token persisted at: {}", path.display()), ])) @@ -792,12 +792,12 @@ async fn handle_matrix_sync_admin() -> Result { async fn handle_matrix_promote_user(name: &str) -> Result { require_matrix_present()?; - let admin_token = crate::matrix::read_admin_token()?; + let hive_token = crate::matrix::read_hive_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - crate::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name) + crate::matrix::promote_user_to_admin(&client, &hive_token, name, &server_name) .await .with_context(|| format!("matrix promote-user {name}"))?; Ok(HostResponse::messages(vec![format!( @@ -807,12 +807,12 @@ async fn handle_matrix_promote_user(name: &str) -> Result { async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result { require_matrix_present()?; - let admin_token = crate::matrix::read_admin_token()?; + let hive_token = crate::matrix::read_hive_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - let room_id = crate::matrix::invite_user(&client, &admin_token, user, room, &server_name) + let room_id = crate::matrix::invite_user(&client, &hive_token, user, room, &server_name) .await .with_context(|| format!("matrix invite {user}"))?; let target = if user.starts_with('@') { @@ -827,12 +827,12 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result Result { require_matrix_present()?; - let admin_token = crate::matrix::read_admin_token()?; + let hive_token = crate::matrix::read_hive_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - crate::matrix::reset_user_password(&client, &admin_token, name, &server_name) + crate::matrix::reset_user_password(&client, &hive_token, name, &server_name) .await .with_context(|| format!("matrix reset-password {name}"))?; // Password is persisted by reset_user_password. diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 12a81b75..aa48c444 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -335,10 +335,10 @@ pub enum MatrixCmd { #[arg(long, conflicts_with = "password")] password_stdin: bool, }, - /// Provision (or re-provision) the hive system admin matrix account. + /// Provision (or re-provision) the hive's own `@hive:` matrix account. /// /// Runs automatically on startup; run manually to recover a missing - /// admin token. + /// access token. SyncAdmin, /// Promote a matrix user to homeserver admin. PromoteUser { diff --git a/hivectl/src/matrix.rs b/hivectl/src/matrix.rs index a75ce7bf..32c31ebc 100644 --- a/hivectl/src/matrix.rs +++ b/hivectl/src/matrix.rs @@ -1,5 +1,5 @@ //! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards -//! each request to the daemon (which owns the register + admin tokens and +//! each request to the daemon (which owns the register + `@hive:` tokens and //! the matrix creds dir) and renders the reply; it no longer links the //! matrix machinery itself. @@ -28,7 +28,7 @@ pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> /// Send a matrix provisioning request to the daemon and print the /// operator-facing result lines it returns. The daemon owns the register + -/// admin tokens and the matrix creds dir, so hivectl no longer links the +/// `@hive:` tokens and the matrix creds dir, so hivectl no longer links the /// matrix machinery — it just forwards the request and renders the reply. async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> { let resp = crate::client::request(socket, req) diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 0161ea2d..14f62bf1 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -27,6 +27,7 @@ ./glue-controller-bao-identity.nix ./glue-grafana-oidc-client.nix ./glue-matrix-bao-token.nix + ./glue-matrix-minter-bao-identity.nix ./glue-queue-agent-credential.nix ./glue-secret-publisher-bao-identity.nix ./glue-swarm-otel-oidc-client.nix diff --git a/nix/host-modules/glue-bao-tls.nix b/nix/host-modules/glue-bao-tls.nix index 419f9c63..88eb78b7 100644 --- a/nix/host-modules/glue-bao-tls.nix +++ b/nix/host-modules/glue-bao-tls.nix @@ -147,6 +147,19 @@ in # to be this one whenever the store has a host of its own. [ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \ ${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth + + # The matrix container's minter. Minted unconditionally like the two + # above, and for the third variant of the same reason: the homeserver + # is a swarm singleton, so on every hive but the one running it this + # leaf is the file an operator copies rather than a file anything + # local reads. + # + # Its own CN, not the reader's: the reader's leaf carries this hive's + # name and its policy reads the whole store, while this principal may + # only write one path — which is the entire point of giving the + # container an identity instead of lending it the hive's. + [ -s ${pkiDir}/matrix-minter.pem ] || ${signLeaf} ${pkiDir} matrix-minter \ + ${lib.escapeShellArg deployCfg.bao.matrixMinterCommonName} "" clientAuth ''; }; }; diff --git a/nix/host-modules/glue-matrix-minter-bao-identity.nix b/nix/host-modules/glue-matrix-minter-bao-identity.nix new file mode 100644 index 00000000..2274b4cc --- /dev/null +++ b/nix/host-modules/glue-matrix-minter-bao-identity.nix @@ -0,0 +1,40 @@ +# Glue: point the matrix container's minter at the bao leaf minted for it. +# +# ONE PAIRING PER FILE — minter ← bao, and nothing else. Deleting this leaves a +# minter that takes operator-provided certificate paths, which is what any +# deployment not minting its own already does. +# +# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the +# leaf, because the thing that owns a private key owns issuing from it. What +# belongs here is the pairing: which paths the minter presents. +# +# ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule +# ./glue-secret-publisher-bao-identity.nix states, and it bites harder here: a +# swarm runs ONE homeserver, so the hive hosting it is the one least likely to +# also be the hive hosting the store. +# +# Everything is `mkDefault`. An operator naming their own paths wins. +{ + lib, + config, + ... +}: +let + hyperhiveCfg = config.services.hyperhive; + deployCfg = hyperhiveCfg.deploy; + baoDeploy = deployCfg.bao; + + # Where ./glue-bao-tls.nix puts the leaves, derived from the reader's own path + # rather than repeating that file's directory literal: an operator who moves + # the PKI moves both, and the two cannot drift apart. + haveMintedPki = baoDeploy.clientCertFile != null; + pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null; +in +{ + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) { + services.hyperhive.deploy.matrix = { + minterBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-minter.pem"; + minterBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-minter-key.pem"; + }; + }; +} diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index eeb72d16..db0817de 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -26,6 +26,11 @@ let # `null/.well-known/…`. autheliaCfg = config.services.hyperhive.swarm.authelia; deployCfg = config.services.hyperhive.deploy; + + # Where the swarm's secret store answers. Swarm-tier, identical on every + # host, which is what lets the container address it without knowing whether + # it stands here. + baoCfg = config.services.hyperhive.swarm.bao; autheliaUrl = autheliaCfg.url; # The all-local case: this host runs BOTH the homeserver and the swarm's @@ -59,18 +64,19 @@ let # namespace below, with no shared registration secret in the picture. appserviceId = "hyperhive"; - # The appservice's own user, and deliberately the hive admin account. - # Loading a registration CREATES its `sender_localpart` user when absent - # (tuwunel `src/service/appservice/mod.rs`), on a zero-user database, - # inside `Services::start()` — before the HTTP listener accepts anything. - # That is what lets the `admin_execute` promotion below land on the very - # first boot of a fresh homeserver, instead of depending on hive-c0re - # racing to register the first account and win the auto-admin grant. + # The appservice's own user, and the account the hive acts as. An + # ordinary user, not a homeserver admin: everything the hive does with + # it — the Space, the chat room, the invites — it does as the creator of + # those rooms. Loading a registration CREATES its `sender_localpart` user + # when absent (tuwunel `src/service/appservice/mod.rs`), on a zero-user + # database, inside `Services::start()` — before the HTTP listener accepts + # anything, so the account exists on the very first boot of a fresh + # homeserver without hive-c0re racing to register it. # - # ⚠️ Must equal `matrix::HIVE_ADMIN_LOCALPART` in hive-c0re, which derives + # ⚠️ Must equal `matrix::HIVE_LOCALPART` in hive-c0re, which derives # it independently with nothing wiring an override across — same # agreement, and same reason for saying so, as the token path below. - adminLocalpart = "hive"; + hiveLocalpart = "hive"; # The `as_token`, and the `hs_token` the spec requires alongside it. Both # minted by the render script below, mode 0600; the `as_token` is the one @@ -107,7 +113,62 @@ let appserviceCredentialId = "${appserviceId}-appservice.yaml"; appserviceCredentialDir = "/run/credentials/tuwunel.service"; - # Every local user this hive may provision — agents, the hive admin, and + # ── swarm-matrix-minter ──────────────────────────────────────────────── + # + # The oneshot that publishes the `@hive:` account's access token to + # the swarm's secret store. It runs INSIDE the container, beside tuwunel, + # because the appservice token that authorises the mint is already in here — + # `appserviceDir` below is bound read-only precisely so the homeserver can + # load it — and minting anywhere else would create a second holder of that + # secret, which is the thing this whole arrangement exists to stop. + # + # Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE homeserver + # is the host least likely to also be the host running the store, so + # "co-located with bao" would leave the intended deployment silently minting + # nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity` + # states, for a sharper reason. + minterActive = + deployCfg.matrix.minterBaoClientCertFile != null && deployCfg.matrix.minterBaoClientKeyFile != null; + + # The role on the store's `cert` auth mount, and so the single source of the + # string both ends must agree on: ./swarm-bao.nix writes the role under + # `matrixMinterPolicyName` and this hands it to the binary in the + # environment, so the binary itself spells no role at all. + minterCertRole = "swarm-matrix-minter"; + + # Every host directory the minter's bao identity is spread across. Normally + # one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but + # derived rather than assumed, because an operator naming paths by hand is + # exactly the deployment the gate above is written for. + # + # Directories rather than the files, for the reason `appserviceDir`'s own + # comment gives: a re-issued leaf is a new inode, and binding the file would + # pin the one the container saw when it started. + minterPkiDirs = lib.optionals minterActive ( + lib.unique ( + map builtins.dirOf ( + [ + deployCfg.matrix.minterBaoClientCertFile + deployCfg.matrix.minterBaoClientKeyFile + ] + ++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile + ) + ) + ); + + minterBindMounts = lib.genAttrs minterPkiDirs (dir: { + hostPath = dir; + isReadOnly = true; + }); + + # Where a reader of the published credential is told the token is good for. + # Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an + # `Option`, and the minter reads an empty variable as absent rather than as + # the string "null" — which is what a hive with no gateway host actually + # knows about itself. + minterHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}"; + + # Every local user this hive may provision — agents, `@hive:` itself, and # the operator accounts `hivectl matrix create-user` makes, which is the # whole matrix localpart charset. # @@ -173,7 +234,7 @@ let cat <<'REGISTRATION' id: ${appserviceId} url: null - sender_localpart: ${adminLocalpart} + sender_localpart: ${hiveLocalpart} rate_limited: false namespaces: users: @@ -686,6 +747,56 @@ in match authelia's register. ''; }; + + minterPackage = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-minter"; + description = '' + The `swarm-matrix-minter` build run inside the matrix container. + + ⚠️ Named `minterPackage`, not folded into `package` above: that one is + the homeserver, and this is a hyperhive binary that happens to run + beside it. Same split, and same reason, as + {option}`services.hyperhive.deploy.nats.authPackage`. + ''; + }; + + minterBaoClientCertFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Client certificate the matrix container's minter presents to the + swarm's secret store. Its subject must be + {option}`services.hyperhive.deploy.bao.matrixMinterCommonName` — cert + auth matches on the CN, and the role accepts nothing else. + + ⚠️ **Not the hive's own leaf**, and that is the whole deliverable of + giving this container an identity: the hive's certificate reads every + secret in the store, while this one may write a single path. Pointing + this at `deploy.bao.clientCertFile` would evaluate, deploy and work — + and give away the separation in one line. + + No default: a module that guessed would be holding the CA opinion + ./swarm-bao.nix deliberately does not hold. + ./glue-matrix-minter-bao-identity.nix points it at the leaf + ./glue-bao-tls.nix mints, where this host mints one. + + The file and its key are bind-mounted into the container read-only. + Co-located in the hive's filesystem and therefore readable by hive + **root** — accepted: the boundary this buys is identity (no hive + *process* holds the appservice token), not physical isolation. + ''; + }; + + minterBaoClientKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Private key for + {option}`services.hyperhive.deploy.matrix.minterBaoClientCertFile`. + Both or neither — the minter unit does not exist unless each is set. + ''; + }; }; config = lib.mkIf deployCfg.matrix.enable { @@ -1082,7 +1193,11 @@ in isReadOnly = true; }; } - // caTrust.bindMount; + // caTrust.bindMount + # The minter's bao client identity, and nothing else of the hive's. See + # `minterPkiDirs` above for why it is a derived set of directories + # rather than one literal. + // minterBindMounts; config = { ... }: { @@ -1192,30 +1307,21 @@ in # `Services::start()`, before the listener accepts anything. appservice_dir = appserviceCredentialDir; - # The zero-user bootstrap, and the only thing here that needs - # no account to already exist. These run after startup and - # BEFORE the HTTP listener, with no sender and no permission - # check — which is what makes them the one lever that can - # promote the hive admin on a homeserver where nobody is admin - # yet. The appservice registration above creates - # `@${adminLocalpart}` as its sender user moments earlier; - # this joins it to the admin room at power level 100. + # No `admin_execute` promotion for `@${hiveLocalpart}`. The + # hive's account is an ordinary user: it creates the hive + # Space and chat room and invites agents into them, all of + # which ride on being the rooms' own creator at power level + # 100, and none of which is a homeserver-admin capability. + # Granting it server admin at boot would hand a credential + # that every hive reads far more than the work needs. # - # Idempotent by upstream's own guard: `make_user_admin` - # short-circuits when the user is already joined at 100, so a - # hive that has had an admin for months emits nothing. - admin_execute = [ - "users make-user-admin @${adminLocalpart}:${effectiveServerName}" - ]; - - # ⚠️ Load-bearing, not tidiness. An `admin_execute` command - # that fails aborts startup outright when this is false — so - # the one boot where the promotion cannot work (a homeserver - # that has no `@${adminLocalpart}` and no appservice user yet, - # e.g. a registration file that arrived late) would take the - # homeserver down with it rather than converging on the next - # start. - admin_execute_errors_ignore = true; + # The two operations that do need an admin sender — + # `!admin users make-user-admin` and + # `!admin users reset-password`, both messages into + # `#admins:${effectiveServerName}` — therefore have no + # working sender here. They are swarm-level operations and + # are being rehomed as such; until then they fail, loudly, + # rather than being served by an over-privileged token. # Server-side E2EE is opt-in (default off); the agent matrix # client always supports decryption regardless. allow_encryption = cfg.allowEncryption; @@ -1286,6 +1392,57 @@ in "oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}" ]; + # Publish the `@hive:` account's access token to the swarm + # store, once, under an identity that belongs to this container and + # not to the hive. See `minterActive` above for why it runs here. + # + # A `oneshot` with no timer and no retry loop of its own: the whole + # of "and only once" is the binary's first act, a read of the path it + # would write. `Restart=on-failure` covers a store that is sealed or + # a homeserver still starting; `RemainAfterExit` is deliberately NOT + # set, because the unit having succeeded is not the idempotency + # record — the store is, and it outlives this machine. + systemd.services.swarm-matrix-minter = lib.mkIf minterActive { + description = "publish the @hive: matrix credential to the swarm secret store"; + # Ordered after the homeserver because both of the ladder's arms + # are client-server API calls. `wants`, not `requires`: a run that + # finds the credential already published never touches tuwunel at + # all, so a homeserver that is slow to come up should delay this, + # not cancel it. + after = [ "tuwunel.service" ]; + wants = [ "tuwunel.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${deployCfg.matrix.minterPackage}/bin/swarm-matrix-minter"; + Restart = "on-failure"; + RestartSec = 30; + # Bounded here rather than left to systemd's default, for the + # reason ./swarm-secret-publisher.nix states: a sealed store + # answers on the port and never answers the read. + TimeoutStartSec = 60; + SyslogIdentifier = "swarm-matrix-minter"; + }; + environment = { + BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; + BAO_CLIENT_CERT = deployCfg.matrix.minterBaoClientCertFile; + BAO_CLIENT_KEY = deployCfg.matrix.minterBaoClientKeyFile; + MATRIX_MINTER_CERT_ROLE = minterCertRole; + # Loopback: this container shares the host netns, so the + # homeserver it must talk to is the one in this very unit's + # netns and needs no name, no vhost and no TLS. + MATRIX_MINTER_API_URL = "http://127.0.0.1:${toString cfg.httpPort}"; + # The bind-mounted registration, which IS the as_token. A path, + # never a value. + MATRIX_MINTER_REGISTRATION = appserviceRegistrationPath; + MATRIX_MINTER_LOCALPART = hiveLocalpart; + MATRIX_MINTER_HOMESERVER = minterHomeserverUrl; + } + // lib.optionalAttrs (deployCfg.bao.serverCaFile != null) { + BAO_CACERT = deployCfg.bao.serverCaFile; + }; + }; + environment.systemPackages = [ deployCfg.matrix.package ]; }; }; diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 762cab99..2cf2db26 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -236,6 +236,35 @@ let } ''; + # The identity the matrix container's minter presents. Named outside `hive-*` + # for the reason its two siblings above give — the controller may rewrite + # every policy under that prefix, and a policy it can rewrite constrains + # nothing. + matrixMinterPolicyName = "swarm-matrix-minter"; + matrixMinterCn = baoDeploy.matrixMinterCommonName; + + # ONE path, and every narrowing in it is load-bearing. + # + # `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than + # written by the caller — the same trap as the two grants above. + # + # Not `swarm/services/*` like the publisher's: this principal produces + # exactly one secret, the `@hive:` account's access token, and a + # homeserver is not entitled to overwrite Grafana's OIDC client. The path is + # spelled to the leaf for that reason, not for tidiness. + # + # `read` as well as write, unlike either sibling, and it is what makes "and + # only once" mechanical: the minter's first act is to read this path back and + # stop if something is there, so without the capability every container + # restart would mint a second access token and invalidate the hive's. A read + # here recovers one secret this principal itself wrote, which is a much + # narrower grant than the publisher's would have been. + matrixMinterPolicyText = '' + path "${credentialMountPath}/data/swarm/services/matrix/hive-access-token" { + capabilities = ["create", "update", "read"] + } + ''; + # The KV v2 engine the controller writes agent credentials through. Named # once because the grant above and the `secrets enable` in the bootstrap unit # have to agree: a policy pointing at a mount nobody created is precisely the @@ -659,6 +688,29 @@ in ''; }; + matrixMinterCommonName = lib.mkOption { + type = lib.types.str; + default = "swarm-matrix-minter"; + example = "swarm-matrix-minter.svc"; + description = '' + Subject the store's matrix-minter cert-auth role accepts — the + identity the oneshot inside the matrix container presents when it + publishes the `@hive:` account's access token. + + A **third** identity rather than reuse of either sibling above, and + the narrowest of the three: its grant is one path, that + credential itself. The point of the whole arrangement is that the + process holding the appservice token is not the hive, so handing it + the hive's own leaf — which reads every secret in the store — would + give the separation away in one line. + + ⚠️ Same collision as its siblings, and the same answer: ./swarm.nix + feeds this value into the guard on + {option}`services.hyperhive.swarm.hives`, so a hive named after it + fails evaluation rather than silently receiving the minter's grant. + ''; + }; + serverCaFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; @@ -1222,6 +1274,54 @@ in ''; }; + # A THIRD sibling, for the reason the second one's comment gives: these + # unit names are operator-facing strings, and the grant written here + # belongs to a principal neither of the other two names. Same `after` + # rather than `requires`, and for the same stated reason — the first unit + # creates the mounts this one writes into, but a failed oneshot still + # counts as finished, so only ordering plus this unit's own retry + # converges. + systemd.services.swarm-bao-matrix-minter-policy = lib.mkIf haveBootstrapToken { + description = "write the swarm matrix minter's bao policy and cert-auth role"; + after = [ + "container@${cfg.machine}.service" + "swarm-bao-controller-policy.service" + ]; + wantedBy = [ "multi-user.target" ]; + path = [ + baoCli + pkgs.coreutils + ]; + unitConfig.ConditionPathExists = baoDeploy.bootstrapTokenFile; + # Same unseal wait as its two siblings above, for the reason stated + # there: under `seal = "shamir"` a human unseals by hand. + startLimitBurst = 2880; + startLimitIntervalSec = 90000; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + Restart = "on-failure"; + RestartSec = 30; + }; + script = '' + set -euo pipefail + + BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})" + export BAO_TOKEN + + printf '%s' ${lib.escapeShellArg matrixMinterPolicyText} | + bao policy write ${lib.escapeShellArg matrixMinterPolicyName} - + '' + + lib.optionalString (baoDeploy.clientCaFile != null) '' + + bao write auth/cert/certs/${lib.escapeShellArg matrixMinterPolicyName} \ + certificate=@${tlsDir}/client-ca.pem \ + allowed_common_names=${lib.escapeShellArg matrixMinterCn} \ + token_policies=${lib.escapeShellArg matrixMinterPolicyName} \ + display_name=${lib.escapeShellArg matrixMinterCn} + ''; + }; + containers.${cfg.machine} = { autoStart = true; ephemeral = false; diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 0625d336..118bd261 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -46,6 +46,7 @@ let certAuthCns = [ deployCfg.bao.controllerCommonName deployCfg.bao.secretPublisherCommonName + deployCfg.bao.matrixMinterCommonName ]; # Public hostnames of the swarm's own services, in declaration order. diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 256b1cee..dcb6830c 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -140,6 +140,56 @@ let baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.after ); } + { + # The third principal's grant, and the narrowest of the three: ONE path, + # spelled to the leaf. The negative arms are the property — a homeserver + # is not entitled to overwrite Grafana's OIDC client, so widening this to + # the `services/` prefix the publisher holds would be a real loss even + # though it would read as tidier. + # + # ⚠️ `services` is PLURAL, because the path segment comes from + # `Kind::Service`'s `#[strum(serialize = "services")]` and not from + # `Kind::label`, which renders the singular for error text. The singular + # spelling evaluates, deploys, and 403s every read with "permission + # denied" and nothing else. + name = "the matrix minter's grant is the hive credential's path and nothing else"; + ok = + let + s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; + in + lib.hasInfix "path \"secret/data/swarm/services/matrix/hive-access-token\" {" s + && !(lib.hasInfix "secret/data/swarm/services/*" s) + && !(lib.hasInfix "secret/data/swarm/agents" s) + && !(lib.hasInfix "secret/data/swarm/hives" s) + && !(lib.hasInfix "sys/policies/acl" s); + } + { + # 🩸 `read` is load-bearing here and is the one capability neither + # sibling has. The minter's first act is to read this path back and stop + # if something is there — that read IS "and only once", so without the + # capability every container restart would mint a second access token and + # invalidate the hive's. + name = "the matrix minter may read back the one path it writes"; + ok = + let + s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; + in + lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s + && lib.hasInfix "auth/cert/certs/swarm-matrix-minter" s + && lib.hasInfix "allowed_common_names=swarm-matrix-minter" s; + } + { + # Same two controls its siblings carry: ordered after the unit that makes + # the mounts it writes into, and rendered on the HOST rather than inside + # the store's container, where it would have neither an identity nor a + # route to the store. + name = "the minter's granting unit is ordered after the mounts and rendered on the host"; + ok = + lib.elem "swarm-bao-controller-policy.service" ( + baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.after + ) + && !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-minter-policy); + } { # The policy authorising this route lives in another file, and nothing # else relates the grants to the paths the code actually writes. diff --git a/nix/module-eval/bao-matrix-reader.nix b/nix/module-eval/bao-matrix-reader.nix index 29cd5fdf..eb815761 100644 --- a/nix/module-eval/bao-matrix-reader.nix +++ b/nix/module-eval/bao-matrix-reader.nix @@ -37,6 +37,12 @@ let deploy.bao.clientCertFile = "/etc/pki/bao-client.pem"; deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem"; }; + + # A homeserver on a hive with NO store identity at all — neither a local + # store nor a hand-placed leaf. The absence arm for the minter cases below + # needs it, and defining it here rather than importing keeps each group's + # fixture set its own, as ./lib.nix asks. + matrixNoBaoIdentity = hive { deploy.matrix.enable = true; }; cases = [ { # A login failure is the store being unreachable, sealed, or not yet @@ -219,6 +225,110 @@ let && (s.hive-c0re.environment.BAO_CACERT or null) == "%d/bao-ca.pem" && lib.any (c: lib.hasPrefix "bao-ca.pem:" c) s.hive-c0re.serviceConfig.LoadCredential; } + { + # The same hole a third time, and the leaf whose absence is hardest to + # see from outside: it is consumed by a unit INSIDE a container, so a + # missing pairing renders as a container that comes up fine and publishes + # nothing. + name = "the store mints a leaf for the matrix minter, and the container is pointed at it"; + ok = + let + m = baoWithMatrix; + p = m.services.hyperhive.deploy.matrix; + in + lib.hasInfix "matrix-minter.pem" m.systemd.services.swarm-bao-pki.script + && p.minterBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-minter.pem" + && p.minterBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-minter-key.pem"; + } + { + # 🩸 The identity separation this whole arrangement buys, stated as the + # one thing that would silently undo it. The container gets the MINTER's + # leaf — whose grant is a single path — and not the hive's, which reads + # every secret in the store. Both files exist in the same directory and + # both would evaluate, deploy and work. + name = "the matrix minter presents its own leaf, never the hive's store-wide one"; + ok = + let + env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment; + hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile; + in + env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-minter.pem" + && env.BAO_CLIENT_CERT != hiveLeaf; + } + { + # The bind mount is what makes the environment above resolvable: without + # it the unit names two paths the container does not have, and fails at + # the TLS handshake naming no cause. Read off the mount table rather than + # the option, so a pairing that stops reaching `bindMounts` still fails. + # + # The second arm is the shape guard: `bindMounts` is one literal plus two + # merges, and a rewrite that dropped the appservice registration would + # take the homeserver's own credential with it. + name = "the matrix container binds the minter's PKI read-only, without losing the appservice registration"; + ok = + let + mounts = baoWithMatrix.containers.hive-matrix.bindMounts; + in + mounts ? "/var/lib/swarm-bao-pki" + && mounts."/var/lib/swarm-bao-pki".isReadOnly + && mounts ? "/var/lib/hyperhive/matrix-appservice"; + } + { + # What the unit is for, read as the two agreements it cannot get wrong: + # the cert role ./host-modules/swarm-bao.nix writes, and a homeserver + # address that is loopback because the container shares the host netns. A + # vhost here would be a request out through the gateway and back. + name = "the matrix minter is handed the store role and the loopback homeserver"; + ok = + let + m = baoWithMatrix; + u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-minter; + port = m.services.hyperhive.swarm.matrix.httpPort; + in + u.environment.MATRIX_MINTER_CERT_ROLE == "swarm-matrix-minter" + && u.environment.MATRIX_MINTER_API_URL == "http://127.0.0.1:${toString port}" + && u.environment.MATRIX_MINTER_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml" + && u.serviceConfig.Type == "oneshot"; + } + { + # 🩸 A secret is a path, never a value — checked on the one unit in this + # tree whose whole job is an `as_token`. Every variable it is given names + # a file or an address; the token itself is read out of the bind-mounted + # registration at runtime, so nothing here can be a token and an + # environment block is world-readable through `systemctl show`. + name = "the matrix minter's environment carries paths and addresses, never a token"; + ok = + let + env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment; + in + !(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env)); + } + { + # The absence arm, and the deployment it protects: a homeserver on a hive + # with no store identity at all. Without it the unit would exist naming + # `null` as its certificate, which nixos renders as the literal string. + name = "a matrix container with no store identity runs no minter and binds no PKI"; + ok = + let + units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services; + in + !(units ? swarm-matrix-minter) + && !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki"); + } + { + # 🩸 The privilege arm of the credential this slice publishes: the + # account it belongs to must not be a homeserver admin. Read on the + # rendered homeserver settings rather than on an option, because the + # grant was never an option — it was a boot command in `admin_execute`, + # and a command list is exactly the shape a later edit re-adds without + # anything noticing. + name = "the homeserver promotes no account to admin at boot"; + ok = + let + g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global; + in + !(g ? admin_execute) || g.admin_execute == [ ]; + } ]; in runGroup "bao-matrix-reader" cases diff --git a/nix/module-eval/name-guards.nix b/nix/module-eval/name-guards.nix index d2ee8b32..40775c1b 100644 --- a/nix/module-eval/name-guards.nix +++ b/nix/module-eval/name-guards.nix @@ -52,6 +52,17 @@ let swarm.hives.pubctl.domain = "p.t.local"; }; + # The THIRD element of the same list, colliding on its own so neither of the + # two above can carry it. The minter's grant is one path rather than a whole + # prefix, which is exactly why a dead entry here would be easy to miss: a + # hive that inherited it would not obviously break anything, it would + # silently gain the ability to overwrite the swarm's matrix credential. + hiveNamedAfterMinterSubject = hive { + deploy.swarm-otel.enable = false; + deploy.bao.matrixMinterCommonName = "mintctl"; + swarm.hives.mintctl.domain = "m.t.local"; + }; + hiveNameWithComposedWord = hive { deploy.swarm-otel.enable = false; swarm.hives."h1-agent".domain = "a.t.local"; @@ -88,6 +99,18 @@ let a: !a.assertion && lib.hasInfix "'pubctl'" a.message ) hiveNamedAfterPublisherSubject.assertions; } + { + # And the third, for the reason the second one's comment gives one list + # element earlier. `certAuthCns` is where a role added beside the others + # has to register itself, and nothing but a case per element notices when + # one forgets. + name = "a hive named after the matrix minter's subject is refused too"; + ok = + equalityGuardFired hiveNamedAfterMinterSubject + && lib.any ( + a: !a.assertion && lib.hasInfix "'mintctl'" a.message + ) hiveNamedAfterMinterSubject.assertions; + } { # Without this the case above proves nothing: an arm that fires for every # roster is not a guard, and `hives` is non-empty in both fixtures. diff --git a/nix/packages/default.nix b/nix/packages/default.nix index ac18933d..296a53a3 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -163,6 +163,13 @@ in # rather than every hive's. swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; + # The matrix admin credential's minter. Out of `daemonBins` for the same + # "runs *inside* a container, not on the host" reason as the responder + # above, and with a second one: putting it in the core bundle would place + # the binary that reads the appservice token on every hive's filesystem, + # which is the arrangement it exists to end. + swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix admin-credential minter"; + # The only process allowed to write swarm-authelia's users database — # same "runs *inside* a container, not on the host" placement as # `swarm-nats-auth` above (this one lives in `swarm-authelia`'s diff --git a/swarm-matrix-minter/Cargo.toml b/swarm-matrix-minter/Cargo.toml new file mode 100644 index 00000000..8d56e641 --- /dev/null +++ b/swarm-matrix-minter/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "swarm-matrix-minter" +version.workspace = true +readme = "README.md" +edition.workspace = true + +[[bin]] +name = "swarm-matrix-minter" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +reqwest.workspace = true +serde_json.workspace = true +# The agreement this binary is one end of: where the credential lives, what the +# object at that path holds, and the `BAO_*` spellings the unit sets. +swarm-secret-client.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/swarm-matrix-minter/README.md b/swarm-matrix-minter/README.md new file mode 100644 index 00000000..ac0455d8 --- /dev/null +++ b/swarm-matrix-minter/README.md @@ -0,0 +1,44 @@ +# swarm-matrix-minter + +A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the +homeserver, and puts the `@hive:` account's access token into the swarm's secret +store under an identity of its own. + +## Why it lives in the matrix container + +The credential it mints is authorised by the appservice `as_token`, and the +container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the +rendered appservice registration into it read-only, because that is how tuwunel +itself is handed the registration. Minting anywhere else would mean copying the +`as_token` to a second holder — and the point of this component is that the hive +stops being one. + +It is not the swarm controller for the same reason, plus a structural one: a +homeserver has exactly **one** `@hive:` account and a swarm runs one +homeserver, so "mint it once" needs no lock, no lease and no trigger surface — it +is a property of the thing being minted. + +## Idempotency + +The **store** is the key, not the homeserver. A run reads +`swarm/services/matrix/hive-access-token` first and returns without touching the +homeserver when something is already there. Only an empty path reaches the mint +ladder: + +1. `POST /_matrix/client/v3/register` with `"type": "m.login.application_service"` + — one round trip, no UIAA. +2. `M_USER_IN_USE` (the expected arm on a homeserver that has already loaded the + registration, since the account is the appservice's own `sender_localpart`) → + `POST /_matrix/client/v3/login` as the appservice, same pinned `device_id`, so + the old device is replaced rather than duplicated. +3. Write the result to the store. + +A crash between the homeserver call and the store write is recoverable: the next +run takes arm 2. + +## 🩸 A secret is a path, never a value + +Nothing here logs, prints or interpolates a token. The mint ladder's errors are +built from the homeserver's _status_ and its `errcode`, never its body, because a +`/login` response body is an access token. The one identifier this binary logs is +the store path it wrote. diff --git a/swarm-matrix-minter/src/homeserver.rs b/swarm-matrix-minter/src/homeserver.rs new file mode 100644 index 00000000..3a82a659 --- /dev/null +++ b/swarm-matrix-minter/src/homeserver.rs @@ -0,0 +1,286 @@ +//! The two calls the mint ladder is made of, against the homeserver next door. +//! +//! 🩸 **Every error in this module is built from the response's `status` and +//! its `errcode`, never its body.** A successful `/register` or `/login` body +//! *is* an access token, and an error body is one malformed response away from +//! being the same bytes — so a `body: {json}` in a message here would put the +//! `@hive:` credential in the journal. + +use anyhow::{Context, Result, bail}; + +/// Client-server API calls are one round trip each against a homeserver in the +/// same netns; a slow one is a broken one. +const TIMEOUT_SECS: u64 = 10; + +/// Bytes of the throwaway password `/register` is given. +/// +/// Protocol overhead, and stored nowhere: this account authenticates by access +/// token, and the recovery path when that token is lost is the appservice login +/// below rather than anything a password could open. +const PASSWORD_BYTES: usize = 32; + +/// What the homeserver said about a registration attempt. +/// +/// An enum rather than a string match on the error text: `M_USER_IN_USE` is the +/// *expected* answer here — the `@hive:` account is the appservice registration's +/// own `sender_localpart`, so the homeserver creates it at startup, before +/// anything gets to ask — and an expected answer should not have to be +/// recovered from a formatted message. +pub enum Registered { + /// A fresh account, and the access token minted with it. + Token(String), + /// The account is already there; it has to be logged into instead. + AlreadyExists, +} + +/// An HTTP client with this module's timeout. +/// +/// # Errors +/// When the TLS backend will not initialise, which is the only way building a +/// client fails. +pub fn client() -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(TIMEOUT_SECS)) + .build() + .context("building the HTTP client for the homeserver") +} + +/// Create `localpart`'s account as the appservice and return its access token. +/// +/// One round trip: an appservice-typed registration needs no UIAA stage, so +/// there is no session to carry and no shared registration secret in the +/// picture. The `device_id` is pinned so that a later [`appservice_login`] +/// replaces this device rather than accumulating one per run. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses with anything other than `M_USER_IN_USE` — which is +/// [`Registered::AlreadyExists`] rather than an error. +pub async fn register( + client: &reqwest::Client, + base: &str, + localpart: &str, + as_token: &str, +) -> Result { + let body = serde_json::json!({ + // What makes this an appservice registration rather than an ordinary + // one: without it the homeserver asks for a UIAA flow even though the + // request carries the as_token. + "type": "m.login.application_service", + "username": localpart, + "password": random_password()?, + "device_id": device_id(localpart), + "initial_device_display_name": format!("hyperhive ({localpart})"), + "inhibit_login": false, + }); + let (status, json) = post( + client, + &format!("{base}/_matrix/client/v3/register?kind=user"), + as_token, + &body, + ) + .await + .context("POST /register as the appservice")?; + + if status.is_success() { + return Ok(Registered::Token(access_token(&json)?)); + } + if errcode(&json) == Some("M_USER_IN_USE") { + return Ok(Registered::AlreadyExists); + } + bail!( + "the homeserver refused to register @{localpart}: {}", + why(status, &json) + ); +} + +/// Log in as an **existing** account using the appservice's authority, and +/// return a fresh access token for it. +/// +/// No password: the appservice is authorised for every localpart in its +/// namespace, so it mints a session for one without knowing anything about the +/// account — which is just as well, since an account the homeserver created for +/// its own registration has none. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses. +pub async fn appservice_login( + client: &reqwest::Client, + base: &str, + localpart: &str, + as_token: &str, +) -> Result { + let body = serde_json::json!({ + "type": "m.login.application_service", + "identifier": { + "type": "m.id.user", + "user": localpart, + }, + // Matching `register`'s, so a re-login REPLACES that device's token + // rather than leaving a second live device behind. + "device_id": device_id(localpart), + "initial_device_display_name": format!("hyperhive ({localpart})"), + }); + let (status, json) = post( + client, + &format!("{base}/_matrix/client/v3/login"), + as_token, + &body, + ) + .await + .context("POST /login as the appservice")?; + + if !status.is_success() { + bail!( + "the homeserver refused to log in @{localpart}: {}", + why(status, &json) + ); + } + access_token(&json) +} + +/// The device every token this binary mints is pinned to. +fn device_id(localpart: &str) -> String { + format!("hyperhive-{localpart}") +} + +/// One authenticated JSON POST, returning the status beside the decoded body. +async fn post( + client: &reqwest::Client, + url: &str, + as_token: &str, + body: &serde_json::Value, +) -> Result<(reqwest::StatusCode, serde_json::Value)> { + let resp = client + .post(url) + .bearer_auth(as_token) + .json(body) + .send() + .await + .context("sending the request")?; + let status = resp.status(); + let json = resp + .json::() + .await + .context("decoding the response as JSON")?; + Ok((status, json)) +} + +/// Pull `access_token` out of a successful response. +fn access_token(json: &serde_json::Value) -> Result { + json["access_token"] + .as_str() + .map(str::to_owned) + // Not `{json}`: on the success path this object holds the credential, + // and a response missing the field is exactly when a reflex to print it + // would fire. + .context("the homeserver's response carried no `access_token`") +} + +/// The matrix error code, when the body is a standard error object. +fn errcode(json: &serde_json::Value) -> Option<&str> { + json["errcode"].as_str() +} + +/// Everything about a refusal that is safe to put in a message. +/// +/// The `errcode` is a closed vocabulary from the spec and the status is a +/// number; between them they say which of the ladder's arms was taken. The +/// `error` string beside them is free-form homeserver text, so it stays out. +fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String { + match errcode(json) { + Some(code) => format!("HTTP {status}, errcode {code}"), + None => format!("HTTP {status}, no errcode"), + } +} + +/// A throwaway password for [`register`], as hex. +/// +/// From `/dev/urandom` directly rather than through an RNG crate: this is the +/// one random value the binary needs, and the kernel is already the source any +/// such crate would reach for here. +/// +/// # Errors +/// When `/dev/urandom` cannot be read. +fn random_password() -> Result { + use std::io::Read as _; + + let mut buf = [0u8; PASSWORD_BYTES]; + std::fs::File::open("/dev/urandom") + .context("opening /dev/urandom")? + .read_exact(&mut buf) + .context("reading from /dev/urandom")?; + Ok(buf.iter().fold(String::new(), |mut acc, b| { + use std::fmt::Write as _; + // Infallible: `write!` into a `String` only fails if the formatter + // does, and `{:02x}` of a `u8` has nothing to fail at. + let _ = write!(acc, "{b:02x}"); + acc + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() { + // The whole reason the recovery arm is safe to take repeatedly: an + // unpinned login mints a NEW device each time, and a homeserver + // accumulating devices for this account is one where revoking the credential + // means finding all of them. + assert_eq!(device_id("hive"), "hyperhive-hive"); + } + + #[test] + fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() { + // 🩸 The invariant this module exists to keep. `error` is free-form + // homeserver text and `access_token` is the credential itself; a + // message built from the body would carry whichever of them the + // response happened to hold. + let json = serde_json::json!({ + "errcode": "M_FORBIDDEN", + "error": "some free-form text", + "access_token": "syt_the_actual_secret", + }); + let message = why(reqwest::StatusCode::FORBIDDEN, &json); + assert!(message.contains("M_FORBIDDEN"), "{message}"); + assert!(!message.contains("syt_the_actual_secret"), "{message}"); + assert!(!message.contains("free-form"), "{message}"); + } + + #[test] + fn a_refusal_with_no_errcode_still_produces_a_message() { + // A homeserver behind a proxy answers with HTML, not a matrix error + // object. The status is then the only thing there is to say, and + // saying it is better than an empty report. + let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({})); + assert!(message.contains("502"), "{message}"); + } + + #[test] + fn the_expected_already_exists_answer_is_recognised_by_its_errcode() { + // Matched on the spec's code rather than on message text, because this + // is the arm a healthy homeserver takes every time: the `@hive:` account + // is the appservice registration's own sender, created at startup. + let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" }); + assert_eq!(errcode(&json), Some("M_USER_IN_USE")); + } + + #[test] + fn a_response_without_a_token_is_an_error_that_does_not_quote_it() { + let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" })) + .expect_err("no access_token in this object"); + assert!(!format!("{e}").contains("@hive:t.local"), "{e}"); + } + + #[test] + fn a_minted_password_is_hex_of_the_declared_length() { + // The control on the hex fold: a short or non-hex password would be + // accepted by the homeserver and only surface much later, if at all. + let pw = random_password().expect("/dev/urandom is readable"); + assert_eq!(pw.len(), PASSWORD_BYTES * 2); + assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex"); + } +} diff --git a/swarm-matrix-minter/src/main.rs b/swarm-matrix-minter/src/main.rs new file mode 100644 index 00000000..25cd3fcb --- /dev/null +++ b/swarm-matrix-minter/src/main.rs @@ -0,0 +1,249 @@ +//! Mint the `@hive:` account's homeserver access token, once, and publish it to the +//! swarm's secret store. +//! +//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the +//! swarm controller. It is in the container because the appservice `as_token` +//! that authorises the mint is *already* there — the registration tuwunel loads +//! is bind-mounted in — so no second holder of that secret is created. It is +//! this account rather than an agent's because a homeserver has **one** `@hive:` +//! account and a swarm runs one homeserver: "only once" is a property of what +//! is being minted, so there is nothing to lock and no trigger to serve. +//! +//! The store, not the homeserver, is the idempotency key — see +//! [`already_published`]. On the hive side `hive-c0re`'s +//! `matrix::ensure_hive_user` reads exactly the path written here, which is how +//! a hive that holds no `as_token` still gets its matrix account. +//! +//! 🩸 A secret is a path, never a value. The only identifier this binary logs is +//! the store path; see `homeserver`'s module doc for the same rule applied to +//! error messages. + +mod homeserver; +mod registration; + +use anyhow::{Context, Result}; +use swarm_secret_client::{ + SecretStore, + client::{DEFAULT_CERT_MOUNT, Settings}, + matrix, +}; + +/// Role on the store's `cert` auth mount to log in with. Its policy is what +/// allows the write below; the certificate the `BAO_*` variables name has to +/// carry the CN that role accepts. +const ENV_CERT_ROLE: &str = "MATRIX_MINTER_CERT_ROLE"; +/// Client-server API base of the homeserver beside us — loopback, since the +/// container shares the host netns. +const ENV_API_URL: &str = "MATRIX_MINTER_API_URL"; +/// The bind-mounted appservice registration, which is where the `as_token` +/// comes from. A path, never a value. +const ENV_REGISTRATION: &str = "MATRIX_MINTER_REGISTRATION"; +/// Localpart of the hive's account. The appservice registration's own +/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`. +const ENV_LOCALPART: &str = "MATRIX_MINTER_LOCALPART"; +/// Public base URL of the homeserver, stored beside the token so a reader can +/// reconstruct where it is good for. Optional: a swarm with no gateway vhost +/// has no such URL, and `matrix::Credential` types the field to say so. +const ENV_HOMESERVER: &str = "MATRIX_MINTER_HOMESERVER"; + +/// Everything the unit tells this process, checked before anything is opened. +/// +/// Separate from the work for the reason `swarm_secret_client::client::Settings` +/// is: every arm is a misconfiguration an operator reads an error about, and +/// none of them needs a reachable homeserver or store to happen. +#[derive(Debug, PartialEq, Eq)] +struct Config { + cert_role: String, + api_url: String, + registration: String, + localpart: String, + homeserver: Option, +} + +impl Config { + /// Read the `MATRIX_MINTER_*` variables from the process environment. + /// + /// # Errors + /// Naming the first variable that is unset or empty. + fn from_env() -> Result { + Self::from_lookup(|k| std::env::var(k).ok()) + } + + /// [`Config::from_env`] against an arbitrary lookup. + /// + /// # Errors + /// Naming the first variable that is unset or empty. + fn from_lookup(get: impl Fn(&str) -> Option) -> Result { + let required = |var: &'static str| -> Result { + get(var) + .filter(|v| !v.is_empty()) + .with_context(|| format!("{var} is unset or empty")) + }; + Ok(Self { + cert_role: required(ENV_CERT_ROLE)?, + api_url: required(ENV_API_URL)?, + registration: required(ENV_REGISTRATION)?, + localpart: required(ENV_LOCALPART)?, + // Empty is absent: systemd renders an unset nix option as + // `Environment=VAR=`, so that is the shape this arrives in. + homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()), + }) + } +} + +/// Is the credential already in the store? +/// +/// **This read is the "and only once".** The homeserver is not asked — a +/// re-run of the container, or of this unit, costs one store read and stops. +/// It is also the read-back of what a previous run wrote, so the path published +/// and the path consulted cannot drift apart: they are one function call. +/// +/// A failure to read is reported and treated as absent rather than raised. The +/// two cases that reach it are a path that has never been written (the first +/// run, which must go on to mint) and a token whose policy does not cover the +/// path — and the second fails again, loudly and with the store's own message, +/// at the write below. +async fn already_published(store: &SecretStore, path: &str) -> bool { + match store.read::(path).await { + Ok(credential) => !credential.value.trim().is_empty(), + Err(e) => { + tracing::info!(%path, error = %e, "nothing readable in the store yet"); + false + } + } +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let config = Config::from_env()?; + // Explicitly, rather than through `SecretStore::from_env`: a missing or + // misspelled `BAO_*` variable is the most likely thing to be wrong with a + // freshly deployed unit, and this reports it before the homeserver is + // touched at all. + let settings = Settings::from_env().context("reading the store's BAO_* environment")?; + let store = SecretStore::connect(&settings, &config.cert_role, DEFAULT_CERT_MOUNT) + .await + .with_context(|| { + format!( + "logging in to the swarm secret store as cert role {}", + config.cert_role + ) + })?; + + let path = matrix::hive_token_path(); + if already_published(&store, &path).await { + tracing::info!(%path, "the @hive: credential is already published; not minting"); + return Ok(()); + } + + let as_token = registration::as_token(&config.registration)?; + let http = homeserver::client()?; + let token = + match homeserver::register(&http, &config.api_url, &config.localpart, &as_token).await? { + homeserver::Registered::Token(token) => token, + homeserver::Registered::AlreadyExists => { + // The expected arm, not an edge case: this account is the + // appservice's own `sender_localpart`, so the homeserver creates it + // when it loads the registration — before anything gets to ask. + tracing::info!("the @hive: account exists; logging in as the appservice instead"); + homeserver::appservice_login(&http, &config.api_url, &config.localpart, &as_token) + .await? + } + }; + + store + .write( + &path, + &matrix::Credential { + value: token, + homeserver: config.homeserver, + }, + ) + .await + .with_context(|| format!("writing the @hive: credential to {path}"))?; + tracing::info!(%path, "published the @hive: credential"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A lookup standing in for a fully-configured unit's environment. + fn full(k: &str) -> Option { + match k { + ENV_CERT_ROLE => Some("swarm-matrix-minter".to_owned()), + ENV_API_URL => Some("http://127.0.0.1:8008".to_owned()), + ENV_REGISTRATION => { + Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned()) + } + ENV_LOCALPART => Some("hive".to_owned()), + _ => None, + } + } + + #[test] + fn a_complete_environment_is_accepted() { + // The control: without it every assertion below could be passing + // because `from_lookup` rejects everything. + let c = Config::from_lookup(full).expect("every required variable is set"); + assert_eq!(c.localpart, "hive"); + assert_eq!(c.homeserver, None, "an absent public URL is not an error"); + } + + #[test] + fn each_required_variable_is_named_when_it_is_the_missing_one() { + for var in [ENV_CERT_ROLE, ENV_API_URL, ENV_REGISTRATION, ENV_LOCALPART] { + let e = Config::from_lookup(|k| if k == var { None } else { full(k) }) + .expect_err("one required variable is absent"); + assert!( + format!("{e}").contains(var), + "dropping {var} should name {var}, got {e}" + ); + } + } + + #[test] + fn an_empty_variable_is_as_absent_as_an_unset_one() { + // systemd writes `Environment=VAR=` for an unset nix option, so empty + // is the shape these actually arrive in. + let e = Config::from_lookup(|k| { + if k == ENV_CERT_ROLE { + Some(String::new()) + } else { + full(k) + } + }) + .expect_err("an empty role is not a role"); + assert!(format!("{e}").contains(ENV_CERT_ROLE), "{e}"); + + let c = Config::from_lookup(|k| { + if k == ENV_HOMESERVER { + Some(String::new()) + } else { + full(k) + } + }) + .expect("an empty public URL is optional, not fatal"); + assert_eq!(c.homeserver, None); + } + + #[test] + fn the_published_path_is_the_one_the_hive_reads() { + // Both ends of this slice's loop resolve the same function, so there is + // no second spelling to drift — this pins that the loop exists at all, + // and names the literal so a move of the path is a deliberate edit on + // both sides rather than a silent 404 on the reading one. + assert_eq!( + matrix::hive_token_path(), + "swarm/services/matrix/hive-access-token" + ); + } +} diff --git a/swarm-matrix-minter/src/registration.rs b/swarm-matrix-minter/src/registration.rs new file mode 100644 index 00000000..6d2da06d --- /dev/null +++ b/swarm-matrix-minter/src/registration.rs @@ -0,0 +1,104 @@ +//! Reading the `as_token` out of the appservice registration the matrix +//! container already has. +//! +//! No new credential is delivered for this. `nix/host-modules/hive-matrix.nix` +//! binds the registration directory into the container read-only so tuwunel can +//! load it, and the registration **is** the `as_token` — so the file this +//! module opens is one this process could already read, and one the homeserver +//! beside it reads too. +//! +//! Scanned line-by-line rather than parsed as YAML. The file has exactly one +//! renderer (`appserviceRegistrationScript` in that same module, a `printf` of +//! `as_token: `), so a parser would be a second, looser reading of a shape +//! this repo writes itself — and it would pull a YAML crate into a binary whose +//! only other input is JSON. + +use anyhow::{Context, Result, bail}; + +/// The key the token is stored under, and the whole of the agreement with the +/// renderer. +const KEY: &str = "as_token:"; + +/// Read the registration at `path` and return its `as_token`. +/// +/// # Errors +/// When the file cannot be read, or holds no `as_token` with a value — which is +/// what a registration rendered by something other than this repo looks like +/// from here. +pub fn as_token(path: &str) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("reading the appservice registration at {path}"))?; + // The path, not the file's contents: every line of it is either a secret or + // a shape this module already knows. + extract(&text).with_context(|| format!("no `as_token` in the registration at {path}")) +} + +/// [`as_token`] over text already in hand, so the agreement with the renderer +/// can be tested without a file. +fn extract(text: &str) -> Result { + for line in text.lines() { + if let Some(rest) = line.strip_prefix(KEY) { + let token = rest.trim(); + if token.is_empty() { + bail!("the registration's `as_token` is empty"); + } + return Ok(token.to_owned()); + } + } + bail!("the registration carries no `as_token` line") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The registration exactly as `appserviceRegistrationScript` renders it — + /// the quoted heredoc, then the `printf` of the two tokens. Reproduced + /// verbatim because that script is the other end of this agreement and + /// lives in a file no Rust test can reach. + const RENDERED: &str = "id: hyperhive\n\ + url: null\n\ + sender_localpart: hive\n\ + rate_limited: false\n\ + namespaces:\n \ + users:\n \ + - exclusive: false\n \ + regex: '@[a-z0-9._=/+-]+:example\\.test$'\n \ + aliases: []\n \ + rooms: []\n\ + as_token: deadbeef\n\ + hs_token: cafebabe\n"; + + #[test] + fn the_token_is_taken_from_the_registration_this_repo_renders() { + assert_eq!( + extract(RENDERED).expect("the rendered shape parses"), + "deadbeef" + ); + } + + #[test] + fn the_homeservers_own_token_is_not_mistaken_for_the_appservices() { + // `hs_token` authenticates the homeserver TO the appservice and is a + // different secret with a confusingly similar name; a substring search + // would find it inside neither, but a `contains("s_token")`-shaped one + // would. The control is that the line order in `RENDERED` puts + // `as_token` first, so this arm needs the reverse to mean anything. + let reversed = "hs_token: cafebabe\nas_token: deadbeef\n"; + assert_eq!( + extract(reversed).expect("order does not matter"), + "deadbeef" + ); + } + + #[test] + fn a_registration_with_no_token_is_an_error_rather_than_an_empty_string() { + // An empty token authenticates nothing, and a homeserver answers a + // request carrying one with a 403 that names the account rather than + // the credential — so failing here is the only report an operator can + // act on. + for bad in ["id: hyperhive\n", "as_token:\n", "as_token: \n"] { + assert!(extract(bad).is_err(), "{bad:?} must not yield a token"); + } + } +} diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index 9a0cda5b..1aa39a22 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::{ Error, - path::{Kind, checked_segment, principal_prefix}, + path::{Kind, ROOT, checked_segment, principal_prefix}, }; /// The path holding `agent`'s token for the external matrix account `account`. @@ -26,6 +26,32 @@ pub fn account_path(agent: &str, account: &str) -> Result { Ok(format!("{prefix}/matrix/{account}")) } +/// The swarm service the homeserver is, as the name segment under +/// [`Kind::Service`]. +/// +/// A swarm runs one homeserver, so this is a constant rather than a parameter — +/// and that is the whole of what makes the credential below mintable +/// "only once" without a lock. +pub const HOMESERVER_SERVICE: &str = "matrix"; + +/// The path holding the `@hive:` account's homeserver access token. +/// +/// Keyed per **homeserver**, not per hive and not per agent: a homeserver has one +/// `@hive:` account (hive-c0re's `matrix::HIVE_LOCALPART`), so a +/// per-hive copy would be several names for one secret. That is also why this +/// takes no argument and cannot fail — there is no caller-supplied segment in +/// it to reject. +/// +/// Reachable by every hive without a new grant: [`crate::policy::render`] +/// already grants a hive read on the whole [`Kind::Service`] tree. +#[must_use] +pub fn hive_token_path() -> String { + format!( + "{ROOT}/{}/{HOMESERVER_SERVICE}/hive-access-token", + <&str>::from(Kind::Service) + ) +} + /// The path holding `hive`'s matrix appservice token (`as_token`). /// /// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`] @@ -86,6 +112,28 @@ mod tests { assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay"); } + #[test] + fn the_hive_credential_lands_under_the_service_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/services/*`, so this exact + // string is what makes the path reachable at all. The `services` + // segment is PLURAL; `Kind::label` renders the singular and is for + // error text only, so reading it as the path segment produces a + // 403 the store explains as "permission denied" and nothing else. + assert_eq!(hive_token_path(), "swarm/services/matrix/hive-access-token"); + } + + #[test] + fn the_hive_credential_sits_where_a_service_principal_would() { + // The constant path above must be the same one the general builder + // produces, or the grant covering `Kind::Service` would cover a + // neighbouring tree instead of this one. + let prefix = + principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal"); + assert_eq!(hive_token_path(), format!("{prefix}/hive-access-token")); + } + #[test] fn the_appservice_token_lands_under_the_hive_prefix_the_grant_covers() { // Spelled out for the same reason as above, and with a second job here: