matrix: create accounts as the appservice, and promote the admin explicitly

Account creation stops presenting a shared registration token in a UIAA
flow and starts acting as the hive's appservice: one POST, typed
`m.login.application_service`, authorised by the `as_token` the
registration file names. The account that comes out is an ordinary user
with its own device and its own access token — nothing about what an agent
holds changes.

Three things get better than "one fewer round-trip":

- An account whose token file was lost is re-tokened by an appservice
  login, which needs neither its password nor admin rights. That was
  previously a stored-password login, and failing that an admin-room
  password reset. Both are kept behind it, for accounts created before
  this existed or named outside the appservice's namespace.
- The hive admin no longer has to be the first account ever registered.
  It could not be, in fact: tuwunel excludes appservice-created users from
  the automatic first-user grant, and on a homeserver that already had
  users the rule never fired anyway. Rights now come from an explicit
  `make_user_admin` — performed by `admin_execute` at homeserver startup,
  and verified here each sweep by reading the account's own joined-rooms
  list. Absent rights are reported with the one command that grants them,
  and are not fatal: agent accounts, the Space and the chat room all work
  without them.
- hive-c0re reads the appservice token and never mints it. The old token
  was the whole agreement, so whoever wrote it first was right; this one
  is also named by a registration file that only the nix side writes, and
  a token minted here would be one the homeserver has never heard of.

Also fixes the `make-user-admin` reply matcher, which recognised neither
spelling tuwunel v1.9.0 uses ("<user> has been granted admin
privileges.") — a promotion that had already taken effect was reported as
a 15-second timeout.

Refs #4402
This commit is contained in:
atlas 2026-09-15 19:29:13 +02:00
commit 43cd8607ba
3 changed files with 379 additions and 153 deletions

View file

@ -1,12 +1,19 @@
//! Optional matrix-tuwunel wiring: shared registration token (host) +
//! per-agent UIAA registration → `<agent-state>/matrix-token`. No-op
//! Optional matrix-tuwunel wiring: the hive's appservice identity (host) +
//! per-agent account creation → `<agent-state>/matrix-token`. No-op
//! when the `hive-matrix` container isn't running, so operators who
//! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay
//! nothing.
//!
//! See `docs/integrations/matrix.md::Provisioning flow (registration token)` for
//! the full UIAA round-trip, token-file shape, and host/container
//! bind-mount layout.
//! Accounts are created **as the hive's appservice**, not by presenting a
//! shared registration token in a UIAA flow. The difference that matters
//! here is not the round-trip count: an appservice token is an *identity*
//! the homeserver knows, so the secret never has to be the same on both
//! sides of the wire, and account creation does not depend on registration
//! being open to anyone who learns a token.
//!
//! See `docs/integrations/matrix.md::Provisioning flow (appservice)` for the
//! registration file's shape, how its token reaches both halves, and the
//! host/container bind-mount layout.
use std::path::PathBuf;
@ -49,11 +56,8 @@ fn matrix_base() -> Result<&'static str> {
this path should have been gated on matrix::is_present()",
)
}
/// Length (bytes) of the random registration token. 32 raw bytes ⇒
/// 64-char hex string; comfortable for a long-lived shared secret.
const REGISTER_TOKEN_BYTES: usize = 32;
/// HTTP timeout for registration round-trips. UIAA is two POSTs; even
/// the slow path should finish well inside this budget.
/// HTTP timeout for registration round-trips. Account creation is one
/// POST; even the slow path should finish well inside this budget.
const HTTP_TIMEOUT_SECS: u64 = 10;
/// Length (bytes) of the throwaway per-agent matrix password. Random
/// 32-byte hex — agents never log in with the password (they
@ -61,10 +65,17 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
/// store it nowhere.
const PASSWORD_BYTES: usize = 32;
/// Matrix localpart for the hive system admin account. Registered
/// before any agent account in [`ensure_all`] so it becomes the first
/// user on the homeserver — Conduit/tuwunel grants admin rights to the
/// first registered user automatically. Not an agent; has no state dir.
/// Matrix localpart for the hive system admin account. Not an agent; has
/// no state dir.
///
/// Also the `sender_localpart` of the hive's appservice registration,
/// which is what creates this account on a homeserver that has never had
/// one: the homeserver creates a registration's sender user itself, at
/// startup, before it accepts a request. The account's *admin rights*
/// then come from the `admin_execute` promotion beside that registration
/// — see `nix/host-modules/hive-matrix.nix`, where this same literal
/// appears as `adminLocalpart`. **The two must match**; nothing wires an
/// override across.
pub const HIVE_ADMIN_LOCALPART: &str = "hive";
/// Display name of the hive Space. Plain text, no special characters, so
@ -164,30 +175,36 @@ fn random_hex(n: usize) -> Result<String> {
Ok(hex)
}
/// Ensure the registration token file exists; returns its contents.
/// Generates a fresh 32-byte hex token on first call (mode 0600,
/// root-only), then re-reads on subsequent calls. The hive-matrix
/// nixos module bind-mounts this file read-only into the tuwunel
/// container so the homeserver can authenticate registration requests
/// against the same secret hive-c0re holds.
pub fn ensure_register_token() -> Result<String> {
use std::os::unix::fs::PermissionsExt;
let path = crate::paths::matrix_register_token();
if let Ok(existing) = std::fs::read_to_string(&path) {
let trimmed = existing.trim().to_owned();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
let token = random_hex(REGISTER_TOKEN_BYTES)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, format!("{token}\n"))
.with_context(|| format!("write registration token to {}", path.display()))?;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
tracing::info!(path = %path.display(), "matrix: generated registration token");
Ok(token)
/// Read the hive's appservice token — the `as_token` of the registration
/// the homeserver loaded at boot. Every account this module creates is
/// authorised by it.
///
/// **Reads, never mints**, unlike the registration token it replaced.
/// That token was the whole agreement, so whichever side wrote it first
/// was right; this one has a second half — the registration file naming
/// it, which only the nix side writes. A token minted here would be a
/// token the homeserver has never heard of, and the failure would surface
/// as every request being refused rather than as a missing file.
///
/// # Errors
/// When the file is absent or empty. That means the host activation
/// script has not run on this generation yet; callers log it and leave
/// existing accounts alone rather than trying to proceed.
pub fn read_appservice_token() -> Result<String> {
let path = crate::paths::matrix_appservice_token();
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"matrix appservice token not found at {} — it is minted by the \
hive-matrix activation script, which also renders the registration \
file naming it; deploy the hive-matrix module (or re-run \
`nixos-rebuild switch`) before provisioning matrix users",
path.display()
)
})
}
/// Build the localpart of a matrix user id for `agent`. Matrix
@ -198,19 +215,20 @@ fn user_localpart(agent: &str) -> &str {
agent
}
/// Send a single registration POST and parse the response. Returns
/// `Ok((status, body))` on any successful HTTP round-trip (including
/// the expected 401 from the first UIAA leg); errors only on transport
/// failure. Body is the parsed JSON value — UIAA passes session +
/// flow state via JSON, never via headers.
/// Send the registration POST as the appservice and parse the response.
/// Returns `Ok((status, body))` on any completed HTTP round-trip
/// (including the `M_USER_IN_USE` 400 the caller treats as "already
/// exists"); errors only on transport failure.
async fn register_post(
client: &reqwest::Client,
as_token: &str,
body: &serde_json::Value,
) -> Result<(StatusCode, serde_json::Value)> {
let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/register");
let resp = client
.post(&url)
.bearer_auth(as_token)
.json(body)
.send()
.await
@ -233,24 +251,38 @@ pub fn random_password() -> Result<String> {
random_hex(PASSWORD_BYTES)
}
/// Run the matrix-spec UIAA flow to register `agent` with the given
/// `password` and return the resulting access token. Two round-trips:
/// first POST elicits the 401 + session id, second POST supplies the
/// registration token in the `auth` block. If the homeserver returns
/// 200 on the first POST (no flow stages required — `allow_registration`
/// with no token), we take the access token directly.
/// Create the matrix account for `agent` as the hive's appservice and
/// return an access token for it. One round-trip: an appservice-typed
/// registration needs no UIAA stage at all, so there is no session to
/// carry and no shared secret to present.
///
/// The account is created **by** the appservice but is an ordinary user
/// afterwards — it gets its own device and its own access token, and the
/// agent authenticates with that rather than with anything the hive
/// holds. The `as_token` never leaves the host.
///
/// Caller picks the password: agents use [`random_password`] (throwaway
/// — they auth by `access_token`), operators on the `hivectl` path
/// supply their own so they can log into matrix web clients.
///
/// # Errors
/// Propagates the homeserver's own body, which is what the
/// `M_USER_IN_USE` callers match on. A `M_EXCLUSIVE` body means the
/// localpart falls outside the appservice's namespace — the registration
/// file's `namespaces.users` regex is the place to look, not this call.
async fn register_user(
client: &reqwest::Client,
agent: &str,
register_token: &str,
as_token: &str,
password: &str,
) -> Result<String> {
let localpart = user_localpart(agent);
let initial = serde_json::json!({
let body = serde_json::json!({
// What makes this an appservice registration rather than an
// ordinary one. Without it the homeserver treats the request as a
// normal client's and asks for a UIAA flow — even holding the
// as_token.
"type": "m.login.application_service",
"username": localpart,
"password": password,
// device_id stays stable across re-runs of ensure_user_for so
@ -259,37 +291,53 @@ async fn register_user(
"initial_device_display_name": format!("hyperhive ({agent})"),
"inhibit_login": false,
});
let (status, body) = register_post(client, &initial).await?;
// Some servers accept the first POST when allow_registration is
// open. Take the access_token + we're done.
if status.is_success() {
return extract_access_token(&body);
}
if status != StatusCode::UNAUTHORIZED {
anyhow::bail!("matrix: /register first leg HTTP {status}, body: {body}");
}
let session = body["session"]
.as_str()
.with_context(|| format!("matrix: missing UIAA session in 401, body: {body}"))?;
let authed = serde_json::json!({
"username": localpart,
"password": password,
"device_id": format!("hyperhive-{agent}"),
"initial_device_display_name": format!("hyperhive ({agent})"),
"inhibit_login": false,
"auth": {
"type": "m.login.registration_token",
"token": register_token,
"session": session,
},
});
let (status, body) = register_post(client, &authed).await?;
let (status, body) = register_post(client, as_token, &body).await?;
if !status.is_success() {
anyhow::bail!("matrix: /register auth leg HTTP {status}, body: {body}");
anyhow::bail!("matrix: /register as appservice HTTP {status}, body: {body}");
}
extract_access_token(&body)
}
/// Log in as an **existing** account using the hive's appservice token,
/// and return a fresh access token for it. No password involved: the
/// appservice is authorised for every localpart in its namespace, so it
/// can mint a session for one without knowing anything about the account.
///
/// This is the recovery path that used to need a stored password or an
/// admin-room password reset — an account whose token file was lost is
/// re-tokened from the hive's own identity instead. The device id matches
/// [`register_user`]'s, so a re-login replaces that device's token rather
/// than accumulating devices.
async fn appservice_login(client: &reqwest::Client, as_token: &str, agent: &str) -> Result<String> {
let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/login");
let body = serde_json::json!({
"type": "m.login.application_service",
"identifier": {
"type": "m.id.user",
"user": user_localpart(agent),
},
"device_id": format!("hyperhive-{agent}"),
"initial_device_display_name": format!("hyperhive ({agent})"),
});
let resp = client
.post(&url)
.bearer_auth(as_token)
.json(&body)
.send()
.await
.context("matrix: POST /login as appservice")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("matrix: parse appservice /login response")?;
if !status.is_success() {
anyhow::bail!("matrix: appservice /login HTTP {status} for {agent}, body: {json}");
}
extract_access_token(&json)
}
/// Pull `access_token` out of a successful /register response.
fn extract_access_token(body: &serde_json::Value) -> Result<String> {
body["access_token"]
@ -637,11 +685,7 @@ async fn admin_room_reset_password(
///
/// `client` is shared across the sweep so we build one reqwest
/// connection pool for all agents rather than one per call.
pub async fn ensure_user_for(
client: &reqwest::Client,
name: &str,
register_token: &str,
) -> Result<()> {
pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let agent = hive_types::Ident::parse(name)
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
@ -678,7 +722,7 @@ pub async fn ensure_user_for(
}
let password = random_password()?;
let access_token = match register_user(client, name, register_token, &password).await {
let access_token = match register_user(client, name, as_token, &password).await {
Ok(token) => {
// Successful registration — persist the password so we can
// fall back to login if the token file is deleted later.
@ -694,8 +738,21 @@ pub async fn ensure_user_for(
token
}
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
// Account already exists — try to re-login with the stored password.
tracing::info!(%name, "matrix: user already exists, attempting login with stored password");
// Account already exists and its token file was lost. The
// appservice can mint a session for any account in its
// namespace, so this needs no password and no admin rights —
// try it before the password paths below, which exist for
// accounts created before the appservice did (or named
// outside its namespace) and stay as the fallback.
tracing::info!(%name, "matrix: user already exists, logging in as the appservice");
match appservice_login(client, as_token, name).await {
Ok(token) => return finish_user_provisioning(name, &token).await,
Err(e) => tracing::warn!(
%name,
error = ?e,
"matrix: appservice login failed; falling back to the stored password"
),
}
let pw_path = password_path(name);
let stored = if let Some(pw) = std::fs::read_to_string(&pw_path)
.ok()
@ -734,11 +791,19 @@ pub async fn ensure_user_for(
Err(other) => return Err(other),
};
finish_user_provisioning(name, &access_token).await
}
/// Persist a freshly-obtained agent access token and kick the agent's
/// matrix daemon. Shared by every way [`ensure_user_for`] can end up
/// holding a token — creation, appservice login, password login — so a
/// new recovery path cannot forget half of it.
async fn finish_user_provisioning(name: &str, access_token: &str) -> Result<()> {
// Write the token via hive-priv (root helper): hive-c0re runs as the
// unprivileged `hive-core` user and cannot write to agent-owned state
// directories directly. hive-priv writes the file 0600 and chowns it
// to the agent user so it is readable from inside the container.
crate::priv_client::write_agent_matrix_token(name, &access_token, None, None)
crate::priv_client::write_agent_matrix_token(name, access_token, None, None)
.await
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
tracing::info!(%name, "matrix: provisioned access token");
@ -767,30 +832,30 @@ pub async fn ensure_user_for(
/// behaviour.
///
/// **Not idempotent** (unlike [`crate::forge::provision_user_token`]): the
/// matrix UIAA `/register` endpoint returns `M_USER_IN_USE` (HTTP 400)
/// on second call for the same localpart. Callers re-running this for
/// a known-existing matrix user should expect a hard error from this
/// fn and route to a password-reset path instead.
/// matrix `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) on a
/// second call for the same localpart, appservice-authorised or not.
/// Callers re-running this for a known-existing matrix user should expect
/// a hard error from this fn and route to a password-reset path instead.
pub async fn provision_user_token(
client: &reqwest::Client,
name: &str,
register_token: &str,
as_token: &str,
password: &str,
) -> Result<String> {
register_user(client, name, register_token, password).await
register_user(client, name, as_token, password).await
}
/// Per-agent matrix sync: ensure the agent has a matrix account + token.
/// All operations are idempotent; failures are logged as warnings but
/// don't abort the caller.
pub async fn sync_agent(client: &reqwest::Client, name: &str, register_token: &str) {
if let Err(e) = ensure_user_for(client, name, register_token).await {
pub async fn sync_agent(client: &reqwest::Client, name: &str, as_token: &str) {
if let Err(e) = ensure_user_for(client, name, as_token).await {
tracing::warn!(%name, error = ?e, "matrix: ensure_user failed");
}
}
/// Standalone per-agent sync that handles its own setup: checks if
/// hive-matrix is present, reads the registration token, and builds
/// hive-matrix is present, reads the appservice token, and builds
/// an HTTP client before delegating to [`sync_agent`]. Mirrors the
/// setup in [`ensure_all`] so the rebuild path and the startup sweep
/// stay equivalent. No-op when the matrix container is absent.
@ -798,10 +863,10 @@ pub async fn sync_agent_standalone(name: &str) {
if !is_present() {
return;
}
let register_token = match ensure_register_token() {
let as_token = match read_appservice_token() {
Ok(t) => t,
Err(e) => {
tracing::warn!(%name, error = ?e, "matrix: ensure_register_token failed");
tracing::warn!(%name, error = ?e, "matrix: read_appservice_token failed");
return;
}
};
@ -815,22 +880,26 @@ pub async fn sync_agent_standalone(name: &str) {
return;
}
};
sync_agent(&client, name, &register_token).await;
sync_agent(&client, name, &as_token).await;
}
/// Ensure the hive system admin matrix user exists and its token is
/// persisted at [`admin_token_path()`]. Must be called BEFORE
/// [`ensure_all`]'s agent loop so this account is the first to register
/// and becomes the homeserver admin automatically (Conduit/tuwunel:
/// first registered user = admin).
/// Ensure the hive system admin matrix user exists, that its token is
/// persisted at [`admin_token_path()`], and that it actually holds admin
/// rights.
///
/// Idempotent — skips when the token file already exists and is
/// non-empty. Does NOT promote the account via API (that requires
/// admin rights which this fn bootstraps); on a fresh homeserver the
/// first-registered rule fires automatically; on an existing homeserver
/// the operator must promote the account once via
/// `hivectl matrix promote-user hive` or the conduit admin room.
pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -> Result<()> {
/// **Nothing here depends on registration order any more.** The account
/// used to have to be the first ever registered, to win tuwunel's
/// automatic first-user grant — a rule that cannot fire for an
/// appservice-created account at all, and one that silently did nothing
/// on a homeserver that already had users. Admin rights now come from an
/// explicit `make_user_admin`: the `admin_execute` entry beside the
/// appservice registration performs it at homeserver startup, and
/// [`ensure_admin_rights`] checks the result and says so when it is
/// missing.
///
/// Idempotent — skips the account work when the token file already exists
/// and is non-empty.
pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let path = admin_token_path();
if path.exists()
@ -841,8 +910,7 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
return Ok(());
}
let password = random_password()?;
let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, register_token, &password)
.await
let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await
{
Ok(token) => {
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
@ -857,7 +925,17 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
token
}
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
tracing::info!("matrix: hive admin user already exists, re-logging in");
// The expected path, not an edge case: this account is the
// appservice's own `sender_localpart`, so the homeserver
// creates it when it loads the registration — before
// hive-c0re gets a chance to ask. An appservice login needs
// no password, which is just as well since an account the
// homeserver created has none.
tracing::info!("matrix: hive admin user already exists, logging in as the appservice");
match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await {
Ok(token) => token,
Err(e) => {
tracing::warn!(error = ?e, "matrix: appservice login for the hive admin failed; falling back to the stored password");
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
let stored = std::fs::read_to_string(&pw_path)
.ok()
@ -865,13 +943,17 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"matrix: hive admin user exists but password missing at {} — \
manual recovery: reset password via admin API or conduit admin room",
"matrix: hive admin user exists, appservice login failed, and no \
password is stored at {} check that the registration file's \
namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \
loaded it",
pw_path.display()
)
})?;
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
}
}
}
Err(other) => return Err(other),
};
if let Some(parent) = path.parent() {
@ -884,9 +966,148 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
Ok(())
}
/// Check that the hive admin account holds admin rights, and repair it
/// through the admin room when it does not.
///
/// Admin-ness in tuwunel is membership of the admin room, so that is what
/// this reads: the account's own joined-rooms list, which needs no
/// privileges to fetch. When the admin room is in it there is nothing to
/// do and nothing is sent — worth insisting on, because the repair is a
/// message in a room and this runs on every sweep.
///
/// When it is absent, the repair is attempted anyway (a stale or failed
/// read is cheaper to retry than to reason about) and a failure is
/// reported rather than raised: agent accounts, the hive Space and the
/// chat room all work without an admin, so a hive with an unpromoted
/// admin is degraded, not broken. Only `hivectl matrix promote-user` /
/// `reset-password` need it.
///
/// # Errors
/// Never — the outcome is the return value. `false` means "not admin and
/// could not be made one", already logged with what to do about it.
async fn ensure_admin_rights(
client: &reqwest::Client,
admin_token: &str,
server_name: &str,
) -> bool {
let room_id = match discover_admin_room_id(client, admin_token, server_name).await {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights");
return false;
}
};
match joined_rooms(client, admin_token).await {
Some(rooms) if rooms.iter().any(|r| r == &room_id) => {
tracing::debug!("matrix: hive admin is in the admin room");
return true;
}
Some(_) => {
tracing::warn!("matrix: hive admin is not in the admin room — attempting to promote it")
}
None => tracing::debug!(
"matrix: could not read the hive admin's joined rooms; attempting to promote it"
),
}
if let Err(e) =
promote_user_to_admin(client, admin_token, HIVE_ADMIN_LOCALPART, server_name).await
{
// The honest message. Promoting through the admin room requires
// being admin already, so the one lever that works on a
// homeserver with no admin at all is the `admin_execute` entry in
// the hive-matrix module — which runs at startup, which means a
// restart is the fix rather than another sweep.
tracing::warn!(
error = ?e,
"matrix: hive admin '@{HIVE_ADMIN_LOCALPART}' holds no admin rights. \
The homeserver promotes it at startup (admin_execute in hive-matrix.nix), \
so `systemctl restart container@hive-matrix` grants it. Agent accounts and \
room provisioning are unaffected; `hivectl matrix promote-user` and \
`reset-password` need it."
);
return false;
}
tracing::info!("matrix: promoted the hive admin through the admin room");
true
}
/// The rooms `token`'s account has joined, or `None` when the list could
/// not be read. `None` is deliberately not an empty list: "no rooms" and
/// "no answer" lead to different decisions in [`ensure_admin_rights`].
async fn joined_rooms(client: &reqwest::Client, token: &str) -> Option<Vec<String>> {
let base = matrix_http()?;
let url = format!("{base}/_matrix/client/v3/joined_rooms");
let resp = client.get(&url).bearer_auth(token).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let body = resp.json::<serde_json::Value>().await.ok()?;
Some(
body["joined_rooms"]
.as_array()?
.iter()
.filter_map(|r| r.as_str().map(ToOwned::to_owned))
.collect(),
)
}
/// Whether an admin-room reply says a `make-user-admin` succeeded.
///
/// Three spellings, because the reply is prose and prose changes between
/// builds. tuwunel v1.9.0's is `"<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, polls for the bot's "Done:" success response.
/// @hive, polls for the bot's success reply.
///
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only
/// treats a message as a command when its sender is in the admin room.
/// So this promotes a *second* user; it cannot bootstrap the first one.
/// That is what the `admin_execute` entry in `hive-matrix.nix` is for.
///
/// Goes through the admin room rather than a direct HTTP call because
/// tuwunel implements parts of the Synapse admin API but not user
@ -907,14 +1128,7 @@ pub async fn promote_user_to_admin(
server_name,
&room_url,
&command,
|body| {
let lower = body.to_ascii_lowercase();
if lower.starts_with("done") || lower.contains("made") && lower.contains("admin") {
Some(())
} else {
None
}
},
is_make_admin_success,
)
.await
.with_context(|| {
@ -1637,10 +1851,14 @@ pub async fn ensure_all() -> bool {
return true;
}
let mut ok = true;
let register_token = match ensure_register_token() {
// Loud and non-destructive: with no appservice token this sweep can
// create nothing, so it does nothing. Agents that already hold a
// token keep using it — their accounts and sessions are untouched by
// anything in here.
let as_token = match read_appservice_token() {
Ok(t) => t,
Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_register_token failed");
tracing::warn!(error = ?e, "matrix: no appservice token; skipping the user sweep");
return false;
}
};
@ -1656,10 +1874,12 @@ pub async fn ensure_all() -> bool {
return false;
}
};
// Provision hive admin user FIRST so it's the first registered
// account on a fresh homeserver (Conduit/tuwunel makes the first
// registered user admin automatically).
if let Err(e) = ensure_admin_user(&client, &register_token).await {
// The hive admin first, because everything below provisions THROUGH
// it (the Space, the chat room and every invite are sent with its
// token). Not, any more, so that it wins a first-registered-user
// grant: it holds admin rights by explicit promotion, checked in
// `provision_space` once the server name is known.
if let Err(e) = ensure_admin_user(&client, &as_token).await {
tracing::warn!(error = ?e, "matrix: ensure_admin_user failed");
ok = false;
}
@ -1672,7 +1892,7 @@ pub async fn ensure_all() -> bool {
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
continue;
};
sync_agent(&client, name, &register_token).await;
sync_agent(&client, name, &as_token).await;
agent_names.push(name.to_owned());
}
@ -1705,6 +1925,11 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
return false;
}
};
// Before the rooms: the one thing in this sweep that is about the
// admin account itself rather than about what it provisions.
if !ensure_admin_rights(client, &admin_token, &server_name).await {
ok = false;
}
let room_id = match ensure_hive_space(client, &admin_token).await {
Ok(id) => id,
Err(e) => {

View file

@ -253,14 +253,15 @@ pub fn gateway_agents_conf() -> PathBuf {
// `nix/host-modules/hive-c0re/default.nix` and `nix/host-modules/hive-ci.nix` — must match.
pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token";
/// `matrix-register-token` — shared matrix registration token.
// nix: bind-mounted into the tuwunel/matrix container (hive-matrix.nix) — must match.
// Not operator-option-driven: `registrationTokenFile` is `internal` on the nix
// side, and an `assertions` entry there rejects any attempt to move it, so this
// literal can never diverge from it.
/// `matrix-appservice-token` — the `as_token` of the hive's appservice
/// registration, which authorises every account this daemon creates.
// nix: minted by the `hive-matrix-appservice` activation script in
// `nix/host-modules/hive-matrix.nix`, which renders it into the registration
// file the homeserver loads — must match. Read-only here on purpose: a token
// minted on this side would not be the one in that file.
#[must_use]
pub fn matrix_register_token() -> PathBuf {
state_root().join("matrix-register-token")
pub fn matrix_appservice_token() -> PathBuf {
state_root().join("matrix-appservice-token")
}
/// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs).

View file

@ -558,8 +558,8 @@ async fn handle_matrix_create_user(
password: Option<&str>,
) -> Result<HostResponse> {
require_matrix_present()?;
let register_token =
crate::matrix::ensure_register_token().context("read matrix register token")?;
let as_token =
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?;
let mut out = Vec::new();
if agent_exists(name)? {
@ -571,7 +571,7 @@ async fn handle_matrix_create_user(
"matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
);
}
crate::matrix::ensure_user_for(&client, name.as_str(), &register_token)
crate::matrix::ensure_user_for(&client, name.as_str(), &as_token)
.await
.with_context(|| format!("matrix create-user {name}"))?;
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
@ -585,7 +585,7 @@ async fn handle_matrix_create_user(
let token = crate::matrix::provision_user_token(
&client,
name.as_str(),
&register_token,
&as_token,
&effective_password,
)
.await
@ -770,10 +770,10 @@ async fn handle_push_snapshot(
async fn handle_matrix_sync_admin() -> Result<HostResponse> {
require_matrix_present()?;
let register_token =
crate::matrix::ensure_register_token().context("read matrix register token")?;
let as_token =
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?;
crate::matrix::ensure_admin_user(&client, &register_token)
crate::matrix::ensure_admin_user(&client, &as_token)
.await
.context("matrix sync-admin")?;
let path = crate::matrix::admin_token_path();