//! Minimal privileged helper for hive-c0re. //! //! Runs as root. Exposes a narrow unix socket at `/run/hive/priv.sock` //! that accepts `PrivRequest` JSON lines and executes only the //! operations that genuinely require root. All coordination logic, //! broker, HTTP, and scheduling stay in the unprivileged hive-c0re //! process. //! //! **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 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::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{AGENT_PREFIX, MANAGER_NAME, META_DIR, PRIV_SOCK, SIBLING_CONTAINERS, BindMount, PrivRequest, PrivResponse}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tokio::process::Command; /// 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<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info".into()), ) .init(); let listener = socket_listener()?; tracing::info!("hive-priv listening"); loop { match listener.accept().await { Ok((stream, _)) => { tokio::spawn(handle(stream)); } Err(e) => { tracing::error!(error = %e, "accept failed"); } } } } fn socket_listener() -> Result { // Socket activation: systemd passes the socket as fd 3 when // LISTEN_FDS >= 1 and LISTEN_PID matches our pid. let listen_fds: Option = std::env::var("LISTEN_FDS") .ok() .and_then(|s| s.parse().ok()); let listen_pid: Option = std::env::var("LISTEN_PID") .ok() .and_then(|s| s.parse().ok()); if let (Some(n), Some(p)) = (listen_fds, listen_pid) { if n >= 1 && p == std::process::id() { // SAFETY: systemd has passed us a ready UnixListener on fd 3. let std_listener = unsafe { use std::os::unix::io::FromRawFd; std::os::unix::net::UnixListener::from_raw_fd(3) }; std_listener .set_nonblocking(true) .context("set socket non-blocking")?; let listener = tokio::net::UnixListener::from_std(std_listener).context("wrap systemd socket")?; tracing::info!("using systemd-activated socket"); return Ok(listener); } } // Fallback: bind the socket ourselves. let path = Path::new(PRIV_SOCK); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create {}", parent.display()))?; } let _ = std::fs::remove_file(path); 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"); Ok(listener) } async fn handle(stream: UnixStream) { let (reader, mut writer) = stream.into_split(); let mut lines = BufReader::new(reader).lines(); while let Ok(Some(line)) = lines.next_line().await { let resp = dispatch(&line).await; let mut json = serde_json::to_string(&resp).unwrap_or_else(|e| { format!("{{\"ok\":false,\"stdout\":\"\",\"stderr\":\"\",\"error\":\"serialise failed: {e}\"}}") }); json.push('\n'); if let Err(e) = writer.write_all(json.as_bytes()).await { tracing::warn!(error = %e, "write response failed"); break; } } } async fn dispatch(line: &str) -> PrivResponse { match serde_json::from_str::(line) { Ok(req) => match exec(req).await { Ok((stdout, stderr)) => PrivResponse { ok: true, stdout, stderr, error: None, }, Err(e) => PrivResponse { ok: false, stdout: String::new(), stderr: String::new(), error: Some(format!("{e:#}")), }, }, Err(e) => PrivResponse { ok: false, stdout: String::new(), stderr: String::new(), error: Some(format!("parse request: {e}")), }, } } /// Execute a validated `PrivRequest`. Returns `(stdout, stderr)` on success. async fn exec(req: PrivRequest) -> Result<(String, String)> { match req { 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 } => { validate_container_name(name)?; let flake_ref = agent_flake_ref(name); container_run(&["update", &container_system_name(name), "--flake", &flake_ref]).await } PrivRequest::CreateContainer { ref name } => { validate_container_name(name)?; let flake_ref = agent_flake_ref(name); 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::WriteNspawnFlags { ref container, ref binds } => { validate_container_system_name(container)?; for bind in binds { validate_bind_path(&bind.host_path)?; validate_bind_path(&bind.container_path)?; } write_nspawn_flags(container, binds)?; 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}"))?; } Ok((String::new(), String::new())) } PrivRequest::DaemonReload => { let out = Command::new("systemctl") .arg("daemon-reload") .output() .await .context("invoke systemctl daemon-reload")?; if !out.status.success() { bail!( "systemctl daemon-reload failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } Ok((String::new(), String::new())) } PrivRequest::ReloadGatewayNginx => { let out = Command::new("systemd-run") .args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"]) .output() .await .context("invoke systemd-run for gateway nginx reload")?; if !out.status.success() { bail!( "gateway nginx reload failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } 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())) } } } /// 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}"); } 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)) } /// 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 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(()); } if SIBLING_CONTAINERS.contains(&name) { 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_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(()) } /// Derive the meta-flake ref for an agent by name. fn agent_flake_ref(name: &str) -> String { format!("{META_DIR}#{name}") } /// Validate a bind-mount path: must be absolute, non-empty, and contain /// no newlines, null bytes, or double-quotes (which would break the /// `EXTRA_NSPAWN_FLAGS="..."` conf line format). fn validate_bind_path(path: &str) -> Result<()> { if path.is_empty() || !path.starts_with('/') || path.bytes().any(|b| b == 0 || b == b'\n' || b == b'"' || b == b':') { bail!( "invalid bind path {path:?}: must be an absolute path with no colons, newlines, null bytes, or double-quotes" ); } Ok(()) } /// Update `/etc/nixos-containers/.conf`: strips network-isolation /// vars (`PRIVATE_NETWORK`, `HOST_ADDRESS*`, `LOCAL_ADDRESS*`, `HOST_BRIDGE`, /// `EXTRA_NSPAWN_FLAGS`), forces `PRIVATE_NETWORK=0` and blank network vars, /// then appends `EXTRA_NSPAWN_FLAGS=""`. fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> { let path = format!("/etc/nixos-containers/{container}.conf"); let original = std::fs::read_to_string(&path) .with_context(|| format!("read {path}"))?; let mut lines: Vec<&str> = original .lines() .filter(|line| { let t = line.trim_start(); !t.starts_with("EXTRA_NSPAWN_FLAGS=") && !t.starts_with("PRIVATE_NETWORK=") && !t.starts_with("HOST_ADDRESS=") && !t.starts_with("LOCAL_ADDRESS=") && !t.starts_with("HOST_ADDRESS6=") && !t.starts_with("LOCAL_ADDRESS6=") && !t.starts_with("HOST_BRIDGE=") }) .collect(); let mut out = lines.join("\n"); if !out.is_empty() { out.push('\n'); } out.push_str("PRIVATE_NETWORK=0\n"); out.push_str("HOST_ADDRESS=\n"); out.push_str("LOCAL_ADDRESS=\n"); out.push_str("HOST_ADDRESS6=\n"); out.push_str("LOCAL_ADDRESS6=\n"); out.push_str("HOST_BRIDGE=\n"); let flags: Vec = binds.iter().map(|b| { let flag = if b.read_only { "--bind-ro" } else { "--bind" }; format!("{flag}={}:{}", b.host_path, b.container_path) }).collect(); let flags_joined = flags.join(" "); out.push_str(&format!("EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"\n")); std::fs::write(&path, out).with_context(|| format!("write {path}")) }