matrix: mint the appservice sender token in the matrix container

A swarm runs one homeserver and a homeserver has one appservice sender
account, so "mint it once" is a property of the thing being minted
rather than something a lock has to enforce. That is what makes this
account the one to move first: no trigger route, no controller change
and no agent list — a boot-time oneshot beside tuwunel is the whole
mechanism.

`swarm-matrix-minter` runs inside `containers.hive-matrix`, which
already holds the appservice token: the rendered registration is bound
in read-only because that is how tuwunel is handed it. What the
container lacked was an identity of its own, so this adds one — a leaf
from the store's CA with a grant of exactly one path, not the hive's
leaf, which reads every secret in the store.

Both ends of the credential ship here. The minter reads the path it
publishes to before it touches the homeserver, and returning on a
non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user`
reads the same path, authenticating with the hive name already in
`HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login
ladder stays as the fallback for a store that is empty, unconfigured or
unreachable, which is every swarm deployed before this — so nothing
needs backfilling and nothing breaks if the rest of the sequence never
lands.

The credential is not an admin credential, and is not named like one.
It is the access token of the appservice registration's own
`sender_localpart` — `@hive:<server_name>`, an account the homeserver
creates for itself when it loads the registration. The store path is
`swarm/services/matrix/sender-token`, the host path is
`matrix/access-token`, and the homeserver no longer runs an
`admin_execute` promotion for that account at boot. Everything the hive
provisions with it — the Space, the chat room, their hierarchy and join
rules, the invites — rides on being the creator of those rooms at power
level 100, not on homeserver admin; there is no Synapse admin API here
to need, tuwunel has none.

Two operations do need an admin *sender* and therefore stop working:
`hivectl matrix promote-user` and `hivectl matrix reset-password`, both
`!admin …` messages into `#admins:<server>`, plus the password-reset
recovery path that an agent with a lost password file falls back to.
They are swarm-level operations and are left failing loudly rather than
served by an over-privileged token every other call site would also
carry. The sweep's own admin-rights check and self-repair go with them:
an account that is deliberately not an admin has nothing to check.

`ephemeral = false` stays, and hive root can still read the container's
filesystem. Accepted: what this buys is identity separation — no hive
*process* holds or reads the appservice token — not physical isolation.

Refs #4345
This commit is contained in:
atlas 2026-09-20 13:42:17 +02:00 committed by mara
commit f778122f5a
28 changed files with 1566 additions and 320 deletions

View file

@ -71,12 +71,15 @@ const PASSWORD_BYTES: usize = 32;
/// 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";
/// startup, before it accepts a request. See
/// `nix/host-modules/hive-matrix.nix`, where this same literal appears as
/// `hiveLocalpart`. **The two must match**; nothing wires an override
/// across.
///
/// An ordinary account, with no homeserver-admin standing: what it
/// provisions — the Space, the chat room, the invites — it provisions as
/// the creator of those rooms.
pub const HIVE_LOCALPART: &str = "hive";
/// Display name of the hive Space. Plain text, no special characters, so
/// the Space stays rediscoverable by name (no room alias needed) even when
@ -94,11 +97,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat";
const HIVE_CHAT_ROOM_TOPIC: &str =
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re.";
/// Host path for the hive admin matrix access token. Outside every
/// Host path for the `@hive:` account's matrix access token. Outside every
/// purgeable path — not deleted by `destroy --purge` on any agent.
#[must_use]
pub fn admin_token_path() -> PathBuf {
crate::paths::matrix_admin_token()
pub fn hive_token_path() -> PathBuf {
crate::paths::matrix_hive_token()
}
/// Token file inside the agent's bind-mounted state dir (visible as
@ -382,21 +385,26 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
extract_access_token(&json)
}
/// Auto-recovery helper: reset a user's matrix password via the admin API
/// when the locally stored password is missing. Requires a valid hive admin
/// token at [`admin_token_path()`]. Returns the new password (already
/// persisted to [`password_path`]) on success.
/// Auto-recovery helper: reset a user's matrix password through the admin
/// room when the locally stored password is missing. Returns the new
/// password (already persisted to [`password_path`]) on success.
///
/// ⚠️ Needs an **admin sender**, which `@hive:` is not — the reset is a
/// `!admin` command and tuwunel only treats a message as a command when
/// its sender is an admin in that room. So this recovery path fails until
/// the two admin operations are rehomed at swarm level; the ordinary
/// route (the stored password, or an appservice login) is unaffected.
///
/// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE`
/// but the password file is absent — covers the case where agent state dirs
/// were wiped but the homeserver still has the accounts.
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
let admin_token = read_admin_token()
.context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?;
let hive_token = read_hive_token()
.context("matrix: the @hive: access token is unavailable for auto-recovery")?;
let server_name = discover_server_name(client)
.await
.context("matrix: discover_server_name for auto-recovery")?;
let effective_password = reset_user_password(client, &admin_token, name, &server_name)
let effective_password = reset_user_password(client, &hive_token, name, &server_name)
.await
.with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?;
tracing::info!(%name, "matrix: auto-recovered password via admin-room reset");
@ -416,7 +424,7 @@ fn encode_room_id_for_url(room_id: &str) -> String {
/// Look up the room ID for the `#admins:<server>` alias.
async fn discover_admin_room_id(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
server_name: &str,
) -> Result<String> {
let base = matrix_base()?;
@ -425,7 +433,7 @@ async fn discover_admin_room_id(
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
let resp = client
.get(&url)
.bearer_auth(admin_token)
.bearer_auth(hive_token)
.send()
.await
.context("matrix: GET admin room alias")?;
@ -553,7 +561,7 @@ mod extract_new_password_tests {
/// Generic over `T` so both password-returning and `()` callers share the loop.
async fn admin_room_send_and_poll<T>(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
server_name: &str,
room_url: &str,
command: &str,
@ -566,7 +574,7 @@ async fn admin_room_send_and_poll<T>(
format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
let send_resp = client
.put(&send_url)
.bearer_auth(admin_token)
.bearer_auth(hive_token)
.json(&serde_json::json!({"msgtype": "m.text", "body": command}))
.send()
.await
@ -587,13 +595,13 @@ async fn admin_room_send_and_poll<T>(
// 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!("@{HIVE_ADMIN_LOCALPART}:{server_name}");
let own_user_id = format!("@{HIVE_LOCALPART}:{server_name}");
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(admin_token)
.bearer_auth(hive_token)
.send()
.await
.context("matrix: admin room poll")?
@ -647,16 +655,16 @@ async fn admin_room_send_and_poll<T>(
/// Returns the new password; caller is responsible for persisting it.
async fn admin_room_reset_password(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
server_name: &str,
localpart: &str,
) -> Result<String> {
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
let room_id = discover_admin_room_id(client, hive_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,
admin_token,
hive_token,
server_name,
&room_url,
&command,
@ -761,13 +769,15 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st
{
pw
} else {
// Password file missing — attempt auto-recovery via admin API.
// Password file missing — attempt auto-recovery through the
// admin room.
// This covers the case where agent state dirs were wiped but the
// homeserver still has the accounts. Requires the hive admin
// token at /var/lib/hyperhive/matrix/admin-token.
// homeserver still has the accounts. Requires the `@hive:`
// token at /var/lib/hyperhive/matrix/access-token, and an admin
// sender, which `@hive:` no longer is.
tracing::info!(
%name,
"matrix: stored password missing, attempting admin-API auto-recovery"
"matrix: stored password missing, attempting admin-room auto-recovery"
);
match auto_reset_password(client, name).await {
Ok(new_pw) => new_pw,
@ -883,42 +893,49 @@ pub async fn sync_agent_standalone(name: &str) {
sync_agent(&client, name, &as_token).await;
}
/// Ensure the hive system admin matrix user exists, that its token is
/// persisted at [`admin_token_path()`], and that it actually holds admin
/// rights.
/// Ensure the `@hive:` matrix user exists and that its access token is
/// persisted at [`hive_token_path()`].
///
/// **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.
/// **Nothing here depends on registration order, and nothing here is
/// privileged.** 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. It is now an
/// ordinary account: the homeserver creates it because it is the
/// appservice registration's `sender_localpart`, and everything the hive
/// provisions with it, it provisions as the creator of those rooms.
///
/// 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<()> {
///
/// The token is taken from the **swarm secret store** when it is there:
/// `swarm-matrix-minter`, the oneshot inside the matrix container, publishes
/// it under an identity of its own, and taking it from there is what lets a
/// hive that holds no `as_token` have an admin at all. The mint ladder below
/// stays as the fallback for a store that is empty, unconfigured or
/// unreachable — which is every swarm whose matrix container predates that
/// minter.
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let path = admin_token_path();
let path = hive_token_path();
if path.exists()
&& let Ok(existing) = std::fs::read_to_string(&path)
&& !existing.trim().is_empty()
{
tracing::debug!("matrix: hive admin token already present");
tracing::debug!("matrix: the @hive: access token is already present");
return Ok(());
}
if let Some(token) = stored_hive_token().await {
return persist_hive_token(&path, &token);
}
let password = random_password()?;
let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await
{
let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await {
Ok(token) => {
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
let pw_path = password_path(HIVE_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!(error = ?e, "matrix: failed to persist hive admin password");
tracing::warn!(error = ?e, "matrix: failed to persist the @hive: account password");
} else {
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
}
@ -931,125 +948,104 @@ pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Resu
// 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 {
tracing::info!("matrix: the @hive: user already exists, logging in as the appservice");
match appservice_login(client, as_token, HIVE_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);
tracing::warn!(error = ?e, "matrix: appservice login for @hive: failed; falling back to the stored password");
let pw_path = password_path(HIVE_LOCALPART);
let stored = std::fs::read_to_string(&pw_path)
.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 \
"matrix: the @hive: 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 \
namespace covers @{HIVE_LOCALPART} and that the homeserver \
loaded it",
pw_path.display()
)
})?;
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
login_user(client, HIVE_LOCALPART, &stored).await?
}
}
}
Err(other) => return Err(other),
};
persist_hive_token(&path, &access_token)
}
/// Write the `@hive:` account's access token to `path`, 0600, creating the
/// directory if it is not there.
///
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into
/// the store one: the file's mode is the only thing keeping an unprivileged
/// reader off the hive's matrix credential, and a second copy of that decision
/// is one that can be edited alone.
fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, format!("{access_token}\n"))
.with_context(|| format!("matrix: write hive admin token to {}", path.display()))?;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
tracing::info!(path = %path.display(), "matrix: provisioned hive admin token");
std::fs::write(path, format!("{access_token}\n")).with_context(|| {
format!(
"matrix: write the @hive: access token to {}",
path.display()
)
})?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
tracing::info!(path = %path.display(), "matrix: provisioned the @hive: access token");
Ok(())
}
/// Check that the hive admin account holds admin rights, and repair it
/// through the admin room when it does not.
/// Fetch the `@hive:` access token `swarm-matrix-minter` published, under
/// this hive's own store identity.
///
/// 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.
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
/// the same role string `workers::credential` logs in with, and already in
/// this process's environment, so the store read costs no plumbing through
/// [`ensure_all`]. No new grant either: a hive's policy already covers the
/// whole `swarm/services/*` tree the minter writes into.
///
/// 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.
/// `None`, never an error, for every way this can come up empty — no hive
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
/// four mean the same thing to the caller ("mint it the old way"), and three
/// of them are the ordinary state of a swarm that has not deployed the minter
/// yet, so raising would turn a supported deployment into a warning every
/// sweep.
///
/// Returns whether the account holds admin rights now. A `false` has
/// already been 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,
/// 🩸 Logs the store **path** and never the value.
async fn stored_hive_token() -> Option<String> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|h| !h.is_empty())?;
let path = swarm_secret_client::matrix::hive_token_path();
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
Ok(store) => store,
Err(e) => {
tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights");
return false;
tracing::debug!(error = %e, "matrix: no swarm secret store to read the @hive: access token from");
return None;
}
};
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
match store
.read::<swarm_secret_client::matrix::Credential>(&path)
.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;
Ok(credential) if !credential.value.trim().is_empty() => {
tracing::info!(%path, "matrix: taking the @hive: access token from the swarm store");
Some(credential.value)
}
Ok(_) => {
tracing::warn!(%path, "matrix: the stored @hive: credential is empty; minting instead");
None
}
Err(e) => {
tracing::debug!(%path, error = %e, "matrix: no @hive: credential in the store; minting instead");
None
}
}
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.
@ -1116,16 +1112,16 @@ mod is_make_admin_success_tests {
/// intended long-term mechanism, not a stopgap awaiting an upstream fix.
pub async fn promote_user_to_admin(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
let room_id = discover_admin_room_id(client, hive_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,
admin_token,
hive_token,
server_name,
&room_url,
&command,
@ -1151,11 +1147,11 @@ pub async fn promote_user_to_admin(
/// Returns the new password for use in subsequent `login_user` calls.
pub async fn reset_user_password(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
localpart: &str,
server_name: &str,
) -> Result<String> {
let pw = admin_room_reset_password(client, admin_token, server_name, localpart)
let pw = admin_room_reset_password(client, hive_token, server_name, localpart)
.await
.with_context(|| {
format!("matrix: admin-room password reset for @{localpart}:{server_name}")
@ -1205,19 +1201,19 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
})
}
/// Read the hive admin access token from disk. Returns an error if it
/// is absent — callers should gate their admin-API calls on this.
pub fn read_admin_token() -> Result<String> {
let path = admin_token_path();
/// Read the `@hive:` access token from disk. Returns an error if it
/// is absent — callers should gate their homeserver calls on this.
pub fn read_hive_token() -> Result<String> {
let path = hive_token_path();
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"hive admin matrix token not found at {} — \
"the @hive: matrix access token was not found at {} — \
ensure hive-c0re has started at least once with matrix enabled \
(it provisions the admin account on boot)",
(it provisions the @hive: account on boot)",
path.display()
)
})
@ -1244,12 +1240,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> {
///
/// Name-based (not alias-based) rediscovery keeps the Space free of any
/// special-char room alias — the hardcoded plain name is the anchor.
async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Option<String> {
let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client
.get(&joined_url)
.bearer_auth(admin_token)
.bearer_auth(hive_token)
.send()
.await
.ok()?
@ -1264,12 +1260,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
let encoded = encode_room_id_for_url(room_id);
// Must be an m.space (m.room.create `type`).
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client
.get(&create_url)
.bearer_auth(admin_token)
.send()
.await
{
let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await {
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1282,7 +1273,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
}
// …and named HIVE_SPACE_NAME (m.room.name `name`).
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await {
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1313,7 +1304,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
///
/// Returns an error if the homeserver is unreachable, `createRoom` fails,
/// or the room-ID file cannot be written.
pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result<String> {
pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Result<String> {
let base = matrix_base()?;
// 1. Stored room id wins (fast path).
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
@ -1326,7 +1317,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R
// 2. No stored id — rediscover the existing space by its hardcoded name
// before creating a new one (prevents duplicate spaces after a wipe).
if let Some(room_id) = find_space_by_name(client, admin_token).await {
if let Some(room_id) = find_space_by_name(client, hive_token).await {
persist_space_room_id(&room_id)?;
tracing::info!(%room_id, "matrix: recovered hive space by name");
return Ok(room_id);
@ -1342,7 +1333,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R
});
let resp = client
.post(&url)
.bearer_auth(admin_token)
.bearer_auth(hive_token)
.json(&body)
.send()
.await
@ -1382,12 +1373,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> {
/// [`find_space_by_name`] so a lost room-id file recovers the existing chat
/// room instead of spawning a duplicate. `None` if the homeserver is
/// unreachable or no match exists.
async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> Option<String> {
let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client
.get(&joined_url)
.bearer_auth(admin_token)
.bearer_auth(hive_token)
.send()
.await
.ok()?
@ -1402,12 +1393,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) ->
let encoded = encode_room_id_for_url(room_id);
// Skip the Space itself (and any other m.space).
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client
.get(&create_url)
.bearer_auth(admin_token)
.send()
.await
{
let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await {
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1420,7 +1406,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) ->
}
// …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`).
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await {
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1460,10 +1446,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::
/// too, loudly — and a 404 is the expected first-setup case.
async fn current_room_state(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
url: &str,
) -> Option<serde_json::Value> {
let resp = match client.get(url).bearer_auth(admin_token).send().await {
let resp = match client.get(url).bearer_auth(hive_token).send().await {
Ok(resp) => resp,
Err(e) => {
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
@ -1491,7 +1477,7 @@ async fn current_room_state(
}
}
/// PUT a state event into `room_id` using the admin token, **skipping the
/// PUT a state event into `room_id` using the `@hive:` token, **skipping the
/// write when the room already carries identical content**.
///
/// The read is not an optimisation. A PUT of identical content is a no-op
@ -1506,7 +1492,7 @@ async fn current_room_state(
/// missing or divergent link still gets written.
async fn set_room_state(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
room_id: &str,
event_type: &str,
state_key: &str,
@ -1517,7 +1503,7 @@ async fn set_room_state(
let encoded_key = encode_room_id_for_url(state_key);
let url =
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
let current = current_room_state(client, admin_token, &url).await;
let current = current_room_state(client, hive_token, &url).await;
if !state_needs_write(current.as_ref(), content) {
tracing::debug!(
%room_id,
@ -1528,7 +1514,7 @@ async fn set_room_state(
}
let resp = client
.put(&url)
.bearer_auth(admin_token)
.bearer_auth(hive_token)
.json(content)
.send()
.await
@ -1565,7 +1551,7 @@ async fn set_room_state(
/// link is logged but not fatal (the room still exists + is joinable).
pub async fn ensure_hive_chat_room(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
space_room_id: &str,
server_name: &str,
) -> Result<String> {
@ -1579,7 +1565,7 @@ pub async fn ensure_hive_chat_room(
{
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
id
} else if let Some(id) = find_chat_room_by_name(client, admin_token).await {
} else if let Some(id) = find_chat_room_by_name(client, hive_token).await {
persist_chat_room_id(&id)?;
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
id
@ -1619,7 +1605,7 @@ pub async fn ensure_hive_chat_room(
});
let resp = client
.post(&url)
.bearer_auth(admin_token)
.bearer_auth(hive_token)
.json(&body)
.send()
.await
@ -1650,7 +1636,7 @@ pub async fn ensure_hive_chat_room(
});
if let Err(e) = set_room_state(
client,
admin_token,
hive_token,
space_room_id,
"m.space.child",
&room_id,
@ -1668,21 +1654,21 @@ pub async fn ensure_hive_chat_room(
/// account. Idempotent — treats already-member responses as success.
async fn invite_to_room(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
room_id: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
let user_id = format!("@{localpart}:{server_name}");
invite_user_id(client, admin_token, room_id, &user_id).await
invite_user_id(client, hive_token, room_id, &user_id).await
}
/// Fetch a user's current membership in a room via the admin token, or
/// Fetch a user's current membership in a room via the `@hive:` token, or
/// `None` if there is no membership event (never invited) or the lookup
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
async fn room_membership(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
encoded_room_id: &str,
user_id: &str,
) -> Option<String> {
@ -1693,12 +1679,7 @@ async fn room_membership(
let url = format!(
"{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}"
);
let resp = client
.get(&url)
.bearer_auth(admin_token)
.send()
.await
.ok()?;
let resp = client.get(&url).bearer_auth(hive_token).send().await.ok()?;
if !resp.status().is_success() {
// 404 = no membership event yet; anything else we treat as "unknown"
// and let the caller fall through to the invite attempt.
@ -1709,13 +1690,13 @@ async fn room_membership(
}
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
/// using the admin token. Idempotent: a user who is already a member or
/// using the `@hive:` token. Idempotent: a user who is already a member or
/// already has a pending invite is left untouched (no fresh invite is sent,
/// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE`
/// from a racing invite is still treated as success.
async fn invite_user_id(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
room_id: &str,
user_id: &str,
) -> Result<()> {
@ -1727,7 +1708,7 @@ async fn invite_user_id(
// Skip the invite entirely when the user is already invited or joined.
// Re-POSTing an invite to a pending member re-sends the invite event,
// which re-notifies the agent on every provisioning sweep.
if let Some(membership) = room_membership(client, admin_token, &encoded_room_id, user_id).await
if let Some(membership) = room_membership(client, hive_token, &encoded_room_id, user_id).await
&& matches!(membership.as_str(), "invite" | "join")
{
tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)");
@ -1737,7 +1718,7 @@ async fn invite_user_id(
let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
let resp = client
.post(&url)
.bearer_auth(admin_token)
.bearer_auth(hive_token)
.json(&serde_json::json!({ "user_id": user_id }))
.send()
.await
@ -1770,13 +1751,13 @@ async fn invite_user_id(
///
/// # Errors
///
/// Returns an error if the admin token or `server_name` can't be read,
/// Returns an error if the `@hive:` token or `server_name` can't be read,
/// the target room can't be resolved (no `--room` and no persisted hive
/// space), or the invite POST fails for a reason other than the user
/// already being a member / invited.
pub async fn invite_user(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
user: &str,
room_override: Option<&str>,
server_name: &str,
@ -1790,7 +1771,7 @@ pub async fn invite_user(
// Resolve the room: explicit override (id or #alias) wins; otherwise
// the persisted hive Space.
let room_id = match room_override {
Some(r) if r.starts_with('#') => resolve_room_alias(client, admin_token, r).await?,
Some(r) if r.starts_with('#') => resolve_room_alias(client, hive_token, r).await?,
Some(r) if r.starts_with('!') => r.to_owned(),
Some(r) => anyhow::bail!(
"matrix: --room {r:?} is neither a room id nor an alias; \
@ -1804,14 +1785,14 @@ pub async fn invite_user(
(run the hive-c0re matrix sweep first)",
)?,
};
invite_user_id(client, admin_token, &room_id, &user_id).await?;
invite_user_id(client, hive_token, &room_id, &user_id).await?;
Ok(room_id)
}
/// Resolve a `#alias:server` to its room id via the directory API.
async fn resolve_room_alias(
client: &reqwest::Client,
admin_token: &str,
hive_token: &str,
alias: &str,
) -> Result<String> {
let base = matrix_base()?;
@ -1819,7 +1800,7 @@ async fn resolve_room_alias(
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
let resp = client
.get(&url)
.bearer_auth(admin_token)
.bearer_auth(hive_token)
.send()
.await
.with_context(|| format!("matrix: GET directory for {alias}"))?;
@ -1875,13 +1856,12 @@ pub async fn ensure_all() -> bool {
return false;
}
};
// 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");
// The `@hive:` account first, because everything below provisions
// THROUGH it (the Space, the chat room and every invite are sent with
// its token) — as an ordinary user that created those rooms, not as a
// homeserver admin.
if let Err(e) = ensure_hive_user(&client, &as_token).await {
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
ok = false;
}
let Ok(containers) = crate::lifecycle::list().await else {
@ -1911,10 +1891,10 @@ pub async fn ensure_all() -> bool {
async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
let mut ok = true;
// server_name is needed to form full Matrix user IDs for invites.
let admin_token = match read_admin_token() {
let hive_token = match read_hive_token() {
Ok(t) => t,
Err(e) => {
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)");
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no @hive: access token)");
return false;
}
};
@ -1926,33 +1906,22 @@ 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 {
let room_id = match ensure_hive_space(client, &hive_token).await {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
return false;
}
};
// Invite @hive admin first, then all agents.
if let Err(e) = invite_to_room(
client,
&admin_token,
&room_id,
HIVE_ADMIN_LOCALPART,
&server_name,
)
.await
// Invite @hive first, then all agents.
if let Err(e) =
invite_to_room(client, &hive_token, &room_id, HIVE_LOCALPART, &server_name).await
{
tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
ok = false;
}
for name in agent_names {
if let Err(e) = invite_to_room(client, &admin_token, &room_id, name, &server_name).await {
if let Err(e) = invite_to_room(client, &hive_token, &room_id, name, &server_name).await {
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
ok = false;
}
@ -1963,13 +1932,13 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
// rooms to chat in (Matrix semantics — children aren't auto-joined), so
// without this the Space is empty. The restricted join rule additionally
// lets the operator (a Space member) join from the Space hierarchy.
match ensure_hive_chat_room(client, &admin_token, &room_id, &server_name).await {
match ensure_hive_chat_room(client, &hive_token, &room_id, &server_name).await {
Ok(chat_room_id) => {
if let Err(e) = invite_to_room(
client,
&admin_token,
&hive_token,
&chat_room_id,
HIVE_ADMIN_LOCALPART,
HIVE_LOCALPART,
&server_name,
)
.await
@ -1979,7 +1948,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
}
for name in agent_names {
if let Err(e) =
invite_to_room(client, &admin_token, &chat_room_id, name, &server_name).await
invite_to_room(client, &hive_token, &chat_room_id, name, &server_name).await
{
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
ok = false;

View file

@ -2,7 +2,7 @@
//! `/run/hyperhive` + `/run/hive-agent` runtime roots).
//!
//! Historically these were flat string literals scattered across many
//! modules (`broker.sqlite`, `matrix-admin-token`, `agent-sockets.json`,
//! modules (`broker.sqlite`, `matrix-hive-token`, `agent-sockets.json`,
//! …). This module is the **single Rust-side source** for every host
//! path — the strictly host-side ones grouped into subdirs (`db/`,
//! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots
@ -128,7 +128,7 @@ pub fn agent_identity_dir(name: &str) -> PathBuf {
agent_identity_root().join(name)
}
/// `matrix/` — host-side matrix provisioning state (admin token, hive
/// `matrix/` — host-side matrix provisioning state (the `@hive:` access token, hive
/// Space room id, per-agent password creds). The shared registration
/// token is bind-mounted into the tuwunel container via nix and stays
/// at its own path (tracked separately).
@ -137,10 +137,10 @@ pub fn matrix_dir() -> PathBuf {
state_root().join("matrix")
}
/// `matrix/admin-token` — hive system admin access token.
/// `matrix/access-token` — the `@hive:` account's matrix access token.
#[must_use]
pub fn matrix_admin_token() -> PathBuf {
matrix_dir().join("admin-token")
pub fn matrix_hive_token() -> PathBuf {
matrix_dir().join("access-token")
}
/// `matrix/space-room-id` — persisted hive Space room id.
@ -327,7 +327,7 @@ pub fn relocate_legacy_state() {
"forge-agent-configs-avatar-set",
forge_config_org_avatar_marker(),
),
("matrix-admin-token", matrix_admin_token()),
("matrix-hive-token", matrix_hive_token()),
("matrix-space-room-id", matrix_space_room_id()),
("matrix-creds", matrix_creds_dir()),
("agent-sockets.json", agent_sockets_file()),

View file

@ -455,7 +455,7 @@ async fn stream_agent_status(
// The `hivectl matrix` subcommands used to run these in-process, which forced
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
// They now run daemon-side over the host socket: the daemon already holds the
// register + admin tokens and the matrix creds dir. Each op returns the
// register + `@hive:` tokens and the matrix creds dir. Each op returns the
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
// for the client to print verbatim.
// ---------------------------------------------------------------------------
@ -777,14 +777,14 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
let as_token =
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?;
crate::matrix::ensure_admin_user(&client, &as_token)
crate::matrix::ensure_hive_user(&client, &as_token)
.await
.context("matrix sync-admin")?;
let path = crate::matrix::admin_token_path();
let path = crate::matrix::hive_token_path();
Ok(HostResponse::messages(vec![
format!(
"matrix: hive admin user '@{}' provisioned",
crate::matrix::HIVE_ADMIN_LOCALPART
"matrix: the @{}: user is provisioned",
crate::matrix::HIVE_LOCALPART
),
format!("token persisted at: {}", path.display()),
]))
@ -792,12 +792,12 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
require_matrix_present()?;
let admin_token = crate::matrix::read_admin_token()?;
let hive_token = crate::matrix::read_hive_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, &admin_token, name, &server_name)
crate::matrix::promote_user_to_admin(&client, &hive_token, name, &server_name)
.await
.with_context(|| format!("matrix promote-user {name}"))?;
Ok(HostResponse::messages(vec![format!(
@ -807,12 +807,12 @@ async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
require_matrix_present()?;
let admin_token = crate::matrix::read_admin_token()?;
let hive_token = crate::matrix::read_hive_token()?;
let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client)
.await
.context("discover matrix server_name")?;
let room_id = crate::matrix::invite_user(&client, &admin_token, user, room, &server_name)
let room_id = crate::matrix::invite_user(&client, &hive_token, user, room, &server_name)
.await
.with_context(|| format!("matrix invite {user}"))?;
let target = if user.starts_with('@') {
@ -827,12 +827,12 @@ 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 admin_token = crate::matrix::read_admin_token()?;
let hive_token = crate::matrix::read_hive_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, &admin_token, name, &server_name)
crate::matrix::reset_user_password(&client, &hive_token, name, &server_name)
.await
.with_context(|| format!("matrix reset-password {name}"))?;
// Password is persisted by reset_user_password.