dashboard: POST /matrix-account-login + account-aware hive-priv matrix-token write (BE-2)
This commit is contained in:
parent
8799a9aa11
commit
9c480daf0a
6 changed files with 228 additions and 12 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue