From ec12ba4b1a46dac75b00ce01ca9d063640b5a506 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 17:05:31 +0200 Subject: [PATCH] fix(702): narrow all PrivRequest handlers to specific ops --- hive-priv/src/main.rs | 275 +++++++++++++++++++++++++----------------- 1 file changed, 161 insertions(+), 114 deletions(-) diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 23d18c80..fa0d0727 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -9,13 +9,14 @@ //! **Security model**: every request is validated against a strict //! container-name allowlist before any filesystem or process operation. //! Only containers whose names match the hive convention (`h-*`, -//! the manager container, or known sibling services) are accepted. +//! the manager container, or known sibling service containers) are +//! accepted. Every variant maps to a single known operation — no +//! arbitrary command pass-through. //! //! **Socket activation**: when systemd passes the listener socket via //! `LISTEN_FDS=1` + `LISTEN_PID=`, the inherited fd 3 is used //! instead of binding a fresh socket. -use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; @@ -28,13 +29,13 @@ use tokio::process::Command; const AGENT_PREFIX: &str = "h-"; /// Manager container name (mirrors `lifecycle::MANAGER_NAME`). -const MANAGER_NAME: &str = "hm1nd"; +const MANAGER_NAME: &str = "root"; /// Sibling service containers managed by hive-c0re. const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway"]; -/// Allowed path prefixes for `Chown` / `Chmod` operations. -const SAFE_PATH_PREFIXES: &[&str] = &["/run/hive-agent/", "/var/lib/hyperhive/"]; +/// Root of the per-agent unix-socket dirs on the host. +const SOCKET_DIR_ROOT: &str = "/run/hive-agent"; #[tokio::main] async fn main() -> Result<()> { @@ -94,9 +95,9 @@ fn socket_listener() -> Result { .with_context(|| format!("create {}", parent.display()))?; } let _ = std::fs::remove_file(path); - let listener = - UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?; + let listener = UnixListener::bind(path).with_context(|| format!("bind {PRIV_SOCK}"))?; // Mode 0660: only the hive-core group can connect. + use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660)) .context("chmod priv.sock")?; tracing::info!(path = PRIV_SOCK, "bound priv socket"); @@ -147,32 +148,69 @@ async fn dispatch(line: &str) -> PrivResponse { /// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success. async fn exec(req: PrivRequest) -> Result<(String, String)> { match req { - PrivRequest::ContainerRun { ref args } => { - validate_container_args(args)?; - let out = Command::new("nixos-container") - .args(args) - .output() - .await - .context("invoke nixos-container")?; - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - // Log each line so progress is visible in journald even - // without a streaming protocol. - for line in stdout.lines() { - tracing::info!(target: "nixos-container", "{line}"); + PrivRequest::StartContainer { ref name } => { + validate_container_name(name)?; + container_run(&["start", &container_system_name(name)]).await + } + + PrivRequest::StopContainer { ref name } => { + validate_container_name(name)?; + container_run(&["stop", &container_system_name(name)]).await + } + + PrivRequest::KillContainer { ref name } => { + validate_container_name(name)?; + container_run(&["kill", &container_system_name(name)]).await + } + + PrivRequest::UpdateContainer { ref name, ref flake_ref } => { + validate_container_name(name)?; + validate_flake_ref(flake_ref)?; + container_run(&["update", &container_system_name(name), "--flake", flake_ref]).await + } + + PrivRequest::CreateContainer { ref name, ref flake_ref } => { + validate_container_name(name)?; + validate_flake_ref(flake_ref)?; + container_run(&["create", &container_system_name(name), "--flake", flake_ref]).await + } + + PrivRequest::DestroyContainer { ref name } => { + validate_container_name(name)?; + container_run(&["destroy", &container_system_name(name)]).await + } + + PrivRequest::ListContainers => container_run(&["list"]).await, + + PrivRequest::WriteNspawnConf { ref container, ref content } => { + validate_container_system_name(container)?; + let path = format!("/etc/nixos-containers/{container}.conf"); + std::fs::write(&path, content).with_context(|| format!("write {path}"))?; + Ok((String::new(), String::new())) + } + + PrivRequest::WriteResourceLimits { + ref container, + ref memory_max, + ref cpu_quota, + } => { + validate_container_system_name(container)?; + let dir = format!("/run/systemd/system/container@{container}.service.d"); + std::fs::create_dir_all(&dir).with_context(|| format!("create {dir}"))?; + let path = format!("{dir}/hyperhive-limits.conf"); + let content = format!("[Service]\nMemoryMax={memory_max}\nCPUQuota={cpu_quota}\n"); + std::fs::write(&path, content).with_context(|| format!("write {path}"))?; + Ok((String::new(), String::new())) + } + + PrivRequest::RemoveServiceDropin { ref container } => { + validate_container_system_name(container)?; + let dir = format!("/run/systemd/system/container@{container}.service.d"); + if Path::new(&dir).exists() { + std::fs::remove_dir_all(&dir) + .with_context(|| format!("remove {dir}"))?; } - for line in stderr.lines() { - tracing::warn!(target: "nixos-container", "{line}"); - } - if !out.status.success() { - bail!( - "nixos-container {} failed ({}): {}", - args.join(" "), - out.status, - stderr.trim() - ); - } - Ok((stdout, stderr)) + Ok((String::new(), String::new())) } PrivRequest::DaemonReload => { @@ -191,58 +229,6 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { Ok((String::new(), String::new())) } - PrivRequest::WriteNspawnConf { ref container, ref content } => { - validate_container_name(container)?; - let path = format!("/etc/nixos-containers/{container}.conf"); - std::fs::write(&path, content).with_context(|| format!("write {path}"))?; - Ok((String::new(), String::new())) - } - - PrivRequest::WriteSystemdDropin { - ref container, - ref filename, - ref content, - } => { - validate_container_name(container)?; - validate_dropin_filename(filename)?; - let dir = - format!("/run/systemd/system/container@{container}.service.d"); - std::fs::create_dir_all(&dir) - .with_context(|| format!("create {dir}"))?; - let path = format!("{dir}/{filename}"); - std::fs::write(&path, content).with_context(|| format!("write {path}"))?; - Ok((String::new(), String::new())) - } - - PrivRequest::RemoveSystemdDropin { ref container } => { - validate_container_name(container)?; - let dir = - format!("/run/systemd/system/container@{container}.service.d"); - if Path::new(&dir).exists() { - std::fs::remove_dir_all(&dir) - .with_context(|| format!("remove {dir}"))?; - } - Ok((String::new(), String::new())) - } - - PrivRequest::Chown { ref path, uid, gid } => { - validate_safe_path(path)?; - std::os::unix::fs::chown(path, Some(uid), Some(gid)) - .with_context(|| { - format!("chown {} to {uid}:{gid}", path.display()) - })?; - Ok((String::new(), String::new())) - } - - PrivRequest::Chmod { ref path, mode } => { - validate_safe_path(path)?; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) - .with_context(|| { - format!("chmod {:o} {}", mode, path.display()) - })?; - Ok((String::new(), String::new())) - } - PrivRequest::ReloadGatewayNginx => { let out = Command::new("systemd-run") .args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"]) @@ -258,27 +244,78 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { } Ok((String::new(), String::new())) } + + PrivRequest::ChownSocketDir { ref agent_name, uid, gid } => { + validate_agent_name(agent_name)?; + let path = socket_dir_path(agent_name); + std::os::unix::fs::chown(&path, Some(uid), Some(gid)) + .with_context(|| format!("chown {} to {uid}:{gid}", path.display()))?; + Ok((String::new(), String::new())) + } + + PrivRequest::ChmodSocketDir { ref agent_name, mode } => { + validate_agent_name(agent_name)?; + let path = socket_dir_path(agent_name); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)) + .with_context(|| format!("chmod {:o} {}", mode, path.display()))?; + Ok((String::new(), String::new())) + } } } -/// Validate that nixos-container args reference only hive-managed containers. -fn validate_container_args(args: &[String]) -> Result<()> { - if args.is_empty() { - bail!("ContainerRun: args must not be empty"); +/// 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") + .args(args) + .output() + .await + .context("invoke nixos-container")?; + 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: "nixos-container", "{line}"); } - // Verbs whose second positional argument is a container name. - const CONTAINER_ARG_VERBS: &[&str] = - &["start", "stop", "kill", "restart", "destroy", "update", "run", "create"]; - if CONTAINER_ARG_VERBS.contains(&args[0].as_str()) { - if let Some(name) = args.get(1) { - validate_container_name(name)?; - } + for line in stderr.lines() { + tracing::warn!(target: "nixos-container", "{line}"); } - // `list` and unknown verbs take no container arg - no further validation needed. + if !out.status.success() { + bail!( + "nixos-container {} failed ({}): {}", + args.join(" "), + out.status, + stderr.trim() + ); + } + Ok((stdout, stderr)) +} + +/// Return the system container name for a logical agent name. +/// Manager (`MANAGER_NAME`) passes through; sub-agents get `h-` prefix. +fn container_system_name(name: &str) -> String { + if name == MANAGER_NAME { + name.to_owned() + } else { + format!("{AGENT_PREFIX}{name}") + } +} + +/// Path of the per-agent unix-socket dir on the host. +fn socket_dir_path(agent_name: &str) -> PathBuf { + PathBuf::from(format!("{SOCKET_DIR_ROOT}/{agent_name}")) +} + +/// Validate a logical agent name (the name hive-c0re uses internally, +/// before the `h-` container prefix is applied). +fn validate_agent_name(name: &str) -> Result<()> { + if name == MANAGER_NAME { + return Ok(()); + } + validate_name_chars(name)?; Ok(()) } -/// Validate that a container name is managed by hive. +/// Validate a logical agent name and check it maps to a hive-managed container. fn validate_container_name(name: &str) -> Result<()> { if name == MANAGER_NAME { return Ok(()); @@ -286,30 +323,40 @@ fn validate_container_name(name: &str) -> Result<()> { if SIBLING_CONTAINERS.contains(&name) { return Ok(()); } - if name.starts_with(AGENT_PREFIX) { - let suffix = &name[AGENT_PREFIX.len()..]; - if !suffix.is_empty() - && suffix.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - { - return Ok(()); - } + validate_name_chars(name)?; + Ok(()) +} + +/// Validate a system-level container name (already has `h-` prefix for +/// sub-agents, or is the manager name / sibling service name). +fn validate_container_system_name(name: &str) -> Result<()> { + if name == MANAGER_NAME { + return Ok(()); + } + if SIBLING_CONTAINERS.contains(&name) { + return Ok(()); + } + if let Some(suffix) = name.strip_prefix(AGENT_PREFIX) { + validate_name_chars(suffix)?; + return Ok(()); } bail!("container name {name:?} is not managed by hive"); } -fn validate_dropin_filename(filename: &str) -> Result<()> { - if filename.is_empty() || filename.contains('/') || filename.contains("..") { - bail!("invalid drop-in filename: {filename:?}"); +fn validate_name_chars(name: &str) -> Result<()> { + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + bail!("invalid name {name:?}: must be non-empty lowercase ascii + digits + hyphens"); } Ok(()) } -fn validate_safe_path(path: &Path) -> Result<()> { - let s = path.to_string_lossy(); - for prefix in SAFE_PATH_PREFIXES { - if s.starts_with(prefix) && !s.contains("..") { - return Ok(()); - } +fn validate_flake_ref(flake_ref: &str) -> Result<()> { + if flake_ref.is_empty() || flake_ref.contains('\n') || flake_ref.contains('\0') { + bail!("invalid flake_ref {flake_ref:?}"); } - bail!("path {} is outside allowed prefixes", path.display()); + Ok(()) }