The controller writes an agent's credential; the hive fetches it back with its own certificate. Nothing said which paths that certificate may read, so the read half of a delivery answers 403 with no way to tell why. The grant is derived from the declaration, so it is re-rendered at the one place the declaration changes -- WantedWriter::set -- rather than at its caller, which would work today and break on the second caller. Emitted before the KV write: a grant that lands late is a 403 on an agent's first fetch, while one that shrinks early only affects an agent already being torn down. A failed write then leaves a superset the next declaration re-renders. Destroyed agents are filtered out. The declared set is a hive's whole history -- a destroyed entry stays so that redeclaring it Up is refused as the terminal transition it is -- so granting every declared agent would leave a torn-down agent's credentials readable forever. The sink is a trait because a missed emission is that same untraceable 403: the double pins which agents were published, and the no-sink and refusing arms pin the two deployments that are not a happy path. Not covered: the call site inside set(), which needs a live queue. The cert role moves to its own module on the way past. It is the controller's identity at the store, not something the matrix route owns, and the policy writer needs the same login.
435 lines
18 KiB
Rust
435 lines
18 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()
|
|
}
|
|
|
|
/// 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(¬ice).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, 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,
|
|
}
|
|
}
|
|
|
|
#[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()
|
|
);
|
|
}
|
|
}
|