//! Optional matrix-tuwunel wiring: shared registration token (host) + //! per-agent UIAA registration → `/matrix-token`. No-op //! when the `hive-matrix` container isn't running, so operators who //! haven't flipped `hyperhive.matrix.enable = true` pay nothing. //! //! See `docs/matrix.md::Provisioning flow (registration token)` for //! the full UIAA round-trip, token-file shape, and host/container //! bind-mount layout. use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use reqwest::StatusCode; use crate::coordinator::Coordinator; /// nspawn container name for the matrix homeserver — mirrors /// `hive-forge` and matches the bare-name allow-list the lifecycle /// scanner skips over. const MATRIX_CONTAINER: &str = "hive-matrix"; /// Local-host URL of the tuwunel client-server API. Shares the host /// netns so `localhost:` resolves both from the daemon and from /// inside any sub-agent container. const MATRIX_HTTP: &str = "http://localhost:8008"; /// Host path of the matrix registration token. Must match /// `hyperhive.matrix.registrationTokenFile` in `nix/modules/hive-matrix.nix` /// (same path is bind-mounted read-only into the tuwunel container so /// the homeserver can read it via `registration_token_file`). const REGISTER_TOKEN_PATH: &str = "/var/lib/hyperhive/matrix-register-token"; /// Length (bytes) of the random registration token. 32 raw bytes ⇒ /// 64-char hex string; comfortable for a long-lived shared secret. const REGISTER_TOKEN_BYTES: usize = 32; /// HTTP timeout for registration round-trips. UIAA is two POSTs; even /// the slow path should finish well inside this budget. const HTTP_TIMEOUT_SECS: u64 = 10; /// Length (bytes) of the throwaway per-agent matrix password. Random /// 32-byte hex — agents never log in with the password (they /// authenticate by `access_token`), so it's protocol overhead. We /// store it nowhere. const PASSWORD_BYTES: usize = 32; /// 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. fn password_path(name: &str) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-password") } /// Probe whether `hive-matrix` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Same shape /// as `forge::is_present` — routed through hive-priv since /// `nixos-container` needs root and hive-c0re runs unprivileged. pub async fn is_present() -> bool { let Ok(stdout) = crate::priv_client::list_containers().await else { return false; }; stdout.lines().any(|l| l.trim() == MATRIX_CONTAINER) } /// Read `n` cryptographic-quality bytes from `/dev/urandom` and return /// them hex-encoded. Avoids pulling a workspace `rand` dep just for /// 32 bytes of randomness; the kernel's CSPRNG is more than enough for /// a long-lived shared secret on the same host. fn random_hex(n: usize) -> Result { use std::io::Read; let mut buf = vec![0_u8; n]; let mut f = std::fs::File::open("/dev/urandom").context("open /dev/urandom")?; f.read_exact(&mut buf).context("read /dev/urandom")?; let mut hex = String::with_capacity(n * 2); for b in &buf { use std::fmt::Write as _; write!(hex, "{b:02x}").ok(); } Ok(hex) } /// Ensure the registration token file exists; returns its contents. /// Generates a fresh 32-byte hex token on first call (mode 0600, /// root-only), then re-reads on subsequent calls. The hive-matrix /// nixos module bind-mounts this file read-only into the tuwunel /// container so the homeserver can authenticate registration requests /// against the same secret hive-c0re holds. pub fn ensure_register_token() -> Result { use std::os::unix::fs::PermissionsExt; let path = Path::new(REGISTER_TOKEN_PATH); if let Ok(existing) = std::fs::read_to_string(path) { let trimmed = existing.trim().to_owned(); if !trimmed.is_empty() { return Ok(trimmed); } } let token = random_hex(REGISTER_TOKEN_BYTES)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(path, format!("{token}\n")) .with_context(|| format!("write registration token to {}", path.display()))?; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); tracing::info!(path = %path.display(), "matrix: generated registration token"); Ok(token) } /// Build the localpart of a matrix user id for `agent`. Matrix /// usernames are 1-255 chars from the `[a-z0-9._=-/]` alphabet; agent /// names already conform (hyperhive enforces a strict subset), so no /// escaping is needed at the boundary. fn user_localpart(agent: &str) -> &str { agent } /// Send a single registration POST and parse the response. Returns /// `Ok((status, body))` on any successful HTTP round-trip (including /// the expected 401 from the first UIAA leg); errors only on transport /// failure. Body is the parsed JSON value — UIAA passes session + /// flow state via JSON, never via headers. async fn register_post( client: &reqwest::Client, body: &serde_json::Value, ) -> Result<(StatusCode, serde_json::Value)> { let url = format!("{MATRIX_HTTP}/_matrix/client/v3/register"); let resp = client .post(&url) .json(body) .send() .await .context("matrix: POST /register")?; let status = resp.status(); let json = resp .json::() .await .context("matrix: parse /register response")?; Ok((status, json)) } /// Generate a throwaway random password for matrix UIAA registration. /// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. Agents authenticate /// by `access_token` so the password is protocol overhead we never /// persist; the operator path in `hivectl` lets the caller supply a /// real password instead so they can log into a matrix web client /// (`m.login.password`). pub fn random_password() -> Result { random_hex(PASSWORD_BYTES) } /// Run the matrix-spec UIAA flow to register `agent` with the given /// `password` and return the resulting access token. Two round-trips: /// first POST elicits the 401 + session id, second POST supplies the /// registration token in the `auth` block. If the homeserver returns /// 200 on the first POST (no flow stages required — `allow_registration` /// with no token), we take the access token directly. /// /// Caller picks the password: agents use [`random_password`] (throwaway /// — they auth by `access_token`), operators on the `hivectl` path /// supply their own so they can log into matrix web clients. async fn register_user( client: &reqwest::Client, agent: &str, register_token: &str, password: &str, ) -> Result { let localpart = user_localpart(agent); let initial = serde_json::json!({ "username": localpart, "password": password, // device_id stays stable across re-runs of ensure_user_for so // a re-mint doesn't strand orphan devices in tuwunel. "device_id": format!("hyperhive-{agent}"), "initial_device_display_name": format!("hyperhive ({agent})"), "inhibit_login": false, }); let (status, body) = register_post(client, &initial).await?; // Some servers accept the first POST when allow_registration is // open. Take the access_token + we're done. if status.is_success() { return extract_access_token(&body); } if status != StatusCode::UNAUTHORIZED { anyhow::bail!("matrix: /register first leg HTTP {status}, body: {body}"); } let session = body["session"] .as_str() .with_context(|| format!("matrix: missing UIAA session in 401, body: {body}"))?; let authed = serde_json::json!({ "username": localpart, "password": password, "device_id": format!("hyperhive-{agent}"), "initial_device_display_name": format!("hyperhive ({agent})"), "inhibit_login": false, "auth": { "type": "m.login.registration_token", "token": register_token, "session": session, }, }); let (status, body) = register_post(client, &authed).await?; if !status.is_success() { anyhow::bail!("matrix: /register auth leg HTTP {status}, body: {body}"); } extract_access_token(&body) } /// Pull `access_token` out of a successful /register response. fn extract_access_token(body: &serde_json::Value) -> Result { body["access_token"] .as_str() .map(str::to_owned) .with_context(|| format!("matrix: missing access_token in response: {body}")) } /// Login with `m.login.password` and return the access token. Fallback /// for when registration fails with `M_USER_IN_USE` — the account /// already exists in the homeserver but the token file was lost. Fails /// if the stored password no longer matches (e.g. homeserver wiped), /// in which case manual recovery via `hivectl matrix create-user` is /// required. async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result { let url = format!("{MATRIX_HTTP}/_matrix/client/v3/login"); let body = serde_json::json!({ "type": "m.login.password", "identifier": { "type": "m.id.user", "user": user_localpart(agent), }, "password": password, "device_id": format!("hyperhive-{agent}"), "initial_device_display_name": format!("hyperhive ({agent})"), }); let resp = client .post(&url) .json(&body) .send() .await .context("matrix: POST /login")?; let status = resp.status(); let json = resp .json::() .await .context("matrix: parse /login response")?; if !status.is_success() { anyhow::bail!("matrix: /login HTTP {status} for agent {agent}, body: {json}"); } extract_access_token(&json) } /// Ensure `name` has a matrix user + token file on the local /// homeserver. Skips provisioning entirely if the token file already /// exists (treating a present token as proof the account is good). /// To force re-provisioning, delete the token file. /// /// When registration fails with `M_USER_IN_USE` (account exists in the /// homeserver but the token file was deleted) this falls back to /// `m.login.password` using the persisted `matrix-password` file. If /// that file is also missing, recovery requires manual intervention: /// `hivectl matrix create-user --password `. /// /// `client` is shared across the sweep so we build one reqwest /// connection pool for all agents rather than one per call. pub async fn ensure_user_for( client: &reqwest::Client, name: &str, register_token: &str, ) -> Result<()> { use std::os::unix::fs::PermissionsExt; let path = token_path(name); if path.exists() && let Ok(existing) = std::fs::read_to_string(&path) && !existing.trim().is_empty() { tracing::debug!(%name, "matrix: token already present"); return Ok(()); } let password = random_password()?; let access_token = match register_user(client, name, register_token, &password).await { Ok(token) => { // Successful registration — persist the password so we can // fall back to login if the token file is deleted later. let pw_path = password_path(name); 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!(%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 } Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => { // Account already exists — try to re-login with the stored password. tracing::info!(%name, "matrix: user already exists, attempting login with stored password"); let pw_path = password_path(name); 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: 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(|| { format!( "matrix: login fallback for {name} failed — if homeserver was wiped, delete \ the matrix-password file and retry" ) })? } 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 token to {}", path.display()))?; let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); crate::lifecycle::chown_to_agent(name, &path, "matrix"); tracing::info!(%name, path = %path.display(), "matrix: provisioned access token"); Ok(()) } /// Register a matrix account for `name` with the supplied `password` /// and return the freshly-minted access token. Unlike [`ensure_user_for`], /// the token is **not** persisted to disk — the caller is responsible /// for storing it. Used by `hivectl matrix create-user` for human /// (non-agent) accounts so we don't create stray /// `/var/lib/hyperhive/agents//` directories for users that /// aren't agents. For operator accounts the caller passes a real /// password so the operator can `m.login.password` into matrix web /// clients afterwards; for headless agent re-provisioning the caller /// can pass [`random_password`] to keep the existing throwaway /// behaviour. /// /// **Not idempotent** (unlike [`forge::provision_user_token`]): the /// matrix UIAA `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) /// on second call for the same localpart. Callers re-running this for /// a known-existing matrix user should expect a hard error from this /// fn and route to a password-reset path instead. pub async fn provision_user_token( client: &reqwest::Client, name: &str, register_token: &str, password: &str, ) -> Result { register_user(client, name, register_token, password).await } /// Per-agent matrix sync: ensure the agent has a matrix account + token. /// All operations are idempotent; failures are logged as warnings but /// don't abort the caller. pub async fn sync_agent(client: &reqwest::Client, name: &str, register_token: &str) { if let Err(e) = ensure_user_for(client, name, register_token).await { tracing::warn!(%name, error = ?e, "matrix: ensure_user failed"); } } /// Standalone per-agent sync that handles its own setup: checks if /// hive-matrix is present, reads the registration token, and builds /// an HTTP client before delegating to [`sync_agent`]. Mirrors the /// setup in [`ensure_all`] so the rebuild path and the startup sweep /// stay equivalent. No-op when the matrix container is absent. pub async fn sync_agent_standalone(name: &str) { if !is_present().await { return; } let register_token = match ensure_register_token() { Ok(t) => t, Err(e) => { tracing::warn!(%name, error = ?e, "matrix: ensure_register_token failed"); return; } }; let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) .build() { Ok(c) => c, Err(e) => { tracing::warn!(%name, error = ?e, "matrix: build HTTP client failed"); return; } }; sync_agent(&client, name, ®ister_token).await; } /// 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 /// hive-matrix container isn't running. Per-step failures are logged /// but don't abort the sweep. pub async fn ensure_all() { if !is_present().await { tracing::debug!("matrix: hive-matrix container absent, skipping user sweep"); return; } let register_token = match ensure_register_token() { Ok(t) => t, Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_register_token failed"); return; } }; // One HTTP client for the whole sweep — connection pool is // reused across agents. let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) .build() { Ok(c) => c, Err(e) => { tracing::warn!(error = ?e, "matrix: build HTTP client failed; skipping sweep"); return; } }; let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("matrix: nixos-container list failed; skipping user sweep"); return; }; for c in containers { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; sync_agent(&client, name, ®ister_token).await; } } #[cfg(test)] mod tests { use super::*; #[test] fn random_hex_is_well_formed_and_correct_length() { let h = random_hex(16).expect("/dev/urandom readable"); assert_eq!(h.len(), 32); assert!(h.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn random_hex_two_calls_differ() { // Sanity check — not a statistical claim, just guards // against ever accidentally returning a constant. let a = random_hex(16).expect("/dev/urandom readable"); let b = random_hex(16).expect("/dev/urandom readable"); assert_ne!(a, b); } #[test] fn extract_access_token_pulls_from_success_body() { let body = serde_json::json!({ "user_id": "@alice:matrix.example.org", "access_token": "syt_abc123", "device_id": "ABC", }); assert_eq!(extract_access_token(&body).unwrap(), "syt_abc123"); } #[test] fn extract_access_token_errors_on_missing_field() { let body = serde_json::json!({"user_id": "@alice:matrix.example.org"}); let err = extract_access_token(&body).unwrap_err(); assert!(err.to_string().contains("missing access_token")); } }