Watch
0
0
Fork
You've already forked hyperhive
0

hivectl, hive-c0re: remove dead matrix create-user/promote-user/reset-password

Human matrix accounts come from SSO, not hivectl. Matrix homeserver
admin will come from authelia's admins group (sync tracked in #4585);
password reset moves to swarm level (#4798). promote-user and
reset-password were already broken from the hive: the hive's sender
account has no admin sender to call the admin room with, only the
swarm's does.

Removes the three hivectl matrix verbs, their HostRequest variants,
their hive-c0re handlers, and the admin-room helpers (discover room id,
send-and-poll, event-id extraction, password/success parsing) that
only they used. sync-admin and invite are unchanged.

Refs #4585
This commit is contained in:
atlas 2026-09-29 10:55:51 +02:00 • committed by mara
commit 93bbec015f
12 changed files with 37 additions and 748 deletions

View file

@ -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 `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 doesn't go through the broker (built alongside `hive-c0re` when the host
module is enabled): module is enabled). Human matrix accounts come from SSO login, not
`hivectl`.
```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.
A human's first SSO login to the forge makes their forge account, not A human's first SSO login to the forge makes their forge account, not
`hivectl`; `swarmctl forge make-admin <name>` on the swarm-controller's `hivectl`; `swarmctl forge make-admin <name>` on the swarm-controller's

View file

@ -286,11 +286,12 @@ hivectl matrix sync-admin
# Invite the operator to the hive Space (and optionally to rooms) # Invite the operator to the hive Space (and optionally to rooms)
hivectl matrix invite mara hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' 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: 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 `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. identity (step 1), and her matrix daemon reads its token from the store.

View file

@ -130,8 +130,7 @@ a token.
so the sibling credentials are invisible to it. The `.yaml` suffix on so the sibling credentials are invisible to it. The `.yaml` suffix on
the credential id is what makes this work. the credential id is what makes this work.
5. **hive-c0re** reads the appservice token and creates its own 5. **hive-c0re** reads the appservice token and creates its own
`@hive-<hive>:` account, and the accounts an operator asks for with `@hive-<hive>:` account. It never mints the token itself: the value
`hivectl matrix create-user`. It never mints the token itself: the value
has to be the one the rendered registration names, and only the nix has to be the one the rendered registration names, and only the nix
side writes that. side writes that.
6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each 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 admin in any of it, and no Synapse admin API to reach for either, since
tuwunel has none. tuwunel has none.
Two operations need an admin **sender**: `hivectl matrix promote-user` Promoting a user to homeserver admin and resetting a password both need an
and `hivectl matrix reset-password`. Both are `!admin …` messages into admin **sender**: `!admin …` messages into `#admins:<server_name>`, and
`#admins:<server_name>`, and tuwunel only treats a message as a command tuwunel only treats a message as a command when its sender is already an
when its sender is already an admin. They're swarm-level operations, admin. `@hive-<hive>:` has no admin sender to make that call with. They're
rehomed to the swarm tier rather than granted here; from the hive, swarm-level operations: matrix admin should eventually come from
`@hive-<hive>:` has no admin sender to make that call with, so both get the membership in authelia's `admins` group; nobody has built that sync yet.
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.
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary> <details><summary>Upgrading a hive that shared one sender account with every other hive</summary>

View file

@ -8,10 +8,7 @@ This document contains the help content for the `hivectl` command-line program.
* [`hivectl forge`↴](#hivectl-forge) * [`hivectl forge`↴](#hivectl-forge)
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config) * [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
* [`hivectl matrix`↴](#hivectl-matrix) * [`hivectl matrix`↴](#hivectl-matrix)
* [`hivectl matrix create-user`↴](#hivectl-matrix-create-user)
* [`hivectl matrix sync-admin`↴](#hivectl-matrix-sync-admin) * [`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 matrix invite`↴](#hivectl-matrix-invite)
* [`hivectl github`↴](#hivectl-github) * [`hivectl github`↴](#hivectl-github)
* [`hivectl github set-token`↴](#hivectl-github-set-token) * [`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:** ###### **Subcommands:**
* `create-user` — Create a matrix account for a person or other non-agent `<name>` and print its access token to stdout
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account * `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 * `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 `<name>` 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] <NAME>`
###### **Arguments:**
* `<NAME>` — Matrix localpart of a non-agent account — `mara`, `damocles`, etc
###### **Options:**
* `--password <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` ## `hivectl matrix sync-admin`
Provision (or re-provision) the matrix appservice's sender account. 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 <NAME>`
###### **Arguments:**
* `<NAME>` — 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 <NAME>`
###### **Arguments:**
* `<NAME>` — Matrix localpart of the account to reset (for example `argus`)
## `hivectl matrix invite` ## `hivectl matrix invite`
Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent

View file

@ -50,31 +50,19 @@ Manual entry to the same idempotent matrix provisioning flow
running (`services.hyperhive.deploy.matrix.enable = true`). running (`services.hyperhive.deploy.matrix.enable = true`).
```bash ```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 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 # invite a user to the hive Space
hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias 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 Human matrix accounts come from SSO, not `hivectl`; matrix homeserver
an agent's name: `swarm-controller` creates an agent's account and admin should eventually come from membership in authelia's `admins`
stores its token where the agent's daemon reads it. group; nobody has built that sync yet.
- `sync-admin`: ensures this hive's appservice sender account - `sync-admin`: ensures this hive's appservice sender account
(`@hive-<hive>:<server_name>`, one per hive) exists (`@hive-<hive>:<server_name>`, one per hive) exists
(the account `hive-c0re` provisions rooms with). Token persisted to the (the account `hive-c0re` provisions rooms with). Token persisted to the
access token path. Safe to run again — idempotent. 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-<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 - `invite`: invites a matrix user (full `@user:server` or a bare
localpart, qualified with the homeserver's `server_name`) to the hive localpart, qualified with the homeserver's `server_name`) to the hive
Space by default, or to a `--room` id / `#alias`. Uses the sender Space by default, or to a `--room` id / `#alias`. Uses the sender

View file

@ -124,8 +124,7 @@ pub fn sender_token_path() -> PathBuf {
crate::paths::matrix_sender_token() crate::paths::matrix_sender_token()
} }
/// Password file for a matrix account this hive holds a password for: its own /// Password file for the hive's own `@hive-<hive>:` account. Stored OUTSIDE
/// `@hive-<hive>:` account, or one reset through the admin room. Stored OUTSIDE
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`. /// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
/// ///
/// Path: `/var/lib/hyperhive/matrix/creds/<name>-password` /// Path: `/var/lib/hyperhive/matrix/creds/<name>-password`
@ -244,11 +243,9 @@ async fn register_post(
} }
/// Generate a throwaway random password for matrix UIAA registration. /// Generate a throwaway random password for matrix UIAA registration.
/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. Agents authenticate /// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. The hive sender
/// by `access_token` so the password is protocol overhead we never /// account authenticates by `access_token`, so this password is
/// persist; the operator path in `hivectl` lets the caller supply a /// protocol overhead never used for login.
/// real password instead so they can log into a matrix web client
/// (`m.login.password`).
pub fn random_password() -> Result<String> { pub fn random_password() -> Result<String> {
random_hex(PASSWORD_BYTES) random_hex(PASSWORD_BYTES)
} }
@ -263,9 +260,9 @@ pub fn random_password() -> Result<String> {
/// agent authenticates with that rather than with anything the hive /// agent authenticates with that rather than with anything the hive
/// holds. The `as_token` never leaves the host. /// holds. The `as_token` never leaves the host.
/// ///
/// Caller picks the password: agents use [`random_password`] (throwaway /// Its one caller, the hive sender account's provisioning, always passes
/// — they auth by `access_token`), operators on the `hivectl` path /// a [`random_password`] throwaway — the account authenticates by
/// supply their own so they can log into matrix web clients. /// `access_token`, never `m.login.password`.
/// ///
/// # Errors /// # Errors
/// Propagates the homeserver's own body, which is what the /// Propagates the homeserver's own body, which is what the
@ -351,9 +348,9 @@ fn extract_access_token(body: &serde_json::Value) -> Result<String> {
/// Login with `m.login.password` and return the access token. Fallback /// Login with `m.login.password` and return the access token. Fallback
/// for when registration fails with `M_USER_IN_USE` — the account /// for when registration fails with `M_USER_IN_USE` — the account
/// already exists in the homeserver but the token file was lost. Fails /// already exists in the homeserver but the token file was lost. Fails
/// if the stored password no longer matches (e.g. homeserver wiped), /// if the stored password no longer matches (e.g. homeserver wiped);
/// in which case manual recovery via `hivectl matrix create-user` is /// the only caller is the hive sender account's own recovery path
/// required. /// (`hivectl matrix sync-admin`).
async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result<String> { async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result<String> {
let base = matrix_base()?; let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/login"); 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) extract_access_token(&json)
} }
// ---------------------------------------------------------------------------
// Admin-room fallback for password reset
// ---------------------------------------------------------------------------
/// Percent-encode a matrix room ID for use in a URL path segment. /// Percent-encode a matrix room ID for use in a URL path segment.
/// Only `:` needs encoding; `!` and alphanumerics are path-safe. /// Only `:` needs encoding; `!` and alphanumerics are path-safe.
fn encode_room_id_for_url(room_id: &str) -> String { fn encode_room_id_for_url(room_id: &str) -> String {
room_id.replace(':', "%3A") room_id.replace(':', "%3A")
} }
/// Look up the room ID for the `#admins:<server>` alias.
async fn discover_admin_room_id(
client: &reqwest::Client,
sender_token: &str,
server_name: &str,
) -> Result<String> {
let base = matrix_base()?;
// #admins:server → %23admins%3A<server>
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::<serde_json::Value>()
.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: `<password>`"
/// 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<String> {
// 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<String> {
let status = resp.status();
if !status.is_success() {
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
anyhow::bail!("matrix: admin room send failed: HTTP {status}, body: {body}");
}
let body = resp
.json::<serde_json::Value>()
.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<T>(
client: &reqwest::Client,
sender_token: &str,
server_name: &str,
room_url: &str,
command: &str,
check: impl Fn(&str) -> Option<T>,
) -> Result<T> {
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::<serde_json::Value>()
.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:<server>`).
/// Sends `!admin users reset-password @<localpart>:<server>` as `@hive-<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<String> {
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<String> {
register_user(client, name, as_token, password).await
}
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is /// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
/// persisted at [`sender_token_path()`]. /// persisted at [`sender_token_path()`].
/// ///
@ -843,141 +549,6 @@ async fn stored_sender_token() -> Option<String> {
} }
} }
/// 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 `"<user id> 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:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
/// `@hive-<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-<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:<server>`).
///
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
/// `@hive-<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-<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<String> {
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 /// Discover the matrix `server_name` from the running homeserver via
/// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint). /// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint).
/// The response JSON always includes `"server_name"` per the matrix spec. /// 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"); 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] #[test]
fn extract_access_token_errors_on_missing_field() { fn extract_access_token_errors_on_missing_field() {
let body = serde_json::json!({"user_id": "@alice:matrix.example.org"}); let body = serde_json::json!({"user_id": "@alice:matrix.example.org"});

View file

@ -133,9 +133,9 @@ pub fn agent_identity_dir(name: &str) -> PathBuf {
} }
/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive /// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive
/// Space room id, per-agent password creds). The shared registration /// Space room id, the hive sender account's password creds). The shared
/// token is bind-mounted into the tuwunel container via nix and stays /// registration token is bind-mounted into the tuwunel container via nix
/// at its own path (tracked separately). /// and stays at its own path (tracked separately).
#[must_use] #[must_use]
pub fn matrix_dir() -> PathBuf { pub fn matrix_dir() -> PathBuf {
state_root().join("matrix") state_root().join("matrix")
@ -160,8 +160,9 @@ pub fn matrix_chat_room_id() -> PathBuf {
matrix_dir().join("chat-room-id") matrix_dir().join("chat-room-id")
} }
/// `matrix/creds/` — per-agent throwaway matrix passwords (survive /// `matrix/creds/` — the hive sender account's throwaway matrix password
/// `destroy --purge`; agents auth by token, this is recovery only). /// (survives `destroy --purge`; it authenticates by token, this is
/// recovery only).
#[must_use] #[must_use]
pub fn matrix_creds_dir() -> PathBuf { pub fn matrix_creds_dir() -> PathBuf {
matrix_dir().join("creds") matrix_dir().join("creds")

View file

@ -220,16 +220,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
) )
.await? .await?
} }
HostRequest::MatrixCreateUser { name, password } => {
handle_matrix_create_user(name, password.as_deref()).await?
}
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().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 } => { HostRequest::MatrixInvite { user, room } => {
handle_matrix_invite(user, room.as_deref()).await? 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<HostResponse> {
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<HostResponse> { async fn handle_set_agent_github_token(agent: &str, token: &str) -> Result<HostResponse> {
crate::priv_client::write_agent_github_token(agent, token) crate::priv_client::write_agent_github_token(agent, token)
.await .await
@ -712,21 +663,6 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
])) ]))
} }
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
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<HostResponse> { async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
require_matrix_present()?; require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?; let sender_token = crate::matrix::read_sender_token()?;
@ -747,24 +683,6 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResp
)])) )]))
} }
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
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 /// Single-agent queue verbs the admin socket exposes. Each submits the
/// matching DAG (persisting the `wanted` intent, serializing on the /// matching DAG (persisting the `wanted` intent, serializing on the
/// agent's lease, with the transient/crash-watch suppression the old /// agent's lease, with the transient/crash-watch suppression the old

View file

@ -284,29 +284,9 @@ pub enum HostRequest {
#[serde(default)] #[serde(default)]
scope: LifecycleScope, 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<String>,
},
/// Provision (or re-provision) the hive system admin matrix account. /// Provision (or re-provision) the hive system admin matrix account.
/// Daemon-side equivalent of `hivectl matrix sync-admin`. /// Daemon-side equivalent of `hivectl matrix sync-admin`.
MatrixSyncAdmin, 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 /// Invite a matrix user to the hive Space (default) or a specific
/// `room`. Uses the daemon's admin token; idempotent /// `room`. Uses the daemon's admin token; idempotent
/// (already-member / already-invited is a no-op). /// (already-member / already-invited is a no-op).
@ -573,8 +553,8 @@ pub struct HostResponse {
pub nodes: Option<Vec<hive_jobq_wire::GraphNode>>, pub nodes: Option<Vec<hive_jobq_wire::GraphNode>>,
/// Free-form operator-facing output lines the client prints verbatim /// Free-form operator-facing output lines the client prints verbatim
/// (one per line). Carries results a request produced daemon-side that /// (one per line). Carries results a request produced daemon-side that
/// have no structured home — e.g. a freshly-minted matrix token, a /// have no structured home — e.g. the sender token path from
/// reset password, or an invited room id from the `Matrix*` requests. /// `MatrixSyncAdmin`, or the invited room id from `MatrixInvite`.
/// Empty for requests that produce no such output. /// Empty for requests that produce no such output.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub messages: Vec<String>, pub messages: Vec<String>,

View file

@ -286,47 +286,11 @@ impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum MatrixCmd { pub enum MatrixCmd {
/// Create a matrix account for a person or other non-agent `<name>` 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<String>,
/// 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. /// Provision (or re-provision) the matrix appservice's sender account.
/// ///
/// Runs automatically on startup; run manually to recover a missing /// Runs automatically on startup; run manually to recover a missing
/// access token. /// access token.
SyncAdmin, 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 /// Invite a matrix user to the hive Space, or a specific room with
/// `--room`. Idempotent. /// `--room`. Idempotent.
Invite { Invite {

View file

@ -8,20 +8,12 @@ use std::path::Path;
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use crate::cli::MatrixCmd; use crate::cli::MatrixCmd;
use crate::util::resolve_password;
/// Route a `matrix` subcommand to its handler. Extracted from `main`'s /// Route a `matrix` subcommand to its handler. Extracted from `main`'s
/// dispatch match so the top-level router stays small. /// dispatch match so the top-level router stays small.
pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> { pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> {
match cmd { 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::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, 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(()) 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<()> { async fn matrix_sync_admin(socket: &Path) -> Result<()> {
matrix_request(socket, hive_host_sock::HostRequest::MatrixSyncAdmin).await 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<()> { async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> {
matrix_request( matrix_request(
socket, socket,
@ -90,13 +52,3 @@ async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<
) )
.await .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
}

View file

@ -200,9 +200,8 @@ let
# knows about itself. # knows about itself.
ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}"; ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
# Every local user this hive may provision — agents, `@hive-<hive>:` itself, and # Every local user this hive may provision — agents and `@hive-<hive>:`
# the operator accounts `hivectl matrix create-user` makes, which is the # itself — which is the whole matrix localpart charset.
# whole matrix localpart charset.
# #
# ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet` # ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet`
# and asks it for a MATCH, not a full match, so an unanchored # and asks it for a MATCH, not a full match, so an unanchored