From 9c480daf0a550678f89d15d26177e67a53b16ec8 Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 16 Jun 2026 10:49:01 +0200 Subject: [PATCH] dashboard: POST /matrix-account-login + account-aware hive-priv matrix-token write (BE-2) --- hive-c0re/src/dashboard.rs | 4 + hive-c0re/src/dashboard/matrix_accounts.rs | 187 ++++++++++++++++++++- hive-c0re/src/matrix.rs | 2 +- hive-c0re/src/priv_client.rs | 17 +- hive-priv/src/main.rs | 16 +- hive-sh4re/src/priv_proto.rs | 14 +- 6 files changed, 228 insertions(+), 12 deletions(-) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index c6814952..aa660640 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -105,6 +105,10 @@ pub async fn serve(port: u16, coord: Arc) -> 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)) diff --git a/hive-c0re/src/dashboard/matrix_accounts.rs b/hive-c0re/src/dashboard/matrix_accounts.rs index a172a241..1afa571c 100644 --- a/hive-c0re/src/dashboard/matrix_accounts.rs +++ b/hive-c0re/src/dashboard/matrix_accounts.rs @@ -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) -> 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, + password: Option, + token: Option, +} + +#[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-` 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) -> 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 `/_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 `/_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 { + 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")); + } } diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index a931e2c3..9de89df9 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -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"); diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 432216dc..6f38a131 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -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 -/// `//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 +/// `/matrix-token`; `account: Some(name)` writes +/// `/matrix-token-` 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?) } diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 78be00a1..55c69f94 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -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-`. 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 } => { diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 32b9be0d..b23fca72 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -277,15 +277,23 @@ pub enum PrivRequest { token: String, }, - /// Write `matrix-token` into `AGENT_STATE_ROOT//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-` 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-` (validated as a plain ident). + account: Option, }, /// Restart `hive-matrix-daemon.service` inside an agent container via