diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 0d4b1bd7..05c23029 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -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 { - 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 `/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//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 diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 1992462b..ae2bb954 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -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 { + 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 \ diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index e78b48d0..6e2389bd 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -248,6 +248,14 @@ pub async fn chmod_socket_dir(agent_name: &str, mode: u32) -> Result<()> { .await?) } +/// Run `forgejo admin ` 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 = 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)) diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index df5111b8..978626bb 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -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 ` 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") diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 2e551e40..3638084c 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -198,6 +198,25 @@ pub enum PrivRequest { /// Set mode of `/run/hive-agent//`. /// Fallback when uid lookup returns `None` on first spawn. ChmodSocketDir { agent_name: String, mode: u32 }, + + // --- Forge admin CLI --- + /// Run `forgejo admin ` 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` 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, + }, } /// Response from the privileged helper.