hyperhive/hive-matrix-mcp/src/client.rs

265 lines
11 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 the `registration_token`
//! UIAA flow already minted the token + user/device, hive-c0re just
//! 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 and
//! automatic key backup are deliberately left at their defaults (disabled)
//! for the first pass: bot accounts authenticated with a static bearer token
//! can't bootstrap cross-signing without MSC3967 on the server side.
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: 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)");
}
}
}