diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index aa660640..c6814952 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -105,10 +105,6 @@ 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 af3ba0e7..a172a241 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::{Form, Query}; +use axum::extract::Query; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; @@ -95,185 +95,9 @@ 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 matching hive-priv's `validate_name_chars` -/// exactly (lowercase ascii + digits + hyphens) — the root-side guard -/// re-applies the same rule before building the token path. Keeping the -/// dashboard check identical means a name that passes here can't then be -/// rejected at the priv boundary with a confusing "write token failed". -fn is_plain_ident(s: &str) -> bool { - !s.is_empty() - && s.chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || 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, is_plain_ident}; + use super::account_name_from_filename; #[test] fn main_account_from_bare_token() { @@ -302,20 +126,4 @@ 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_matches_validate_name_chars() { - // Accepts exactly what hive-priv's validate_name_chars does: - // lowercase ascii + digits + hyphens. - assert!(is_plain_ident("catgirl")); - assert!(is_plain_ident("acct-1")); - assert!(!is_plain_ident("")); - // Rejected: uppercase + underscore (would pass a looser check - // then fail at the priv boundary), and path chars. - assert!(!is_plain_ident("MyAccount")); - assert!(!is_plain_ident("my_account")); - 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 9de89df9..a931e2c3 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, None) + crate::priv_client::write_agent_matrix_token(name, &access_token) .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 6f38a131..432216dc 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -253,21 +253,14 @@ pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<() .await?) } -/// 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<()> { +/// 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<()> { 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 55c69f94..78be00a1 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -242,23 +242,9 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, PrivRequest::WriteAgentMatrixToken { ref agent_name, ref token, - ref account, } => { validate_agent_name(agent_name)?; - // 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")) + write_agent_state_file(agent_name, "matrix-token", &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 b23fca72..32b9be0d 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -277,23 +277,15 @@ pub enum PrivRequest { token: String, }, - /// 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. + /// Write `matrix-token` into `AGENT_STATE_ROOT//state/matrix-token`. /// - /// Same write semantics as `WriteAgentForgeToken` — validates names, - /// creates dir, writes 0600, chowns to agent owner. + /// Same semantics as `WriteAgentForgeToken` — validates name, 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