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
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 <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)
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.

View file

@ -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-<hive>:` account, and the accounts an operator asks for with
`hivectl matrix create-user`. It never mints the token itself: the value
`@hive-<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:<server_name>`, 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-<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:<server_name>`, and
tuwunel only treats a message as a command when its sender is already an
admin. `@hive-<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.
<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 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 `<name>` 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 `<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`
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`
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`).
```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-<hive>:<server_name>`, 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-<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

View file

@ -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-<hive>:` account, or one reset through the admin room. Stored OUTSIDE
/// Password file for the hive's own `@hive-<hive>:` account. Stored OUTSIDE
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
///
/// 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.
/// `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<String> {
random_hex(PASSWORD_BYTES)
}
@ -263,9 +260,9 @@ pub fn random_password() -> Result<String> {
/// 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<String> {
/// 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<String> {
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:<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
/// 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
/// `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"});

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
/// 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")

View file

@ -220,16 +220,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> 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<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> {
crate::priv_client::write_agent_github_token(agent, token)
.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> {
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<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
/// matching DAG (persisting the `wanted` intent, serializing on the
/// agent's lease, with the transient/crash-watch suppression the old

View file

@ -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<String>,
},
/// 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<Vec<hive_jobq_wire::GraphNode>>,
/// 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<String>,

View file

@ -286,47 +286,11 @@ impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
#[derive(Subcommand)]
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.
///
/// 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 {

View file

@ -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
}

View file

@ -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-<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-<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