fix: route forge/matrix token writes through hive-priv

hive-c0re runs as the unprivileged hive-core user (privsep from #702)
and cannot write to agent-owned state directories. forge-token and
matrix-token writes were failing with EACCES on every startup sweep.

Add WriteAgentStateFile to PrivRequest: hive-priv (root) writes the
file 0600 and chowns it to the agent user so the agent can read it.

- hive-sh4re: add AGENT_STATE_ROOT constant + WriteAgentStateFile variant
- hive-priv: validate agent name + filename (no traversal), write via root
- priv_client: add write_agent_state_file helper
- forge: mint_and_persist_token routes agent paths through priv
- matrix: ensure_user_for routes matrix-token through priv

Closes #1257
This commit is contained in:
atlas 2026-06-04 12:11:59 +02:00 committed by mara
commit eb51362d50
5 changed files with 172 additions and 46 deletions

View file

@ -100,9 +100,9 @@ async fn forge_admin(args: &[&str]) -> Result<String> {
// 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(" ")),
)?;
let (stdout, _stderr) = crate::priv_client::run_forge_admin(args)
.await
.with_context(|| format!("forgejo admin {} (via hive-priv)", args.join(" ")))?;
Ok(stdout)
}
@ -291,19 +291,34 @@ async fn mint_token(name: &str, scopes: &str) -> Result<String> {
Ok(token)
}
/// Mint a fresh access token for `name` and persist it to `path`
/// (0600). Wraps [`mint_token`] for callers that want the token on
/// disk under an agent state dir.
/// Mint a fresh access token for `name` and persist it to `path` (0600).
/// For paths outside the agent state tree (e.g. the core admin token at
/// `/var/lib/hyperhive/forge-core-token`), writes directly — hive-c0re
/// owns those paths. For paths inside `AGENT_STATE_ROOT` the write is
/// routed through hive-priv (root helper) because hive-c0re runs
/// unprivileged and cannot write to agent-owned state directories.
async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let token = mint_token(name, scopes).await?;
// Agent state paths must go through hive-priv.
if path.components().any(|c| c.as_os_str() == "agents") {
let filename = path
.file_name()
.and_then(|n| n.to_str())
.context("could not determine filename for priv write")?;
return crate::priv_client::write_agent_state_file(name, filename, &format!("{token}\n"))
.await
.with_context(|| format!("write {filename} for {name} via hive-priv"));
}
// Non-agent paths (core admin token, etc.): write directly.
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, format!("{token}\n"))
.with_context(|| format!("write token to {}", path.display()))?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
crate::lifecycle::chown_to_agent(name, path, "forge");
tracing::info!(%name, path = %path.display(), "forge: persisted access token");
Ok(())
}

View file

@ -302,9 +302,7 @@ async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Re
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)")
})?;
.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)
}
@ -418,14 +416,14 @@ pub async fn ensure_user_for(
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 token to {}", path.display()))?;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
crate::lifecycle::chown_to_agent(name, &path, "matrix");
tracing::info!(%name, path = %path.display(), "matrix: provisioned access token");
// Write the token via hive-priv (root helper): hive-c0re runs as the
// unprivileged `hive-core` user and cannot write to agent-owned state
// directories directly. hive-priv writes the file 0600 and chowns it
// to the agent user so it is readable from inside the container.
crate::priv_client::write_agent_state_file(name, "matrix-token", &format!("{access_token}\n"))
.await
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
tracing::info!(%name, "matrix: provisioned access token");
Ok(())
}
@ -527,10 +525,7 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
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),
);
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
}
token
}
@ -573,9 +568,7 @@ pub async fn promote_user_to_admin(
) -> 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 url = format!("{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}");
let resp = client
.put(&url)
.bearer_auth(admin_token)
@ -605,9 +598,7 @@ pub async fn reset_user_password(
server_name: &str,
new_password: &str,
) -> Result<()> {
let url = format!(
"{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}"
);
let url = format!("{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}");
let resp = client
.put(&url)
.bearer_auth(admin_token)
@ -626,10 +617,7 @@ pub async fn reset_user_password(
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),
);
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
}
return Ok(());
}
@ -656,9 +644,7 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
.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}"
);
anyhow::bail!("matrix: /_matrix/key/v2/server returned HTTP {status}, body: {body}");
}
body["server_name"]
.as_str()
@ -697,10 +683,7 @@ pub fn read_admin_token() -> Result<String> {
///
/// Returns an error if the Matrix homeserver is unreachable, the
/// `createRoom` call fails, or the room-ID file cannot be written.
pub async fn ensure_hive_space(
client: &reqwest::Client,
admin_token: &str,
) -> Result<String> {
pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result<String> {
use std::os::unix::fs::PermissionsExt;
let path = hive_space_room_id_path();
if let Ok(existing) = std::fs::read_to_string(&path) {
@ -859,15 +842,19 @@ pub async fn ensure_all() {
}
};
// Invite @hive admin first, then all agents.
if let Err(e) =
invite_to_room(&client, &admin_token, &room_id, HIVE_ADMIN_LOCALPART, &server_name).await
if let Err(e) = invite_to_room(
&client,
&admin_token,
&room_id,
HIVE_ADMIN_LOCALPART,
&server_name,
)
.await
{
tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
}
for name in &agent_names {
if let Err(e) =
invite_to_room(&client, &admin_token, &room_id, name, &server_name).await
{
if let Err(e) = invite_to_room(&client, &admin_token, &room_id, name, &server_name).await {
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
}
}

View file

@ -256,6 +256,22 @@ pub async fn run_forge_admin(args: &[&str]) -> Result<(String, String)> {
check(call(&PrivRequest::RunForgeAdmin { args: owned }).await?)
}
/// Write `content` to `<agent_state_root>/<agent_name>/state/<filename>`
/// via hive-priv (running as root). The file is written 0600 and chowned
/// to the agent user so it is readable from inside the agent container.
///
/// Used for credential files (forge-token, matrix-token) that hive-c0re
/// mints but cannot write directly because those paths are inside agent-
/// owned (0755) state directories and hive-c0re runs unprivileged.
pub async fn write_agent_state_file(agent_name: &str, filename: &str, content: &str) -> Result<()> {
ok(call(&PrivRequest::WriteAgentStateFile {
agent_name: agent_name.to_owned(),
filename: filename.to_owned(),
content: content.to_owned(),
})
.await?)
}
fn check(resp: PrivResponse) -> Result<(String, String)> {
if resp.ok {
Ok((resp.stdout, resp.stderr))

View file

@ -21,8 +21,9 @@ use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use hive_sh4re::priv_proto::{
AGENT_PREFIX, BindMount, JournalOutput, MANAGER_NAME, META_DIR, NetworkIsolation, PRIV_SOCK,
PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, SIBLING_CONTAINERS,
AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, JournalOutput, MANAGER_NAME, META_DIR,
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine,
SIBLING_CONTAINERS,
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::unix::OwnedWriteHalf;
@ -320,9 +321,86 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
}
run_forge_admin(args).await
}
PrivRequest::WriteAgentStateFile {
ref agent_name,
ref filename,
ref content,
} => {
validate_agent_name(agent_name)?;
validate_state_filename(filename)?;
write_agent_state_file(agent_name, filename, content)
}
}
}
/// Validate a filename destined for `WriteAgentStateFile`. Rejects
/// path separators, null bytes, newlines, and `..` to prevent path traversal.
fn validate_state_filename(filename: &str) -> Result<()> {
if filename.is_empty() {
bail!("state filename must not be empty");
}
if filename == ".." || filename == "." {
bail!("state filename must not be . or ..");
}
if filename
.bytes()
.any(|b| b == 0 || b == b'\n' || b == b'\r' || b == b'/')
{
bail!("state filename {filename:?} contains null byte, newline, or path separator");
}
Ok(())
}
/// Write `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
/// chown to the agent user (derived from the state dir's existing owner),
/// and chmod 0600. Running as root (hive-priv), so all of this succeeds
/// regardless of the file's prior owner/permissions.
fn write_agent_state_file(
agent_name: &str,
filename: &str,
content: &str,
) -> Result<(String, String)> {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let state_dir = PathBuf::from(AGENT_STATE_ROOT)
.join(agent_name)
.join("state");
std::fs::create_dir_all(&state_dir)
.with_context(|| format!("create state dir {}", state_dir.display()))?;
let path = state_dir.join(filename);
std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod 600 {}", path.display()))?;
// Chown to the state dir's owner so the agent process can read the file.
// If stat fails (e.g. dir just created, owner is root), the file stays
// root-owned and 0600 — still unreadable by others, just not agent-readable.
// Log a warning so operators can diagnose.
match std::fs::metadata(&state_dir) {
Ok(meta) => {
if let Err(e) = std::os::unix::fs::chown(&path, Some(meta.uid()), Some(meta.gid())) {
tracing::warn!(
agent = %agent_name,
path = %path.display(),
error = %e,
"write_agent_state_file: chown failed"
);
}
}
Err(e) => {
tracing::warn!(
agent = %agent_name,
error = %e,
"write_agent_state_file: stat state_dir failed, leaving file root-owned"
);
}
}
tracing::info!(agent = %agent_name, file = %filename, "wrote agent state file");
Ok((String::new(), String::new()))
}
/// 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

View file

@ -22,6 +22,12 @@ pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gat
/// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire.
pub const META_DIR: &str = "/var/lib/hyperhive/meta";
/// Root of per-agent state directories on the host.
/// Subdirectory layout: `<AGENT_STATE_ROOT>/<name>/state/<file>`.
/// Used by `WriteAgentStateFile` to derive the write path so the
/// exact path is never passed over the wire.
pub const AGENT_STATE_ROOT: &str = "/var/lib/hyperhive/agents";
/// Output format for `ReadContainerJournal`. Maps to journalctl
/// `--output=<...>`. Restricted to the two formats hive callers use so
/// the wire type can't smuggle an arbitrary `--output` value.
@ -222,6 +228,30 @@ pub enum PrivRequest {
/// Each element is a separate argv word — no shell expansion occurs.
args: Vec<String>,
},
// --- Agent state file writes ---
/// Write a credential file into an agent's bind-mounted state directory.
///
/// Path resolved by hive-priv: `AGENT_STATE_ROOT/<agent_name>/state/<filename>`.
/// hive-priv validates both the agent name and filename before writing.
///
/// After writing, the file is chowned to the agent user's uid/gid
/// (read from the state directory's owner) and chmoded 0600 so
/// only the agent process can read it.
///
/// Required because hive-c0re runs as the unprivileged `hive-core`
/// user and cannot write to agent-owned (0755) state directories
/// after the privsep introduced in #702.
WriteAgentStateFile {
/// Logical agent name (validated by `validate_agent_name`).
agent_name: String,
/// Plain filename within the state dir — no path separators allowed.
/// Example: `"forge-token"`, `"matrix-token"`.
filename: String,
/// File content to write. Written as-is; caller is responsible for
/// including any trailing newline.
content: String,
},
}
/// Response from the privileged helper.