From 6a371908e48a4a7796efcbf57475658a29c3b6d5 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:32:53 +0200 Subject: [PATCH 01/18] feat(#702): add hive-priv to workspace members --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 327d3cbe..b8ac38ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-matrix-mcp", "hive-sh4re"] +members = ["hive-ag3nt", "hive-c0re", "hive-forge", "hive-matrix-mcp", "hive-priv", "hive-sh4re"] [workspace.package] edition = "2024" From d7fa1c5a6e6a5d8a1bcc7d1779e61b5742da7fc3 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:32:54 +0200 Subject: [PATCH 02/18] feat(#702): add priv_proto module to hive-sh4re --- hive-sh4re/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 89c89140..58c8dc8c 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; pub mod assets; +pub mod priv_proto; // ----------------------------------------------------------------------------- // Host admin socket — /run/hyperhive/host.sock From 138d9fdabea7e8516f039c2a65364c868cbaae62 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:32:54 +0200 Subject: [PATCH 03/18] feat(#702): add priv_client module to hive-c0re --- hive-c0re/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index 44728f3a..d9444f5c 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -39,6 +39,7 @@ pub mod matrix; pub mod meta; pub mod migrate; pub mod operator_questions; +pub mod priv_client; pub mod questions; pub mod rebuild_queue; pub mod reminder_scheduler; From b18bdbca1c2bfda47e827024181e0a0f45de56c5 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:33:34 +0200 Subject: [PATCH 04/18] feat(#702): add hive-priv crate --- hive-priv/Cargo.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 hive-priv/Cargo.toml diff --git a/hive-priv/Cargo.toml b/hive-priv/Cargo.toml new file mode 100644 index 00000000..eccd4f69 --- /dev/null +++ b/hive-priv/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hive-priv" +edition.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +hive-sh4re.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true From ab861dd8dc2e6b9d91758c5b9c9f9be398c6f48f Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:33:34 +0200 Subject: [PATCH 05/18] feat(#702): hive-priv privileged helper binary --- hive-priv/src/main.rs | 320 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 hive-priv/src/main.rs diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs new file mode 100644 index 00000000..c86be801 --- /dev/null +++ b/hive-priv/src/main.rs @@ -0,0 +1,320 @@ +//! 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 services) are accepted. +//! +//! **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}; +use hive_sh4re::priv_proto::{PRIV_SOCK, PrivRequest, PrivResponse}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::process::Command; + +/// Sub-agent container prefix (mirrors `lifecycle::AGENT_PREFIX`). +const AGENT_PREFIX: &str = "h-"; + +/// Manager container name (mirrors `lifecycle::MANAGER_NAME`). +const MANAGER_NAME: &str = "hm1nd"; + +/// 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/"]; + +#[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. + 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::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}"); + } + 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)) + } + + 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::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::SystemdRunMachine { ref machine, ref cmd } => { + validate_container_name(machine)?; + let mut args = vec!["--machine".to_owned(), machine.clone(), "--quiet".to_owned(), "--".to_owned()]; + args.extend(cmd.iter().cloned()); + let out = Command::new("systemd-run") + .args(&args) + .output() + .await + .context("invoke systemd-run")?; + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + if !out.status.success() { + bail!( + "systemd-run --machine={machine} failed ({}): {}", + out.status, + stderr.trim() + ); + } + Ok((stdout, stderr)) + } + } +} + +/// 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"); + } + // 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)?; + } + } + // `list` and unknown verbs take no container arg - no further validation needed. + Ok(()) +} + +/// Validate that a container name is managed by hive. +fn validate_container_name(name: &str) -> Result<()> { + if name == MANAGER_NAME { + return Ok(()); + } + 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(()); + } + } + 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:?}"); + } + 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(()); + } + } + bail!("path {} is outside allowed prefixes", path.display()); +} From c8ea28b21878d283b1edb292ce27c88c73dade93 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:34:01 +0200 Subject: [PATCH 06/18] feat(#702): PrivRequest/PrivResponse wire types --- hive-sh4re/src/priv_proto.rs | 69 ++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 hive-sh4re/src/priv_proto.rs diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs new file mode 100644 index 00000000..98e5f558 --- /dev/null +++ b/hive-sh4re/src/priv_proto.rs @@ -0,0 +1,69 @@ +//! Wire types for the `hive-priv` privileged-helper socket. +//! +//! Both `hive-priv` (server) and `hive-c0re` (client via `priv_client`) +//! import these so the shapes stay in sync. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// Default socket path for the privileged helper. +pub const PRIV_SOCK: &str = "/run/hive/priv.sock"; + +/// A request to the privileged helper. +/// +/// Wire format: one JSON object per line over `/run/hive/priv.sock`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum PrivRequest { + /// Run `nixos-container `. + /// + /// The helper validates that the container argument (second positional + /// arg for verbs that take one) matches a hive-managed name + /// (`h-*`, the manager container, or a known sibling service container). + ContainerRun { args: Vec }, + + /// Run `systemctl daemon-reload`. + DaemonReload, + + /// Overwrite `/etc/nixos-containers/.conf` with new content. + WriteNspawnConf { container: String, content: String }, + + /// Write a file into the drop-in dir for `container@.service`. + /// + /// Creates `/run/systemd/system/container@.service.d/`. + WriteSystemdDropin { + container: String, + filename: String, + content: String, + }, + + /// Remove the drop-in dir for `container@.service`, if present. + /// + /// Removes `/run/systemd/system/container@.service.d/`. + RemoveSystemdDropin { container: String }, + + /// `chown(2)` a path under a hive-managed prefix + /// (`/run/hive-agent/` or `/var/lib/hyperhive/`). + Chown { path: PathBuf, uid: u32, gid: u32 }, + + /// `chmod(2)` a path under a hive-managed prefix. + Chmod { path: PathBuf, mode: u32 }, + + /// Run a command inside a machine container via `systemd-run --machine`. + /// + /// The machine name must be a hive-managed container. + SystemdRunMachine { machine: String, cmd: Vec }, +} + +/// Response from the privileged helper. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrivResponse { + pub ok: bool, + #[serde(default)] + pub stdout: String, + #[serde(default)] + pub stderr: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} From c5cab732a2d37ca43682bbbeb9b5da9d485803f9 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:34:02 +0200 Subject: [PATCH 07/18] feat(#702): priv_client - async client for hive-priv --- hive-c0re/src/priv_client.rs | 133 +++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 hive-c0re/src/priv_client.rs diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs new file mode 100644 index 00000000..357d8723 --- /dev/null +++ b/hive-c0re/src/priv_client.rs @@ -0,0 +1,133 @@ +//! Async client for the `hive-priv` privileged-helper socket. +//! +//! Exposes a standalone async function per operation that callers in +//! `lifecycle`, `forge`, `matrix`, and `gateway_nginx` can call +//! without carrying any client state. Each call opens a fresh connection +//! to `/run/hive/priv.sock`, sends one JSON line, reads the response, +//! and closes the connection. +//! +//! Connection-per-call is intentional: priv calls are infrequent (one +//! per rebuild step), so the simplicity is worth more than a persistent +//! connection. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result, bail}; +use hive_sh4re::priv_proto::{PRIV_SOCK, PrivRequest, PrivResponse}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixStream; + +/// Send a single request to `hive-priv` and return the response. +pub async fn call(req: &PrivRequest) -> Result { + let mut stream = UnixStream::connect(PRIV_SOCK) + .await + .context("connect to hive-priv socket")?; + let line = serde_json::to_string(req).context("serialise PrivRequest")? + "\n"; + stream + .write_all(line.as_bytes()) + .await + .context("send request to hive-priv")?; + stream.shutdown().await.context("shutdown write half")?; + let mut resp_line = String::new(); + BufReader::new(stream) + .read_line(&mut resp_line) + .await + .context("read response from hive-priv")?; + serde_json::from_str(&resp_line).context("parse PrivResponse") +} + +/// Run `nixos-container `. +/// +/// On success returns `(stdout, stderr)`. +pub async fn container_run(args: Vec) -> Result<(String, String)> { + let resp = call(&PrivRequest::ContainerRun { args }).await?; + check(resp) +} + +/// Run `systemctl daemon-reload`. +pub async fn daemon_reload() -> Result<()> { + let resp = call(&PrivRequest::DaemonReload).await?; + check(resp)?; + Ok(()) +} + +/// Overwrite `/etc/nixos-containers/.conf`. +pub async fn write_nspawn_conf(container: &str, content: &str) -> Result<()> { + let resp = call(&PrivRequest::WriteNspawnConf { + container: container.to_owned(), + content: content.to_owned(), + }) + .await?; + check(resp)?; + Ok(()) +} + +/// Write a systemd drop-in file for `container@.service`. +pub async fn write_systemd_dropin(container: &str, filename: &str, content: &str) -> Result<()> { + let resp = call(&PrivRequest::WriteSystemdDropin { + container: container.to_owned(), + filename: filename.to_owned(), + content: content.to_owned(), + }) + .await?; + check(resp)?; + Ok(()) +} + +/// Remove the systemd drop-in dir for `container@.service`. +pub async fn remove_systemd_dropin(container: &str) -> Result<()> { + let resp = call(&PrivRequest::RemoveSystemdDropin { + container: container.to_owned(), + }) + .await?; + check(resp)?; + Ok(()) +} + +/// `chown(path, uid, gid)` via hive-priv. +/// +/// `path` must be under `/run/hive-agent/` or `/var/lib/hyperhive/`. +pub async fn chown(path: &Path, uid: u32, gid: u32) -> Result<()> { + let resp = call(&PrivRequest::Chown { + path: PathBuf::from(path), + uid, + gid, + }) + .await?; + check(resp)?; + Ok(()) +} + +/// `chmod(path, mode)` via hive-priv. +/// +/// `path` must be under `/run/hive-agent/` or `/var/lib/hyperhive/`. +pub async fn chmod(path: &Path, mode: u32) -> Result<()> { + let resp = call(&PrivRequest::Chmod { + path: PathBuf::from(path), + mode, + }) + .await?; + check(resp)?; + Ok(()) +} + +/// Run a command inside a machine container via `systemd-run --machine`. +pub async fn systemd_run_machine(machine: &str, cmd: Vec) -> Result<(String, String)> { + let resp = call(&PrivRequest::SystemdRunMachine { + machine: machine.to_owned(), + cmd, + }) + .await?; + check(resp) +} + +fn check(resp: PrivResponse) -> Result<(String, String)> { + if resp.ok { + Ok((resp.stdout, resp.stderr)) + } else { + bail!( + "{}", + resp.error.as_deref().unwrap_or("hive-priv returned error") + ) + } +} From 10871381f30127a2904c04ba9e80e9d6c9e3ca45 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:47:27 +0200 Subject: [PATCH 08/18] fix(702): replace SystemdRunMachine with specific ReloadGatewayNginx --- hive-sh4re/src/priv_proto.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 98e5f558..6dc7e0f9 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -50,10 +50,9 @@ pub enum PrivRequest { /// `chmod(2)` a path under a hive-managed prefix. Chmod { path: PathBuf, mode: u32 }, - /// Run a command inside a machine container via `systemd-run --machine`. - /// - /// The machine name must be a hive-managed container. - SystemdRunMachine { machine: String, cmd: Vec }, + /// Reload nginx inside the `hive-gateway` container via + /// `systemd-run --machine=hive-gateway nginx -s reload`. + ReloadGatewayNginx, } /// Response from the privileged helper. From efedfc3ea6ad791a18764f0ec0b8714c81a2a50d Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:47:28 +0200 Subject: [PATCH 09/18] fix(702): narrow SystemdRunMachine to ReloadGatewayNginx in hive-priv --- hive-priv/src/main.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index c86be801..23d18c80 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -243,25 +243,20 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { Ok((String::new(), String::new())) } - PrivRequest::SystemdRunMachine { ref machine, ref cmd } => { - validate_container_name(machine)?; - let mut args = vec!["--machine".to_owned(), machine.clone(), "--quiet".to_owned(), "--".to_owned()]; - args.extend(cmd.iter().cloned()); + PrivRequest::ReloadGatewayNginx => { let out = Command::new("systemd-run") - .args(&args) + .args(["--machine=hive-gateway", "--quiet", "--", "nginx", "-s", "reload"]) .output() .await - .context("invoke systemd-run")?; - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + .context("invoke systemd-run for gateway nginx reload")?; if !out.status.success() { bail!( - "systemd-run --machine={machine} failed ({}): {}", + "gateway nginx reload failed ({}): {}", out.status, - stderr.trim() + String::from_utf8_lossy(&out.stderr).trim() ); } - Ok((stdout, stderr)) + Ok((String::new(), String::new())) } } } From 29926031aee9cc4dc7021b8e68c52e6143624bc7 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 16:47:28 +0200 Subject: [PATCH 10/18] fix(702): replace systemd_run_machine with reload_gateway_nginx in priv_client --- hive-c0re/src/priv_client.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 357d8723..51a3b646 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -111,14 +111,11 @@ pub async fn chmod(path: &Path, mode: u32) -> Result<()> { Ok(()) } -/// Run a command inside a machine container via `systemd-run --machine`. -pub async fn systemd_run_machine(machine: &str, cmd: Vec) -> Result<(String, String)> { - let resp = call(&PrivRequest::SystemdRunMachine { - machine: machine.to_owned(), - cmd, - }) - .await?; - check(resp) +/// Reload nginx inside `hive-gateway` via `systemd-run --machine`. +pub async fn reload_gateway_nginx() -> Result<()> { + let resp = call(&PrivRequest::ReloadGatewayNginx).await?; + check(resp)?; + Ok(()) } fn check(resp: PrivResponse) -> Result<(String, String)> { From af230479701da3b216fa0744235b89acd3bffbb9 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 17:05:30 +0200 Subject: [PATCH 11/18] fix(702): replace generic variants with specific ops in PrivRequest --- hive-sh4re/src/priv_proto.rs | 88 +++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 6dc7e0f9..38b4493e 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -3,8 +3,6 @@ //! Both `hive-priv` (server) and `hive-c0re` (client via `priv_client`) //! import these so the shapes stay in sync. -use std::path::PathBuf; - use serde::{Deserialize, Serialize}; /// Default socket path for the privileged helper. @@ -13,46 +11,72 @@ pub const PRIV_SOCK: &str = "/run/hive/priv.sock"; /// A request to the privileged helper. /// /// Wire format: one JSON object per line over `/run/hive/priv.sock`. +/// Every variant is a specific known operation — no pass-through +/// shell commands or arbitrary paths. New privileged ops get new +/// variants. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "op", rename_all = "snake_case")] pub enum PrivRequest { - /// Run `nixos-container `. - /// - /// The helper validates that the container argument (second positional - /// arg for verbs that take one) matches a hive-managed name - /// (`h-*`, the manager container, or a known sibling service container). - ContainerRun { args: Vec }, + // --- Container lifecycle --- + + /// `nixos-container start ` + StartContainer { name: String }, + + /// `nixos-container stop ` + StopContainer { name: String }, + + /// `nixos-container kill ` + KillContainer { name: String }, + + /// `nixos-container update --flake ` + UpdateContainer { name: String, flake_ref: String }, + + /// `nixos-container create --flake ` + CreateContainer { name: String, flake_ref: String }, + + /// `nixos-container destroy ` + DestroyContainer { name: String }, + + /// `nixos-container list` + ListContainers, + + // --- Config file writes --- + + /// Overwrite `/etc/nixos-containers/.conf` with new content. + /// Written by `lifecycle::set_nspawn_flags` to inject `EXTRA_NSPAWN_FLAGS`. + WriteNspawnConf { container: String, content: String }, + + /// Write `/run/systemd/system/container@.service.d/hyperhive-limits.conf` + /// with `[Service]\nMemoryMax=\nCPUQuota=\n`. + /// Written by `lifecycle::set_resource_limits`. + WriteResourceLimits { + container: String, + memory_max: String, + cpu_quota: String, + }, + + /// Remove `/run/systemd/system/container@.service.d/` if present. + /// Called by `lifecycle::destroy` to clean up the resource-limits drop-in. + RemoveServiceDropin { container: String }, + + // --- System --- /// Run `systemctl daemon-reload`. DaemonReload, - /// Overwrite `/etc/nixos-containers/.conf` with new content. - WriteNspawnConf { container: String, content: String }, - - /// Write a file into the drop-in dir for `container@.service`. - /// - /// Creates `/run/systemd/system/container@.service.d/`. - WriteSystemdDropin { - container: String, - filename: String, - content: String, - }, - - /// Remove the drop-in dir for `container@.service`, if present. - /// - /// Removes `/run/systemd/system/container@.service.d/`. - RemoveSystemdDropin { container: String }, - - /// `chown(2)` a path under a hive-managed prefix - /// (`/run/hive-agent/` or `/var/lib/hyperhive/`). - Chown { path: PathBuf, uid: u32, gid: u32 }, - - /// `chmod(2)` a path under a hive-managed prefix. - Chmod { path: PathBuf, mode: u32 }, - /// Reload nginx inside the `hive-gateway` container via /// `systemd-run --machine=hive-gateway nginx -s reload`. ReloadGatewayNginx, + + // --- Socket dir ownership --- + + /// Set ownership of `/run/hive-agent//` to `uid:gid`. + /// Called by `lifecycle::set_nspawn_flags` after `create_dir_all`. + ChownSocketDir { agent_name: String, uid: u32, gid: u32 }, + + /// Set mode of `/run/hive-agent//`. + /// Fallback when uid lookup returns `None` on first spawn. + ChmodSocketDir { agent_name: String, mode: u32 }, } /// Response from the privileged helper. From ec12ba4b1a46dac75b00ce01ca9d063640b5a506 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 17:05:31 +0200 Subject: [PATCH 12/18] 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(()) } From 8d5e97ce9fe2d24a9378c726cd705cb6dd4f386c Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 17:05:31 +0200 Subject: [PATCH 13/18] fix(702): update priv_client to match narrowed PrivRequest variants --- hive-c0re/src/priv_client.rs | 147 +++++++++++++++++------------------ 1 file changed, 72 insertions(+), 75 deletions(-) diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 51a3b646..04a3e935 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -1,16 +1,10 @@ //! Async client for the `hive-priv` privileged-helper socket. //! -//! Exposes a standalone async function per operation that callers in -//! `lifecycle`, `forge`, `matrix`, and `gateway_nginx` can call -//! without carrying any client state. Each call opens a fresh connection -//! to `/run/hive/priv.sock`, sends one JSON line, reads the response, -//! and closes the connection. -//! -//! Connection-per-call is intentional: priv calls are infrequent (one -//! per rebuild step), so the simplicity is worth more than a persistent -//! connection. - -use std::path::{Path, PathBuf}; +//! Exposes a standalone async function per operation. Each call opens a +//! fresh connection to `/run/hive/priv.sock`, sends one JSON line, reads +//! the response, and closes. Connection-per-call is intentional: priv +//! calls are infrequent (once per rebuild step), so simplicity wins over +//! a persistent connection. use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{PRIV_SOCK, PrivRequest, PrivResponse}; @@ -36,95 +30,98 @@ pub async fn call(req: &PrivRequest) -> Result { serde_json::from_str(&resp_line).context("parse PrivResponse") } -/// Run `nixos-container `. -/// -/// On success returns `(stdout, stderr)`. -pub async fn container_run(args: Vec) -> Result<(String, String)> { - let resp = call(&PrivRequest::ContainerRun { args }).await?; - check(resp) +pub async fn start_container(name: &str) -> Result<()> { + ok(call(&PrivRequest::StartContainer { name: name.to_owned() }).await?) } -/// Run `systemctl daemon-reload`. -pub async fn daemon_reload() -> Result<()> { - let resp = call(&PrivRequest::DaemonReload).await?; - check(resp)?; - Ok(()) +pub async fn stop_container(name: &str) -> Result<()> { + ok(call(&PrivRequest::StopContainer { name: name.to_owned() }).await?) +} + +pub async fn kill_container(name: &str) -> Result<()> { + ok(call(&PrivRequest::KillContainer { name: name.to_owned() }).await?) +} + +pub async fn update_container(name: &str, flake_ref: &str) -> Result<(String, String)> { + check(call(&PrivRequest::UpdateContainer { + name: name.to_owned(), + flake_ref: flake_ref.to_owned(), + }).await?) +} + +pub async fn create_container(name: &str, flake_ref: &str) -> Result<(String, String)> { + check(call(&PrivRequest::CreateContainer { + name: name.to_owned(), + flake_ref: flake_ref.to_owned(), + }).await?) +} + +pub async fn destroy_container(name: &str) -> Result<()> { + ok(call(&PrivRequest::DestroyContainer { name: name.to_owned() }).await?) +} + +pub async fn list_containers() -> Result { + let (stdout, _) = check(call(&PrivRequest::ListContainers).await?)?; + Ok(stdout) } -/// Overwrite `/etc/nixos-containers/.conf`. pub async fn write_nspawn_conf(container: &str, content: &str) -> Result<()> { - let resp = call(&PrivRequest::WriteNspawnConf { + ok(call(&PrivRequest::WriteNspawnConf { container: container.to_owned(), content: content.to_owned(), - }) - .await?; - check(resp)?; - Ok(()) + }).await?) } -/// Write a systemd drop-in file for `container@.service`. -pub async fn write_systemd_dropin(container: &str, filename: &str, content: &str) -> Result<()> { - let resp = call(&PrivRequest::WriteSystemdDropin { +pub async fn write_resource_limits( + container: &str, + memory_max: &str, + cpu_quota: &str, +) -> Result<()> { + ok(call(&PrivRequest::WriteResourceLimits { container: container.to_owned(), - filename: filename.to_owned(), - content: content.to_owned(), - }) - .await?; - check(resp)?; - Ok(()) + memory_max: memory_max.to_owned(), + cpu_quota: cpu_quota.to_owned(), + }).await?) } -/// Remove the systemd drop-in dir for `container@.service`. -pub async fn remove_systemd_dropin(container: &str) -> Result<()> { - let resp = call(&PrivRequest::RemoveSystemdDropin { +pub async fn remove_service_dropin(container: &str) -> Result<()> { + ok(call(&PrivRequest::RemoveServiceDropin { container: container.to_owned(), - }) - .await?; - check(resp)?; - Ok(()) + }).await?) } -/// `chown(path, uid, gid)` via hive-priv. -/// -/// `path` must be under `/run/hive-agent/` or `/var/lib/hyperhive/`. -pub async fn chown(path: &Path, uid: u32, gid: u32) -> Result<()> { - let resp = call(&PrivRequest::Chown { - path: PathBuf::from(path), +pub async fn daemon_reload() -> Result<()> { + ok(call(&PrivRequest::DaemonReload).await?) +} + +pub async fn reload_gateway_nginx() -> Result<()> { + ok(call(&PrivRequest::ReloadGatewayNginx).await?) +} + +pub async fn chown_socket_dir(agent_name: &str, uid: u32, gid: u32) -> Result<()> { + ok(call(&PrivRequest::ChownSocketDir { + agent_name: agent_name.to_owned(), uid, gid, - }) - .await?; - check(resp)?; - Ok(()) + }).await?) } -/// `chmod(path, mode)` via hive-priv. -/// -/// `path` must be under `/run/hive-agent/` or `/var/lib/hyperhive/`. -pub async fn chmod(path: &Path, mode: u32) -> Result<()> { - let resp = call(&PrivRequest::Chmod { - path: PathBuf::from(path), +pub async fn chmod_socket_dir(agent_name: &str, mode: u32) -> Result<()> { + ok(call(&PrivRequest::ChmodSocketDir { + agent_name: agent_name.to_owned(), mode, - }) - .await?; - check(resp)?; - Ok(()) -} - -/// Reload nginx inside `hive-gateway` via `systemd-run --machine`. -pub async fn reload_gateway_nginx() -> Result<()> { - let resp = call(&PrivRequest::ReloadGatewayNginx).await?; - check(resp)?; - Ok(()) + }).await?) } fn check(resp: PrivResponse) -> Result<(String, String)> { if resp.ok { Ok((resp.stdout, resp.stderr)) } else { - bail!( - "{}", - resp.error.as_deref().unwrap_or("hive-priv returned error") - ) + bail!("{}", resp.error.as_deref().unwrap_or("hive-priv returned error")) } } + +fn ok(resp: PrivResponse) -> Result<()> { + check(resp)?; + Ok(()) +} From 89d0937473224eefb057a9adbba362361b749be1 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 17:15:06 +0200 Subject: [PATCH 14/18] priv: derive flake ref from agent name; WriteNspawnFlags takes flags only --- hive-c0re/src/priv_client.rs | 20 ++++-------- hive-priv/src/main.rs | 62 ++++++++++++++++++++++++++++-------- hive-sh4re/src/priv_proto.rs | 13 +++++--- 3 files changed, 64 insertions(+), 31 deletions(-) diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 04a3e935..06ae9603 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -42,18 +42,12 @@ pub async fn kill_container(name: &str) -> Result<()> { ok(call(&PrivRequest::KillContainer { name: name.to_owned() }).await?) } -pub async fn update_container(name: &str, flake_ref: &str) -> Result<(String, String)> { - check(call(&PrivRequest::UpdateContainer { - name: name.to_owned(), - flake_ref: flake_ref.to_owned(), - }).await?) +pub async fn update_container(name: &str) -> Result<(String, String)> { + check(call(&PrivRequest::UpdateContainer { name: name.to_owned() }).await?) } -pub async fn create_container(name: &str, flake_ref: &str) -> Result<(String, String)> { - check(call(&PrivRequest::CreateContainer { - name: name.to_owned(), - flake_ref: flake_ref.to_owned(), - }).await?) +pub async fn create_container(name: &str) -> Result<(String, String)> { + check(call(&PrivRequest::CreateContainer { name: name.to_owned() }).await?) } pub async fn destroy_container(name: &str) -> Result<()> { @@ -65,10 +59,10 @@ pub async fn list_containers() -> Result { Ok(stdout) } -pub async fn write_nspawn_conf(container: &str, content: &str) -> Result<()> { - ok(call(&PrivRequest::WriteNspawnConf { +pub async fn write_nspawn_flags(container: &str, extra_nspawn_flags: &str) -> Result<()> { + ok(call(&PrivRequest::WriteNspawnFlags { container: container.to_owned(), - content: content.to_owned(), + extra_nspawn_flags: extra_nspawn_flags.to_owned(), }).await?) } diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index fa0d0727..be805fd0 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -37,6 +37,10 @@ const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway /// Root of the per-agent unix-socket dirs on the host. const SOCKET_DIR_ROOT: &str = "/run/hive-agent"; +/// Host path of the meta flake (mirrors `meta::meta_dir()`). +/// The flake ref for agent `` is `{META_DIR}#{name}`. +const META_DIR: &str = "/var/lib/hyperhive/meta"; + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -163,16 +167,16 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { container_run(&["kill", &container_system_name(name)]).await } - PrivRequest::UpdateContainer { ref name, ref flake_ref } => { + PrivRequest::UpdateContainer { ref name } => { validate_container_name(name)?; - validate_flake_ref(flake_ref)?; - container_run(&["update", &container_system_name(name), "--flake", flake_ref]).await + let flake_ref = agent_flake_ref(name); + container_run(&["update", &container_system_name(name), "--flake", &flake_ref]).await } - PrivRequest::CreateContainer { ref name, ref flake_ref } => { + PrivRequest::CreateContainer { ref name } => { validate_container_name(name)?; - validate_flake_ref(flake_ref)?; - container_run(&["create", &container_system_name(name), "--flake", flake_ref]).await + let flake_ref = agent_flake_ref(name); + container_run(&["create", &container_system_name(name), "--flake", &flake_ref]).await } PrivRequest::DestroyContainer { ref name } => { @@ -182,10 +186,9 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { PrivRequest::ListContainers => container_run(&["list"]).await, - PrivRequest::WriteNspawnConf { ref container, ref content } => { + PrivRequest::WriteNspawnFlags { ref container, ref extra_nspawn_flags } => { validate_container_system_name(container)?; - let path = format!("/etc/nixos-containers/{container}.conf"); - std::fs::write(&path, content).with_context(|| format!("write {path}"))?; + write_nspawn_flags(container, extra_nspawn_flags)?; Ok((String::new(), String::new())) } @@ -354,9 +357,42 @@ fn validate_name_chars(name: &str) -> Result<()> { 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:?}"); +/// Derive the meta-flake ref for an agent by name. +fn agent_flake_ref(name: &str) -> String { + format!("{META_DIR}#{name}") +} + +/// 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, extra_nspawn_flags: &str) -> 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'); } - Ok(()) + 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"); + out.push_str(&format!("EXTRA_NSPAWN_FLAGS=\"{extra_nspawn_flags}\"\n")); + std::fs::write(&path, out).with_context(|| format!("write {path}")) } diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 38b4493e..5a3ef08a 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -29,10 +29,12 @@ pub enum PrivRequest { KillContainer { name: String }, /// `nixos-container update --flake ` - UpdateContainer { name: String, flake_ref: String }, + /// The flake ref is derived from `name` by hive-priv. + UpdateContainer { name: String }, /// `nixos-container create --flake ` - CreateContainer { name: String, flake_ref: String }, + /// The flake ref is derived from `name` by hive-priv. + CreateContainer { name: String }, /// `nixos-container destroy ` DestroyContainer { name: String }, @@ -42,9 +44,10 @@ pub enum PrivRequest { // --- Config file writes --- - /// Overwrite `/etc/nixos-containers/.conf` with new content. - /// Written by `lifecycle::set_nspawn_flags` to inject `EXTRA_NSPAWN_FLAGS`. - WriteNspawnConf { container: String, content: String }, + /// Update `/etc/nixos-containers/.conf`: strip network-isolation + /// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS`. + /// Written by `lifecycle::set_nspawn_flags`. + WriteNspawnFlags { container: String, extra_nspawn_flags: String }, /// Write `/run/systemd/system/container@.service.d/hyperhive-limits.conf` /// with `[Service]\nMemoryMax=\nCPUQuota=\n`. From aa7f8e55532eb444552470be3e63296c7d94cffa Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 17:19:39 +0200 Subject: [PATCH 15/18] priv: move shared consts to hive-sh4re; WriteNspawnFlags uses Vec + per-flag validation --- hive-c0re/src/priv_client.rs | 4 ++-- hive-priv/src/main.rs | 34 ++++++++++++++++++---------------- hive-sh4re/src/priv_proto.rs | 19 +++++++++++++++++-- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 06ae9603..cd5fa42b 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -59,10 +59,10 @@ pub async fn list_containers() -> Result { Ok(stdout) } -pub async fn write_nspawn_flags(container: &str, extra_nspawn_flags: &str) -> Result<()> { +pub async fn write_nspawn_flags(container: &str, extra_nspawn_flags: &[&str]) -> Result<()> { ok(call(&PrivRequest::WriteNspawnFlags { container: container.to_owned(), - extra_nspawn_flags: extra_nspawn_flags.to_owned(), + extra_nspawn_flags: extra_nspawn_flags.iter().map(|s| s.to_string()).collect(), }).await?) } diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index be805fd0..69ebd9f3 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -20,27 +20,14 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; -use hive_sh4re::priv_proto::{PRIV_SOCK, PrivRequest, PrivResponse}; +use hive_sh4re::priv_proto::{AGENT_PREFIX, MANAGER_NAME, META_DIR, PRIV_SOCK, SIBLING_CONTAINERS, PrivRequest, PrivResponse}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tokio::process::Command; -/// Sub-agent container prefix (mirrors `lifecycle::AGENT_PREFIX`). -const AGENT_PREFIX: &str = "h-"; - -/// Manager container name (mirrors `lifecycle::MANAGER_NAME`). -const MANAGER_NAME: &str = "root"; - -/// Sibling service containers managed by hive-c0re. -const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway"]; - /// Root of the per-agent unix-socket dirs on the host. const SOCKET_DIR_ROOT: &str = "/run/hive-agent"; -/// Host path of the meta flake (mirrors `meta::meta_dir()`). -/// The flake ref for agent `` is `{META_DIR}#{name}`. -const META_DIR: &str = "/var/lib/hyperhive/meta"; - #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -188,6 +175,9 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { PrivRequest::WriteNspawnFlags { ref container, ref extra_nspawn_flags } => { validate_container_system_name(container)?; + for flag in extra_nspawn_flags { + validate_nspawn_flag(flag)?; + } write_nspawn_flags(container, extra_nspawn_flags)?; Ok((String::new(), String::new())) } @@ -362,11 +352,22 @@ fn agent_flake_ref(name: &str) -> String { format!("{META_DIR}#{name}") } +/// Validate one nspawn flag entry: must be non-empty and contain no +/// ASCII whitespace or null bytes. Whitespace would split the entry +/// into multiple flags when the start script expands +/// `$EXTRA_NSPAWN_FLAGS` unquoted. +fn validate_nspawn_flag(flag: &str) -> Result<()> { + if flag.is_empty() || flag.bytes().any(|b| b == 0 || b.is_ascii_whitespace()) { + bail!("invalid nspawn flag {flag:?}: must be non-empty and contain no whitespace or null bytes"); + } + 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, extra_nspawn_flags: &str) -> Result<()> { +fn write_nspawn_flags(container: &str, extra_nspawn_flags: &[String]) -> Result<()> { let path = format!("/etc/nixos-containers/{container}.conf"); let original = std::fs::read_to_string(&path) .with_context(|| format!("read {path}"))?; @@ -393,6 +394,7 @@ fn write_nspawn_flags(container: &str, extra_nspawn_flags: &str) -> Result<()> { out.push_str("HOST_ADDRESS6=\n"); out.push_str("LOCAL_ADDRESS6=\n"); out.push_str("HOST_BRIDGE=\n"); - out.push_str(&format!("EXTRA_NSPAWN_FLAGS=\"{extra_nspawn_flags}\"\n")); + let flags_joined = extra_nspawn_flags.join(" "); + out.push_str(&format!("EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"\n")); std::fs::write(&path, out).with_context(|| format!("write {path}")) } diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 5a3ef08a..63cf028a 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -8,6 +8,20 @@ use serde::{Deserialize, Serialize}; /// Default socket path for the privileged helper. pub const PRIV_SOCK: &str = "/run/hive/priv.sock"; +/// Manager container name. Used by `hive-priv` to skip the `h-` prefix +/// and by `hive-c0re` for identity checks. +pub const MANAGER_NAME: &str = "root"; + +/// Sub-agent container prefix. System container name = `h-`. +pub const AGENT_PREFIX: &str = "h-"; + +/// Sibling service containers managed by hive-c0re. +pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway"]; + +/// Host path of the meta flake. The flake ref for agent `` is +/// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire. +pub const META_DIR: &str = "/var/lib/hyperhive/meta"; + /// A request to the privileged helper. /// /// Wire format: one JSON object per line over `/run/hive/priv.sock`. @@ -46,8 +60,9 @@ pub enum PrivRequest { /// Update `/etc/nixos-containers/.conf`: strip network-isolation /// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS`. - /// Written by `lifecycle::set_nspawn_flags`. - WriteNspawnFlags { container: String, extra_nspawn_flags: String }, + /// Each entry in `extra_nspawn_flags` is one flag (e.g. `"--bind=/path"`); + /// hive-priv validates and space-joins them. Written by `lifecycle::set_nspawn_flags`. + WriteNspawnFlags { container: String, extra_nspawn_flags: Vec }, /// Write `/run/systemd/system/container@.service.d/hyperhive-limits.conf` /// with `[Service]\nMemoryMax=\nCPUQuota=\n`. From a922376778aa4a761be6604e4f2b9113828a8e7f Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 17:20:45 +0200 Subject: [PATCH 16/18] priv: reject double-quotes in nspawn flag entries --- hive-priv/src/main.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 69ebd9f3..60fc750e 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -352,13 +352,17 @@ fn agent_flake_ref(name: &str) -> String { format!("{META_DIR}#{name}") } -/// Validate one nspawn flag entry: must be non-empty and contain no -/// ASCII whitespace or null bytes. Whitespace would split the entry -/// into multiple flags when the start script expands -/// `$EXTRA_NSPAWN_FLAGS` unquoted. +/// Validate one nspawn flag entry. Must be non-empty and contain no +/// ASCII whitespace (would split the entry when the start script +/// expands `$EXTRA_NSPAWN_FLAGS` unquoted), double-quotes (would +/// break the `EXTRA_NSPAWN_FLAGS="..."` conf line), or null bytes. fn validate_nspawn_flag(flag: &str) -> Result<()> { - if flag.is_empty() || flag.bytes().any(|b| b == 0 || b.is_ascii_whitespace()) { - bail!("invalid nspawn flag {flag:?}: must be non-empty and contain no whitespace or null bytes"); + if flag.is_empty() + || flag.bytes().any(|b| b == 0 || b == b'"' || b.is_ascii_whitespace()) + { + bail!( + "invalid nspawn flag {flag:?}: must be non-empty and contain no whitespace, double-quotes, or null bytes" + ); } Ok(()) } From c9eb520e7c49f9dbc04bad06a471d6e421eda86a Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 17:22:09 +0200 Subject: [PATCH 17/18] priv: WriteNspawnFlags takes Vec instead of raw flag strings --- hive-c0re/src/priv_client.rs | 8 +++++--- hive-priv/src/main.rs | 37 +++++++++++++++++++----------------- hive-sh4re/src/priv_proto.rs | 17 +++++++++++++---- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index cd5fa42b..14abf071 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -7,7 +7,7 @@ //! a persistent connection. use anyhow::{Context as _, Result, bail}; -use hive_sh4re::priv_proto::{PRIV_SOCK, PrivRequest, PrivResponse}; +use hive_sh4re::priv_proto::{PRIV_SOCK, BindMount, PrivRequest, PrivResponse}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -59,13 +59,15 @@ pub async fn list_containers() -> Result { Ok(stdout) } -pub async fn write_nspawn_flags(container: &str, extra_nspawn_flags: &[&str]) -> Result<()> { +pub async fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> { ok(call(&PrivRequest::WriteNspawnFlags { container: container.to_owned(), - extra_nspawn_flags: extra_nspawn_flags.iter().map(|s| s.to_string()).collect(), + binds: binds.to_vec(), }).await?) } +pub use hive_sh4re::priv_proto::BindMount; + pub async fn write_resource_limits( container: &str, memory_max: &str, diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 60fc750e..1a916e1c 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -20,7 +20,7 @@ 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, PrivRequest, PrivResponse}; +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; @@ -173,12 +173,13 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { PrivRequest::ListContainers => container_run(&["list"]).await, - PrivRequest::WriteNspawnFlags { ref container, ref extra_nspawn_flags } => { + PrivRequest::WriteNspawnFlags { ref container, ref binds } => { validate_container_system_name(container)?; - for flag in extra_nspawn_flags { - validate_nspawn_flag(flag)?; + for bind in binds { + validate_bind_path(&bind.host_path)?; + validate_bind_path(&bind.container_path)?; } - write_nspawn_flags(container, extra_nspawn_flags)?; + write_nspawn_flags(container, binds)?; Ok((String::new(), String::new())) } @@ -352,17 +353,15 @@ fn agent_flake_ref(name: &str) -> String { format!("{META_DIR}#{name}") } -/// Validate one nspawn flag entry. Must be non-empty and contain no -/// ASCII whitespace (would split the entry when the start script -/// expands `$EXTRA_NSPAWN_FLAGS` unquoted), double-quotes (would -/// break the `EXTRA_NSPAWN_FLAGS="..."` conf line), or null bytes. -fn validate_nspawn_flag(flag: &str) -> Result<()> { - if flag.is_empty() - || flag.bytes().any(|b| b == 0 || b == b'"' || b.is_ascii_whitespace()) +/// 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'"') { - bail!( - "invalid nspawn flag {flag:?}: must be non-empty and contain no whitespace, double-quotes, or null bytes" - ); + bail!("invalid bind path {path:?}: must be an absolute path with no newlines, null bytes, or double-quotes"); } Ok(()) } @@ -371,7 +370,7 @@ fn validate_nspawn_flag(flag: &str) -> Result<()> { /// 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, extra_nspawn_flags: &[String]) -> Result<()> { +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}"))?; @@ -398,7 +397,11 @@ fn write_nspawn_flags(container: &str, extra_nspawn_flags: &[String]) -> Result< out.push_str("HOST_ADDRESS6=\n"); out.push_str("LOCAL_ADDRESS6=\n"); out.push_str("HOST_BRIDGE=\n"); - let flags_joined = extra_nspawn_flags.join(" "); + 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}")) } diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 63cf028a..37ebf686 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -22,6 +22,16 @@ 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"; +/// One bind-mount entry for `WriteNspawnFlags`. +/// hive-priv constructs `--bind=:` (or `--bind-ro=`) +/// and validates both paths before writing the conf file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BindMount { + pub host_path: String, + pub container_path: String, + pub read_only: bool, +} + /// A request to the privileged helper. /// /// Wire format: one JSON object per line over `/run/hive/priv.sock`. @@ -59,10 +69,9 @@ pub enum PrivRequest { // --- Config file writes --- /// Update `/etc/nixos-containers/.conf`: strip network-isolation - /// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS`. - /// Each entry in `extra_nspawn_flags` is one flag (e.g. `"--bind=/path"`); - /// hive-priv validates and space-joins them. Written by `lifecycle::set_nspawn_flags`. - WriteNspawnFlags { container: String, extra_nspawn_flags: Vec }, + /// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS` from the + /// provided bind-mount list. Written by `lifecycle::set_nspawn_flags`. + WriteNspawnFlags { container: String, binds: Vec }, /// Write `/run/systemd/system/container@.service.d/hyperhive-limits.conf` /// with `[Service]\nMemoryMax=\nCPUQuota=\n`. From 0b5376249d2c481f4523e6260baa496efa50eafb Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 17:23:33 +0200 Subject: [PATCH 18/18] priv: reject colons in bind paths to avoid nspawn delimiter confusion --- hive-priv/src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 1a916e1c..37753711 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -359,9 +359,11 @@ fn agent_flake_ref(name: &str) -> String { 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'"') + || 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 newlines, null bytes, or double-quotes"); + bail!( + "invalid bind path {path:?}: must be an absolute path with no colons, newlines, null bytes, or double-quotes" + ); } Ok(()) }