hyperhive/hive-c0re/src/matrix.rs

347 lines
13 KiB
Rust

//! 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
//! `<agent-state>/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": "<agent>", "password": "<random>"}
//! → 401 {"flows":[{"stages":["m.login.registration_token"]}],
//! "session": "<id>", ...}
//!
//! POST /_matrix/client/v3/register
//! {"username": "<agent>", "password": "<random>",
//! "auth": {"type": "m.login.registration_token",
//! "token": "<reg_token>", "session": "<id>"}}
//! → 200 {"user_id": "@agent:server", "access_token": "<at>", ...}
//! ```
//!
//! 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:<port>` 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<String> {
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<String> {
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::<serde_json::Value>()
.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<String> {
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<String> {
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, &register_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"));
}
}