165 lines
6.8 KiB
Rust
165 lines
6.8 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.
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
use matrix_sdk::{
|
|
Client, SessionMeta, SessionTokens,
|
|
authentication::matrix::MatrixSession,
|
|
ruma::{OwnedDeviceId, OwnedUserId},
|
|
};
|
|
use serde::Deserialize;
|
|
use tokio::fs;
|
|
|
|
/// 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,
|
|
) -> 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 token file (and the
|
|
// matrix-sdk sqlite state keyed to the now-invalid session) so
|
|
// hive-c0re's periodic `ensure_all` sweep re-provisions the account.
|
|
// Exit 0: systemd's Restart=on-failure must not loop us here; the
|
|
// systemd.paths watcher restarts us once the new token file appears.
|
|
tracing::warn!(
|
|
path = %token_file.display(),
|
|
"matrix token rejected (M_UNKNOWN_TOKEN); deleting stale token + \
|
|
sdk state for re-provisioning"
|
|
);
|
|
let _ = fs::remove_file(token_file).await;
|
|
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"
|
|
);
|
|
}
|
|
// Exit 0 rather than returning Err: `hive-matrix-daemon` is a
|
|
// single-purpose process binary; the call site is before any
|
|
// tasks are spawned so there are no resources to clean up.
|
|
// Using exit(0) (not Err) keeps systemd's Restart=on-failure
|
|
// from looping — the systemd.paths watcher re-launches us
|
|
// once hive-c0re writes a fresh token file.
|
|
std::process::exit(0);
|
|
}
|
|
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)
|
|
.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))
|
|
}
|