diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 19ec131b..286d591f 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -74,6 +74,12 @@ const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notific /// See `docs/forge.md::Token scopes`. const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; +/// Token file inside the agent's bind-mounted state dir (visible as +/// `/state/forge-token` from inside the container). +fn token_path(name: &str) -> PathBuf { + Coordinator::agent_notes_dir(name).join("forge-token") +} + /// Probe whether `hive-forge` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Routed /// through hive-priv: `nixos-container` needs root, and hive-c0re runs @@ -94,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) } @@ -285,30 +291,20 @@ async fn mint_token(name: &str, scopes: &str) -> Result { Ok(token) } -/// Mint a fresh Forgejo access token for an agent and write it to the -/// agent's state dir via hive-priv. hive-c0re runs unprivileged and -/// cannot write to agent-owned (0755) state directories directly. -async fn mint_and_persist_agent_token(name: &str) -> Result<()> { - let token = mint_token(name, TOKEN_SCOPES).await?; - crate::priv_client::write_agent_forge_token(name, &token) - .await - .with_context(|| format!("write forge-token for {name} via hive-priv")) -} - -/// Mint a fresh Forgejo access token for the `core` admin user and -/// write it directly to `path`. Unlike agent tokens this path is owned -/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct -/// write is both correct and necessary (no priv round-trip). -async fn mint_and_persist_core_token(path: &Path) -> Result<()> { +/// 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. +async fn mint_and_persist_token(name: &str, path: &Path, scopes: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; - let token = mint_token("core", CORE_TOKEN_SCOPES).await?; + let token = mint_token(name, scopes).await?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(path, format!("{token}\n")) - .with_context(|| format!("write core token to {}", path.display()))?; + .with_context(|| format!("write token to {}", path.display()))?; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); - tracing::info!(path = %path.display(), "forge: persisted core access token"); + crate::lifecycle::chown_to_agent(name, path, "forge"); + tracing::info!(%name, path = %path.display(), "forge: persisted access token"); Ok(()) } @@ -321,7 +317,7 @@ pub async fn ensure_user_for(name: &str) -> Result<()> { } ensure_user_exists(name, false, None).await?; ensure_user_email(name).await; - mint_and_persist_agent_token(name).await + mint_and_persist_token(name, &token_path(name), TOKEN_SCOPES).await } /// Provision a forgejo user for `name` and return the freshly-minted @@ -434,7 +430,7 @@ async fn ensure_core_user_and_token() -> Result { } } ensure_user_exists("core", true, None).await?; - mint_and_persist_core_token(path).await?; + mint_and_persist_token("core", path, CORE_TOKEN_SCOPES).await?; let raw = std::fs::read_to_string(path) .with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?; Ok(raw.trim().to_owned()) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 03d94775..ae2bb954 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -302,7 +302,9 @@ 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) } @@ -416,14 +418,14 @@ pub async fn ensure_user_for( Err(other) => return Err(other), }; - // 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_matrix_token(name, &access_token) - .await - .with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?; - tracing::info!(%name, "matrix: provisioned access token"); + 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"); Ok(()) } @@ -525,7 +527,10 @@ 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 } @@ -568,7 +573,9 @@ 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) @@ -598,7 +605,9 @@ 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) @@ -617,7 +626,10 @@ 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(()); } @@ -644,7 +656,9 @@ 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() @@ -683,7 +697,10 @@ 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) { @@ -842,19 +859,15 @@ 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 d49c920c..6e2389bd 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -256,30 +256,6 @@ pub async fn run_forge_admin(args: &[&str]) -> Result<(String, String)> { check(call(&PrivRequest::RunForgeAdmin { args: owned }).await?) } -/// Write the Forgejo access token for `agent_name` to -/// `//state/forge-token` 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. -pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()> { - ok(call(&PrivRequest::WriteAgentForgeToken { - agent_name: agent_name.to_owned(), - token: token.to_owned(), - }) - .await?) -} - -/// Write the Matrix access token for `agent_name` to -/// `//state/matrix-token` 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. -pub async fn write_agent_matrix_token(agent_name: &str, token: &str) -> Result<()> { - ok(call(&PrivRequest::WriteAgentMatrixToken { - agent_name: agent_name.to_owned(), - token: token.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 4ef47aad..3c435c27 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -21,9 +21,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, JournalOutput, MANAGER_NAME, META_DIR, - NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, - SIBLING_CONTAINERS, + AGENT_PREFIX, 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; @@ -321,82 +320,9 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, } run_forge_admin(args).await } - - PrivRequest::WriteAgentForgeToken { - ref agent_name, - ref token, - } => { - validate_agent_name(agent_name)?; - write_agent_state_file(agent_name, "forge-token", &format!("{token}\n")) - } - - PrivRequest::WriteAgentMatrixToken { - ref agent_name, - ref token, - } => { - validate_agent_name(agent_name)?; - write_agent_state_file(agent_name, "matrix-token", &format!("{token}\n")) - } } } -/// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`. -/// Writes `content` to `AGENT_STATE_ROOT//state/`, -/// chowns to the agent user (derived from the state dir's existing owner), -/// and chmods 0600. Running as root (hive-priv), so 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"); - // NOTE: `create_dir_all` is normally a no-op — lifecycle creates and chowns - // the state dir during spawn. On the rare edge where the dir doesn't exist - // yet (container being provisioned for the first time), the newly created dir - // is root:root. The `stat state_dir` chown below will then see uid=0 and - // leave the file root-owned (0600). The agent won't be able to read it until - // its lifecycle completes. If that happens, a `systemctl restart hive-c0re` - // after provisioning will re-mint and re-write the token correctly. - 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 17a2dbe7..288d53c1 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -22,12 +22,6 @@ 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. @@ -228,31 +222,6 @@ pub enum PrivRequest { /// Each element is a separate argv word — no shell expansion occurs. args: Vec, }, - - // --- Agent credential writes --- - /// Write `forge-token` into `AGENT_STATE_ROOT//state/forge-token`. - /// - /// hive-priv validates `agent_name`, creates the state dir if absent, - /// writes the file 0600, and chowns it to the state dir's owner so - /// the agent process can read it. Required because hive-c0re runs - /// unprivileged and cannot write to agent-owned state directories. - WriteAgentForgeToken { - /// Logical agent name (validated by `validate_agent_name`). - agent_name: String, - /// Token value. hive-priv appends a trailing newline before writing. - token: String, - }, - - /// Write `matrix-token` into `AGENT_STATE_ROOT//state/matrix-token`. - /// - /// Same semantics as `WriteAgentForgeToken` — validates name, creates - /// dir, writes 0600, chowns to agent owner. - WriteAgentMatrixToken { - /// Logical agent name (validated by `validate_agent_name`). - agent_name: String, - /// Token value. hive-priv appends a trailing newline before writing. - token: String, - }, } /// Response from the privileged helper.