From 50ceb929d7da11b5b01c7ba3e7139bd5103ecce3 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 29 May 2026 10:46:24 +0200 Subject: [PATCH] matrix: provision per-agent accounts on startup via UIAA registration (#548 phase 2) --- CLAUDE.md | 9 + hive-c0re/src/main.rs | 9 + hive-c0re/src/matrix.rs | 331 ++++++++++++++++++++++++++++++++++++ nix/modules/hive-matrix.nix | 55 +++++- 4 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 hive-c0re/src/matrix.rs diff --git a/CLAUDE.md b/CLAUDE.md index 8dd9345f..c17b1178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,15 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched) and meta read access; mirrors each applied repo into `agent-configs/` (core-only); agents are read-only collaborators on `core/meta` + src/matrix.rs optional matrix-tuwunel wiring: generates the + shared registration token at + `/var/lib/hyperhive/matrix-register-token`, + provisions per-agent matrix accounts via the + matrix-spec UIAA two-leg registration flow, + persists each agent's access_token to + `/matrix-token`. Idempotent — skips + registration when token file already exists. + No-op when `hive-matrix` container absent (#548). src/dashboard.rs axum HTTP: /api/state JSON + actions + journald viewer + bind-with-retry (SO_REUSEADDR) + deployed_sha chip per container + diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 9b00cc91..717a67b7 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -26,6 +26,7 @@ mod scheduled_prompts_worker; mod limits; mod loose_ends; mod manager_server; +mod matrix; mod meta; mod migrate; mod operator_questions; @@ -231,6 +232,14 @@ async fn cmd_serve( tokio::spawn(async move { forge::ensure_all().await; }); + // Matrix user sweep: same shape — ensure every container has + // an account on the local matrix-tuwunel homeserver with an + // access_token persisted to `/matrix-token` (#548). No-op + // when the hive-matrix container isn't running. Backgrounded + // because UIAA is a two-roundtrip dance per agent. + tokio::spawn(async move { + matrix::ensure_all().await; + }); // Periodic broker vacuum: drop fully-acked messages older // than 30 days. Delivered-but-unacked rows (recoverable via // requeue_inflight) and undelivered rows are always kept. diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs new file mode 100644 index 00000000..dd20c3fd --- /dev/null +++ b/hive-c0re/src/matrix.rs @@ -0,0 +1,331 @@ +//! 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. +pub async fn ensure_user_for(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 client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build() + .context("matrix: build HTTP client")?; + 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(name: &str, register_token: &str) { + if let Err(e) = ensure_user_for(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; + } + }; + 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(&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")); + } +} diff --git a/nix/modules/hive-matrix.nix b/nix/modules/hive-matrix.nix index be218288..edb03f3c 100644 --- a/nix/modules/hive-matrix.nix +++ b/nix/modules/hive-matrix.nix @@ -29,9 +29,21 @@ in # # Initial rollout (#548): federation enabled (needed for multi-hive # swarms; trusted_servers starts empty so no actual federation traffic - # leaves until peers are explicitly listed), registration via admin - # API only, e2ee disabled per operator call (tracked for follow-up at - # #551). + # leaves until peers are explicitly listed), registration enabled via + # a `registration_token_file` known only to hive-c0re (so agents can't + # self-register without going through the coordinator), e2ee disabled + # per operator call (tracked for follow-up at #551). + # + # Provisioning model (matches `nix/modules/hive-forge.nix` shape): + # hive-c0re generates a 32-byte random `registration_token` on first + # boot, writes it to `/var/lib/hyperhive/matrix-register-token` (mode + # 0600, root-only), and bind-mounts that file read-only into the + # tuwunel container at the same path so tuwunel can read it via + # `registration_token_file`. hive-c0re then uses the token to register + # each agent account via the matrix-spec UIAA registration flow, and + # persists the returned `access_token` to `/matrix-token` + # so the agent's matrix MCP client can authenticate without ever + # seeing the shared registration token. options.hyperhive.matrix = { enable = lib.mkOption { @@ -127,6 +139,22 @@ in media uploads + the upstream tuwunel default. ''; }; + + registrationTokenFile = lib.mkOption { + type = lib.types.path; + default = "/var/lib/hyperhive/matrix-register-token"; + description = '' + Host path to a file containing the matrix registration token + tuwunel reads to authorise new-account creation. The token is + generated automatically by `hive-c0re` on first boot (32-byte + random hex, mode 0600) and is bind-mounted read-only into the + tuwunel container at the same path. Agents never see this + token — hive-c0re uses it to provision per-agent accounts + and the agent only receives the resulting `access_token`. + Override only when integrating with externally-managed + registration tokens. + ''; + }; }; config = lib.mkIf cfg.enable { @@ -160,6 +188,16 @@ in # host-side services, no port-forward plumbing, and agent # containers (also host netns) reach it via plain `localhost`. privateNetwork = false; + # Read-only bind of the host-managed registration token so + # tuwunel can resolve `registration_token_file` to a real + # file inside the container. The host path doesn't need to + # exist at eval time (the file is generated by hive-c0re's + # `matrix::ensure_register_token` on first boot); nspawn will + # create the bind mount on container start either way. + bindMounts.${cfg.registrationTokenFile} = { + hostPath = cfg.registrationTokenFile; + isReadOnly = true; + }; config = { ... }: { @@ -181,10 +219,13 @@ in # keeps it effectively closed until peers are listed. allow_federation = true; trusted_servers = cfg.trustedServers; - # Registration off — operator seeds agent accounts via - # the tuwunel admin API (mirrors the forge pattern; - # see `hive-c0re/src/matrix.rs` once #548 PR 2 lands). - allow_registration = false; + # Token-gated registration: hive-c0re holds the token, + # agents never see it. allow_registration must be true + # for the token flow to engage; the absent + # `yes_i_am_very_very_sure_…_open_registration_…` flag + # keeps the server closed to anyone without the token. + allow_registration = true; + registration_token_file = toString cfg.registrationTokenFile; # E2EE disabled in initial rollout per operator call # (#548) — re-enabling tracked at #551. allow_encryption = false;