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) + //! Optional matrix-tuwunel wiring: the hive's appservice identity (host) +
//! per-agent UIAA registration → `<agent-state>/matrix-token`. No-op //! per-agent account creation → `<agent-state>/matrix-token`. No-op
//! when the `hive-matrix` container isn't running, so operators who //! when the `hive-matrix` container isn't running, so operators who
//! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay //! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay
//! nothing. //! nothing.
//! //!
//! See `docs/integrations/matrix.md::Provisioning flow (registration token)` for //! Accounts are created **as the hive's appservice**, not by presenting a
//! the full UIAA round-trip, token-file shape, and host/container //! shared registration token in a UIAA flow. The difference that matters
//! bind-mount layout. //! 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; use std::path::PathBuf;
@ -49,11 +56,8 @@ fn matrix_base() -> Result<&'static str> {
this path should have been gated on matrix::is_present()", this path should have been gated on matrix::is_present()",
) )
} }
/// Length (bytes) of the random registration token. 32 raw bytes ⇒ /// HTTP timeout for registration round-trips. Account creation is one
/// 64-char hex string; comfortable for a long-lived shared secret. /// POST; even the slow path should finish well inside this budget.
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.
const HTTP_TIMEOUT_SECS: u64 = 10; const HTTP_TIMEOUT_SECS: u64 = 10;
/// Length (bytes) of the throwaway per-agent matrix password. Random /// Length (bytes) of the throwaway per-agent matrix password. Random
/// 32-byte hex — agents never log in with the password (they /// 32-byte hex — agents never log in with the password (they
@ -61,10 +65,17 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
/// store it nowhere. /// store it nowhere.
const PASSWORD_BYTES: usize = 32; const PASSWORD_BYTES: usize = 32;
/// Matrix localpart for the hive system admin account. Registered /// Matrix localpart for the hive system admin account. Not an agent; has
/// before any agent account in [`ensure_all`] so it becomes the first /// no state dir.
/// user on the homeserver — Conduit/tuwunel grants admin rights to the ///
/// first registered user automatically. 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"; pub const HIVE_ADMIN_LOCALPART: &str = "hive";
/// Display name of the hive Space. Plain text, no special characters, so /// 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) Ok(hex)
} }
/// Ensure the registration token file exists; returns its contents. /// Read the hive's appservice token — the `as_token` of the registration
/// Generates a fresh 32-byte hex token on first call (mode 0600, /// the homeserver loaded at boot. Every account this module creates is
/// root-only), then re-reads on subsequent calls. The hive-matrix /// authorised by it.
/// nixos module bind-mounts this file read-only into the tuwunel ///
/// container so the homeserver can authenticate registration requests /// **Reads, never mints**, unlike the registration token it replaced.
/// against the same secret hive-c0re holds. /// That token was the whole agreement, so whichever side wrote it first
pub fn ensure_register_token() -> Result<String> { /// was right; this one has a second half — the registration file naming
use std::os::unix::fs::PermissionsExt; /// it, which only the nix side writes. A token minted here would be a
let path = crate::paths::matrix_register_token(); /// token the homeserver has never heard of, and the failure would surface
if let Ok(existing) = std::fs::read_to_string(&path) { /// as every request being refused rather than as a missing file.
let trimmed = existing.trim().to_owned(); ///
if !trimmed.is_empty() { /// # Errors
return Ok(trimmed); /// 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.
let token = random_hex(REGISTER_TOKEN_BYTES)?; pub fn read_appservice_token() -> Result<String> {
if let Some(parent) = path.parent() { let path = crate::paths::matrix_appservice_token();
std::fs::create_dir_all(parent).ok(); std::fs::read_to_string(&path)
} .ok()
std::fs::write(&path, format!("{token}\n")) .map(|s| s.trim().to_owned())
.with_context(|| format!("write registration token to {}", path.display()))?; .filter(|s| !s.is_empty())
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); .with_context(|| {
tracing::info!(path = %path.display(), "matrix: generated registration token"); format!(
Ok(token) "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 /// Build the localpart of a matrix user id for `agent`. Matrix
@ -198,19 +215,20 @@ fn user_localpart(agent: &str) -> &str {
agent agent
} }
/// Send a single registration POST and parse the response. Returns /// Send the registration POST as the appservice and parse the response.
/// `Ok((status, body))` on any successful HTTP round-trip (including /// Returns `Ok((status, body))` on any completed HTTP round-trip
/// the expected 401 from the first UIAA leg); errors only on transport /// (including the `M_USER_IN_USE` 400 the caller treats as "already
/// failure. Body is the parsed JSON value — UIAA passes session + /// exists"); errors only on transport failure.
/// flow state via JSON, never via headers.
async fn register_post( async fn register_post(
client: &reqwest::Client, client: &reqwest::Client,
as_token: &str,
body: &serde_json::Value, body: &serde_json::Value,
) -> Result<(StatusCode, serde_json::Value)> { ) -> Result<(StatusCode, serde_json::Value)> {
let base = matrix_base()?; let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/register"); let url = format!("{base}/_matrix/client/v3/register");
let resp = client let resp = client
.post(&url) .post(&url)
.bearer_auth(as_token)
.json(body) .json(body)
.send() .send()
.await .await
@ -233,24 +251,38 @@ pub fn random_password() -> Result<String> {
random_hex(PASSWORD_BYTES) random_hex(PASSWORD_BYTES)
} }
/// Run the matrix-spec UIAA flow to register `agent` with the given /// Create the matrix account for `agent` as the hive's appservice and
/// `password` and return the resulting access token. Two round-trips: /// return an access token for it. One round-trip: an appservice-typed
/// first POST elicits the 401 + session id, second POST supplies the /// registration needs no UIAA stage at all, so there is no session to
/// registration token in the `auth` block. If the homeserver returns /// carry and no shared secret to present.
/// 200 on the first POST (no flow stages required — `allow_registration` ///
/// with no token), we take the access token directly. /// 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 /// Caller picks the password: agents use [`random_password`] (throwaway
/// — they auth by `access_token`), operators on the `hivectl` path /// — they auth by `access_token`), operators on the `hivectl` path
/// supply their own so they can log into matrix web clients. /// 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( async fn register_user(
client: &reqwest::Client, client: &reqwest::Client,
agent: &str, agent: &str,
register_token: &str, as_token: &str,
password: &str, password: &str,
) -> Result<String> { ) -> Result<String> {
let localpart = user_localpart(agent); 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, "username": localpart,
"password": password, "password": password,
// device_id stays stable across re-runs of ensure_user_for so // 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})"), "initial_device_display_name": format!("hyperhive ({agent})"),
"inhibit_login": false, "inhibit_login": false,
}); });
let (status, body) = register_post(client, &initial).await?; let (status, body) = register_post(client, as_token, &body).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?;
if !status.is_success() { 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) 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. /// Pull `access_token` out of a successful /register response.
fn extract_access_token(body: &serde_json::Value) -> Result<String> { fn extract_access_token(body: &serde_json::Value) -> Result<String> {
body["access_token"] body["access_token"]
@ -637,11 +685,7 @@ async fn admin_room_reset_password(
/// ///
/// `client` is shared across the sweep so we build one reqwest /// `client` is shared across the sweep so we build one reqwest
/// connection pool for all agents rather than one per call. /// connection pool for all agents rather than one per call.
pub async fn ensure_user_for( pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &str) -> Result<()> {
client: &reqwest::Client,
name: &str,
register_token: &str,
) -> Result<()> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let agent = hive_types::Ident::parse(name) let agent = hive_types::Ident::parse(name)
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; .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 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) => { Ok(token) => {
// Successful registration — persist the password so we can // Successful registration — persist the password so we can
// fall back to login if the token file is deleted later. // fall back to login if the token file is deleted later.
@ -694,8 +738,21 @@ pub async fn ensure_user_for(
token token
} }
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => { Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
// Account already exists — try to re-login with the stored password. // Account already exists and its token file was lost. The
tracing::info!(%name, "matrix: user already exists, attempting login with stored password"); // 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 pw_path = password_path(name);
let stored = if let Some(pw) = std::fs::read_to_string(&pw_path) let stored = if let Some(pw) = std::fs::read_to_string(&pw_path)
.ok() .ok()
@ -734,11 +791,19 @@ pub async fn ensure_user_for(
Err(other) => return Err(other), 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 // Write the token via hive-priv (root helper): hive-c0re runs as the
// unprivileged `hive-core` user and cannot write to agent-owned state // unprivileged `hive-core` user and cannot write to agent-owned state
// directories directly. hive-priv writes the file 0600 and chowns it // directories directly. hive-priv writes the file 0600 and chowns it
// to the agent user so it is readable from inside the container. // 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 .await
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?; .with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
tracing::info!(%name, "matrix: provisioned access token"); tracing::info!(%name, "matrix: provisioned access token");
@ -767,30 +832,30 @@ pub async fn ensure_user_for(
/// behaviour. /// behaviour.
/// ///
/// **Not idempotent** (unlike [`crate::forge::provision_user_token`]): the /// **Not idempotent** (unlike [`crate::forge::provision_user_token`]): the
/// matrix UIAA `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) /// matrix `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) on a
/// on second call for the same localpart. Callers re-running this for /// second call for the same localpart, appservice-authorised or not.
/// a known-existing matrix user should expect a hard error from this /// Callers re-running this for a known-existing matrix user should expect
/// fn and route to a password-reset path instead. /// a hard error from this fn and route to a password-reset path instead.
pub async fn provision_user_token( pub async fn provision_user_token(
client: &reqwest::Client, client: &reqwest::Client,
name: &str, name: &str,
register_token: &str, as_token: &str,
password: &str, password: &str,
) -> Result<String> { ) -> 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. /// Per-agent matrix sync: ensure the agent has a matrix account + token.
/// All operations are idempotent; failures are logged as warnings but /// All operations are idempotent; failures are logged as warnings but
/// don't abort the caller. /// don't abort the caller.
pub async fn sync_agent(client: &reqwest::Client, name: &str, register_token: &str) { pub async fn sync_agent(client: &reqwest::Client, name: &str, as_token: &str) {
if let Err(e) = ensure_user_for(client, name, register_token).await { if let Err(e) = ensure_user_for(client, name, as_token).await {
tracing::warn!(%name, error = ?e, "matrix: ensure_user failed"); tracing::warn!(%name, error = ?e, "matrix: ensure_user failed");
} }
} }
/// Standalone per-agent sync that handles its own setup: checks if /// 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 /// an HTTP client before delegating to [`sync_agent`]. Mirrors the
/// setup in [`ensure_all`] so the rebuild path and the startup sweep /// setup in [`ensure_all`] so the rebuild path and the startup sweep
/// stay equivalent. No-op when the matrix container is absent. /// 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() { if !is_present() {
return; return;
} }
let register_token = match ensure_register_token() { let as_token = match read_appservice_token() {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::warn!(%name, error = ?e, "matrix: ensure_register_token failed"); tracing::warn!(%name, error = ?e, "matrix: read_appservice_token failed");
return; return;
} }
}; };
@ -815,22 +880,26 @@ pub async fn sync_agent_standalone(name: &str) {
return; 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 /// Ensure the hive system admin matrix user exists, that its token is
/// persisted at [`admin_token_path()`]. Must be called BEFORE /// persisted at [`admin_token_path()`], and that it actually holds admin
/// [`ensure_all`]'s agent loop so this account is the first to register /// rights.
/// and becomes the homeserver admin automatically (Conduit/tuwunel:
/// first registered user = admin).
/// ///
/// Idempotent — skips when the token file already exists and is /// **Nothing here depends on registration order any more.** The account
/// non-empty. Does NOT promote the account via API (that requires /// used to have to be the first ever registered, to win tuwunel's
/// admin rights which this fn bootstraps); on a fresh homeserver the /// automatic first-user grant — a rule that cannot fire for an
/// first-registered rule fires automatically; on an existing homeserver /// appservice-created account at all, and one that silently did nothing
/// the operator must promote the account once via /// on a homeserver that already had users. Admin rights now come from an
/// `hivectl matrix promote-user hive` or the conduit admin room. /// explicit `make_user_admin`: the `admin_execute` entry beside the
pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -> Result<()> { /// 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; use std::os::unix::fs::PermissionsExt;
let path = admin_token_path(); let path = admin_token_path();
if path.exists() if path.exists()
@ -841,8 +910,7 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
return Ok(()); return Ok(());
} }
let password = random_password()?; let password = random_password()?;
let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, register_token, &password) let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await
.await
{ {
Ok(token) => { Ok(token) => {
let pw_path = password_path(HIVE_ADMIN_LOCALPART); let pw_path = password_path(HIVE_ADMIN_LOCALPART);
@ -857,20 +925,34 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
token token
} }
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => { 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
let pw_path = password_path(HIVE_ADMIN_LOCALPART); // appservice's own `sender_localpart`, so the homeserver
let stored = std::fs::read_to_string(&pw_path) // creates it when it loads the registration — before
.ok() // hive-c0re gets a chance to ask. An appservice login needs
.map(|s| s.trim().to_owned()) // no password, which is just as well since an account the
.filter(|s| !s.is_empty()) // homeserver created has none.
.with_context(|| { tracing::info!("matrix: hive admin user already exists, logging in as the appservice");
format!( match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await {
"matrix: hive admin user exists but password missing at {} — \ Ok(token) => token,
manual recovery: reset password via admin API or conduit admin room", Err(e) => {
pw_path.display() 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)
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await? .ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"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), Err(other) => return Err(other),
}; };
@ -884,9 +966,148 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
Ok(()) 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 /// Promote a user to homeserver admin via the Matrix admin room
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as /// (`#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 /// Goes through the admin room rather than a direct HTTP call because
/// tuwunel implements parts of the Synapse admin API but not user /// tuwunel implements parts of the Synapse admin API but not user
@ -907,14 +1128,7 @@ pub async fn promote_user_to_admin(
server_name, server_name,
&room_url, &room_url,
&command, &command,
|body| { is_make_admin_success,
let lower = body.to_ascii_lowercase();
if lower.starts_with("done") || lower.contains("made") && lower.contains("admin") {
Some(())
} else {
None
}
},
) )
.await .await
.with_context(|| { .with_context(|| {
@ -1637,10 +1851,14 @@ pub async fn ensure_all() -> bool {
return true; return true;
} }
let mut ok = 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, Ok(t) => t,
Err(e) => { 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; return false;
} }
}; };
@ -1656,10 +1874,12 @@ pub async fn ensure_all() -> bool {
return false; return false;
} }
}; };
// Provision hive admin user FIRST so it's the first registered // The hive admin first, because everything below provisions THROUGH
// account on a fresh homeserver (Conduit/tuwunel makes the first // it (the Space, the chat room and every invite are sent with its
// registered user admin automatically). // token). Not, any more, so that it wins a first-registered-user
if let Err(e) = ensure_admin_user(&client, &register_token).await { // 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"); tracing::warn!(error = ?e, "matrix: ensure_admin_user failed");
ok = false; ok = false;
} }
@ -1672,7 +1892,7 @@ pub async fn ensure_all() -> bool {
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
continue; continue;
}; };
sync_agent(&client, name, &register_token).await; sync_agent(&client, name, &as_token).await;
agent_names.push(name.to_owned()); agent_names.push(name.to_owned());
} }
@ -1705,6 +1925,11 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
return false; 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 { let room_id = match ensure_hive_space(client, &admin_token).await {
Ok(id) => id, Ok(id) => id,
Err(e) => { 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. // `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"; pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token";
/// `matrix-register-token` — shared matrix registration token. /// `matrix-appservice-token` — the `as_token` of the hive's appservice
// nix: bind-mounted into the tuwunel/matrix container (hive-matrix.nix) — must match. /// registration, which authorises every account this daemon creates.
// Not operator-option-driven: `registrationTokenFile` is `internal` on the nix // nix: minted by the `hive-matrix-appservice` activation script in
// side, and an `assertions` entry there rejects any attempt to move it, so this // `nix/host-modules/hive-matrix.nix`, which renders it into the registration
// literal can never diverge from it. // 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] #[must_use]
pub fn matrix_register_token() -> PathBuf { pub fn matrix_appservice_token() -> PathBuf {
state_root().join("matrix-register-token") state_root().join("matrix-appservice-token")
} }
/// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs). /// `/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>, password: Option<&str>,
) -> Result<HostResponse> { ) -> Result<HostResponse> {
require_matrix_present()?; require_matrix_present()?;
let register_token = let as_token =
crate::matrix::ensure_register_token().context("read matrix register token")?; crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
let mut out = Vec::new(); let mut out = Vec::new();
if agent_exists(name)? { 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" "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 .await
.with_context(|| format!("matrix create-user {name}"))?; .with_context(|| format!("matrix create-user {name}"))?;
let path = Coordinator::agent_notes_dir(name).join("matrix-token"); 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( let token = crate::matrix::provision_user_token(
&client, &client,
name.as_str(), name.as_str(),
&register_token, &as_token,
&effective_password, &effective_password,
) )
.await .await
@ -770,10 +770,10 @@ async fn handle_push_snapshot(
async fn handle_matrix_sync_admin() -> Result<HostResponse> { async fn handle_matrix_sync_admin() -> Result<HostResponse> {
require_matrix_present()?; require_matrix_present()?;
let register_token = let as_token =
crate::matrix::ensure_register_token().context("read matrix register token")?; crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
crate::matrix::ensure_admin_user(&client, &register_token) crate::matrix::ensure_admin_user(&client, &as_token)
.await .await
.context("matrix sync-admin")?; .context("matrix sync-admin")?;
let path = crate::matrix::admin_token_path(); let path = crate::matrix::admin_token_path();