hive-matrix-mcp: probe whoami through swarm-matrix-client
This commit is contained in:
parent
1d22041f82
commit
b17da10cfc
2 changed files with 67 additions and 95 deletions
|
|
@ -723,7 +723,9 @@ fn forbidden_invite(membership: Option<&str>, user_id: &str, room_id: &str) -> R
|
|||
tracing::debug!(%user_id, %room_id, "matrix: invite refused, user already member/invited");
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP 403 and the user is not invited or joined")
|
||||
anyhow::bail!(
|
||||
"matrix: invite {user_id} to {room_id}: HTTP 403 and the user is not invited or joined"
|
||||
)
|
||||
}
|
||||
|
||||
/// Invite an arbitrary Matrix user to the hive Space (default) or an
|
||||
|
|
@ -1044,7 +1046,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/// Serve a fake homeserver on loopback: `!space` is an `m.space` and
|
||||
/// `!chat` a plain room, both named "shared"; `!decoy` is a plain room
|
||||
/// named "other". Returns the base URL and the server task.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ use matrix_sdk::{
|
|||
encryption::{BackupDownloadStrategy, EncryptionSettings},
|
||||
ruma::{OwnedDeviceId, OwnedUserId},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use swarm_matrix_client::WhoamiSession;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::credential::{Passphrase, Token};
|
||||
|
|
@ -61,22 +61,12 @@ impl std::fmt::Display for PermanentBringUpError {
|
|||
|
||||
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`. The Client points at `homeserver`, persists its sqlite cache
|
||||
/// under `state_dir`, and is ready for sync once returned.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
|
||||
/// 1. A `swarm-matrix-client` whoami call with
|
||||
/// the bearer — this gives us back the matrix `user_id` +
|
||||
/// `device_id` (the registration response had them but only the
|
||||
/// token was kept; whoami is the cheapest recovery path
|
||||
|
|
@ -95,59 +85,53 @@ pub async fn build_and_restore(
|
|||
state_dir: &Path,
|
||||
is_primary: bool,
|
||||
) -> Result<Client> {
|
||||
let (user_id, device_id) = match whoami(homeserver, token.expose()).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. Discard it so we don't loop on it
|
||||
// (which for a store-held credential is a log line and nothing
|
||||
// else — see `Token::discard_stale`). 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).
|
||||
token.discard_stale().await;
|
||||
if is_primary {
|
||||
tracing::warn!(
|
||||
credential = %token.origin(),
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token"
|
||||
);
|
||||
// Primary: also drop the matrix-sdk sqlite state keyed to the
|
||||
// now-invalid session, then exit 0; the swarm's backfill
|
||||
// re-mints the token and systemd restarts us once it
|
||||
// exists (store re-check timer, or the path watcher). 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: a file token is gone, a store token is untouched; the
|
||||
// daemon tries the account again later and reads the token afresh.
|
||||
// The sdk state stays in place in case the operator re-provisions a
|
||||
// token for the same device.
|
||||
// Return a PermanentBringUpError so the caller can distinguish
|
||||
// "retrying now won't help" from a transient network/DNS failure.
|
||||
// Every try of a down secondary lands here, so the warning is the
|
||||
// caller's, once per outage.
|
||||
tracing::debug!(
|
||||
credential = %token.origin(),
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token"
|
||||
let Some((user_id, device_id)) = whoami(homeserver, token.expose()).await? else {
|
||||
// Homeserver rejected our token — stale session after a homeserver
|
||||
// state wipe or token expiry. Discard it so we don't loop on it
|
||||
// (which for a store-held credential is a log line and nothing
|
||||
// else — see `Token::discard_stale`). 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).
|
||||
token.discard_stale().await;
|
||||
if is_primary {
|
||||
tracing::warn!(
|
||||
credential = %token.origin(),
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token"
|
||||
);
|
||||
// Primary: also drop the matrix-sdk sqlite state keyed to the
|
||||
// now-invalid session, then exit 0; the swarm's backfill
|
||||
// re-mints the token and systemd restarts us once it
|
||||
// exists (store re-check timer, or the path watcher). 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"
|
||||
);
|
||||
return Err(anyhow::Error::new(PermanentBringUpError(
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, leaving account down".into(),
|
||||
)));
|
||||
}
|
||||
return Err(e);
|
||||
std::process::exit(0);
|
||||
}
|
||||
// Secondary: a file token is gone, a store token is untouched; the
|
||||
// daemon tries the account again later and reads the token afresh.
|
||||
// The sdk state stays in place in case the operator re-provisions a
|
||||
// token for the same device.
|
||||
// Return a PermanentBringUpError so the caller can distinguish
|
||||
// "retrying now won't help" from a transient network/DNS failure.
|
||||
// Every try of a down secondary lands here, so the warning is the
|
||||
// caller's, once per outage.
|
||||
tracing::debug!(
|
||||
credential = %token.origin(),
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token"
|
||||
);
|
||||
return Err(anyhow::Error::new(PermanentBringUpError(
|
||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, leaving account down"
|
||||
.into(),
|
||||
)));
|
||||
};
|
||||
|
||||
fs::create_dir_all(state_dir)
|
||||
|
|
@ -182,38 +166,25 @@ pub async fn build_and_restore(
|
|||
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
|
||||
/// 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.
|
||||
///
|
||||
/// `None` is the homeserver's `M_UNKNOWN_TOKEN` verdict on the token.
|
||||
async fn whoami(homeserver: &str, token: &str) -> Result<Option<(OwnedUserId, OwnedDeviceId)>> {
|
||||
let http = swarm_matrix_client::client()?;
|
||||
let (user_id, device_id) =
|
||||
match swarm_matrix_client::whoami_session(&http, homeserver, token).await? {
|
||||
WhoamiSession::UnknownToken => return Ok(None),
|
||||
WhoamiSession::Session { user_id, device_id } => (user_id, device_id),
|
||||
};
|
||||
let parsed: OwnedUserId = 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))
|
||||
.with_context(|| format!("invalid user_id in whoami: {user_id}"))?;
|
||||
let device_id = device_id.ok_or_else(|| anyhow!("whoami response missing device_id"))?;
|
||||
Ok(Some((parsed, device_id.into())))
|
||||
}
|
||||
|
||||
/// Best-effort: bootstrap this account's cross-signing identity so its
|
||||
|
|
|
|||
Loading…
Reference in a new issue