Compare commits
6 changed files with 12 additions and 237 deletions
|
|
@ -105,10 +105,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
"/api/matrix-accounts",
|
"/api/matrix-accounts",
|
||||||
get(matrix_accounts::get_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/reminders", get(reminders::api_reminders))
|
||||||
.route("/api/operator-inbox", get(api_operator_inbox))
|
.route("/api/operator-inbox", get(api_operator_inbox))
|
||||||
.route("/api/stats-hive", get(api_stats_hive))
|
.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
|
//! Homeserver + live up/down status arrive in v2 from the daemon's account
|
||||||
//! registry; here `homeserver` is always `null`.
|
//! registry; here `homeserver` is always `null`.
|
||||||
|
|
||||||
use axum::extract::{Form, Query};
|
use axum::extract::Query;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -95,185 +95,9 @@ pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) ->
|
||||||
axum::Json(MatrixAccountsResponse { accounts }).into_response()
|
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 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-<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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{account_name_from_filename, is_plain_ident};
|
use super::account_name_from_filename;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn main_account_from_bare_token() {
|
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("notes.md"), None);
|
||||||
assert_eq!(account_name_from_filename("matrix-avatar-icon-hash"), 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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -709,7 +709,7 @@ pub async fn ensure_user_for(
|
||||||
// unprivileged `hive-core` user and cannot write to agent-owned state
|
// unprivileged `hive-core` user and cannot write to agent-owned state
|
||||||
// directories directly. hive-priv writes the file 0600 and chowns it
|
// directories directly. hive-priv writes the file 0600 and chowns it
|
||||||
// to the agent user so it is readable from inside the container.
|
// 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
|
.await
|
||||||
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
|
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
|
||||||
tracing::info!(%name, "matrix: provisioned access token");
|
tracing::info!(%name, "matrix: provisioned access token");
|
||||||
|
|
|
||||||
|
|
@ -253,21 +253,14 @@ pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a Matrix access token for `agent_name` via hive-priv (running as
|
/// Write the Matrix access token for `agent_name` to
|
||||||
/// root). `account: None` writes the hive-internal
|
/// `<agent_state_root>/<agent_name>/state/matrix-token` via hive-priv
|
||||||
/// `<state>/matrix-token`; `account: Some(name)` writes
|
/// (running as root). The file is written 0600 and chowned to the agent
|
||||||
/// `<state>/matrix-token-<name>` for an extra (external) account. The file
|
/// user so it is readable from inside the agent container.
|
||||||
/// is written 0600 and chowned to the agent user so it is readable from
|
pub async fn write_agent_matrix_token(agent_name: &str, token: &str) -> Result<()> {
|
||||||
/// 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 {
|
ok(call(&PrivRequest::WriteAgentMatrixToken {
|
||||||
agent_name: agent_name.to_owned(),
|
agent_name: agent_name.to_owned(),
|
||||||
token: token.to_owned(),
|
token: token.to_owned(),
|
||||||
account: account.map(ToOwned::to_owned),
|
|
||||||
})
|
})
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -242,23 +242,9 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
|
||||||
PrivRequest::WriteAgentMatrixToken {
|
PrivRequest::WriteAgentMatrixToken {
|
||||||
ref agent_name,
|
ref agent_name,
|
||||||
ref token,
|
ref token,
|
||||||
ref account,
|
|
||||||
} => {
|
} => {
|
||||||
validate_agent_name(agent_name)?;
|
validate_agent_name(agent_name)?;
|
||||||
// Build the token filename. `None` → the hive account's
|
write_agent_state_file(agent_name, "matrix-token", &format!("{token}\n"))
|
||||||
// `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 } => {
|
PrivRequest::RestartMatrixDaemon { ref agent_name } => {
|
||||||
|
|
|
||||||
|
|
@ -277,23 +277,15 @@ pub enum PrivRequest {
|
||||||
token: String,
|
token: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Write a matrix access token into the agent's state dir. With
|
/// Write `matrix-token` into `AGENT_STATE_ROOT/<agent_name>/state/matrix-token`.
|
||||||
/// `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 write semantics as `WriteAgentForgeToken` — validates names,
|
/// Same semantics as `WriteAgentForgeToken` — validates name, creates
|
||||||
/// creates dir, writes 0600, chowns to agent owner.
|
/// dir, writes 0600, chowns to agent owner.
|
||||||
WriteAgentMatrixToken {
|
WriteAgentMatrixToken {
|
||||||
/// Logical agent name (validated by `validate_agent_name`).
|
/// Logical agent name (validated by `validate_agent_name`).
|
||||||
agent_name: String,
|
agent_name: String,
|
||||||
/// Token value. hive-priv appends a trailing newline before writing.
|
/// Token value. hive-priv appends a trailing newline before writing.
|
||||||
token: String,
|
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
|
/// Restart `hive-matrix-daemon.service` inside an agent container via
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue