hyperhive/hive-matrix-mcp/src/client.rs
atlas 7ee7080b21 matrix: remove the registration token
Nothing reads it any more: hive-c0re creates accounts as the hive's
appservice, so the mint, the host file, the bind mount, the
`LoadCredential` entry and tuwunel's `registration_token_file` all go.

⚠️ `allow_registration` has to go to `false` in the same change, and not
as hardening. tuwunel refuses to START when registration is allowed with
no token configured — it demands
`yes_i_am_very_very_sure_…_open_registration_…` instead — so dropping the
token and leaving the flag true is not a lax homeserver, it is one that
does not boot. The flag is checked only for requests arriving without an
appservice token, so hive-c0re provisions exactly as before and everyone
else is refused outright.

The swarm secret store keeps its role, repointed at the credential that
replaced the token (`swarm/hives/<hive>/matrix/appservice-token`). Its
unit now also re-runs hive-matrix's own registration renderer after
writing the file: the token is half an agreement, and a registration
still naming the previous value authenticates nobody. The renderer is
shared through an internal option rather than copied, so the
registration's shape has one home.

Both spellings of `registrationTokenFile` become
`mkRemovedOptionModule` with a message naming what replaced them. A hive
that never set the option — the default — is unaffected; one that pinned
it fails to evaluate with instructions instead of a silent no-op.

An upgraded hive needs no intervention: the activation script has both
halves in place before the homeserver restarts, existing agents keep the
tokens their devices already hold, and the old token file is left on
disk read by nothing. docs/integrations/matrix.md spells the path out.

Refs #4402
2026-09-15 19:58:10 +02:00

314 lines
13 KiB
Rust

//! matrix-sdk `Client` setup for the daemon: read the per-agent access
//! token from the state-dir file `hive-c0re::matrix::ensure_user_for`
//! wrote, probe `whoami` to recover the agent's matrix `user_id` +
//! `device_id`, restore the matrix-sdk session, return the Client ready
//! to start sync.
//!
//! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted
//! the token + user/device as the hive's appservice and handed us the
//! bearer in a file. matrix-sdk's `restore_session` with a constructed
//! `MatrixSession` skips the login flow entirely.
//!
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
//! Crypto keys are persisted in the sqlite store under `state_dir`
//! (survives container restarts, lost on `--purge`).
//!
//! Cross-signing is bootstrapped best-effort after session restore
//! (`ensure_cross_signing`) so the agent's device stops showing as
//! "unverified" in other users' Matrix clients: the in-hive tuwunel accepts
//! the initial device-signing-key upload on the bearer token alone (no UIAA
//! re-auth). A homeserver that DID require UIAA just fails the call, which is
//! swallowed — the session stays exactly as unverified as before. Automatic
//! key backup is still left at its default (disabled).
use std::path::Path;
use anyhow::{Context, Result, anyhow};
use matrix_sdk::{
Client, SessionMeta, SessionTokens,
authentication::matrix::MatrixSession,
encryption::EncryptionSettings,
ruma::{OwnedDeviceId, OwnedUserId},
};
use serde::Deserialize;
use tokio::fs;
/// Sentinel returned when `build_and_restore` detects that the token is
/// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been
/// removed from disk. Callers should NOT retry — the account needs
/// re-provisioning by hive-c0re.
///
/// Distinct from the general `anyhow::Error` path so callers can use
/// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry
/// won't help" from a transient network/DNS/5xx failure.
#[derive(Debug)]
pub struct PermanentBringUpError(pub String);
impl std::fmt::Display for PermanentBringUpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for PermanentBringUpError {}
/// Subset of the `/_matrix/client/v3/account/whoami` response we care
/// about. matrix-spec field names; `device_id` is optional per spec
/// (servers MAY omit it for legacy bearer scopes) but tuwunel always
/// returns it.
#[derive(Debug, Deserialize)]
struct WhoamiResponse {
user_id: String,
device_id: Option<String>,
}
/// Build + restore a matrix-sdk `Client` for the per-agent bearer
/// token at `token_file`. The Client points at `homeserver`, persists
/// its sqlite cache under `state_dir`, and is ready for sync once
/// returned.
///
/// Steps:
/// 1. Read the bearer token from `token_file` (trim trailing whitespace).
/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
/// the bearer — this gives us back the matrix `user_id` +
/// `device_id` (the registration response had them but hive-c0re
/// only persisted the token; whoami is the cheapest recovery path
/// and avoids matrix-sdk's circular requirement of needing a
/// session to call whoami).
/// 3. Build the real Client with the sqlite store + `restore_session`
/// using a synthetic `MatrixSession`.
///
/// # Errors
///
/// Returns an error if the token file is missing or empty, if the
/// `whoami` request fails, or if the matrix-sdk client fails to build
/// or restore the session.
pub async fn build_and_restore(
homeserver: &str,
token_file: &Path,
state_dir: &Path,
is_primary: bool,
) -> Result<Client> {
let token = fs::read_to_string(token_file)
.await
.with_context(|| format!("read matrix token from {}", token_file.display()))?
.trim()
.to_owned();
if token.is_empty() {
return Err(anyhow!("matrix token at {} is empty", token_file.display()));
}
let (user_id, device_id) = match whoami(homeserver, &token).await {
Ok(ids) => ids,
Err(e) => {
let msg = format!("{e:#}");
if msg.contains("M_UNKNOWN_TOKEN") {
// Homeserver rejected our token — stale session after a homeserver
// state wipe or token expiry. Delete the stale token file so we
// don't loop on it. The rest of the handling depends on whether
// this is the primary (hive-internal) account or a secondary one,
// because a single bad secondary token must NOT take down the
// whole daemon (and with it every healthy account).
tracing::warn!(
path = %token_file.display(),
is_primary,
"matrix token rejected (M_UNKNOWN_TOKEN); removing stale token"
);
let _ = fs::remove_file(token_file).await;
if is_primary {
// Primary: also drop the matrix-sdk sqlite state keyed to the
// now-invalid session, then exit 0 so hive-c0re's `ensure_all`
// re-provisions the account and the systemd.paths watcher
// restarts us once the fresh token file appears. Exit 0 (not
// Err) keeps systemd's Restart=on-failure from looping; the
// call site is before any tasks are spawned so there's
// nothing to clean up.
if let Err(re) = fs::remove_dir_all(state_dir).await {
tracing::warn!(
path = %state_dir.display(),
err = %re,
"failed to remove sdk state dir; next startup may fail with stale state"
);
}
std::process::exit(0);
}
// Secondary: token removed (so it's cleanly skipped next boot
// rather than re-erroring); leave the sdk state in place in case
// the operator re-provisions a fresh token for the same device.
// Return a PermanentBringUpError so the caller can distinguish
// "don't retry" from a transient network/DNS failure.
return Err(anyhow::Error::new(PermanentBringUpError(
"matrix token rejected (M_UNKNOWN_TOKEN); removed stale token, skipping account".into(),
)));
}
return Err(e);
}
};
fs::create_dir_all(state_dir)
.await
.with_context(|| format!("mkdir matrix state dir {}", state_dir.display()))?;
let client = Client::builder()
.homeserver_url(homeserver)
.sqlite_store(state_dir, None)
.with_encryption_settings(EncryptionSettings::default())
.build()
.await
.with_context(|| format!("build matrix client for {homeserver}"))?;
let session = MatrixSession {
meta: SessionMeta { user_id, device_id },
tokens: SessionTokens {
access_token: token,
refresh_token: None,
},
};
client
.restore_session(session)
.await
.context("restore matrix session")?;
tracing::info!(
user = %client.user_id().map(ToString::to_string).unwrap_or_default(),
device = %client.device_id().map(ToString::to_string).unwrap_or_default(),
"matrix session restored"
);
Ok(client)
}
/// Bare-reqwest whoami probe — used at startup to recover the
/// `user_id` + `device_id` the registration response carried but
/// hive-c0re didn't persist alongside the access token. Cheaper than
/// teaching the hive-c0re side to persist them, plus matches what a
/// fresh deployment with a hand-rolled token (`HIVE_MATRIX_TOKEN_FILE`
/// pointing somewhere unexpected) needs anyway.
async fn whoami(homeserver: &str, token: &str) -> Result<(OwnedUserId, OwnedDeviceId)> {
let url = format!("{homeserver}/_matrix/client/v3/account/whoami");
let resp = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("build whoami reqwest client")?
.get(&url)
.bearer_auth(token)
.send()
.await
.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("whoami GET {url} → HTTP {status}, body: {body}"));
}
let body: WhoamiResponse = resp.json().await.context("parse whoami response")?;
let user_id: OwnedUserId = body
.user_id
.parse()
.with_context(|| format!("invalid user_id in whoami: {}", body.user_id))?;
let device_id_raw = body
.device_id
.ok_or_else(|| anyhow!("whoami response missing device_id"))?;
let device_id: OwnedDeviceId = device_id_raw.into();
Ok((user_id, device_id))
}
/// Best-effort: bootstrap this account's cross-signing identity so its
/// device stops showing as "unverified" in other users' Matrix clients.
///
/// `bootstrap_cross_signing_if_needed(None)` is idempotent — it queries the
/// account's existing keys and only uploads the master / self-signing /
/// user-signing key set when no cross-signing identity exists yet. That
/// upload also self-signs the current device, which is what actually clears
/// the unverified warning. On a restart with the identity already present
/// it's a no-op.
///
/// The `None` is the UIAA auth: the in-hive tuwunel accepts the initial
/// `device_signing/upload` on the bearer token alone (no interactive
/// re-auth). If a homeserver required UIAA the call returns a `UiaaResponse`
/// error we can't satisfy with a bare bearer token — so this is best-effort:
/// the failure is logged and swallowed, leaving the session no worse off than
/// before. Never breaks account bring-up.
///
/// Known gap (self-verify only): this signs the *current* device against a
/// freshly-bootstrapped identity. A device re-provisioned under an already
/// existing identity (new `device_id`, e.g. after a token reissue) would need
/// the cross-signing secret keys via secret storage to re-sign itself —
/// tracked as the longer-term mutual-verification follow-up.
pub async fn ensure_cross_signing(client: &Client, account: &str) {
match client
.encryption()
.bootstrap_cross_signing_if_needed(None)
.await
{
Ok(()) => {
tracing::info!(
account,
"matrix cross-signing ensured (device self-verified)"
);
}
Err(e) => {
tracing::warn!(
account,
error = %format!("{e:#}"),
"matrix cross-signing bootstrap failed; session stays unverified (non-fatal)"
);
}
}
}
/// Best-effort: set this account's matrix avatar from the rasterized icon
/// PNG. The path comes from `HIVE_ICON_PNG` (a nix-built derivation the
/// harness forwards into the daemon env; absent when no icon resolves).
/// Idempotent via a per-account `avatar-icon-hash` file in the account's
/// sdk `state_dir` — a re-upload only happens when the icon bytes change,
/// so daemon restarts don't re-spam the homeserver.
///
/// This replaces the old `matrix-avatar-sync` systemd curl oneshot: the
/// daemon already holds an authenticated `Client` pointed at the correct
/// homeserver, so it uploads over the live connection — no hardcoded URL,
/// no token re-read, no token-file globbing. Any failure is logged and
/// swallowed: avatar trouble must never break account bring-up or sync.
pub async fn sync_avatar(client: &Client, state_dir: &Path, account: &str) {
let Ok(png_path) = std::env::var("HIVE_ICON_PNG") else {
return; // no icon forwarded → nothing to sync
};
let bytes = match fs::read(&png_path).await {
Ok(b) => b,
Err(e) => {
tracing::warn!(account, path = %png_path, error = %e, "matrix avatar: icon PNG unreadable; skipping");
return;
}
};
// FNV-1a content hash — enough to answer "did the icon change?" and
// deterministic across Rust/std versions (unlike `DefaultHasher`, whose
// output may change between toolchains, spuriously mismatching the
// persisted hash and re-uploading the same avatar). No crypto strength
// needed here, so no dependency on a hashing crate.
let hash = {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in &bytes {
h ^= u64::from(b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
};
let hash_file = state_dir.join("avatar-icon-hash");
if let Ok(prev) = fs::read_to_string(&hash_file).await
&& prev.trim() == hash
{
tracing::debug!(account, "matrix avatar: icon unchanged; skipping upload");
return;
}
match client
.account()
.upload_avatar(&mime::IMAGE_PNG, bytes)
.await
{
Ok(mxc) => {
tracing::info!(account, mxc = %mxc, "matrix avatar set");
if let Err(e) = fs::write(&hash_file, &hash).await {
tracing::warn!(account, error = %e, "matrix avatar: set ok but hash write failed (re-uploads next start)");
}
}
Err(e) => {
tracing::warn!(account, error = %format!("{e:#}"), "matrix avatar: upload failed; skipping (non-fatal)");
}
}
}