feat: hive matrix admin user + hivectl matrix promote-user/reset-password

- provision @hive:<server> as the first matrix account in ensure_all()
  (Conduit/tuwunel makes the first registered user admin automatically)
- add hivectl matrix sync-admin — manual re-provision of the admin token
- add hivectl matrix promote-user <name> --server <name> — promote via
  Synapse-compat admin API using the hive admin token
- add hivectl matrix reset-password <name> --server <name> — reset an
  agent's password + persist it so ensure_user_for can re-login; follow
  with hivectl matrix create-user <name> to mint a fresh access token
- both commands fall back to HYPERHIVE_MATRIX_SERVER_NAME env var for
  --server when omitted
This commit is contained in:
atlas 2026-06-03 21:11:46 +02:00 committed by mara
commit 8757dc615d
2 changed files with 309 additions and 0 deletions

View file

@ -39,6 +39,18 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
/// store it nowhere.
const PASSWORD_BYTES: usize = 32;
/// Matrix localpart for the hive system admin account. Registered
/// before any agent account in [`ensure_all`] so it becomes the first
/// user on the homeserver — Conduit/tuwunel grants admin rights to the
/// first registered user automatically. Not an agent; has no state dir.
pub const HIVE_ADMIN_LOCALPART: &str = "hive";
/// Host path for the hive admin matrix access token. Outside every
/// purgeable path — not deleted by `destroy --purge` on any agent.
pub fn admin_token_path() -> PathBuf {
PathBuf::from("/var/lib/hyperhive/matrix-admin-token")
}
/// Token file inside the agent's bind-mounted state dir (visible as
/// `/state/matrix-token` from inside the container).
fn token_path(name: &str) -> PathBuf {
@ -439,6 +451,171 @@ pub async fn sync_agent_standalone(name: &str) {
sync_agent(&client, name, &register_token).await;
}
/// Ensure the hive system admin matrix user exists and its token is
/// persisted at [`admin_token_path()`]. Must be called BEFORE
/// [`ensure_all`]'s agent loop so this account is the first to register
/// and becomes the homeserver admin automatically (Conduit/tuwunel:
/// first registered user = admin).
///
/// Idempotent — skips when the token file already exists and is
/// non-empty. Does NOT promote the account via API (that requires
/// admin rights which this fn bootstraps); on a fresh homeserver the
/// first-registered rule fires automatically; on an existing homeserver
/// the operator must promote the account once via `hivectl matrix
/// promote-user hive --server <name>` or the conduit admin room.
pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let path = admin_token_path();
if path.exists()
&& let Ok(existing) = std::fs::read_to_string(&path)
&& !existing.trim().is_empty()
{
tracing::debug!("matrix: hive admin token already present");
return Ok(());
}
let password = random_password()?;
let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, register_token, &password)
.await
{
Ok(token) => {
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
if let Some(parent) = pw_path.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
tracing::warn!(error = ?e, "matrix: failed to persist hive admin password");
} else {
let _ = std::fs::set_permissions(
&pw_path,
std::fs::Permissions::from_mode(0o600),
);
}
token
}
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
tracing::info!("matrix: hive admin user already exists, re-logging in");
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
let stored = std::fs::read_to_string(&pw_path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"matrix: hive admin user exists but password missing at {} — \
manual recovery: reset password via admin API or conduit admin room",
pw_path.display()
)
})?;
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
}
Err(other) => return Err(other),
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, format!("{access_token}\n"))
.with_context(|| format!("matrix: write hive admin token to {}", path.display()))?;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
tracing::info!(path = %path.display(), "matrix: provisioned hive admin token");
Ok(())
}
/// Call `PUT /_synapse/admin/v2/users/@{localpart}:{server_name}` with
/// `{"admin": true}` to promote a user to homeserver admin.
/// Requires the hive admin access token at [`admin_token_path()`].
pub async fn promote_user_to_admin(
client: &reqwest::Client,
admin_token: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
// URL-encode the @user:server path segment manually — only `@` and
// `:` need escaping; localpart + server_name use only safe chars.
let url = format!(
"{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}"
);
let resp = client
.put(&url)
.bearer_auth(admin_token)
.json(&serde_json::json!({"admin": true}))
.send()
.await
.context("matrix: PUT /_synapse/admin/v2/users (promote)")?;
let status = resp.status();
if status.is_success() {
return Ok(());
}
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
anyhow::bail!(
"matrix: promote @{localpart}:{server_name} to admin: HTTP {status}, body: {body}\n\
note: tuwunel must implement /_synapse/admin/v2/users; if 404 use the conduit admin room instead"
)
}
/// Call `PUT /_synapse/admin/v2/users/@{localpart}:{server_name}` with
/// `{"password": new_password}` to reset a user's password.
/// Writes the new password to the non-purgeable creds path so
/// [`ensure_user_for`] can re-login on next provisioning sweep.
pub async fn reset_user_password(
client: &reqwest::Client,
admin_token: &str,
localpart: &str,
server_name: &str,
new_password: &str,
) -> Result<()> {
let url = format!(
"{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}"
);
let resp = client
.put(&url)
.bearer_auth(admin_token)
.json(&serde_json::json!({"password": new_password}))
.send()
.await
.context("matrix: PUT /_synapse/admin/v2/users (reset password)")?;
let status = resp.status();
if status.is_success() {
// Persist the new password so ensure_user_for can re-login.
let pw_path = password_path(localpart);
if let Some(parent) = pw_path.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Err(e) = std::fs::write(&pw_path, format!("{new_password}\n")) {
tracing::warn!(%localpart, error = ?e, "matrix: failed to persist reset password");
} else {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
&pw_path,
std::fs::Permissions::from_mode(0o600),
);
}
return Ok(());
}
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
anyhow::bail!(
"matrix: reset password for @{localpart}:{server_name}: HTTP {status}, body: {body}\n\
note: tuwunel must implement /_synapse/admin/v2/users; if 404 use the conduit admin room instead"
)
}
/// Read the hive admin access token from disk. Returns an error if it
/// is absent — callers should gate their admin-API calls on this.
pub fn read_admin_token() -> Result<String> {
let path = admin_token_path();
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"hive admin matrix token not found at {} — \
ensure hive-c0re has started at least once with matrix enabled \
(it provisions the admin account on boot)",
path.display()
)
})
}
/// Sweep every existing container (manager + sub-agents) and ensure
/// each has a matrix user + token on the local homeserver. Called once
/// at hive-c0re startup, alongside `forge::ensure_all`. No-op when the
@ -468,6 +645,12 @@ pub async fn ensure_all() {
return;
}
};
// Provision hive admin user FIRST so it's the first registered
// account on a fresh homeserver (Conduit/tuwunel makes the first
// registered user admin automatically).
if let Err(e) = ensure_admin_user(&client, &register_token).await {
tracing::warn!(error = ?e, "matrix: ensure_admin_user failed");
}
let Ok(containers) = crate::lifecycle::list().await else {
tracing::warn!("matrix: nixos-container list failed; skipping user sweep");
return;