dashboard: POST /matrix-account-login + account-aware hive-priv matrix-token write (BE-2)

This commit is contained in:
damocles 2026-06-16 10:49:01 +02:00 committed by mara
commit 9c480daf0a
6 changed files with 228 additions and 12 deletions

View file

@ -105,6 +105,10 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
"/api/matrix-accounts",
get(matrix_accounts::get_matrix_accounts),
)
.route(
"/matrix-account-login",
post(matrix_accounts::post_matrix_account_login),
)
.route("/api/reminders", get(reminders::api_reminders))
.route("/api/operator-inbox", get(api_operator_inbox))
.route("/api/stats-hive", get(api_stats_hive))

View file

@ -12,7 +12,7 @@
//! Homeserver + live up/down status arrive in v2 from the daemon's account
//! registry; here `homeserver` is always `null`.
use axum::extract::Query;
use axum::extract::{Form, Query};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
@ -95,9 +95,182 @@ pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) ->
axum::Json(MatrixAccountsResponse { accounts }).into_response()
}
/// Form body for `POST /matrix-account-login` (urlencoded, the dashboard's
/// mutation convention). `mode` is `"password"` (needs `user_id` +
/// `password`) or `"token"` (needs `token`; `user_id` is recovered via
/// whoami).
#[derive(Deserialize)]
pub(super) struct MatrixLoginForm {
agent: String,
account: String,
homeserver: String,
mode: String,
user_id: Option<String>,
password: Option<String>,
token: Option<String>,
}
#[derive(Serialize)]
struct MatrixLoginResult {
ok: bool,
user_id: String,
}
/// Plain-identifier check (ascii alnum + `-`/`_`), matching what hive-priv
/// re-applies root-side to the agent + account before building the path.
fn is_plain_ident(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
/// Provision (or refresh) the token for an agent's extra matrix account.
/// password mode → `m.login.password`; token mode → validate via `whoami`.
/// On success writes the token to `matrix-token-<account>` via hive-priv and
/// kicks the daemon. Operator-authenticated (dashboard). Never echoes the
/// token back — only `{ ok, user_id }`.
pub(super) async fn post_matrix_account_login(Form(f): Form<MatrixLoginForm>) -> Response {
let agent = f.agent.trim();
let account = f.account.trim();
let homeserver = f.homeserver.trim().trim_end_matches('/');
if !is_plain_ident(agent) {
return error_response(&format!("matrix-account-login: invalid agent {agent:?}"));
}
if !is_plain_ident(account) {
return error_response(&format!(
"matrix-account-login: invalid account {account:?}"
));
}
if account == "main" {
return error_response(
"matrix-account-login: 'main' is the hive-internal account; it is \
provisioned via the normal flow, not this form",
);
}
if !(homeserver.starts_with("http://") || homeserver.starts_with("https://")) {
return error_response(&format!(
"matrix-account-login: homeserver must be an http(s) URL, got {homeserver:?}"
));
}
let (token, user_id) = match f.mode.as_str() {
"password" => {
let (Some(uid), Some(pw)) = (f.user_id.as_deref(), f.password.as_deref()) else {
return error_response(
"matrix-account-login: password mode needs user_id + password",
);
};
match matrix_password_login(homeserver, uid, pw).await {
Ok(pair) => pair,
Err(e) => return error_response(&format!("matrix-account-login: {e}")),
}
}
"token" => {
let Some(tok) = f.token.as_deref() else {
return error_response("matrix-account-login: token mode needs token");
};
match matrix_whoami(homeserver, tok).await {
Ok(uid) => (tok.to_owned(), uid),
Err(e) => return error_response(&format!("matrix-account-login: {e}")),
}
}
other => {
return error_response(&format!(
"matrix-account-login: unknown mode {other:?} (want password|token)"
));
}
};
if let Err(e) = crate::priv_client::write_agent_matrix_token(agent, &token, Some(account)).await
{
return error_response(&format!("matrix-account-login: write token failed: {e:#}"));
}
// Best-effort kick so the daemon picks up the new account without a full
// container restart; not fatal if the container isn't running.
if let Err(e) = crate::priv_client::restart_matrix_daemon(agent).await {
tracing::warn!(
%agent, %account, error = ?e,
"matrix-account-login: daemon restart failed (token written; loads on next start)"
);
}
tracing::info!(%agent, %account, "matrix-account-login: provisioned extra matrix account");
axum::Json(MatrixLoginResult { ok: true, user_id }).into_response()
}
/// POST `m.login.password` to `<homeserver>/_matrix/client/v3/login`.
/// Returns `(access_token, user_id)`.
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()))
}
/// GET `<homeserver>/_matrix/client/v3/account/whoami` with the bearer token
/// to validate it and recover the `user_id`.
async fn matrix_whoami(homeserver: &str, token: &str) -> Result<String, String> {
let url = format!("{homeserver}/_matrix/client/v3/account/whoami");
let resp = reqwest::Client::new()
.get(&url)
.bearer_auth(token)
.send()
.await
.map_err(|e| format!("GET /whoami: {e}"))?;
let status = resp.status();
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("parse /whoami response: {e}"))?;
if !status.is_success() {
let err = json
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("token rejected");
return Err(format!("/whoami HTTP {status}: {err}"));
}
json.get("user_id")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| "whoami response missing user_id".to_owned())
}
#[cfg(test)]
mod tests {
use super::account_name_from_filename;
use super::{account_name_from_filename, is_plain_ident};
#[test]
fn main_account_from_bare_token() {
@ -126,4 +299,14 @@ mod tests {
assert_eq!(account_name_from_filename("notes.md"), None);
assert_eq!(account_name_from_filename("matrix-avatar-icon-hash"), None);
}
#[test]
fn is_plain_ident_rejects_path_chars() {
assert!(is_plain_ident("catgirl"));
assert!(is_plain_ident("acct-1_x"));
assert!(!is_plain_ident(""));
assert!(!is_plain_ident("../escape"));
assert!(!is_plain_ident("a/b"));
assert!(!is_plain_ident("a.b"));
}
}

View file

@ -709,7 +709,7 @@ pub async fn ensure_user_for(
// unprivileged `hive-core` user and cannot write to agent-owned state
// directories directly. hive-priv writes the file 0600 and chowns it
// to the agent user so it is readable from inside the container.
crate::priv_client::write_agent_matrix_token(name, &access_token)
crate::priv_client::write_agent_matrix_token(name, &access_token, None)
.await
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
tracing::info!(%name, "matrix: provisioned access token");

View file

@ -253,14 +253,21 @@ pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()
.await?)
}
/// Write the Matrix access token for `agent_name` to
/// `<agent_state_root>/<agent_name>/state/matrix-token` via hive-priv
/// (running as root). The file is written 0600 and chowned to the agent
/// user so it is readable from inside the agent container.
pub async fn write_agent_matrix_token(agent_name: &str, token: &str) -> Result<()> {
/// Write a Matrix access token for `agent_name` via hive-priv (running as
/// root). `account: None` writes the hive-internal
/// `<state>/matrix-token`; `account: Some(name)` writes
/// `<state>/matrix-token-<name>` for an extra (external) account. The file
/// is written 0600 and chowned to the agent user so it is readable from
/// inside the agent container. hive-priv validates the account suffix.
pub async fn write_agent_matrix_token(
agent_name: &str,
token: &str,
account: Option<&str>,
) -> Result<()> {
ok(call(&PrivRequest::WriteAgentMatrixToken {
agent_name: agent_name.to_owned(),
token: token.to_owned(),
account: account.map(ToOwned::to_owned),
})
.await?)
}