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:
parent
3830b13b03
commit
34bc4c0b06
5 changed files with 147 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue