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;