From 47e9c1cc1baf7344931df4c5786507e5ac114c74 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 3 Jun 2026 20:44:56 +0200 Subject: [PATCH 1/4] fix(#1185): move matrix-password outside purgeable agent_state_root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The password file was stored at agent_notes_dir/matrix-password which lives inside agent_state_root — wiped by destroy --purge. On re-spawn with the same agent name, the matrix user still exists in the homeserver but the stored password is gone, making re-login impossible. Move password to /var/lib/hyperhive/matrix-creds/-password which is not deleted by purge. On re-spawn, ensure_user_for finds M_USER_IN_USE, reads the preserved password, re-logins, and writes a fresh token. Also: - add one-time migration that moves existing passwords from the old path to the new location on first access after upgrade - remove chown_to_agent on the password file (it is now host-only, not inside the agent bind-mount tree) - fix the error message to give actionable recovery steps instead of suggesting hivectl matrix create-user --password which is rejected for agent accounts --- hive-c0re/src/matrix.rs | 59 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index fb7a349a..9a18876d 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -45,10 +45,27 @@ fn token_path(name: &str) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-token") } -/// Password file alongside the token. Persisted so we can fall back to -/// `m.login.password` if the token file is deleted but the homeserver -/// account still exists. Mode 0600, same dir as the token. +/// Password file for the agent's matrix account. Stored OUTSIDE the +/// purgeable `agent_state_root` tree so it survives `destroy --purge` +/// and allows re-login recovery when the same agent name is re-spawned. +/// +/// Path: `/var/lib/hyperhive/matrix-creds/-password` +/// +/// The token file lives inside the agent's bind-mounted state dir (under +/// `agent_notes_dir`) so the agent container can read it; the password +/// file is host-side only (agents never log in by password — they use +/// the access token exclusively) and belongs with other hive-c0re +/// credential state, not inside the purgeable per-agent tree. fn password_path(name: &str) -> PathBuf { + PathBuf::from("/var/lib/hyperhive/matrix-creds").join(format!("{name}-password")) +} + +/// Legacy password path (inside the old purgeable `agent_notes_dir`). +/// Used only during the one-time migration in [`ensure_user_for`] to +/// move credentials from old deployments to the new location. Safe to +/// call after `destroy --purge` — the path will simply not exist and +/// the migration is a no-op. +fn legacy_password_path(name: &str) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-password") } @@ -276,6 +293,29 @@ pub async fn ensure_user_for( return Ok(()); } + // One-time migration: move the password from the old location inside + // agent_notes_dir (purgeable) to the new location outside it. + let new_pw_path = password_path(name); + let old_pw_path = legacy_password_path(name); + if !new_pw_path.exists() && old_pw_path.exists() { + if let Some(parent) = new_pw_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + if let Err(e) = std::fs::rename(&old_pw_path, &new_pw_path) { + // Rename across filesystems or read-only src — copy + delete. + if let Ok(content) = std::fs::read(&old_pw_path) { + if std::fs::write(&new_pw_path, &content).is_ok() { + let _ = std::fs::remove_file(&old_pw_path); + tracing::info!(%name, "matrix: migrated password file to non-purgeable location"); + } + } else { + tracing::warn!(%name, error = ?e, "matrix: password migration failed (old path stays)"); + } + } else { + tracing::info!(%name, "matrix: migrated password file to non-purgeable location"); + } + } + let password = random_password()?; let access_token = match register_user(client, name, register_token, &password).await { Ok(token) => { @@ -289,7 +329,6 @@ pub async fn ensure_user_for( tracing::warn!(%name, error = ?e, "matrix: failed to persist password (token still saved)"); } else { let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); - crate::lifecycle::chown_to_agent(name, &pw_path, "matrix"); } token } @@ -303,8 +342,16 @@ pub async fn ensure_user_for( .filter(|s| !s.is_empty()) .with_context(|| { format!( - "matrix: user {name} already exists in homeserver but matrix-password \ - is missing — manual recovery: hivectl matrix create-user {name} --password " + "matrix: user {name} already exists in homeserver but the stored \ + password is missing — manual recovery:\n\ + 1. reset the password via the matrix admin API:\n\ + curl -X PUT http://localhost:8008/_synapse/admin/v2/users/@{name}: \\\n\ + -H 'Authorization: Bearer ' \\\n\ + -d '{{\"password\": \"\"}}'\n\ + 2. write the new password to {pw_path}:\n\ + echo '' > {pw_path} && chmod 600 {pw_path}\n\ + 3. run: hivectl matrix create-user {name}", + pw_path = pw_path.display() ) })?; login_user(client, name, &stored).await.with_context(|| { From 8757dc615d92737512c4a123d799e8ec6ed2a907 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 3 Jun 2026 21:11:46 +0200 Subject: [PATCH 2/4] feat: hive matrix admin user + hivectl matrix promote-user/reset-password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - provision @hive: as the first matrix account in ensure_all() (Conduit/tuwunel makes the first registered user admin automatically) - add hivectl matrix sync-admin — manual re-provision of the admin token - add hivectl matrix promote-user --server — promote via Synapse-compat admin API using the hive admin token - add hivectl matrix reset-password --server — reset an agent's password + persist it so ensure_user_for can re-login; follow with hivectl matrix create-user to mint a fresh access token - both commands fall back to HYPERHIVE_MATRIX_SERVER_NAME env var for --server when omitted --- hive-c0re/src/bin/hivectl.rs | 126 ++++++++++++++++++++++++ hive-c0re/src/matrix.rs | 183 +++++++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+) diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index c847edda..99de95d3 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -150,6 +150,39 @@ enum MatrixCmd { #[arg(long, conflicts_with = "password")] password_stdin: bool, }, + /// Provision (or re-provision) the hive system admin matrix account + /// (`@hive:`). hive-c0re runs this automatically on startup + /// before the agent sweep so the account is the first registered + /// user — Conduit/tuwunel grants admin rights to the first user. + /// Run manually to recover a missing admin token file. + SyncAdmin, + /// Promote a matrix user to homeserver admin via the admin API. + /// Uses the hive system admin token at + /// `/var/lib/hyperhive/matrix-admin-token`. Requires `--server` to + /// be the matrix `server_name` (e.g. `pr1ma.darkest.space`). + PromoteUser { + /// Matrix localpart of the user to promote (e.g. `argus`). + name: String, + /// Matrix server_name embedded in user IDs (`@user:`). + /// Falls back to the `HYPERHIVE_MATRIX_SERVER_NAME` env var + /// when omitted. + #[arg(long)] + server: Option, + }, + /// Reset a matrix user's password via the admin API and persist the + /// new password to `/var/lib/hyperhive/matrix-creds/-password` + /// so the next `ensure_user_for` (or `create-user`) can re-login. + /// + /// After this command succeeds, run `hivectl matrix create-user + /// ` to mint a fresh access token for the agent. + ResetPassword { + /// Matrix localpart of the account to reset (e.g. `argus`). + name: String, + /// Matrix server_name embedded in user IDs. Falls back to the + /// `HYPERHIVE_MATRIX_SERVER_NAME` env var when omitted. + #[arg(long)] + server: Option, + }, } /// Default htpasswd file path — the host-side location of the gateway's @@ -250,6 +283,15 @@ async fn main() -> Result<()> { password, password_stdin, } => matrix_create_user(&name, password.as_deref(), password_stdin).await, + MatrixCmd::SyncAdmin => matrix_sync_admin().await, + MatrixCmd::PromoteUser { name, server } => { + let server_name = resolve_server_name(server.as_deref())?; + matrix_promote_user(&name, &server_name).await + } + MatrixCmd::ResetPassword { name, server } => { + let server_name = resolve_server_name(server.as_deref())?; + matrix_reset_password(&name, &server_name).await + } }, Cmd::Gateway { cmd } => match cmd { GatewayCmd::CreateUser { @@ -399,6 +441,90 @@ async fn matrix_create_user( Ok(()) } +/// Resolve matrix server_name from flag or env var. +fn resolve_server_name(flag: Option<&str>) -> Result { + if let Some(s) = flag { + return Ok(s.to_owned()); + } + std::env::var("HYPERHIVE_MATRIX_SERVER_NAME") + .map_err(|_| anyhow::anyhow!( + "matrix server_name required — pass --server or \ + set HYPERHIVE_MATRIX_SERVER_NAME" + )) +} + +async fn matrix_sync_admin() -> Result<()> { + if !hive_c0re::matrix::is_present().await { + bail!( + "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" + ); + } + let register_token = + hive_c0re::matrix::ensure_register_token().context("read matrix register token")?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("build reqwest client")?; + hive_c0re::matrix::ensure_admin_user(&client, ®ister_token) + .await + .context("matrix sync-admin")?; + let path = hive_c0re::matrix::admin_token_path(); + println!( + "matrix: hive admin user '@{}' provisioned", + hive_c0re::matrix::HIVE_ADMIN_LOCALPART + ); + println!("token persisted at: {}", path.display()); + Ok(()) +} + +async fn matrix_promote_user(name: &str, server_name: &str) -> Result<()> { + if !hive_c0re::matrix::is_present().await { + bail!( + "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" + ); + } + let admin_token = hive_c0re::matrix::read_admin_token()?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("build reqwest client")?; + hive_c0re::matrix::promote_user_to_admin(&client, &admin_token, name, server_name) + .await + .with_context(|| format!("matrix promote-user {name}"))?; + println!("matrix: promoted @{name}:{server_name} to admin"); + Ok(()) +} + +async fn matrix_reset_password(name: &str, server_name: &str) -> Result<()> { + if !hive_c0re::matrix::is_present().await { + bail!( + "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" + ); + } + let admin_token = hive_c0re::matrix::read_admin_token()?; + let new_password = + hive_c0re::matrix::random_password().context("generate random password")?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("build reqwest client")?; + hive_c0re::matrix::reset_user_password( + &client, + &admin_token, + name, + server_name, + &new_password, + ) + .await + .with_context(|| format!("matrix reset-password {name}"))?; + let pw_path = PathBuf::from("/var/lib/hyperhive/matrix-creds") + .join(format!("{name}-password")); + println!("matrix: password for @{name}:{server_name} reset"); + println!("password persisted at: {}", pw_path.display()); + println!("next: hivectl matrix create-user {name} # mints a fresh access token"); + Ok(()) +} + // --------------------------------------------------------------------------- // Gateway htpasswd helpers // --------------------------------------------------------------------------- diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 9a18876d..6d713204 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -39,6 +39,18 @@ 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. +pub const HIVE_ADMIN_LOCALPART: &str = "hive"; + +/// Host path for the hive admin matrix access token. Outside every +/// purgeable path — not deleted by `destroy --purge` on any agent. +pub fn admin_token_path() -> PathBuf { + PathBuf::from("/var/lib/hyperhive/matrix-admin-token") +} + /// Token file inside the agent's bind-mounted state dir (visible as /// `/state/matrix-token` from inside the container). fn token_path(name: &str) -> PathBuf { @@ -439,6 +451,171 @@ pub async fn sync_agent_standalone(name: &str) { sync_agent(&client, name, ®ister_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). +/// +/// 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 --server ` or the conduit admin room. +pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let path = admin_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"); + return Ok(()); + } + let password = random_password()?; + let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, register_token, &password) + .await + { + Ok(token) => { + let pw_path = password_path(HIVE_ADMIN_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"); + } else { + let _ = std::fs::set_permissions( + &pw_path, + std::fs::Permissions::from_mode(0o600), + ); + } + 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"); + let pw_path = password_path(HIVE_ADMIN_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 but password missing at {} — \ + manual recovery: reset password via admin API or conduit admin room", + pw_path.display() + ) + })?; + login_user(client, HIVE_ADMIN_LOCALPART, &stored).await? + } + Err(other) => return Err(other), + }; + 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"); + Ok(()) +} + +/// Call `PUT /_synapse/admin/v2/users/@{localpart}:{server_name}` with +/// `{"admin": true}` to promote a user to homeserver admin. +/// Requires the hive admin access token at [`admin_token_path()`]. +pub async fn promote_user_to_admin( + client: &reqwest::Client, + admin_token: &str, + localpart: &str, + server_name: &str, +) -> Result<()> { + // URL-encode the @user:server path segment manually — only `@` and + // `:` need escaping; localpart + server_name use only safe chars. + let url = format!( + "{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}" + ); + let resp = client + .put(&url) + .bearer_auth(admin_token) + .json(&serde_json::json!({"admin": true})) + .send() + .await + .context("matrix: PUT /_synapse/admin/v2/users (promote)")?; + let status = resp.status(); + if status.is_success() { + return Ok(()); + } + let body = resp.json::().await.unwrap_or_default(); + anyhow::bail!( + "matrix: promote @{localpart}:{server_name} to admin: HTTP {status}, body: {body}\n\ + note: tuwunel must implement /_synapse/admin/v2/users; if 404 use the conduit admin room instead" + ) +} + +/// Call `PUT /_synapse/admin/v2/users/@{localpart}:{server_name}` with +/// `{"password": new_password}` to reset a user's password. +/// Writes the new password to the non-purgeable creds path so +/// [`ensure_user_for`] can re-login on next provisioning sweep. +pub async fn reset_user_password( + client: &reqwest::Client, + admin_token: &str, + localpart: &str, + server_name: &str, + new_password: &str, +) -> Result<()> { + let url = format!( + "{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}" + ); + let resp = client + .put(&url) + .bearer_auth(admin_token) + .json(&serde_json::json!({"password": new_password})) + .send() + .await + .context("matrix: PUT /_synapse/admin/v2/users (reset password)")?; + let status = resp.status(); + if status.is_success() { + // Persist the new password so ensure_user_for can re-login. + let pw_path = password_path(localpart); + if let Some(parent) = pw_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + if let Err(e) = std::fs::write(&pw_path, format!("{new_password}\n")) { + tracing::warn!(%localpart, error = ?e, "matrix: failed to persist reset password"); + } else { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + &pw_path, + std::fs::Permissions::from_mode(0o600), + ); + } + return Ok(()); + } + let body = resp.json::().await.unwrap_or_default(); + anyhow::bail!( + "matrix: reset password for @{localpart}:{server_name}: HTTP {status}, body: {body}\n\ + note: tuwunel must implement /_synapse/admin/v2/users; if 404 use the conduit admin room instead" + ) +} + +/// 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 { + let path = admin_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 {} — \ + ensure hive-c0re has started at least once with matrix enabled \ + (it provisions the admin account on boot)", + path.display() + ) + }) +} + /// Sweep every existing container (manager + sub-agents) and ensure /// each has a matrix user + token on the local homeserver. Called once /// at hive-c0re startup, alongside `forge::ensure_all`. No-op when the @@ -468,6 +645,12 @@ pub async fn ensure_all() { return; } }; + // 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, ®ister_token).await { + tracing::warn!(error = ?e, "matrix: ensure_admin_user failed"); + } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("matrix: nixos-container list failed; skipping user sweep"); return; From c4a8b90236e2fb0213c82a249a571c6b0eb59ff3 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 3 Jun 2026 21:15:22 +0200 Subject: [PATCH 3/4] fix: drop --server flag from hivectl matrix; discover server_name from homeserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add matrix::discover_server_name() via GET /_matrix/key/v2/server (unauthenticated federation endpoint, always returns server_name). hivectl is always talking to the local hive — no reason to require the operator to spell out the server_name. --- hive-c0re/src/bin/hivectl.rs | 49 +++++++++++------------------------- hive-c0re/src/matrix.rs | 28 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 99de95d3..9f190cfd 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -158,16 +158,11 @@ enum MatrixCmd { SyncAdmin, /// Promote a matrix user to homeserver admin via the admin API. /// Uses the hive system admin token at - /// `/var/lib/hyperhive/matrix-admin-token`. Requires `--server` to - /// be the matrix `server_name` (e.g. `pr1ma.darkest.space`). + /// `/var/lib/hyperhive/matrix-admin-token`. The server_name is + /// discovered automatically from the running homeserver. PromoteUser { /// Matrix localpart of the user to promote (e.g. `argus`). name: String, - /// Matrix server_name embedded in user IDs (`@user:`). - /// Falls back to the `HYPERHIVE_MATRIX_SERVER_NAME` env var - /// when omitted. - #[arg(long)] - server: Option, }, /// Reset a matrix user's password via the admin API and persist the /// new password to `/var/lib/hyperhive/matrix-creds/-password` @@ -178,10 +173,6 @@ enum MatrixCmd { ResetPassword { /// Matrix localpart of the account to reset (e.g. `argus`). name: String, - /// Matrix server_name embedded in user IDs. Falls back to the - /// `HYPERHIVE_MATRIX_SERVER_NAME` env var when omitted. - #[arg(long)] - server: Option, }, } @@ -284,14 +275,8 @@ async fn main() -> Result<()> { password_stdin, } => matrix_create_user(&name, password.as_deref(), password_stdin).await, MatrixCmd::SyncAdmin => matrix_sync_admin().await, - MatrixCmd::PromoteUser { name, server } => { - let server_name = resolve_server_name(server.as_deref())?; - matrix_promote_user(&name, &server_name).await - } - MatrixCmd::ResetPassword { name, server } => { - let server_name = resolve_server_name(server.as_deref())?; - matrix_reset_password(&name, &server_name).await - } + MatrixCmd::PromoteUser { name } => matrix_promote_user(&name).await, + MatrixCmd::ResetPassword { name } => matrix_reset_password(&name).await, }, Cmd::Gateway { cmd } => match cmd { GatewayCmd::CreateUser { @@ -441,18 +426,6 @@ async fn matrix_create_user( Ok(()) } -/// Resolve matrix server_name from flag or env var. -fn resolve_server_name(flag: Option<&str>) -> Result { - if let Some(s) = flag { - return Ok(s.to_owned()); - } - std::env::var("HYPERHIVE_MATRIX_SERVER_NAME") - .map_err(|_| anyhow::anyhow!( - "matrix server_name required — pass --server or \ - set HYPERHIVE_MATRIX_SERVER_NAME" - )) -} - async fn matrix_sync_admin() -> Result<()> { if !hive_c0re::matrix::is_present().await { bail!( @@ -477,7 +450,7 @@ async fn matrix_sync_admin() -> Result<()> { Ok(()) } -async fn matrix_promote_user(name: &str, server_name: &str) -> Result<()> { +async fn matrix_promote_user(name: &str) -> Result<()> { if !hive_c0re::matrix::is_present().await { bail!( "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" @@ -488,14 +461,17 @@ async fn matrix_promote_user(name: &str, server_name: &str) -> Result<()> { .timeout(std::time::Duration::from_secs(30)) .build() .context("build reqwest client")?; - hive_c0re::matrix::promote_user_to_admin(&client, &admin_token, name, server_name) + let server_name = hive_c0re::matrix::discover_server_name(&client) + .await + .context("discover matrix server_name")?; + hive_c0re::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name) .await .with_context(|| format!("matrix promote-user {name}"))?; println!("matrix: promoted @{name}:{server_name} to admin"); Ok(()) } -async fn matrix_reset_password(name: &str, server_name: &str) -> Result<()> { +async fn matrix_reset_password(name: &str) -> Result<()> { if !hive_c0re::matrix::is_present().await { bail!( "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" @@ -508,11 +484,14 @@ async fn matrix_reset_password(name: &str, server_name: &str) -> Result<()> { .timeout(std::time::Duration::from_secs(30)) .build() .context("build reqwest client")?; + let server_name = hive_c0re::matrix::discover_server_name(&client) + .await + .context("discover matrix server_name")?; hive_c0re::matrix::reset_user_password( &client, &admin_token, name, - server_name, + &server_name, &new_password, ) .await diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 6d713204..5621e2ba 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -598,6 +598,34 @@ pub async fn reset_user_password( ) } +/// Discover the matrix `server_name` from the running homeserver via +/// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint). +/// The response JSON always includes `"server_name"` per the matrix spec. +pub async fn discover_server_name(client: &reqwest::Client) -> Result { + let url = format!("{MATRIX_HTTP}/_matrix/key/v2/server"); + let resp = client + .get(&url) + .send() + .await + .context("matrix: GET /_matrix/key/v2/server")?; + let status = resp.status(); + let body = resp + .json::() + .await + .context("matrix: parse /_matrix/key/v2/server response")?; + if !status.is_success() { + anyhow::bail!( + "matrix: /_matrix/key/v2/server returned HTTP {status}, body: {body}" + ); + } + body["server_name"] + .as_str() + .map(str::to_owned) + .with_context(|| { + format!("matrix: /_matrix/key/v2/server response missing server_name field: {body}") + }) +} + /// 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 { From a604fbf197ece79a98d545e7bf435d573bbf2015 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 3 Jun 2026 21:21:05 +0200 Subject: [PATCH 4/4] fix: address argus review nits on matrix admin additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop stale --server flag reference from ensure_admin_user doc comment - simplify M_USER_IN_USE recovery message: point at hivectl commands - add #[must_use] to admin_token_path() - rename tracing field rename_error in migration warn log (was error, which held rename err but fired on read failure — misleading) --- hive-c0re/src/matrix.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 5621e2ba..f44e62e0 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -47,6 +47,7 @@ pub const HIVE_ADMIN_LOCALPART: &str = "hive"; /// Host path for the hive admin matrix access token. Outside every /// purgeable path — not deleted by `destroy --purge` on any agent. +#[must_use] pub fn admin_token_path() -> PathBuf { PathBuf::from("/var/lib/hyperhive/matrix-admin-token") } @@ -321,7 +322,7 @@ pub async fn ensure_user_for( tracing::info!(%name, "matrix: migrated password file to non-purgeable location"); } } else { - tracing::warn!(%name, error = ?e, "matrix: password migration failed (old path stays)"); + tracing::warn!(%name, rename_error = ?e, "matrix: password migration failed — could not read old path (old path stays)"); } } else { tracing::info!(%name, "matrix: migrated password file to non-purgeable location"); @@ -355,15 +356,9 @@ pub async fn ensure_user_for( .with_context(|| { format!( "matrix: user {name} already exists in homeserver but the stored \ - password is missing — manual recovery:\n\ - 1. reset the password via the matrix admin API:\n\ - curl -X PUT http://localhost:8008/_synapse/admin/v2/users/@{name}: \\\n\ - -H 'Authorization: Bearer ' \\\n\ - -d '{{\"password\": \"\"}}'\n\ - 2. write the new password to {pw_path}:\n\ - echo '' > {pw_path} && chmod 600 {pw_path}\n\ - 3. run: hivectl matrix create-user {name}", - pw_path = pw_path.display() + password is missing — run:\n\ + hivectl matrix reset-password {name}\n\ + hivectl matrix create-user {name}" ) })?; login_user(client, name, &stored).await.with_context(|| { @@ -462,7 +457,7 @@ pub async fn sync_agent_standalone(name: &str) { /// 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 --server ` or the conduit admin room. +/// `hivectl matrix promote-user hive` or the conduit admin room. pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; let path = admin_token_path();