//! Optional matrix-tuwunel wiring. When the `hive-matrix` nixos-container //! is present and running, hive-c0re ensures: //! //! 1. A shared `registration_token` exists at //! `/var/lib/hyperhive/matrix-register-token` (mode 0600, generated //! once on first boot). The hive-matrix module bind-mounts that file //! read-only into the tuwunel container so tuwunel can resolve its //! `registration_token_file` setting against it. //! 2. Every agent (and the manager) has a matrix account on the local //! homeserver with an `access_token` written to //! `/matrix-token`. Idempotent: skips registration when //! the token file already exists. //! //! Agents never see the registration token — only their own `access_token`. //! Account provisioning rides the matrix-spec UIAA flow: //! //! ```text //! POST /_matrix/client/v3/register //! {"username": "", "password": ""} //! → 401 {"flows":[{"stages":["m.login.registration_token"]}], //! "session": "", ...} //! //! POST /_matrix/client/v3/register //! {"username": "", "password": "", //! "auth": {"type": "m.login.registration_token", //! "token": "", "session": ""}} //! → 200 {"user_id": "@agent:server", "access_token": "", ...} //! ``` //! //! No-op when the `hive-matrix` container isn't running (detected via //! `nixos-container list`), so operators who haven't flipped //! `hyperhive.matrix.enable = true` pay nothing. use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use reqwest::StatusCode; use tokio::process::Command; 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") } /// 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`. pub async fn is_present() -> bool { let Ok(out) = Command::new("nixos-container").arg("list").output().await else { return false; }; if !out.status.success() { return false; } String::from_utf8_lossy(&out.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)) } /// Run the matrix-spec UIAA flow to register `agent` 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. async fn register_user( client: &reqwest::Client, agent: &str, register_token: &str, ) -> Result { let localpart = user_localpart(agent); let password = random_hex(PASSWORD_BYTES)?; 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}")) } /// Ensure `name` has a matrix user + token file on the local /// homeserver. Skips registration entirely if the token file already /// exists (treating a present token as proof the account is good). /// To force re-registration, delete the token file. /// /// `client` is shared across the sweep so we build one reqwest /// connection pool for all agents rather than one per call (argus /// nit on #565 — bounded but wasteful). 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 access_token = register_user(client, name, register_token).await?; 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)); tracing::info!(%name, path = %path.display(), "matrix: registered user + persisted access token"); Ok(()) } /// 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"); } } /// 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 name = if c == crate::lifecycle::MANAGER_NAME { c } else if let Some(n) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) { n.to_owned() } 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")); } }