//! An agent's matrix accounts, from this daemon's two sides of them. //! //! **The internal one** — [`agent_token`] — is the agent's own `main` account on //! the swarm's homeserver, minted with the swarm's appservice token and stored //! where the agent's daemon reads it. See docs/swarm/credentials.md for why //! `main` is the one name [`put_matrix_account`] refuses to write. //! [`hive_sender`] mints each **hive's** sender account the same way, sharing this module's helpers with [`agent_token`]. //! //! **The external one** — [`put_matrix_account`] and everything under it — is //! an account somewhere else that an operator hands us a credential for: put it //! in the swarm's secret store. //! //! The agent end is `hive-matrix-daemon`, which lists the agent's accounts and //! reads each under the agent's own certificate. No hive is in the path. //! //! 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 — only the derived token //! is). Done here 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_secret_client::matrix; use utoipa::ToSchema; use super::linked_accounts::{AccountStore, Linking, link, refuse_linked}; use super::{AppState, error_problem, swarm_hive}; pub mod agent_token; pub mod hive_sender; 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. /// /// Also the homeserver [`agent_token`] mints agents' own accounts on. Not yet /// consulted by [`put_matrix_account`]: `homeserver_or_configured_default` /// below exists for a later slice of this homeserver-default rollout to call. 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, default: Option, ) -> Option { 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 { std::env::var(DEFAULT_HOMESERVER_ENV).ok() } /// The credential to store for one agent's external matrix account. /// /// No `Debug` derive: this carries a password and a token, so nothing may /// `{:?}`-log it 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, /// Password-mode only: the matrix user id (or bare localpart) to log in /// as. user_id: Option, /// Password-mode only. Never logged and never stored — only the token /// `m.login.password` returns is. password: Option, /// The account's homeserver, when it is not this swarm's own. Stored /// beside the token, which is where the agent's daemon reads it. /// /// Optional in token mode (omitted means "resolve to the agent's own /// `services.hyperhive.agent.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, } /// `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, } /// Store an agent's external matrix account credential, unless an account is /// stored under the name already. #[utoipa::path( put, path = "/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}", params( ("hive" = String, Path, description = "hive the agent runs on"), ("agent" = String, Path, description = "agent the credential belongs to"), ("account" = String, Path, description = "the external account this credential authenticates as"), ), request_body = PutMatrixAccountRequest, responses( (status = 200, description = "stored", 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 = 409, description = "an account is stored under the name already; nothing was written and no login was made (problem+json)", body = String), (status = 500, description = "the store could not be read or written (problem+json)", body = String), ), tag = "agents" )] pub async fn put_matrix_account( State(state): State, axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>, Json(req): Json, ) -> Result, problem_details::ProblemDetails> { 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` // declares per agent from `services.hyperhive.agent.matrix.url` — the // module owns that `matrixAccounts` entry, which is why 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. 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, declared per agent from \ services.hyperhive.agent.matrix.url — it cannot be set through this route.", )); } 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()) })?; let user_id = link_matrix(&store, &secret_path, &existing(&agent, &account), &req) .await .map_err(|b| *b)?; tracing::info!(%hive, %agent, %account, "credential stored"); Ok(Json(PutMatrixAccountResponse { user_id })) } /// The account a refused link names. fn existing(agent: &str, account: &str) -> String { format!("agent {agent} already has matrix account {account:?}") } /// Resolve `req`'s credential and write it at `path`, unless an account is /// stored there already; `existing` names that account in the 409. /// /// The check comes before password mode's login, so a refused link makes no /// login and so mints no device at the homeserver. A failed login writes /// nothing. async fn link_matrix( store: &impl AccountStore, path: &str, existing: &str, req: &PutMatrixAccountRequest, ) -> Result, Box> { let refused = |e: Linking| { // The path names the agent and the account; the value is not in it. tracing::warn!(path, error = ?e, "linking the matrix account failed"); Box::new(e.problem(existing)) }; refuse_linked::(store, path) .await .map_err(refused)?; let (token, homeserver, user_id) = resolve_credential(req).await?; link( store, path, &matrix::Credential { value: token, homeserver, }, ) .await .map_err(refused)?; Ok(user_id) } /// Whether `account` is the agent's own account, which [`agent_token`] mints /// and `nix/agent-modules/matrix.nix` declares per agent — see /// [`put_matrix_account`]'s comment on it for why that route must never write /// one. `linked_accounts::delete_matrix_account` refuses to delete it too. pub(crate) fn is_reserved_account(account: &str) -> bool { account == agent_token::ACCOUNT } /// 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), &'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, &'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 in `put_matrix_account` instead of /// changing that fn's own (exempt, and part of the route's documented /// contract) return type. async fn resolve_credential( req: &PutMatrixAccountRequest, ) -> Result<(String, Option, Option), Box> { 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)"), ))), } } /// Bound on reaching the homeserver. const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); /// Bound on one whole homeserver round trip, body included. A password /// login makes the homeserver hash the password before it answers, so this /// is looser than a plain API call needs. const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); /// A client with both homeserver bounds applied. fn http_client() -> Result { reqwest::Client::builder() .connect_timeout(HTTP_CONNECT_TIMEOUT) .timeout(HTTP_TIMEOUT) .build() .map_err(|e| format!("build HTTP client: {e}")) } /// `what` failed with `e`; a timeout names the bound that fired. fn http_error(what: &str, e: &reqwest::Error) -> String { if e.is_connect() && e.is_timeout() { format!("{what}: connect timed out after {HTTP_CONNECT_TIMEOUT:?}") } else if e.is_timeout() { format!("{what}: timed out after {HTTP_TIMEOUT:?}") } else { format!("{what}: {e}") } } /// POST `m.login.password` to `/_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 = http_client()? .post(&url) .json(&body) .send() .await .map_err(|e| http_error("POST /login", &e))?; let status = resp.status(); let json: serde_json::Value = resp .json() .await .map_err(|e| http_error("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())) } /// POST `/_matrix/client/v3/logout` with `token`, which /// invalidates that token at the homeserver. /// /// A 401 `M_UNKNOWN_TOKEN` means the homeserver already does not recognise /// the token — the state a logout wants — so that one answer counts as /// success rather than failure. Every other non-2xx is a failure. /// /// The error names the status and the homeserver's `error` text, never the /// token. pub(crate) async fn matrix_logout(homeserver: &str, token: &str) -> Result<(), String> { let url = format!( "{}/_matrix/client/v3/logout", homeserver.trim_end_matches('/') ); let resp = http_client()? .post(&url) .bearer_auth(token) .json(&serde_json::json!({})) .send() .await .map_err(|e| http_error("POST /logout", &e))?; let status = resp.status(); if status.is_success() { return Ok(()); } let body = resp.json::().await.ok(); if status == StatusCode::UNAUTHORIZED && body .as_ref() .and_then(|j| j.get("errcode")) .and_then(serde_json::Value::as_str) == Some("M_UNKNOWN_TOKEN") { return Ok(()); } let err = body .and_then(|j| { j.get("error") .and_then(serde_json::Value::as_str) .map(str::to_owned) }) .unwrap_or_else(|| "logout failed".to_owned()); Err(format!("/logout HTTP {status}: {err}")) } #[cfg(test)] mod tests { use super::{ PutMatrixAccountRequest, homeserver_or_configured_default, is_reserved_account, matrix_logout, 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() ); } /// Bare-minimum `AppState` for a handler test: one hive, nothing /// queue-backed, and an empty in-memory job graph the endpoint under test /// never touches. fn state() -> super::super::AppState { super::super::AppState { hives: std::sync::Arc::new(vec![super::super::HiveEntry { name: "pr1ma".to_owned(), domain: "pr1ma.example".to_owned(), }]), links: std::sync::Arc::new(Vec::new()), status: None, wanted: None, agent_status: None, agent_icons: None, jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), ))), webhook_secret: None, config_prs: None, swarm_name: None, auth: None, forge: None, create_gate: std::sync::Arc::default(), } } /// Refused before the store is reached: with `BAO_*` unset a store /// connect would answer 500, so a 400 is the reserved-name check. #[tokio::test] async fn a_reserved_account_name_is_refused_before_the_store() { for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] { assert!( std::env::var(var).is_err(), "{var} must be unset for this test to prove anything" ); } let result = super::put_matrix_account( axum::extract::State(state()), axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), "main".to_owned())), axum::Json(PutMatrixAccountRequest { mode: "token".to_owned(), token: Some("t0k3n".to_owned()), user_id: None, password: None, homeserver: None, }), ) .await; let problem = result.expect_err("'main' is reserved"); assert_eq!( problem.status, Some(axum::http::StatusCode::BAD_REQUEST), "{problem:?}" ); } /// The control for the test above: an ordinary name gets past every check /// and fails at the store connect, so the 400 above is not what every call /// answers. #[tokio::test] async fn an_ordinary_account_name_reaches_the_store() { let result = super::put_matrix_account( axum::extract::State(state()), axum::extract::Path(( "pr1ma".to_owned(), "atlas".to_owned(), "workaccount".to_owned(), )), axum::Json(PutMatrixAccountRequest { mode: "token".to_owned(), token: Some("t0k3n".to_owned()), user_id: None, password: None, homeserver: None, }), ) .await; let problem = result.expect_err("no store is configured in a test"); assert_eq!( problem.status, Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR), "{problem:?}" ); } /// The second link is password mode against a port nothing listens on: /// a login attempt would answer 400, so the 409 is the check running first. #[tokio::test] async fn linking_a_name_twice_is_a_409_and_the_first_account_stays() { use super::super::linked_accounts::{AccountStore, tests::FakeStore}; use swarm_secret_client::matrix; let store = FakeStore::default(); let path = matrix::account_path("atlas", "workaccount").expect("a valid path"); let existing = &super::existing("atlas", "workaccount"); let mut first = request("token"); first.token = Some("t0k3n-first".to_owned()); first.homeserver = Some("https://matrix.example.org".to_owned()); let mut second = request("password"); second.homeserver = Some("http://127.0.0.1:9".to_owned()); second.user_id = Some("@a:matrix.example.org".to_owned()); second.password = Some("hunter2".to_owned()); super::link_matrix(&store, &path, existing, &first) .await .expect("nothing is stored"); let problem = super::link_matrix(&store, &path, existing, &second) .await .expect_err("an account is stored"); assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT)); let detail = problem.detail.expect("a detail"); assert_eq!( detail, r#"agent atlas already has matrix account "workaccount" linked; delete it first"# ); assert_eq!(store.written(), std::slice::from_ref(&path)); let kept: matrix::Credential = store .read_optional(&path) .await .expect("store answers") .expect("still stored"); assert_eq!(kept.value, "t0k3n-first"); assert_eq!( kept.homeserver.as_deref(), Some("https://matrix.example.org") ); } /// A stand-in homeserver on a loopback port, answering every /// `/_matrix/client/v3/logout` with `status` and `body`. async fn stub_homeserver(status: u16, body: &'static str) -> String { let app = axum::Router::new().route( "/_matrix/client/v3/logout", axum::routing::post(move || async move { ( axum::http::StatusCode::from_u16(status).unwrap(), [(axum::http::header::CONTENT_TYPE, "application/json")], body, ) }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await }); format!("http://{addr}") } /// A revoke whose token the homeserver already does not recognise has /// reached logout's end state, not failed it. #[tokio::test] async fn logout_treats_401_m_unknown_token_as_already_revoked() { let homeserver = stub_homeserver( 401, r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Access token invalid"}"#, ) .await; assert!(matrix_logout(&homeserver, "t0k3n").await.is_ok()); } /// The control: a 401 with a different errcode is still a failure, so /// the fold above is specific to `M_UNKNOWN_TOKEN` and not any 401. #[tokio::test] async fn logout_fails_on_other_401s() { let homeserver = stub_homeserver(401, r#"{"errcode":"M_FORBIDDEN","error":"nope"}"#).await; let err = matrix_logout(&homeserver, "t0k3n") .await .expect_err("not M_UNKNOWN_TOKEN"); assert!(err.contains("401"), "{err}"); } }