fix: route forge_admin through hive-priv; auto-recover matrix passwords

forge_admin() spawned nixos-container run hive-forge directly from the
hive-core process. nixos-container run uses nsenter to enter the container
namespaces, which requires root. hive-core is unprivileged, so every call
failed with: nsenter: stat of /proc/<pid>/ns/user failed: Permission denied

Fix: add RunForgeAdmin { args } to PrivRequest. hive-priv (root) handles
it by spawning nixos-container run hive-forge -- runuser -u forgejo --
forgejo --work-path /var/lib/forgejo admin <args>. forge_admin() now calls
priv_client::run_forge_admin().

matrix: ensure_user_for hit M_USER_IN_USE then failed when the stored
password file was missing (state dirs wiped but homeserver kept accounts).
Previously required manual hivectl matrix reset-password <name>.

Fix: add auto_reset_password() — calls the admin API (PUT
/_synapse/admin/v2/users/@<name>:<server> with the hive admin token) to
set a new random password, then proceeds with login. Falls back to the
existing manual-recovery error if the admin token is unavailable.

Closes #1234
This commit is contained in:
atlas 2026-06-03 23:17:39 +02:00
commit 34bc4c0b06
5 changed files with 147 additions and 47 deletions

View file

@ -95,44 +95,15 @@ pub async fn is_present() -> bool {
/// forgejo user (the only uid with write access to the state dir).
/// Returns stdout on success; bails with stderr context on failure.
async fn forge_admin(args: &[&str]) -> Result<String> {
let mut cmd = Command::new("nixos-container");
// `runuser` (util-linux, always present in a NixOS container)
// beats `sudo` here — sudo isn't installed unless `security.sudo`
// is enabled, and we don't want to depend on that.
//
// `--work-path` is mandatory: without it, the admin CLI defaults
// WorkPath to `dirname(executable)` (a RO nix-store path), then
// looks for `<WorkPath>/custom/conf/app.ini` which doesn't
// exist, falls back to defaults, and F3 init tries to mkdir
// under the nix store and fatals. The systemd unit sets
// WORK_PATH for the daemon; we mirror it here for the CLI.
cmd.args([
"run",
FORGE_CONTAINER,
"--",
"runuser",
"-u",
"forgejo",
"--",
"forgejo",
"--work-path",
"/var/lib/forgejo",
"admin",
]);
cmd.args(args);
let out = cmd
.output()
.await
.context("invoke nixos-container run hive-forge -- forgejo admin")?;
if !out.status.success() {
anyhow::bail!(
"forgejo admin {} failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim(),
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
// Route through hive-priv (root helper) because `nixos-container run`
// uses nsenter to enter the container's namespaces, which requires root.
// hive-c0re runs as the unprivileged `hive-core` user and cannot call
// nsenter directly — doing so produces:
// nsenter: stat of /proc/<pid>/ns/user failed: Permission denied
let (stdout, _stderr) = crate::priv_client::run_forge_admin(args).await.with_context(
|| format!("forgejo admin {} (via hive-priv)", args.join(" ")),
)?;
Ok(stdout)
}
/// Pull the access token out of forgejo's success message. Format

View file

@ -285,6 +285,30 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
extract_access_token(&json)
}
/// Auto-recovery helper: reset a user's matrix password via the admin API
/// when the locally stored password is missing. Requires a valid hive admin
/// token at [`admin_token_path()`]. Returns the new password (already
/// persisted to [`password_path(name)`]) on success.
///
/// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE`
/// but the password file is absent — covers the case where agent state dirs
/// were wiped but the homeserver still has the accounts.
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
let admin_token = read_admin_token()
.context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?;
let server_name = discover_server_name(client)
.await
.context("matrix: discover_server_name for auto-recovery")?;
let new_password = random_password()?;
reset_user_password(client, &admin_token, name, &server_name, &new_password)
.await
.with_context(|| {
format!("matrix: admin API password reset for {name} (auto-recovery)")
})?;
tracing::info!(%name, "matrix: auto-recovered password via admin API reset");
Ok(new_password)
}
/// Ensure `name` has a matrix user + token file on the local
/// homeserver. Skips provisioning entirely if the token file already
/// exists (treating a present token as proof the account is good).
@ -356,18 +380,34 @@ pub async fn ensure_user_for(
// Account already exists — try to re-login with the stored password.
tracing::info!(%name, "matrix: user already exists, attempting login with stored password");
let pw_path = password_path(name);
let stored = std::fs::read_to_string(&pw_path)
let stored = match std::fs::read_to_string(&pw_path)
.ok()
.map(|s| s.trim().to_owned())
.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}"
)
})?;
{
Some(pw) => pw,
None => {
// Password file missing — attempt auto-recovery via admin API.
// This covers the case where agent state dirs were wiped but the
// homeserver still has the accounts. Requires the hive admin
// token at /var/lib/hyperhive/matrix-admin-token.
tracing::info!(
%name,
"matrix: stored password missing, attempting admin-API auto-recovery"
);
match auto_reset_password(client, name).await {
Ok(new_pw) => new_pw,
Err(e) => {
anyhow::bail!(
"matrix: user {name} already exists but password is missing \
and admin auto-recovery failed ({e:#}) run:\n\
hivectl matrix reset-password {name}\n\
hivectl matrix create-user {name}"
)
}
}
}
};
login_user(client, name, &stored).await.with_context(|| {
format!(
"matrix: login fallback for {name} failed — if homeserver was wiped, delete \

View file

@ -248,6 +248,14 @@ pub async fn chmod_socket_dir(agent_name: &str, mode: u32) -> Result<()> {
.await?)
}
/// Run `forgejo admin <args>` inside the `hive-forge` container via
/// hive-priv (which runs as root and can nsenter into the container).
/// Returns `(stdout, stderr)` on success.
pub async fn run_forge_admin(args: &[&str]) -> Result<(String, String)> {
let owned: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
check(call(&PrivRequest::RunForgeAdmin { args: owned }).await?)
}
fn check(resp: PrivResponse) -> Result<(String, String)> {
if resp.ok {
Ok((resp.stdout, resp.stderr))

View file

@ -334,9 +334,71 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
.with_context(|| format!("chmod {:o} {}", mode, path.display()))?;
Ok((String::new(), String::new()))
}
PrivRequest::RunForgeAdmin { ref args } => {
for arg in args {
validate_forge_admin_arg(arg)?;
}
run_forge_admin(args).await
}
}
}
/// Validate a single argument destined for `forgejo admin`. Rejects
/// null bytes and newlines (which could corrupt the subprocess args list
/// or log output). Shell metacharacters are harmless since the command
/// is spawned directly (no shell), but we reject them defensively.
fn validate_forge_admin_arg(arg: &str) -> Result<()> {
if arg.bytes().any(|b| b == 0 || b == b'\n' || b == b'\r') {
bail!("forge admin arg {arg:?} contains null byte or newline");
}
Ok(())
}
/// Run `forgejo admin <args>` inside the `hive-forge` container as the
/// `forgejo` unix user. Requires root (for nsenter into the container's
/// namespaces). Returns `(stdout, stderr)`.
async fn run_forge_admin(args: &[String]) -> Result<(String, String)> {
let mut cmd_args: Vec<&str> = vec![
"run",
"hive-forge",
"--",
"runuser",
"-u",
"forgejo",
"--",
"forgejo",
"--work-path",
"/var/lib/forgejo",
"admin",
];
for a in args {
cmd_args.push(a.as_str());
}
let out = Command::new("nixos-container")
.args(&cmd_args)
.output()
.await
.context("invoke nixos-container run hive-forge -- forgejo admin")?;
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
for line in stdout.lines() {
tracing::info!(target: "forgejo-admin", "{line}");
}
for line in stderr.lines() {
tracing::warn!(target: "forgejo-admin", "{line}");
}
if !out.status.success() {
bail!(
"forgejo admin {} failed ({}): {}",
args.join(" "),
out.status,
stderr.trim()
);
}
Ok((stdout, stderr))
}
/// Invoke `nixos-container` with the given args, log output to journald.
async fn container_run(args: &[&str]) -> Result<(String, String)> {
let out = Command::new("nixos-container")

View file

@ -198,6 +198,25 @@ pub enum PrivRequest {
/// Set mode of `/run/hive-agent/<agent_name>/`.
/// Fallback when uid lookup returns `None` on first spawn.
ChmodSocketDir { agent_name: String, mode: u32 },
// --- Forge admin CLI ---
/// Run `forgejo admin <args>` inside the `hive-forge` container as the
/// `forgejo` unix user. hive-priv executes:
///
/// nixos-container run hive-forge -- runuser -u forgejo --
/// forgejo --work-path /var/lib/forgejo admin <args>
///
/// `args` must not contain null bytes, newlines, or shell metacharacters;
/// hive-priv validates this before spawning the subprocess.
///
/// This operation requires root (to nsenter into the forge container's
/// namespaces); hive-c0re (which runs as `hive-core`) calls it through
/// this route instead of spawning `nixos-container run` directly.
RunForgeAdmin {
/// Argument list appended after `forgejo --work-path /var/lib/forgejo admin`.
/// Each element is a separate argv word — no shell expansion occurs.
args: Vec<String>,
},
}
/// Response from the privileged helper.