diff --git a/CLAUDE.md b/CLAUDE.md index c17b1178..8dd9345f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,15 +107,6 @@ 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 717a67b7..9b00cc91 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -26,7 +26,6 @@ mod scheduled_prompts_worker; mod limits; mod loose_ends; mod manager_server; -mod matrix; mod meta; mod migrate; mod operator_questions; @@ -232,14 +231,6 @@ 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 deleted file mode 100644 index ac317790..00000000 --- a/hive-c0re/src/matrix.rs +++ /dev/null @@ -1,347 +0,0 @@ -//! 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")); - } -} diff --git a/nix/modules/hive-matrix.nix b/nix/modules/hive-matrix.nix index d029728d..be218288 100644 --- a/nix/modules/hive-matrix.nix +++ b/nix/modules/hive-matrix.nix @@ -29,21 +29,9 @@ 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 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. + # leaves until peers are explicitly listed), registration via admin + # API only, e2ee disabled per operator call (tracked for follow-up at + # #551). options.hyperhive.matrix = { enable = lib.mkOption { @@ -139,22 +127,6 @@ 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 { @@ -181,25 +153,6 @@ in } ]; - # Generate the registration token at system activation time, BEFORE - # the hive-matrix container would otherwise start with an empty - # bind-mount target (argus nit on #565: nspawn creates an empty - # file when the host path is missing, tuwunel reads it as - # `registration_token_file=""` and rejects every registration - # until the next restart). Idempotent: only writes when the file - # doesn't exist. 32-byte hex = 64 chars, same shape hive-c0re's - # `matrix::ensure_register_token` would produce. - system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] '' - tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)} - if [ ! -s "$tokenFile" ]; then - mkdir -p "$(dirname "$tokenFile")" - head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$tokenFile" - echo >> "$tokenFile" - chmod 600 "$tokenFile" - echo "hive-matrix: generated registration token at $tokenFile" - fi - ''; - containers.hive-matrix = { autoStart = true; ephemeral = false; @@ -207,16 +160,6 @@ 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 activation script above - # ensures the host path exists with a valid 64-char hex token - # before any container starts, so the bind always finds real - # content (no first-boot empty-file race; argus #565 nit). - bindMounts.${cfg.registrationTokenFile} = { - hostPath = cfg.registrationTokenFile; - isReadOnly = true; - }; config = { ... }: { @@ -238,13 +181,10 @@ in # keeps it effectively closed until peers are listed. allow_federation = true; trusted_servers = cfg.trustedServers; - # 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; + # 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; # E2EE disabled in initial rollout per operator call # (#548) — re-enabling tracked at #551. allow_encryption = false;