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:
parent
69ae23f801
commit
93bbec015f
12 changed files with 37 additions and 748 deletions
13
README.md
13
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
|
`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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"});
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue