diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 720c851d..5ec53206 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -1,12 +1,19 @@ -//! Optional matrix-tuwunel wiring: shared registration token (host) + -//! per-agent UIAA registration → `/matrix-token`. No-op +//! Optional matrix-tuwunel wiring: the hive's appservice identity (host) + +//! per-agent account creation → `/matrix-token`. No-op //! when the `hive-matrix` container isn't running, so operators who //! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay //! nothing. //! -//! See `docs/integrations/matrix.md::Provisioning flow (registration token)` for -//! the full UIAA round-trip, token-file shape, and host/container -//! bind-mount layout. +//! Accounts are created **as the hive's appservice**, not by presenting a +//! shared registration token in a UIAA flow. The difference that matters +//! here is not the round-trip count: an appservice token is an *identity* +//! the homeserver knows, so the secret never has to be the same on both +//! sides of the wire, and account creation does not depend on registration +//! being open to anyone who learns a token. +//! +//! See `docs/integrations/matrix.md::Provisioning flow (appservice)` for the +//! registration file's shape, how its token reaches both halves, and the +//! host/container bind-mount layout. use std::path::PathBuf; @@ -49,11 +56,8 @@ fn matrix_base() -> Result<&'static str> { this path should have been gated on matrix::is_present()", ) } -/// Length (bytes) of the random registration token. 32 raw bytes ⇒ -/// 64-char hex string; comfortable for a long-lived shared secret. -const REGISTER_TOKEN_BYTES: usize = 32; -/// HTTP timeout for registration round-trips. UIAA is two POSTs; even -/// the slow path should finish well inside this budget. +/// HTTP timeout for registration round-trips. Account creation is one +/// POST; even the slow path should finish well inside this budget. const HTTP_TIMEOUT_SECS: u64 = 10; /// Length (bytes) of the throwaway per-agent matrix password. Random /// 32-byte hex — agents never log in with the password (they @@ -61,10 +65,17 @@ const HTTP_TIMEOUT_SECS: u64 = 10; /// store it nowhere. const PASSWORD_BYTES: usize = 32; -/// Matrix localpart for the hive system admin account. Registered -/// before any agent account in [`ensure_all`] so it becomes the first -/// user on the homeserver — Conduit/tuwunel grants admin rights to the -/// first registered user automatically. Not an agent; has no state dir. +/// Matrix localpart for the hive system admin account. Not an agent; has +/// no state dir. +/// +/// 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"; /// Display name of the hive Space. Plain text, no special characters, so @@ -164,30 +175,36 @@ fn random_hex(n: usize) -> Result { Ok(hex) } -/// Ensure the registration token file exists; returns its contents. -/// Generates a fresh 32-byte hex token on first call (mode 0600, -/// root-only), then re-reads on subsequent calls. The hive-matrix -/// nixos module bind-mounts this file read-only into the tuwunel -/// container so the homeserver can authenticate registration requests -/// against the same secret hive-c0re holds. -pub fn ensure_register_token() -> Result { - use std::os::unix::fs::PermissionsExt; - let path = crate::paths::matrix_register_token(); - if let Ok(existing) = std::fs::read_to_string(&path) { - let trimmed = existing.trim().to_owned(); - if !trimmed.is_empty() { - return Ok(trimmed); - } - } - let token = random_hex(REGISTER_TOKEN_BYTES)?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).ok(); - } - std::fs::write(&path, format!("{token}\n")) - .with_context(|| format!("write registration token to {}", path.display()))?; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); - tracing::info!(path = %path.display(), "matrix: generated registration token"); - Ok(token) +/// Read the hive's appservice token — the `as_token` of the registration +/// the homeserver loaded at boot. Every account this module creates is +/// authorised by it. +/// +/// **Reads, never mints**, unlike the registration token it replaced. +/// That token was the whole agreement, so whichever side wrote it first +/// was right; this one has a second half — the registration file naming +/// it, which only the nix side writes. A token minted here would be a +/// token the homeserver has never heard of, and the failure would surface +/// as every request being refused rather than as a missing file. +/// +/// # Errors +/// When the file is absent or empty. That means the host activation +/// script has not run on this generation yet; callers log it and leave +/// existing accounts alone rather than trying to proceed. +pub fn read_appservice_token() -> Result { + let path = crate::paths::matrix_appservice_token(); + std::fs::read_to_string(&path) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .with_context(|| { + format!( + "matrix appservice token not found at {} — it is minted by the \ + hive-matrix activation script, which also renders the registration \ + file naming it; deploy the hive-matrix module (or re-run \ + `nixos-rebuild switch`) before provisioning matrix users", + path.display() + ) + }) } /// Build the localpart of a matrix user id for `agent`. Matrix @@ -198,19 +215,20 @@ fn user_localpart(agent: &str) -> &str { agent } -/// Send a single registration POST and parse the response. Returns -/// `Ok((status, body))` on any successful HTTP round-trip (including -/// the expected 401 from the first UIAA leg); errors only on transport -/// failure. Body is the parsed JSON value — UIAA passes session + -/// flow state via JSON, never via headers. +/// Send the registration POST as the appservice and parse the response. +/// Returns `Ok((status, body))` on any completed HTTP round-trip +/// (including the `M_USER_IN_USE` 400 the caller treats as "already +/// exists"); errors only on transport failure. async fn register_post( client: &reqwest::Client, + as_token: &str, body: &serde_json::Value, ) -> Result<(StatusCode, serde_json::Value)> { let base = matrix_base()?; let url = format!("{base}/_matrix/client/v3/register"); let resp = client .post(&url) + .bearer_auth(as_token) .json(body) .send() .await @@ -233,24 +251,38 @@ pub fn random_password() -> Result { random_hex(PASSWORD_BYTES) } -/// Run the matrix-spec UIAA flow to register `agent` with the given -/// `password` and return the resulting access token. Two round-trips: -/// first POST elicits the 401 + session id, second POST supplies the -/// registration token in the `auth` block. If the homeserver returns -/// 200 on the first POST (no flow stages required — `allow_registration` -/// with no token), we take the access token directly. +/// Create the matrix account for `agent` as the hive's appservice and +/// return an access token for it. One round-trip: an appservice-typed +/// registration needs no UIAA stage at all, so there is no session to +/// carry and no shared secret to present. +/// +/// The account is created **by** the appservice but is an ordinary user +/// afterwards — it gets its own device and its own access token, and the +/// agent authenticates with that rather than with anything the hive +/// holds. The `as_token` never leaves the host. /// /// Caller picks the password: agents use [`random_password`] (throwaway /// — they auth by `access_token`), operators on the `hivectl` path /// supply their own so they can log into matrix web clients. +/// +/// # Errors +/// Propagates the homeserver's own body, which is what the +/// `M_USER_IN_USE` callers match on. A `M_EXCLUSIVE` body means the +/// localpart falls outside the appservice's namespace — the registration +/// file's `namespaces.users` regex is the place to look, not this call. async fn register_user( client: &reqwest::Client, agent: &str, - register_token: &str, + as_token: &str, password: &str, ) -> Result { let localpart = user_localpart(agent); - let initial = serde_json::json!({ + let body = serde_json::json!({ + // What makes this an appservice registration rather than an + // ordinary one. Without it the homeserver treats the request as a + // normal client's and asks for a UIAA flow — even holding the + // as_token. + "type": "m.login.application_service", "username": localpart, "password": password, // device_id stays stable across re-runs of ensure_user_for so @@ -259,37 +291,53 @@ async fn register_user( "initial_device_display_name": format!("hyperhive ({agent})"), "inhibit_login": false, }); - let (status, body) = register_post(client, &initial).await?; - // Some servers accept the first POST when allow_registration is - // open. Take the access_token + we're done. - if status.is_success() { - return extract_access_token(&body); - } - if status != StatusCode::UNAUTHORIZED { - anyhow::bail!("matrix: /register first leg HTTP {status}, body: {body}"); - } - let session = body["session"] - .as_str() - .with_context(|| format!("matrix: missing UIAA session in 401, body: {body}"))?; - let authed = serde_json::json!({ - "username": localpart, - "password": password, - "device_id": format!("hyperhive-{agent}"), - "initial_device_display_name": format!("hyperhive ({agent})"), - "inhibit_login": false, - "auth": { - "type": "m.login.registration_token", - "token": register_token, - "session": session, - }, - }); - let (status, body) = register_post(client, &authed).await?; + let (status, body) = register_post(client, as_token, &body).await?; if !status.is_success() { - anyhow::bail!("matrix: /register auth leg HTTP {status}, body: {body}"); + anyhow::bail!("matrix: /register as appservice HTTP {status}, body: {body}"); } extract_access_token(&body) } +/// Log in as an **existing** account using the hive's appservice token, +/// and return a fresh access token for it. No password involved: the +/// appservice is authorised for every localpart in its namespace, so it +/// can mint a session for one without knowing anything about the account. +/// +/// This is the recovery path that used to need a stored password or an +/// admin-room password reset — an account whose token file was lost is +/// re-tokened from the hive's own identity instead. The device id matches +/// [`register_user`]'s, so a re-login replaces that device's token rather +/// than accumulating devices. +async fn appservice_login(client: &reqwest::Client, as_token: &str, agent: &str) -> Result { + let base = matrix_base()?; + let url = format!("{base}/_matrix/client/v3/login"); + let body = serde_json::json!({ + "type": "m.login.application_service", + "identifier": { + "type": "m.id.user", + "user": user_localpart(agent), + }, + "device_id": format!("hyperhive-{agent}"), + "initial_device_display_name": format!("hyperhive ({agent})"), + }); + let resp = client + .post(&url) + .bearer_auth(as_token) + .json(&body) + .send() + .await + .context("matrix: POST /login as appservice")?; + let status = resp.status(); + let json = resp + .json::() + .await + .context("matrix: parse appservice /login response")?; + if !status.is_success() { + anyhow::bail!("matrix: appservice /login HTTP {status} for {agent}, body: {json}"); + } + extract_access_token(&json) +} + /// Pull `access_token` out of a successful /register response. fn extract_access_token(body: &serde_json::Value) -> Result { body["access_token"] @@ -637,11 +685,7 @@ async fn admin_room_reset_password( /// /// `client` is shared across the sweep so we build one reqwest /// connection pool for all agents rather than one per call. -pub async fn ensure_user_for( - client: &reqwest::Client, - name: &str, - register_token: &str, -) -> Result<()> { +pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; let agent = hive_types::Ident::parse(name) .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; @@ -678,7 +722,7 @@ pub async fn ensure_user_for( } let password = random_password()?; - let access_token = match register_user(client, name, register_token, &password).await { + let access_token = match register_user(client, name, as_token, &password).await { Ok(token) => { // Successful registration — persist the password so we can // fall back to login if the token file is deleted later. @@ -694,8 +738,21 @@ pub async fn ensure_user_for( token } Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => { - // Account already exists — try to re-login with the stored password. - tracing::info!(%name, "matrix: user already exists, attempting login with stored password"); + // Account already exists and its token file was lost. The + // appservice can mint a session for any account in its + // namespace, so this needs no password and no admin rights — + // try it before the password paths below, which exist for + // accounts created before the appservice did (or named + // outside its namespace) and stay as the fallback. + tracing::info!(%name, "matrix: user already exists, logging in as the appservice"); + match appservice_login(client, as_token, name).await { + Ok(token) => return finish_user_provisioning(name, &token).await, + Err(e) => tracing::warn!( + %name, + error = ?e, + "matrix: appservice login failed; falling back to the stored password" + ), + } let pw_path = password_path(name); let stored = if let Some(pw) = std::fs::read_to_string(&pw_path) .ok() @@ -734,11 +791,19 @@ pub async fn ensure_user_for( Err(other) => return Err(other), }; + finish_user_provisioning(name, &access_token).await +} + +/// Persist a freshly-obtained agent access token and kick the agent's +/// matrix daemon. Shared by every way [`ensure_user_for`] can end up +/// holding a token — creation, appservice login, password login — so a +/// new recovery path cannot forget half of it. +async fn finish_user_provisioning(name: &str, access_token: &str) -> Result<()> { // Write the token via hive-priv (root helper): hive-c0re runs as the // unprivileged `hive-core` user and cannot write to agent-owned state // directories directly. hive-priv writes the file 0600 and chowns it // to the agent user so it is readable from inside the container. - crate::priv_client::write_agent_matrix_token(name, &access_token, None, None) + crate::priv_client::write_agent_matrix_token(name, access_token, None, None) .await .with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?; tracing::info!(%name, "matrix: provisioned access token"); @@ -767,30 +832,30 @@ pub async fn ensure_user_for( /// behaviour. /// /// **Not idempotent** (unlike [`crate::forge::provision_user_token`]): the -/// matrix UIAA `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) -/// on second call for the same localpart. Callers re-running this for -/// a known-existing matrix user should expect a hard error from this -/// fn and route to a password-reset path instead. +/// matrix `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) on a +/// second call for the same localpart, appservice-authorised or not. +/// Callers re-running this for a known-existing matrix user should expect +/// a hard error from this fn and route to a password-reset path instead. pub async fn provision_user_token( client: &reqwest::Client, name: &str, - register_token: &str, + as_token: &str, password: &str, ) -> Result { - register_user(client, name, register_token, password).await + register_user(client, name, as_token, password).await } /// Per-agent matrix sync: ensure the agent has a matrix account + token. /// All operations are idempotent; failures are logged as warnings but /// don't abort the caller. -pub async fn sync_agent(client: &reqwest::Client, name: &str, register_token: &str) { - if let Err(e) = ensure_user_for(client, name, register_token).await { +pub async fn sync_agent(client: &reqwest::Client, name: &str, as_token: &str) { + if let Err(e) = ensure_user_for(client, name, as_token).await { tracing::warn!(%name, error = ?e, "matrix: ensure_user failed"); } } /// Standalone per-agent sync that handles its own setup: checks if -/// hive-matrix is present, reads the registration token, and builds +/// hive-matrix is present, reads the appservice token, and builds /// an HTTP client before delegating to [`sync_agent`]. Mirrors the /// setup in [`ensure_all`] so the rebuild path and the startup sweep /// stay equivalent. No-op when the matrix container is absent. @@ -798,10 +863,10 @@ pub async fn sync_agent_standalone(name: &str) { if !is_present() { return; } - let register_token = match ensure_register_token() { + let as_token = match read_appservice_token() { Ok(t) => t, Err(e) => { - tracing::warn!(%name, error = ?e, "matrix: ensure_register_token failed"); + tracing::warn!(%name, error = ?e, "matrix: read_appservice_token failed"); return; } }; @@ -815,22 +880,26 @@ pub async fn sync_agent_standalone(name: &str) { return; } }; - sync_agent(&client, name, ®ister_token).await; + sync_agent(&client, name, &as_token).await; } -/// Ensure the hive system admin matrix user exists and its token is -/// persisted at [`admin_token_path()`]. Must be called BEFORE -/// [`ensure_all`]'s agent loop so this account is the first to register -/// and becomes the homeserver admin automatically (Conduit/tuwunel: -/// first registered user = admin). +/// Ensure the hive system admin matrix user exists, that its token is +/// persisted at [`admin_token_path()`], and that it actually holds admin +/// rights. /// -/// Idempotent — skips when the token file already exists and is -/// non-empty. Does NOT promote the account via API (that requires -/// admin rights which this fn bootstraps); on a fresh homeserver the -/// first-registered rule fires automatically; on an existing homeserver -/// the operator must promote the account once via -/// `hivectl matrix promote-user hive` or the conduit admin room. -pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -> Result<()> { +/// **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. +/// +/// 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<()> { use std::os::unix::fs::PermissionsExt; let path = admin_token_path(); if path.exists() @@ -841,8 +910,7 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) - return Ok(()); } let password = random_password()?; - let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, register_token, &password) - .await + let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await { Ok(token) => { let pw_path = password_path(HIVE_ADMIN_LOCALPART); @@ -857,20 +925,34 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) - token } Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => { - tracing::info!("matrix: hive admin user already exists, re-logging in"); - let pw_path = password_path(HIVE_ADMIN_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 but password missing at {} — \ - manual recovery: reset password via admin API or conduit admin room", - pw_path.display() - ) - })?; - login_user(client, HIVE_ADMIN_LOCALPART, &stored).await? + // The expected path, not an edge case: this account is the + // appservice's own `sender_localpart`, so the homeserver + // creates it when it loads the registration — before + // 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 { + 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); + 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 \ + password is stored at {} — check that the registration file's \ + namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \ + loaded it", + pw_path.display() + ) + })?; + login_user(client, HIVE_ADMIN_LOCALPART, &stored).await? + } + } } Err(other) => return Err(other), }; @@ -884,9 +966,148 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) - Ok(()) } +/// Check that the hive admin account holds admin rights, and repair it +/// through the admin room when it does not. +/// +/// 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. +/// +/// 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. +/// +/// # Errors +/// Never — the outcome is the return value. `false` means "not admin and +/// could not be made one", already 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, + Err(e) => { + tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights"); + return false; + } + }; + 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 + { + // 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; + } + 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. +/// +/// Three spellings, because the reply is prose and prose changes between +/// builds. tuwunel v1.9.0's is `" has been granted admin +/// privileges."` — which the original two patterns here (`done…`, +/// `made…admin`) do not match at all, so a promotion that had already +/// worked was reported as a 15-second timeout. The older spellings are +/// kept: a homeserver is not necessarily the version this was written +/// against. +fn is_make_admin_success(body: &str) -> Option<()> { + let lower = body.to_ascii_lowercase(); + let says_ok = lower.starts_with("done") + || lower.contains("granted admin privileges") + || (lower.contains("made") && lower.contains("admin")); + says_ok.then_some(()) +} + +#[cfg(test)] +mod is_make_admin_success_tests { + use super::is_make_admin_success; + + /// The reply tuwunel v1.9.0 actually sends + /// (`src/admin/user/make_user_admin.rs`). This is the case the + /// pre-existing matcher missed. + #[test] + fn tuwunel_1_9_grant_reply() { + let msg = "@hive:pr1ma.darkest.space has been granted admin privileges."; + assert_eq!(is_make_admin_success(msg), Some(())); + } + + #[test] + fn older_spellings_still_match() { + assert_eq!( + is_make_admin_success("Done: user is now an admin"), + Some(()) + ); + assert_eq!(is_make_admin_success("Made @x:y an admin"), Some(())); + } + + /// The control: an unrelated or failing reply must not read as + /// success, or a failed promotion returns Ok and the warning that + /// would have named it never fires. + #[test] + fn failures_and_noise_do_not_match() { + assert_eq!(is_make_admin_success("Command not recognised."), None); + assert_eq!(is_make_admin_success("User @x:y does not exist"), None); + } +} + /// Promote a user to homeserver admin via the Matrix admin room /// (`#admins:`). Sends `!admin users make-user-admin @:` as -/// @hive, polls for the bot's "Done:" success response. +/// @hive, polls for the bot's success reply. +/// +/// ⚠️ Requires the **sender** to be an admin already — tuwunel only +/// treats a message as a command when its sender is in the admin room. +/// So this promotes a *second* user; it cannot bootstrap the first one. +/// That is what the `admin_execute` entry in `hive-matrix.nix` is for. /// /// Goes through the admin room rather than a direct HTTP call because /// tuwunel implements parts of the Synapse admin API but not user @@ -907,14 +1128,7 @@ pub async fn promote_user_to_admin( server_name, &room_url, &command, - |body| { - let lower = body.to_ascii_lowercase(); - if lower.starts_with("done") || lower.contains("made") && lower.contains("admin") { - Some(()) - } else { - None - } - }, + is_make_admin_success, ) .await .with_context(|| { @@ -1637,10 +1851,14 @@ pub async fn ensure_all() -> bool { return true; } let mut ok = true; - let register_token = match ensure_register_token() { + // Loud and non-destructive: with no appservice token this sweep can + // create nothing, so it does nothing. Agents that already hold a + // token keep using it — their accounts and sessions are untouched by + // anything in here. + let as_token = match read_appservice_token() { Ok(t) => t, Err(e) => { - tracing::warn!(error = ?e, "matrix: ensure_register_token failed"); + tracing::warn!(error = ?e, "matrix: no appservice token; skipping the user sweep"); return false; } }; @@ -1656,10 +1874,12 @@ pub async fn ensure_all() -> bool { return false; } }; - // Provision hive admin user FIRST so it's the first registered - // account on a fresh homeserver (Conduit/tuwunel makes the first - // registered user admin automatically). - if let Err(e) = ensure_admin_user(&client, ®ister_token).await { + // 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"); ok = false; } @@ -1672,7 +1892,7 @@ pub async fn ensure_all() -> bool { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; - sync_agent(&client, name, ®ister_token).await; + sync_agent(&client, name, &as_token).await; agent_names.push(name.to_owned()); } @@ -1705,6 +1925,11 @@ 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 { Ok(id) => id, Err(e) => { diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 759f7eae..dcf16ef4 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -253,14 +253,15 @@ pub fn gateway_agents_conf() -> PathBuf { // `nix/host-modules/hive-c0re/default.nix` and `nix/host-modules/hive-ci.nix` — must match. pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token"; -/// `matrix-register-token` — shared matrix registration token. -// nix: bind-mounted into the tuwunel/matrix container (hive-matrix.nix) — must match. -// Not operator-option-driven: `registrationTokenFile` is `internal` on the nix -// side, and an `assertions` entry there rejects any attempt to move it, so this -// literal can never diverge from it. +/// `matrix-appservice-token` — the `as_token` of the hive's appservice +/// registration, which authorises every account this daemon creates. +// nix: minted by the `hive-matrix-appservice` activation script in +// `nix/host-modules/hive-matrix.nix`, which renders it into the registration +// file the homeserver loads — must match. Read-only here on purpose: a token +// minted on this side would not be the one in that file. #[must_use] -pub fn matrix_register_token() -> PathBuf { - state_root().join("matrix-register-token") +pub fn matrix_appservice_token() -> PathBuf { + state_root().join("matrix-appservice-token") } /// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs). diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 67af8b76..547540d4 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -558,8 +558,8 @@ async fn handle_matrix_create_user( password: Option<&str>, ) -> Result { require_matrix_present()?; - let register_token = - crate::matrix::ensure_register_token().context("read matrix register token")?; + let as_token = + crate::matrix::read_appservice_token().context("read matrix appservice token")?; let client = matrix_http_client()?; let mut out = Vec::new(); if agent_exists(name)? { @@ -571,7 +571,7 @@ async fn handle_matrix_create_user( "matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token" ); } - crate::matrix::ensure_user_for(&client, name.as_str(), ®ister_token) + crate::matrix::ensure_user_for(&client, name.as_str(), &as_token) .await .with_context(|| format!("matrix create-user {name}"))?; let path = Coordinator::agent_notes_dir(name).join("matrix-token"); @@ -585,7 +585,7 @@ async fn handle_matrix_create_user( let token = crate::matrix::provision_user_token( &client, name.as_str(), - ®ister_token, + &as_token, &effective_password, ) .await @@ -770,10 +770,10 @@ async fn handle_push_snapshot( async fn handle_matrix_sync_admin() -> Result { require_matrix_present()?; - let register_token = - crate::matrix::ensure_register_token().context("read matrix register token")?; + let as_token = + crate::matrix::read_appservice_token().context("read matrix appservice token")?; let client = matrix_http_client()?; - crate::matrix::ensure_admin_user(&client, ®ister_token) + crate::matrix::ensure_admin_user(&client, &as_token) .await .context("matrix sync-admin")?; let path = crate::matrix::admin_token_path();