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?)
}

View file

@ -242,9 +242,23 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
PrivRequest::WriteAgentMatrixToken {
ref agent_name,
ref token,
ref account,
} => {
validate_agent_name(agent_name)?;
write_agent_state_file(agent_name, "matrix-token", &format!("{token}\n"))
// Build the token filename. `None` → the hive account's
// `matrix-token`; `Some(a)` → `matrix-token-<a>`. The account
// suffix MUST be validated as a plain identifier (no `/`, `.`,
// `..`) before it goes into the filename, or a crafted account
// could traverse out of the state dir — `write_agent_state_file`
// trusts its `filename` argument.
let filename = match account {
None => "matrix-token".to_owned(),
Some(a) => {
validate_name_chars(a)?;
format!("matrix-token-{a}")
}
};
write_agent_state_file(agent_name, &filename, &format!("{token}\n"))
}
PrivRequest::RestartMatrixDaemon { ref agent_name } => {

View file

@ -277,15 +277,23 @@ pub enum PrivRequest {
token: String,
},
/// Write `matrix-token` into `AGENT_STATE_ROOT/<agent_name>/state/matrix-token`.
/// Write a matrix access token into the agent's state dir. With
/// `account: None` it targets the hive-internal `matrix-token`; with
/// `account: Some(name)` it targets `matrix-token-<name>` for an extra
/// (external) account. hive-priv validates both `agent_name` and the
/// `account` suffix as plain identifiers before building the path, so a
/// crafted account name cannot traverse out of the state dir.
///
/// Same semantics as `WriteAgentForgeToken` — validates name, creates
/// dir, writes 0600, chowns to agent owner.
/// Same write semantics as `WriteAgentForgeToken` — validates names,
/// creates dir, writes 0600, chowns to agent owner.
WriteAgentMatrixToken {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Token value. hive-priv appends a trailing newline before writing.
token: String,
/// Extra-account suffix. `None` → `matrix-token` (the hive account);
/// `Some(name)` → `matrix-token-<name>` (validated as a plain ident).
account: Option<String>,
},
/// Restart `hive-matrix-daemon.service` inside an agent container via