diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index c847edda..9f190cfd 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -150,6 +150,30 @@ 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`. The server_name is + /// discovered automatically from the running homeserver. + PromoteUser { + /// Matrix localpart of the user to promote (e.g. `argus`). + name: String, + }, + /// 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, + }, } /// Default htpasswd file path — the host-side location of the gateway's @@ -250,6 +274,9 @@ 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 } => matrix_promote_user(&name).await, + MatrixCmd::ResetPassword { name } => matrix_reset_password(&name).await, }, Cmd::Gateway { cmd } => match cmd { GatewayCmd::CreateUser { @@ -399,6 +426,84 @@ async fn matrix_create_user( Ok(()) } +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) -> 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")?; + 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) -> 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")?; + 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, + &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 fb7a349a..f44e62e0 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -39,16 +39,46 @@ 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. +#[must_use] +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 { 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 +306,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, 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"); + } + } + let password = random_password()?; let access_token = match register_user(client, name, register_token, &password).await { Ok(token) => { @@ -289,7 +342,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 +355,10 @@ 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 — run:\n\ + hivectl matrix reset-password {name}\n\ + hivectl matrix create-user {name}" ) })?; login_user(client, name, &stored).await.with_context(|| { @@ -392,6 +446,199 @@ 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 +/// `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(); + 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" + ) +} + +/// 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 { + 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 @@ -421,6 +668,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;