diff --git a/README.md b/README.md index 68c5a5eb..13bd172a 100644 --- a/README.md +++ b/README.md @@ -111,17 +111,8 @@ For the full list of host and agent NixOS options see the `hivectl` is the operator-facing host CLI for ad-hoc administration that doesn't go through the broker (built alongside `hive-c0re` when the host -module is enabled): - -```sh -sudo hivectl matrix create-user mara # provisions a matrix user -sudo hivectl matrix create-user mara --password-stdin # … reading one line from stdin -``` - -For a name that's a managed agent, `hivectl` persists the resulting token -to that agent's state dir, the same as the boot sweep does. For a -non-agent name (for example the operator's own matrix account), it prints the -token to stdout and writes nothing. +module is enabled). Human matrix accounts come from SSO login, not +`hivectl`. A human's first SSO login to the forge makes their forge account, not `hivectl`; `swarmctl forge make-admin ` on the swarm-controller's diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 52568922..a3f9367e 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -286,11 +286,12 @@ hivectl matrix sync-admin # Invite the operator to the hive Space (and optionally to rooms) hivectl matrix invite mara hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' - -# Promote the operator to homeserver admin if needed -hivectl matrix promote-user mara ``` +The operator's own matrix account comes from SSO, not `hivectl` — matrix +homeserver admin should eventually come from membership in authelia's +`admins` group; nobody has built that sync yet. + ruth's own matrix account comes from the swarm, like every agent's: `swarm-controller` creates it within five minutes of her holding a store identity (step 1), and her matrix daemon reads its token from the store. diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 2b3a34a8..f2a04edc 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -130,8 +130,7 @@ a token. so the sibling credentials are invisible to it. The `.yaml` suffix on the credential id is what makes this work. 5. **hive-c0re** reads the appservice token and creates its own - `@hive-:` account, and the accounts an operator asks for with - `hivectl matrix create-user`. It never mints the token itself: the value + `@hive-:` account. It never mints the token itself: the value has to be the one the rendered registration names, and only the nix side writes that. 6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each @@ -199,15 +198,12 @@ being the rooms' own creator at power level 100 — there is no homeserver admin in any of it, and no Synapse admin API to reach for either, since tuwunel has none. -Two operations need an admin **sender**: `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're swarm-level operations, -rehomed to the swarm tier rather than granted here; from the hive, -`@hive-:` has no admin sender to make that call with, so both get the -admin room's refusal rather than an over-privileged credential that -every other call site would also carry. The swarm's own sender is the -admin they need; moving them there is separate work. +Promoting a user to homeserver admin and resetting a password both need an +admin **sender**: `!admin …` messages into `#admins:`, and +tuwunel only treats a message as a command when its sender is already an +admin. `@hive-:` has no admin sender to make that call with. They're +swarm-level operations: matrix admin should eventually come from +membership in authelia's `admins` group; nobody has built that sync yet.
Upgrading a hive that shared one sender account with every other hive diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 7ec4afa4..eb8702c0 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -8,10 +8,7 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl forge`↴](#hivectl-forge) * [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config) * [`hivectl matrix`↴](#hivectl-matrix) -* [`hivectl matrix create-user`↴](#hivectl-matrix-create-user) * [`hivectl matrix sync-admin`↴](#hivectl-matrix-sync-admin) -* [`hivectl matrix promote-user`↴](#hivectl-matrix-promote-user) -* [`hivectl matrix reset-password`↴](#hivectl-matrix-reset-password) * [`hivectl matrix invite`↴](#hivectl-matrix-invite) * [`hivectl github`↴](#hivectl-github) * [`hivectl github set-token`↴](#hivectl-github-set-token) @@ -142,33 +139,11 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for ###### **Subcommands:** -* `create-user` — Create a matrix account for a person or other non-agent `` and print its access token to stdout * `sync-admin` — Provision (or re-provision) the matrix appservice's sender account -* `promote-user` — Promote a matrix user to homeserver admin -* `reset-password` — Reset a matrix user's password via the admin API * `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent -## `hivectl matrix create-user` - -Create a matrix account for a person or other non-agent `` and print its access token to stdout. - -Refuses an agent's name: its account comes from the swarm (`swarm-controller` creates it and stores its token where the agent reads it). Set a password to enable matrix web-client login (otherwise it uses a random throwaway). - -**Usage:** `hivectl matrix create-user [OPTIONS] ` - -###### **Arguments:** - -* `` — Matrix localpart of a non-agent account — `mara`, `damocles`, etc - -###### **Options:** - -* `--password ` — Set the account password to this string instead of a random throwaway. Use this for operator accounts that need to log into matrix web clients via `m.login.password`. Mutually exclusive with `--password-stdin`. WARNING: the password is visible in shell history + process listings; prefer `--password-stdin` for anything sensitive -* `--password-stdin` — Read the password from stdin (single line, trailing newline stripped) instead of an inline flag. Mutually exclusive with `--password` - - - ## `hivectl matrix sync-admin` Provision (or re-provision) the matrix appservice's sender account. @@ -179,32 +154,6 @@ Runs automatically on startup; run manually to recover a missing access token. -## `hivectl matrix promote-user` - -Promote a matrix user to homeserver admin - -**Usage:** `hivectl matrix promote-user ` - -###### **Arguments:** - -* `` — Matrix localpart of the user to promote (for example `argus`) - - - -## `hivectl matrix reset-password` - -Reset a matrix user's password via the admin API. - -Persists the new password so a later `create-user` can re-login. - -**Usage:** `hivectl matrix reset-password ` - -###### **Arguments:** - -* `` — Matrix localpart of the account to reset (for example `argus`) - - - ## `hivectl matrix invite` Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 3cb96b87..0b0357e7 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -50,31 +50,19 @@ Manual entry to the same idempotent matrix provisioning flow running (`services.hyperhive.deploy.matrix.enable = true`). ```bash -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 appservice's sender 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 hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias ``` -- `create-user`: for people and other non-agent accounts. It refuses - an agent's name: `swarm-controller` creates an agent's account and - stores its token where the agent's daemon reads it. +Human matrix accounts come from SSO, not `hivectl`; matrix homeserver +admin should eventually come from membership in authelia's `admins` +group; nobody has built that sync yet. + - `sync-admin`: ensures this hive's appservice sender account (`@hive-:`, one per hive) 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 by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**, - which `@hive-:` isn't — promotion is a swarm-level operation, rehomed to - the swarm tier rather than granted here, so it has no admin sender to - call it with from the hive. -- `reset-password`: asks the admin room to set a new random - password and prints it to stdout. ⚠️ Same admin-**sender** requirement, - and the same swarm-level rehoming, so it's unavailable from the hive - too. 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 sender diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index fa3fe718..a79d432b 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -124,8 +124,7 @@ pub fn sender_token_path() -> PathBuf { crate::paths::matrix_sender_token() } -/// Password file for a matrix account this hive holds a password for: its own -/// `@hive-:` account, or one reset through the admin room. Stored OUTSIDE +/// Password file for the hive's own `@hive-:` account. Stored OUTSIDE /// the purgeable `agent_state_root` tree so it survives `destroy --purge`. /// /// Path: `/var/lib/hyperhive/matrix/creds/-password` @@ -244,11 +243,9 @@ async fn register_post( } /// Generate a throwaway random password for matrix UIAA registration. -/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. Agents authenticate -/// by `access_token` so the password is protocol overhead we never -/// persist; the operator path in `hivectl` lets the caller supply a -/// real password instead so they can log into a matrix web client -/// (`m.login.password`). +/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. The hive sender +/// account authenticates by `access_token`, so this password is +/// protocol overhead never used for login. pub fn random_password() -> Result { random_hex(PASSWORD_BYTES) } @@ -263,9 +260,9 @@ pub fn random_password() -> Result { /// 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. +/// Its one caller, the hive sender account's provisioning, always passes +/// a [`random_password`] throwaway — the account authenticates by +/// `access_token`, never `m.login.password`. /// /// # Errors /// Propagates the homeserver's own body, which is what the @@ -351,9 +348,9 @@ fn extract_access_token(body: &serde_json::Value) -> Result { /// Login with `m.login.password` and return the access token. Fallback /// for when registration fails with `M_USER_IN_USE` — the account /// already exists in the homeserver but the token file was lost. Fails -/// if the stored password no longer matches (e.g. homeserver wiped), -/// in which case manual recovery via `hivectl matrix create-user` is -/// required. +/// if the stored password no longer matches (e.g. homeserver wiped); +/// the only caller is the hive sender account's own recovery path +/// (`hivectl matrix sync-admin`). async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result { let base = matrix_base()?; let url = format!("{base}/_matrix/client/v3/login"); @@ -384,303 +381,12 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re extract_access_token(&json) } -// --------------------------------------------------------------------------- -// Admin-room fallback for password reset -// --------------------------------------------------------------------------- - /// Percent-encode a matrix room ID for use in a URL path segment. /// Only `:` needs encoding; `!` and alphanumerics are path-safe. fn encode_room_id_for_url(room_id: &str) -> String { room_id.replace(':', "%3A") } -/// Look up the room ID for the `#admins:` alias. -async fn discover_admin_room_id( - client: &reqwest::Client, - sender_token: &str, - server_name: &str, -) -> Result { - let base = matrix_base()?; - // #admins:server → %23admins%3A - let encoded_alias = format!("%23admins%3A{server_name}"); - let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}"); - let resp = client - .get(&url) - .bearer_auth(sender_token) - .send() - .await - .context("matrix: GET admin room alias")?; - let status = resp.status(); - let json = resp - .json::() - .await - .context("matrix: parse admin room alias response")?; - if !status.is_success() { - anyhow::bail!("matrix: admin room alias lookup failed: HTTP {status}, body: {json}"); - } - json["room_id"] - .as_str() - .map(ToString::to_string) - .ok_or_else(|| anyhow::anyhow!("matrix: admin room alias response missing room_id: {json}")) -} - -/// Extract the new password from a conduit/tuwunel admin-room reset reply. -/// -/// The admin bot always renders the new password as a backtick code span. -/// The live reply observed in the `#admins` room is: -/// "Successfully reset the password for user @x:server: ``" -/// The surrounding prose varies between builds (the delimiter is `: ` after -/// the user id, not `" to:"`), so we anchor on the code span rather than -/// parsing the prose. Returns the content of the first backtick pair when the -/// message is a password-reset success. -/// -/// Guard: an error reply can also code-span the *user id* ("@x:server"); a -/// real password has no whitespace and isn't a `@localpart:server` id, so we -/// reject that shape and return `None`. On `None` the caller surfaces the -/// timeout and `admin_room_send_and_poll` logs the unparsed body — so a -/// future format change is visible rather than silently mis-parsed. -fn extract_new_password(bot_message: &str) -> Option { - // Only consider password-reset success replies. - if !bot_message.to_ascii_lowercase().contains("password") { - return None; - } - // Content of the first backtick code span. - let open = bot_message.find('`')?; - let after = &bot_message[open + 1..]; - let close = after.find('`')?; - let pw = &after[..close]; - // Reject a code-spanned matrix user id from an error reply, and any - // multi-token span — generated passwords are a single whitespace-free run. - if pw.is_empty() - || pw.contains(char::is_whitespace) - || (pw.starts_with('@') && pw.contains(':')) - { - return None; - } - Some(pw.to_owned()) -} - -#[cfg(test)] -mod extract_new_password_tests { - use super::extract_new_password; - - #[test] - fn conduit_live_admin_room_format() { - // The exact reply observed in the live #admins room: ": " after the - // user id, password in a backtick code span. - let msg = "Successfully reset the password for user @triage:pr1ma.darkest.space: `hVfa6TpvIKnADoEJNWn9saHoI`"; - assert_eq!( - extract_new_password(msg).as_deref(), - Some("hVfa6TpvIKnADoEJNWn9saHoI") - ); - } - - #[test] - fn backtick_span_anywhere_in_prose() { - // Wording around the code span is irrelevant — we anchor on the span. - let msg = "Done. New password is: `hunter2` (store it now)"; - assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2")); - } - - #[test] - fn password_with_symbols_inside_span() { - // '@' mid-token is fine — only a leading "@…:…" user-id shape is rejected. - let msg = - "Successfully reset the password for user @atlas:pr1ma.darkest.space: `N3wP@ss-w0rd!`"; - assert_eq!(extract_new_password(msg).as_deref(), Some("N3wP@ss-w0rd!")); - } - - #[test] - fn codespan_userid_in_error_not_mistaken_for_password() { - // An error that code-spans the user id must not yield it as a password. - let msg = "Failed to reset password for `@sock:pr1ma.darkest.space` — user not found"; - assert_eq!(extract_new_password(msg), None); - } - - #[test] - fn no_codespan_returns_none() { - // No backtick span → unparseable here; the caller logs the raw body - // so a genuinely new format surfaces instead of being mis-parsed. - let msg = "Password reset complete. New password is: abc123XYZ"; - assert_eq!(extract_new_password(msg), None); - } - - #[test] - fn non_password_message_returns_none() { - let msg = "Command not recognised. Please try again."; - assert_eq!(extract_new_password(msg), None); - } - - #[test] - fn empty_codespan_returns_none() { - let msg = "Successfully reset the password for user @x:server: ``"; - assert_eq!(extract_new_password(msg), None); - } -} - -/// The `event_id` of an admin-room command, from its `PUT .../send` -/// response. It is the anchor separating the bot's reply to this command -/// from older replies in the room, so a response without one is an error: -/// unanchored, an earlier reply (an older reset password) would be returned -/// as this command's result. -async fn sent_event_id(resp: reqwest::Response) -> Result { - let status = resp.status(); - if !status.is_success() { - let body = resp.json::().await.unwrap_or_default(); - anyhow::bail!("matrix: admin room send failed: HTTP {status}, body: {body}"); - } - let body = resp - .json::() - .await - .context("matrix: parse admin room send response")?; - body["event_id"] - .as_str() - .filter(|id| !id.is_empty()) - .map(str::to_owned) - .with_context(|| format!("matrix: admin room send response has no event_id: {body}")) -} - -/// Send a command to the Matrix admin room and poll for a bot response. -/// -/// Strategy: send the command, capture its `event_id`, then poll backwards -/// (`dir=b&limit=20`) on each tick. Events in a backward response are -/// newest-first; we walk the list until we find our own command `event_id`, -/// then stop — everything before that marker in the list is a response that -/// arrived *after* our command. We check `body` and `formatted_body` of -/// every non-self message in that window. -/// -/// This avoids forward-pagination token direction issues that occur with -/// some tuwunel builds: backward fetches are always anchored at the live -/// timeline end and need no stored token. -/// -/// Generic over `T` so both password-returning and `()` callers share the loop. -async fn admin_room_send_and_poll( - client: &reqwest::Client, - sender_token: &str, - server_name: &str, - room_url: &str, - command: &str, - check: impl Fn(&str) -> Option, -) -> Result { - let base = matrix_base()?; - // Send the command; record the event_id so we can use it as an anchor. - let txn_id = random_hex(8)?; - let send_url = - format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); - let send_resp = client - .put(&send_url) - .bearer_auth(sender_token) - .json(&serde_json::json!({"msgtype": "m.text", "body": command})) - .send() - .await - .context("matrix: PUT admin room message")?; - let our_event_id = sent_event_id(send_resp).await?; - - // 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!("@{}:{server_name}", hive_localpart()?); - 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(sender_token) - .send() - .await - .context("matrix: admin room poll")? - .json::() - .await - .context("matrix: parse admin room poll response")?; - - if let Some(events) = poll_json["chunk"].as_array() { - for event in events { - // Stop as soon as we reach our own command — everything - // older (further into the list) predates our request. - if event["event_id"].as_str() == Some(our_event_id.as_str()) { - break; - } - if event["type"].as_str() != Some("m.room.message") { - continue; - } - if event["sender"].as_str() == Some(own_user_id.as_str()) { - continue; - } - // Check both plain body and formatted_body (HTML) — some - // admin bots put the password only in formatted_body. - let body = event["content"]["body"].as_str().unwrap_or_default(); - let formatted = event["content"]["formatted_body"] - .as_str() - .unwrap_or_default(); - for text in [body, formatted] { - if let Some(result) = check(text) { - return Ok(result); - } - } - } - } - } - - anyhow::bail!( - "matrix: admin room command timed out after 15 seconds. \ - Command: '{command}'. No matching bot response received." - ) -} - -/// Reset a user's password via the Matrix admin room (`#admins:`). -/// Sends `!admin users reset-password @:` as `@hive-:`, polls for -/// the bot's response containing the new password. -/// -/// Returns the new password; caller is responsible for persisting it. -async fn admin_room_reset_password( - client: &reqwest::Client, - sender_token: &str, - server_name: &str, - localpart: &str, -) -> Result { - let room_id = discover_admin_room_id(client, sender_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, - sender_token, - server_name, - &room_url, - &command, - extract_new_password, - ) - .await - .with_context(|| { - format!( - "matrix: admin room reset-password for @{localpart}:{server_name}: \ - no password response received within 15 seconds. \ - Verify the admin room accepts '!admin users reset-password @user:server' commands." - ) - }) -} - -/// Register a matrix account for `name` with the supplied `password` -/// and return the freshly-minted access token. The token is **not** -/// persisted to disk — the caller is responsible for storing it. Used by -/// `hivectl matrix create-user` for human (non-agent) accounts. For operator accounts the caller passes a real -/// password so the operator can `m.login.password` into matrix web -/// clients afterwards; without one the caller passes -/// [`random_password`]. -/// -/// **Not idempotent**: the 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, - as_token: &str, - password: &str, -) -> Result { - register_user(client, name, as_token, password).await -} - /// Ensure the hive's `@hive-:` matrix user exists and that its access token is /// persisted at [`sender_token_path()`]. /// @@ -843,141 +549,6 @@ async fn stored_sender_token() -> Option { } } -/// 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 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. -/// `@hive-:` is an ordinary account (`hive-matrix.nix` grants it no -/// `admin_execute` promotion), so this call has no working sender from -/// the hive and fails with the admin room's refusal. Promotion is a -/// swarm-level operation and is being rehomed as such; this stays here, -/// failing loudly, rather than justifying an over-privileged token that -/// all 13 ordinary call sites would also carry. -/// -/// Goes through the admin room rather than a direct HTTP call because -/// tuwunel implements parts of the Synapse admin API but not user -/// creation, and upstream does not intend to add it. This is the -/// intended long-term mechanism, not a stopgap awaiting an upstream fix. -pub async fn promote_user_to_admin( - client: &reqwest::Client, - sender_token: &str, - localpart: &str, - server_name: &str, -) -> Result<()> { - let room_id = discover_admin_room_id(client, sender_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, - sender_token, - server_name, - &room_url, - &command, - is_make_admin_success, - ) - .await - .with_context(|| { - format!( - "matrix: admin room make-user-admin for @{localpart}:{server_name}: \ - no success response within 15 seconds. \ - Verify the admin room accepts '!admin users make-user-admin @user:server' commands." - ) - }) -} - -/// Reset a user's password via the Matrix admin room (`#admins:`). -/// -/// Sends `!admin users reset-password @:` to the admin room as -/// `@hive-:`, polls for the bot's response containing the new password, and persists -/// it to the non-purgeable creds path. -/// -/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and -/// the same consequence: `@hive-:` is an ordinary account with no admin -/// sender, and reset, like promotion, is a swarm-level operation rehomed -/// to the swarm tier rather than granted here — so it has no working -/// sender from the hive either. -/// -/// Returns the new password for use in subsequent `login_user` calls. -pub async fn reset_user_password( - client: &reqwest::Client, - sender_token: &str, - localpart: &str, - server_name: &str, -) -> Result { - let pw = admin_room_reset_password(client, sender_token, server_name, localpart) - .await - .with_context(|| { - format!("matrix: admin-room password reset for @{localpart}:{server_name}") - })?; - persist_password(localpart, &pw); - Ok(pw) -} - -/// Persist the matrix password for `localpart` to the non-purgeable creds path. -fn persist_password(localpart: &str, password: &str) { - use std::os::unix::fs::PermissionsExt; - let pw_path = password_path(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!(%localpart, error = ?e, "matrix: failed to persist reset password"); - } else { - let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); - } -} - /// Discover the matrix `server_name` from the running homeserver via /// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint). /// The response JSON always includes `"server_name"` per the matrix spec. @@ -1915,27 +1486,6 @@ mod tests { assert!(outcome.is_err(), "a 500 is not excused by membership"); } - #[tokio::test] - async fn an_admin_send_without_an_event_id_is_an_error() { - for (status, body) in [ - (500, r#"{"event_id":"$e"}"#), - (200, "not json"), - (200, "{}"), - (200, r#"{"event_id":""}"#), - ] { - let outcome = sent_event_id(response(status, body)).await; - assert!(outcome.is_err(), "HTTP {status} {body:?} must fail"); - } - } - - #[tokio::test] - async fn an_admin_send_returns_its_event_id() { - let id = sent_event_id(response(200, r#"{"event_id":"$e"}"#)) - .await - .expect("well-formed send response"); - assert_eq!(id, "$e"); - } - #[test] fn extract_access_token_errors_on_missing_field() { let body = serde_json::json!({"user_id": "@alice:matrix.example.org"}); diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 3f1cdf3e..37a1370b 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -133,9 +133,9 @@ pub fn agent_identity_dir(name: &str) -> PathBuf { } /// `matrix/` — host-side matrix provisioning state (the appservice sender 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). +/// Space room id, the hive sender account's password creds). The shared +/// registration token is bind-mounted into the tuwunel container via nix +/// and stays at its own path (tracked separately). #[must_use] pub fn matrix_dir() -> PathBuf { state_root().join("matrix") @@ -160,8 +160,9 @@ pub fn matrix_chat_room_id() -> PathBuf { matrix_dir().join("chat-room-id") } -/// `matrix/creds/` — per-agent throwaway matrix passwords (survive -/// `destroy --purge`; agents auth by token, this is recovery only). +/// `matrix/creds/` — the hive sender account's throwaway matrix password +/// (survives `destroy --purge`; it authenticates by token, this is +/// recovery only). #[must_use] pub fn matrix_creds_dir() -> PathBuf { matrix_dir().join("creds") diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index c36cfbb4..9ecedb6e 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -220,16 +220,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { ) .await? } - HostRequest::MatrixCreateUser { name, password } => { - handle_matrix_create_user(name, password.as_deref()).await? - } HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?, - HostRequest::MatrixPromoteUser { name } => { - handle_matrix_promote_user(name.as_str()).await? - } - HostRequest::MatrixResetPassword { name } => { - handle_matrix_reset_password(name.as_str()).await? - } HostRequest::MatrixInvite { user, room } => { handle_matrix_invite(user, room.as_deref()).await? } @@ -536,46 +527,6 @@ fn require_matrix_present() -> Result<()> { ) } -async fn handle_matrix_create_user( - name: &hive_types::Ident, - password: Option<&str>, -) -> Result { - require_matrix_present()?; - if agent_exists(name)? { - // The swarm mints an agent's account and stores its token where the - // agent reads it; a second minter here would replace that token on the - // same device. - anyhow::bail!( - "matrix create-user: '{name}' is an agent, and an agent's matrix account comes from \ - the swarm: swarm-controller creates it and re-checks it every five minutes" - ); - } - let as_token = - crate::matrix::read_appservice_token().context("read matrix appservice token")?; - let client = matrix_http_client()?; - let mut out = Vec::new(); - let effective_password = match password { - Some(p) => p.to_owned(), - None => crate::matrix::random_password().context("generate random matrix password")?, - }; - let token = - crate::matrix::provision_user_token(&client, name.as_str(), &as_token, &effective_password) - .await - .with_context(|| format!("matrix create-user {name}"))?; - out.push(format!( - "matrix: provisioned user '{name}' (not an agent — token not persisted)" - )); - out.push(format!("token: {token}")); - if password.is_some() { - out.push("password: set as supplied — use it to log into a matrix web client".to_owned()); - } else { - out.push( - "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), - ); - } - Ok(HostResponse::messages(out)) -} - async fn handle_set_agent_github_token(agent: &str, token: &str) -> Result { crate::priv_client::write_agent_github_token(agent, token) .await @@ -712,21 +663,6 @@ async fn handle_matrix_sync_admin() -> Result { ])) } -async fn handle_matrix_promote_user(name: &str) -> Result { - require_matrix_present()?; - let sender_token = crate::matrix::read_sender_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, &sender_token, name, &server_name) - .await - .with_context(|| format!("matrix promote-user {name}"))?; - Ok(HostResponse::messages(vec![format!( - "matrix: promoted @{name}:{server_name} to admin" - )])) -} - async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result { require_matrix_present()?; let sender_token = crate::matrix::read_sender_token()?; @@ -747,24 +683,6 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result Result { - require_matrix_present()?; - let sender_token = crate::matrix::read_sender_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, &sender_token, name, &server_name) - .await - .with_context(|| format!("matrix reset-password {name}"))?; - // Password is persisted by reset_user_password. - let pw_path = crate::paths::matrix_creds_dir().join(format!("{name}-password")); - Ok(HostResponse::messages(vec![ - format!("matrix: password for @{name}:{server_name} reset"), - format!("password persisted at: {}", pw_path.display()), - ])) -} - /// Single-agent queue verbs the admin socket exposes. Each submits the /// matching DAG (persisting the `wanted` intent, serializing on the /// agent's lease, with the transient/crash-watch suppression the old diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 40cfa32e..b1d133b2 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -284,29 +284,9 @@ pub enum HostRequest { #[serde(default)] scope: LifecycleScope, }, - /// Create or refresh a matrix account + access token for `name`. - /// The daemon runs the provisioning (it holds the register + admin - /// tokens and the matrix creds dir) and returns the operator-facing - /// results (persisted-token path for agents, or the freshly-minted - /// token + password for non-agent accounts) in - /// [`HostResponse::messages`]. `password` is resolved by the client - /// (inline flag or stdin) and `None` requests a random throwaway. - MatrixCreateUser { - name: Ident, - #[serde(default)] - password: Option, - }, /// Provision (or re-provision) the hive system admin matrix account. /// Daemon-side equivalent of `hivectl matrix sync-admin`. MatrixSyncAdmin, - /// Promote a matrix user to homeserver admin via the admin API. - /// Uses the daemon's system admin token; `server_name` is discovered - /// from the running homeserver. - MatrixPromoteUser { name: Ident }, - /// Reset a matrix user's password via the admin API and persist the - /// new password to the matrix creds dir so a later token mint can - /// re-login. Returns the outcome in [`HostResponse::messages`]. - MatrixResetPassword { name: Ident }, /// Invite a matrix user to the hive Space (default) or a specific /// `room`. Uses the daemon's admin token; idempotent /// (already-member / already-invited is a no-op). @@ -573,8 +553,8 @@ pub struct HostResponse { pub nodes: Option>, /// Free-form operator-facing output lines the client prints verbatim /// (one per line). Carries results a request produced daemon-side that - /// have no structured home — e.g. a freshly-minted matrix token, a - /// reset password, or an invited room id from the `Matrix*` requests. + /// have no structured home — e.g. the sender token path from + /// `MatrixSyncAdmin`, or the invited room id from `MatrixInvite`. /// Empty for requests that produce no such output. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub messages: Vec, diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index f94da8cf..fe05d67c 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -286,47 +286,11 @@ impl From for hive_host_sock::ReconcileDirection { #[derive(Subcommand)] pub enum MatrixCmd { - /// Create a matrix account for a person or other non-agent `` and - /// print its access token to stdout. - /// - /// Refuses an agent's name: its account comes from the swarm - /// (`swarm-controller` creates it and stores its token where the agent - /// reads it). Set a password to enable matrix web-client login - /// (otherwise it uses a random throwaway). - CreateUser { - /// Matrix localpart of a non-agent account — `mara`, `damocles`, etc. - name: String, - /// Set the account password to this string instead of a random - /// throwaway. Use this for operator accounts that need to log - /// into matrix web clients via `m.login.password`. Mutually - /// exclusive with `--password-stdin`. WARNING: the - /// password is visible in shell history + process listings; - /// prefer `--password-stdin` for anything sensitive. - #[arg(long)] - password: Option, - /// Read the password from stdin (single line, trailing newline - /// stripped) instead of an inline flag. Mutually exclusive with - /// `--password`. - #[arg(long, conflicts_with = "password")] - password_stdin: bool, - }, /// Provision (or re-provision) the matrix appservice's sender account. /// /// Runs automatically on startup; run manually to recover a missing /// access token. SyncAdmin, - /// Promote a matrix user to homeserver admin. - PromoteUser { - /// Matrix localpart of the user to promote (for example `argus`). - name: String, - }, - /// Reset a matrix user's password via the admin API. - /// - /// Persists the new password so a later `create-user` can re-login. - ResetPassword { - /// Matrix localpart of the account to reset (for example `argus`). - name: String, - }, /// Invite a matrix user to the hive Space, or a specific room with /// `--room`. Idempotent. Invite { diff --git a/hivectl/src/matrix.rs b/hivectl/src/matrix.rs index d3d86475..0ce7734c 100644 --- a/hivectl/src/matrix.rs +++ b/hivectl/src/matrix.rs @@ -8,20 +8,12 @@ use std::path::Path; use anyhow::{Context as _, Result, bail}; use crate::cli::MatrixCmd; -use crate::util::resolve_password; /// Route a `matrix` subcommand to its handler. Extracted from `main`'s /// dispatch match so the top-level router stays small. pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> { match cmd { - MatrixCmd::CreateUser { - name, - password, - password_stdin, - } => matrix_create_user(socket, &name, password.as_deref(), password_stdin).await, MatrixCmd::SyncAdmin => matrix_sync_admin(socket).await, - MatrixCmd::PromoteUser { name } => matrix_promote_user(socket, &name).await, - MatrixCmd::ResetPassword { name } => matrix_reset_password(socket, &name).await, MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await, } } @@ -46,40 +38,10 @@ async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Resu Ok(()) } -async fn matrix_create_user( - socket: &Path, - name: &str, - password: Option<&str>, - password_stdin: bool, -) -> Result<()> { - // Resolve the password client-side (an inline flag or a stdin read); - // the daemon never touches this process's stdin. The agent-vs-operator - // branch + throwaway-password handling now live in the daemon handler. - let password = resolve_password(password, password_stdin)?; - matrix_request( - socket, - hive_host_sock::HostRequest::MatrixCreateUser { - name: crate::util::parse_ident(name)?, - password, - }, - ) - .await -} - async fn matrix_sync_admin(socket: &Path) -> Result<()> { matrix_request(socket, hive_host_sock::HostRequest::MatrixSyncAdmin).await } -async fn matrix_promote_user(socket: &Path, name: &str) -> Result<()> { - matrix_request( - socket, - hive_host_sock::HostRequest::MatrixPromoteUser { - name: crate::util::parse_ident(name)?, - }, - ) - .await -} - async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> { matrix_request( socket, @@ -90,13 +52,3 @@ async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result< ) .await } - -async fn matrix_reset_password(socket: &Path, name: &str) -> Result<()> { - matrix_request( - socket, - hive_host_sock::HostRequest::MatrixResetPassword { - name: crate::util::parse_ident(name)?, - }, - ) - .await -} diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 4ef3971d..ed172875 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -200,9 +200,8 @@ let # knows about itself. ctlHomeserverUrl = 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. + # Every local user this hive may provision — agents and `@hive-:` + # itself — which is the whole matrix localpart charset. # # ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet` # and asks it for a MATCH, not a full match, so an unanchored