diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 286d591f..512f9351 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -100,9 +100,9 @@ async fn forge_admin(args: &[&str]) -> Result { // 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(" ")), - )?; + 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 { 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(()) } diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index ae2bb954..6c35fe5f 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -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 { .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 { /// /// 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 { +pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result { 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"); } } diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 6e2389bd..46859835 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -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 `//state/` +/// 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)) diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 3c435c27..3bbd1f09 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -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//state/`, +/// 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 diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 288d53c1..de631736 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -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: `//state/`. +/// 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, }, + + // --- Agent state file writes --- + /// Write a credential file into an agent's bind-mounted state directory. + /// + /// Path resolved by hive-priv: `AGENT_STATE_ROOT//state/`. + /// 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.