Compare commits
2 changed files with 6 additions and 364 deletions
|
|
@ -150,30 +150,6 @@ enum MatrixCmd {
|
|||
#[arg(long, conflicts_with = "password")]
|
||||
password_stdin: bool,
|
||||
},
|
||||
/// Provision (or re-provision) the hive system admin matrix account
|
||||
/// (`@hive:<server>`). hive-c0re runs this automatically on startup
|
||||
/// before the agent sweep so the account is the first registered
|
||||
/// user — Conduit/tuwunel grants admin rights to the first user.
|
||||
/// Run manually to recover a missing admin token file.
|
||||
SyncAdmin,
|
||||
/// Promote a matrix user to homeserver admin via the admin API.
|
||||
/// Uses the hive system admin token at
|
||||
/// `/var/lib/hyperhive/matrix-admin-token`. The server_name is
|
||||
/// discovered automatically from the running homeserver.
|
||||
PromoteUser {
|
||||
/// Matrix localpart of the user to promote (e.g. `argus`).
|
||||
name: String,
|
||||
},
|
||||
/// Reset a matrix user's password via the admin API and persist the
|
||||
/// new password to `/var/lib/hyperhive/matrix-creds/<name>-password`
|
||||
/// so the next `ensure_user_for` (or `create-user`) can re-login.
|
||||
///
|
||||
/// After this command succeeds, run `hivectl matrix create-user
|
||||
/// <name>` to mint a fresh access token for the agent.
|
||||
ResetPassword {
|
||||
/// Matrix localpart of the account to reset (e.g. `argus`).
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Default htpasswd file path — the host-side location of the gateway's
|
||||
|
|
@ -274,9 +250,6 @@ async fn main() -> Result<()> {
|
|||
password,
|
||||
password_stdin,
|
||||
} => matrix_create_user(&name, password.as_deref(), password_stdin).await,
|
||||
MatrixCmd::SyncAdmin => matrix_sync_admin().await,
|
||||
MatrixCmd::PromoteUser { name } => matrix_promote_user(&name).await,
|
||||
MatrixCmd::ResetPassword { name } => matrix_reset_password(&name).await,
|
||||
},
|
||||
Cmd::Gateway { cmd } => match cmd {
|
||||
GatewayCmd::CreateUser {
|
||||
|
|
@ -426,84 +399,6 @@ async fn matrix_create_user(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn matrix_sync_admin() -> Result<()> {
|
||||
if !hive_c0re::matrix::is_present().await {
|
||||
bail!(
|
||||
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first"
|
||||
);
|
||||
}
|
||||
let register_token =
|
||||
hive_c0re::matrix::ensure_register_token().context("read matrix register token")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
hive_c0re::matrix::ensure_admin_user(&client, ®ister_token)
|
||||
.await
|
||||
.context("matrix sync-admin")?;
|
||||
let path = hive_c0re::matrix::admin_token_path();
|
||||
println!(
|
||||
"matrix: hive admin user '@{}' provisioned",
|
||||
hive_c0re::matrix::HIVE_ADMIN_LOCALPART
|
||||
);
|
||||
println!("token persisted at: {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn matrix_promote_user(name: &str) -> Result<()> {
|
||||
if !hive_c0re::matrix::is_present().await {
|
||||
bail!(
|
||||
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first"
|
||||
);
|
||||
}
|
||||
let admin_token = hive_c0re::matrix::read_admin_token()?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
let server_name = hive_c0re::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
hive_c0re::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix promote-user {name}"))?;
|
||||
println!("matrix: promoted @{name}:{server_name} to admin");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn matrix_reset_password(name: &str) -> Result<()> {
|
||||
if !hive_c0re::matrix::is_present().await {
|
||||
bail!(
|
||||
"hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first"
|
||||
);
|
||||
}
|
||||
let admin_token = hive_c0re::matrix::read_admin_token()?;
|
||||
let new_password =
|
||||
hive_c0re::matrix::random_password().context("generate random password")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("build reqwest client")?;
|
||||
let server_name = hive_c0re::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
hive_c0re::matrix::reset_user_password(
|
||||
&client,
|
||||
&admin_token,
|
||||
name,
|
||||
&server_name,
|
||||
&new_password,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
let pw_path = PathBuf::from("/var/lib/hyperhive/matrix-creds")
|
||||
.join(format!("{name}-password"));
|
||||
println!("matrix: password for @{name}:{server_name} reset");
|
||||
println!("password persisted at: {}", pw_path.display());
|
||||
println!("next: hivectl matrix create-user {name} # mints a fresh access token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gateway htpasswd helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -39,46 +39,16 @@ 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.
|
||||
#[must_use]
|
||||
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 {
|
||||
Coordinator::agent_notes_dir(name).join("matrix-token")
|
||||
}
|
||||
|
||||
/// Password file for the agent's matrix account. Stored OUTSIDE the
|
||||
/// purgeable `agent_state_root` tree so it survives `destroy --purge`
|
||||
/// and allows re-login recovery when the same agent name is re-spawned.
|
||||
///
|
||||
/// Path: `/var/lib/hyperhive/matrix-creds/<name>-password`
|
||||
///
|
||||
/// The token file lives inside the agent's bind-mounted state dir (under
|
||||
/// `agent_notes_dir`) so the agent container can read it; the password
|
||||
/// file is host-side only (agents never log in by password — they use
|
||||
/// the access token exclusively) and belongs with other hive-c0re
|
||||
/// credential state, not inside the purgeable per-agent tree.
|
||||
/// Password file alongside the token. Persisted so we can fall back to
|
||||
/// `m.login.password` if the token file is deleted but the homeserver
|
||||
/// account still exists. Mode 0600, same dir as the token.
|
||||
fn password_path(name: &str) -> PathBuf {
|
||||
PathBuf::from("/var/lib/hyperhive/matrix-creds").join(format!("{name}-password"))
|
||||
}
|
||||
|
||||
/// Legacy password path (inside the old purgeable `agent_notes_dir`).
|
||||
/// Used only during the one-time migration in [`ensure_user_for`] to
|
||||
/// move credentials from old deployments to the new location. Safe to
|
||||
/// call after `destroy --purge` — the path will simply not exist and
|
||||
/// the migration is a no-op.
|
||||
fn legacy_password_path(name: &str) -> PathBuf {
|
||||
Coordinator::agent_notes_dir(name).join("matrix-password")
|
||||
}
|
||||
|
||||
|
|
@ -306,29 +276,6 @@ pub async fn ensure_user_for(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// One-time migration: move the password from the old location inside
|
||||
// agent_notes_dir (purgeable) to the new location outside it.
|
||||
let new_pw_path = password_path(name);
|
||||
let old_pw_path = legacy_password_path(name);
|
||||
if !new_pw_path.exists() && old_pw_path.exists() {
|
||||
if let Some(parent) = new_pw_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&old_pw_path, &new_pw_path) {
|
||||
// Rename across filesystems or read-only src — copy + delete.
|
||||
if let Ok(content) = std::fs::read(&old_pw_path) {
|
||||
if std::fs::write(&new_pw_path, &content).is_ok() {
|
||||
let _ = std::fs::remove_file(&old_pw_path);
|
||||
tracing::info!(%name, "matrix: migrated password file to non-purgeable location");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(%name, rename_error = ?e, "matrix: password migration failed — could not read old path (old path stays)");
|
||||
}
|
||||
} else {
|
||||
tracing::info!(%name, "matrix: migrated password file to non-purgeable location");
|
||||
}
|
||||
}
|
||||
|
||||
let password = random_password()?;
|
||||
let access_token = match register_user(client, name, register_token, &password).await {
|
||||
Ok(token) => {
|
||||
|
|
@ -342,6 +289,7 @@ pub async fn ensure_user_for(
|
|||
tracing::warn!(%name, error = ?e, "matrix: failed to persist password (token still saved)");
|
||||
} else {
|
||||
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
|
||||
crate::lifecycle::chown_to_agent(name, &pw_path, "matrix");
|
||||
}
|
||||
token
|
||||
}
|
||||
|
|
@ -355,10 +303,8 @@ pub async fn ensure_user_for(
|
|||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"matrix: user {name} already exists in homeserver but the stored \
|
||||
password is missing — run:\n\
|
||||
hivectl matrix reset-password {name}\n\
|
||||
hivectl matrix create-user {name}"
|
||||
"matrix: user {name} already exists in homeserver but matrix-password \
|
||||
is missing — manual recovery: hivectl matrix create-user {name} --password <pw>"
|
||||
)
|
||||
})?;
|
||||
login_user(client, name, &stored).await.with_context(|| {
|
||||
|
|
@ -446,199 +392,6 @@ pub async fn sync_agent_standalone(name: &str) {
|
|||
sync_agent(&client, name, ®ister_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
|
||||
/// `hivectl matrix promote-user hive` 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"
|
||||
)
|
||||
}
|
||||
|
||||
/// Discover the matrix `server_name` from the running homeserver via
|
||||
/// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint).
|
||||
/// The response JSON always includes `"server_name"` per the matrix spec.
|
||||
pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
|
||||
let url = format!("{MATRIX_HTTP}/_matrix/key/v2/server");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: GET /_matrix/key/v2/server")?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("matrix: parse /_matrix/key/v2/server response")?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!(
|
||||
"matrix: /_matrix/key/v2/server returned HTTP {status}, body: {body}"
|
||||
);
|
||||
}
|
||||
body["server_name"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.with_context(|| {
|
||||
format!("matrix: /_matrix/key/v2/server response missing server_name field: {body}")
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -668,12 +421,6 @@ 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, ®ister_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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue