hyperhive/swarm-controller/src/matrix_account.rs
atlas 72bff26336 fix tracker refs flagged by check-issue-refs.sh
Reworded the two hits scripts/check-issue-refs.sh found — a bare hash-
number tag in .forgejo/workflows/ci.yml's push-trigger comment and a
hyperhive#4345 tag in matrix_account.rs's doc comment — into prose that
stands on its own, per the lint's own rule. Neither carried semantic
weight beyond what the prose already says once reworded.

Refs #4345
2026-09-16 14:53:44 +02:00

496 lines
21 KiB
Rust

//! Give one agent an external matrix account: put the credential in the
//! swarm's secret store, then tell that agent's hive it is there.
//!
//! The hive end is `hive-c0re/src/workers/credential.rs`, which reads the
//! value under its own identity and writes it into the agent's state dir. The
//! notice carries only names, so the queue never holds the secret — see
//! [`swarm_queue_client::credential_subject`] for why that is a requirement
//! rather than a preference.
//!
//! ⚠️ Store first, notify second, and the order cannot be swapped: a notice
//! that overtakes its own write reaches a hive that reads nothing, and the
//! hive deliberately does not retry.
//!
//! Two credential modes, chosen by `PutMatrixAccountRequest::mode`: `token`
//! (default, back-compat with the original blind-store shape — the caller
//! already has a bearer token) and `password` (this daemon performs
//! `m.login.password` against the caller-given homeserver itself and stores
//! the resulting token; the password is never stored, and is not sent to the
//! hive either — only the derived token is). Mirrors what hive-c0re's own
//! `/api/matrix-account-login` does for a *hive-local* account, done here
//! instead so the browser never has to hold the password long enough to call
//! an arbitrary homeserver directly.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use swarm_queue_client::{CredentialNotice, credential_subject};
use swarm_secret_client::matrix;
use utoipa::ToSchema;
use super::{AppState, error_problem, swarm_hive};
fn default_mode() -> String {
"token".to_owned()
}
/// Env var the controller's NixOS module sets from
/// `services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl` — the
/// swarm-wide default `PutMatrixAccountRequest::homeserver` falls back to
/// when a caller omits one. Read via [`configured_default_homeserver`], not
/// directly — see that fn's doc.
///
/// Not yet consulted by [`put_matrix_account`]: `homeserver_or_configured_default`
/// below exists for a later slice of this homeserver-default rollout to
/// call; this one only wires the config through.
pub(crate) const DEFAULT_HOMESERVER_ENV: &str = "SWARM_CONTROLLER_MATRIX_HOMESERVER_URL";
/// `caller`'s own homeserver, or `default` when the caller left it unset.
/// `caller` always wins — this only fills a gap it left, never replaces a
/// value it gave.
///
/// Takes `default` as a plain parameter rather than reading
/// [`DEFAULT_HOMESERVER_ENV`] itself: a caller wants that env read done once,
/// at the edge (see [`configured_default_homeserver`]), and keeping this fn
/// pure makes it testable without mutating shared process env — three tests
/// doing exactly that raced each other under `cargo test`'s default
/// parallelism in review.
pub(crate) fn homeserver_or_configured_default(
caller: Option<String>,
default: Option<String>,
) -> Option<String> {
caller.or(default)
}
/// Reads [`DEFAULT_HOMESERVER_ENV`] fresh each call — the one place this
/// crate touches that env var, so [`homeserver_or_configured_default`] above
/// can stay a pure function.
pub(crate) fn configured_default_homeserver() -> Option<String> {
std::env::var(DEFAULT_HOMESERVER_ENV).ok()
}
/// The credential to store for one agent's external matrix account.
///
/// No `Debug` derive — matching `hive-c0re::dashboard::matrix_accounts`'s
/// analogous `MatrixLoginForm`, which also carries a password field and
/// deliberately omits it so nothing can `{:?}`-log this by accident.
#[derive(Deserialize, ToSchema)]
pub struct PutMatrixAccountRequest {
/// `"token"` (default, back-compat) or `"password"`. Token mode stores
/// `token` as given; password mode logs into `homeserver` with `user_id`
/// + `password` and stores the token that comes back instead.
#[serde(default = "default_mode")]
#[schema(example = "token")]
mode: String,
/// The access token. Required (and used as given) in token mode; ignored
/// in password mode, where the token comes from the login instead. Never
/// logged, and never returned by this route.
token: Option<String>,
/// Password-mode only: the matrix user id (or bare localpart) to log in
/// as.
user_id: Option<String>,
/// Password-mode only. Never logged and never stored — only the token
/// `m.login.password` returns is.
password: Option<String>,
/// The account's homeserver, when it is not this swarm's own.
///
/// Stored beside the token rather than sent on the notice: a notice is a
/// queue message, so a homeserver carried there would exist only in
/// flight, with nowhere to reconstruct it from on a re-delivery.
///
/// Optional in token mode (omitted means "resolve to the agent's own
/// `hyperhive.matrix.url` on the hive side" — this route never needs to
/// know it itself for a blind store). **Required** in password mode:
/// logging in needs somewhere to log in against, and unlike token mode
/// there is no hive-side fallback to defer to.
#[schema(example = "https://matrix.example.org")]
homeserver: Option<String>,
}
/// `put_matrix_account`'s success body.
#[derive(Debug, Serialize, ToSchema)]
pub struct PutMatrixAccountResponse {
/// The account's matrix user id, recovered from the login response in
/// password mode. `None` in token mode — the caller already knows which
/// account their own token belongs to, and this route does not spend a
/// `whoami` round trip validating a token it was simply handed.
user_id: Option<String>,
}
/// Store an agent's external matrix account credential and notify its hive.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
/// value the agent will next read rather than adding a second account.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}",
params(
("hive" = String, Path, description = "hive whose agent receives the credential"),
("agent" = String, Path, description = "agent the credential is delivered to"),
("account" = String, Path, description = "the external account this credential authenticates as"),
),
request_body = PutMatrixAccountRequest,
responses(
(status = 200, description = "stored, and the hive has been told", body = PutMatrixAccountResponse),
(status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
(status = 503, description = "no swarm queue is wired up (problem+json)", body = String),
(status = 500, description = "the store write, the encode or the publish failed (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn put_matrix_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
Json(req): Json<PutMatrixAccountRequest>,
) -> Result<Json<PutMatrixAccountResponse>, problem_details::ProblemDetails> {
// The queue first, so a deployment that has none answers 503 whatever the
// caller spelled — and before the store is touched, so a request that
// could never be delivered does not leave a credential behind.
let Some(status) = state.status.as_ref() else {
return Err(error_problem(
StatusCode::SERVICE_UNAVAILABLE,
"this deployment wired up no swarm queue, so there is no hive to notify",
));
};
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
// Built before the store is reached, so a malformed account name costs a
// parse and not a login. `account_path` is the validator: the store's
// charset is its rule to state, not this handler's to restate.
let secret_path = matrix::account_path(&agent, &account)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
// `main` is the hive-internal account `nix/agent-modules/matrix.nix`
// synthesizes per agent from `hyperhive.matrix.url` — the schema there
// forbids declaring a key by that name for the same reason this route
// refuses to write one: an extra account literally named `main` would
// not overwrite the real one (it lands at a different token-file suffix)
// but would confuse anything that lists accounts by name. Same guard
// hive-c0re's own `/api/matrix-account-login` applies, checked before any
// mode-specific work (including a network login) runs.
if is_reserved_account(&account) {
return Err(error_problem(
StatusCode::BAD_REQUEST,
"'main' is the hive-internal account, synthesized per agent from \
hyperhive.matrix.url — it cannot be set through this route.",
));
}
// Password mode's network call happens here, before the store is
// touched, so a failed login leaves no partial state behind.
let (token, homeserver, user_id) = resolve_credential(&req).await.map_err(|b| *b)?;
let store = crate::store::connect().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
store
.write(
&secret_path,
&matrix::Credential {
value: token,
homeserver,
},
)
.await
.map_err(|e| {
// The path names the agent and the account; the value is not in it.
tracing::warn!(path = %secret_path, error = %e, "writing the credential failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
let notice = CredentialNotice {
agent: agent.clone(),
account: account.clone(),
};
let payload = serde_json::to_vec(&notice).map_err(|e| {
error_problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("encoding the credential notice failed: {e}"),
)
})?;
let subject = credential_subject(&hive);
let client = status.queue_client();
client
.publish(subject.clone(), payload.into())
.await
.map_err(|e| {
error_problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("publishing to {subject} failed: {e}"),
)
})?;
// Flushed for the reason `publish_deploy` flushes: `publish` hands the
// message to the connection's write buffer and returns, so without this
// the response can outrun the notice it reports as sent.
client.flush().await.map_err(|e| {
error_problem(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("flushing the credential notice to {subject} failed: {e}"),
)
})?;
tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified");
Ok(Json(PutMatrixAccountResponse { user_id }))
}
/// Whether `account` is the hive-internal name every hive synthesizes per
/// agent (`nix/agent-modules/matrix.nix`) — see the call site's own comment
/// for why this route must never write one.
fn is_reserved_account(account: &str) -> bool {
account == "main"
}
/// Token-mode's only requirement: a token was actually given. Split out of
/// `resolve_credential` (with `password_fields` below) so each mode's
/// validation is unit-testable directly — only the actual network login in
/// `resolve_credential` itself needs an async runtime to exercise. Returns a
/// plain `&'static str` rather than a `ProblemDetails` — `clippy::result_large_err`
/// (this workspace runs `pedantic = deny`) flags a private fn returning one of
/// those directly; `put_matrix_account` itself is exempt only because it is
/// `pub` (clippy's `avoid-breaking-exported-api` default), which these
/// helpers are not.
fn token_credential(
req: &PutMatrixAccountRequest,
) -> Result<(String, Option<String>), &'static str> {
let Some(token) = req.token.clone() else {
return Err("token mode needs a token");
};
Ok((token, req.homeserver.clone()))
}
/// Password mode's required fields, validated present, with the
/// homeserver's trailing slash already trimmed — this is what actually
/// reaches `matrix_password_login`, not the caller's raw string, so the
/// login URL below never ends up with a doubled `//`.
struct PasswordFields<'a> {
homeserver: String,
user_id: &'a str,
password: &'a str,
}
fn password_fields(req: &PutMatrixAccountRequest) -> Result<PasswordFields<'_>, &'static str> {
let (Some(homeserver), Some(user_id), Some(password)) = (
req.homeserver.as_deref(),
req.user_id.as_deref(),
req.password.as_deref(),
) else {
return Err("password mode needs homeserver, user_id, and password");
};
Ok(PasswordFields {
homeserver: homeserver.trim_end_matches('/').to_owned(),
user_id,
password,
})
}
/// Resolve a request's credential mode to a concrete `(token, homeserver,
/// resolved user id)`. Extracted out of `put_matrix_account` to keep that
/// function under `clippy::too_many_lines`, and so the mode branching has
/// something to unit-test directly (via `token_credential`/`password_fields`
/// above) instead of only through the full route.
///
/// `Box`ed error for the same `result_large_err` reason `token_credential`'s
/// doc explains — this fn is private too, so it does not get `put_matrix_account`'s
/// exported-API exemption. Unboxed at the one call site instead of changing
/// `put_matrix_account`'s own (exempt, and part of the route's documented
/// contract) return type.
async fn resolve_credential(
req: &PutMatrixAccountRequest,
) -> Result<(String, Option<String>, Option<String>), Box<problem_details::ProblemDetails>> {
match req.mode.as_str() {
"token" => {
let (token, homeserver) = token_credential(req)
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
Ok((token, homeserver, None))
}
"password" => {
let fields = password_fields(req)
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
let (token, resolved_user_id) =
matrix_password_login(&fields.homeserver, fields.user_id, fields.password)
.await
.map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, &e)))?;
Ok((token, Some(fields.homeserver), Some(resolved_user_id)))
}
other => Err(Box::new(error_problem(
StatusCode::BAD_REQUEST,
&format!("unknown mode {other:?} (want token|password)"),
))),
}
}
/// POST `m.login.password` to `<homeserver>/_matrix/client/v3/login`.
/// Returns `(access_token, user_id)`.
///
/// Deliberately its own copy rather than a shared crate with
/// `hive-c0re::dashboard::matrix_accounts`'s near-identical helper: the two
/// log in on behalf of different callers (a hive's own dashboard vs. this
/// swarm-wide provisioning route) and share no other code — four lines of
/// JSON construction do not justify a dependency edge between them.
async fn matrix_password_login(
homeserver: &str,
user_id: &str,
password: &str,
) -> Result<(String, String), String> {
let url = format!("{homeserver}/_matrix/client/v3/login");
let body = serde_json::json!({
"type": "m.login.password",
"identifier": { "type": "m.id.user", "user": user_id },
"password": password,
"initial_device_display_name": "hyperhive",
});
let resp = reqwest::Client::new()
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("POST /login: {e}"))?;
let status = resp.status();
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("parse /login response: {e}"))?;
if !status.is_success() {
let err = json
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("login failed");
return Err(format!("/login HTTP {status}: {err}"));
}
let token = json
.get("access_token")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "login response missing access_token".to_owned())?;
let uid = json
.get("user_id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "login response missing user_id".to_owned())?;
Ok((token.to_owned(), uid.to_owned()))
}
#[cfg(test)]
mod tests {
use super::{
PutMatrixAccountRequest, homeserver_or_configured_default, is_reserved_account,
password_fields, resolve_credential, token_credential,
};
fn request(mode: &str) -> PutMatrixAccountRequest {
PutMatrixAccountRequest {
mode: mode.to_owned(),
token: None,
user_id: None,
password: None,
homeserver: None,
}
}
// `homeserver_or_configured_default` takes its default as a plain
// parameter rather than reading the env var itself, so these are pure
// — no process env mutation, and so no risk of racing each other (or
// any other test in the crate) under `cargo test`'s default parallelism.
#[test]
fn caller_supplied_homeserver_wins_even_with_a_default_configured() {
let result = homeserver_or_configured_default(
Some("https://caller.example.org".to_owned()),
Some("https://default.example.org".to_owned()),
);
assert_eq!(result.as_deref(), Some("https://caller.example.org"));
}
#[test]
fn falls_back_to_the_configured_default_when_the_caller_omits_one() {
let result =
homeserver_or_configured_default(None, Some("https://default.example.org".to_owned()));
assert_eq!(result.as_deref(), Some("https://default.example.org"));
}
#[test]
fn none_when_neither_caller_nor_default_is_set() {
assert_eq!(homeserver_or_configured_default(None, None), None);
}
#[test]
fn main_is_reserved_but_nothing_else_is() {
assert!(is_reserved_account("main"));
assert!(!is_reserved_account("ops-relay"));
// Case-sensitive on purpose: `matrixAccounts` is a nix attrset, so
// `Main` is a distinct, legal key from the reserved `main`.
assert!(!is_reserved_account("Main"));
}
#[test]
fn token_mode_needs_a_token() {
assert!(token_credential(&request("token")).is_err());
}
#[test]
fn token_mode_passes_through_token_and_homeserver_unchanged() {
let mut req = request("token");
req.token = Some("t0k3n".to_owned());
req.homeserver = Some("https://matrix.example.org".to_owned());
let (token, homeserver) = token_credential(&req).expect("token + homeserver both given");
assert_eq!(token, "t0k3n");
assert_eq!(homeserver.as_deref(), Some("https://matrix.example.org"));
}
#[test]
fn password_mode_needs_all_three_fields() {
let mut req = request("password");
assert!(password_fields(&req).is_err(), "none given");
req.homeserver = Some("https://matrix.example.org".to_owned());
assert!(
password_fields(&req).is_err(),
"still missing user_id + password"
);
req.user_id = Some("@a:matrix.example.org".to_owned());
assert!(password_fields(&req).is_err(), "still missing password");
req.password = Some("hunter2".to_owned());
assert!(password_fields(&req).is_ok(), "now all three given");
}
#[test]
fn password_mode_trims_a_trailing_slash_off_the_homeserver() {
let mut req = request("password");
req.homeserver = Some("https://matrix.example.org/".to_owned());
req.user_id = Some("@a:matrix.example.org".to_owned());
req.password = Some("hunter2".to_owned());
let fields = password_fields(&req).expect("all three fields given");
assert_eq!(fields.homeserver, "https://matrix.example.org");
}
#[test]
fn password_mode_leaves_a_homeserver_with_no_trailing_slash_unchanged() {
let mut req = request("password");
req.homeserver = Some("https://matrix.example.org".to_owned());
req.user_id = Some("@a:matrix.example.org".to_owned());
req.password = Some("hunter2".to_owned());
let fields = password_fields(&req).expect("all three fields given");
assert_eq!(fields.homeserver, "https://matrix.example.org");
}
#[tokio::test]
async fn resolve_credential_token_mode_end_to_end() {
let mut req = request("token");
req.token = Some("t0k3n".to_owned());
let (token, homeserver, user_id) = resolve_credential(&req)
.await
.expect("token mode with a token given");
assert_eq!(token, "t0k3n");
assert_eq!(homeserver, None);
assert_eq!(user_id, None);
}
#[tokio::test]
async fn resolve_credential_rejects_an_unknown_mode() {
assert!(
resolve_credential(&request("carrier-pigeon"))
.await
.is_err()
);
}
}