diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 9f190cfd..c847edda 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -150,30 +150,6 @@ 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 @@ -274,9 +250,6 @@ 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 { @@ -426,84 +399,6 @@ 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 f44e62e0..fb7a349a 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -39,46 +39,16 @@ 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 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. +/// 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. 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") } @@ -306,29 +276,6 @@ 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) => { @@ -342,6 +289,7 @@ 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 } @@ -355,10 +303,8 @@ pub async fn ensure_user_for( .filter(|s| !s.is_empty()) .with_context(|| { format!( - "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}" + "matrix: user {name} already exists in homeserver but matrix-password \ + is missing — manual recovery: hivectl matrix create-user {name} --password " ) })?; login_user(client, name, &stored).await.with_context(|| { @@ -446,199 +392,6 @@ 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 @@ -668,12 +421,6 @@ 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;