From 3bb07b1fde193562f384989069c4dc329cc0aeb2 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 31 May 2026 21:06:07 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(#14):=20network=20isolation=20rust=20s?= =?UTF-8?q?ide=20=E2=80=94=20PRIVATE=5FNETWORK=20+=20veth=20wiring=20in=20?= =?UTF-8?q?set=5Fnspawn=5Fflags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hive-c0re/src/lifecycle.rs | 135 +++++++++++++++++++++++++++++++++-- hive-c0re/src/priv_client.rs | 11 ++- hive-priv/src/main.rs | 40 +++++++---- hive-sh4re/src/priv_proto.rs | 23 +++++- 4 files changed, 185 insertions(+), 24 deletions(-) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 77f17d0e..d59d063c 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -55,6 +55,17 @@ const WEB_PORT_RANGE: u16 = 900; const DEFAULT_MEMORY_MAX: &str = "2G"; const DEFAULT_CPU_QUOTA: &str = "50%"; +/// FNV-1a hash of a string — shared by `agent_web_port` and +/// `agent_network_ip` so the derivation rule is identical. +fn fnv1a(s: &str) -> u32 { + let mut hash: u32 = 2_166_136_261; + for b in s.bytes() { + hash ^= u32::from(b); + hash = hash.wrapping_mul(16_777_619); + } + hash +} + /// Per-agent web UI port — `WEB_PORT_BASE + FNV-1a(name) % /// WEB_PORT_RANGE` for every agent including the manager. The port /// allocation rule reads the same for every name; collisions are @@ -64,13 +75,56 @@ const DEFAULT_CPU_QUOTA: &str = "50%"; /// no state-file dance. #[must_use] pub fn agent_web_port(name: &str) -> u16 { - let mut hash: u32 = 2_166_136_261; - for b in name.bytes() { - hash ^= u32::from(b); - hash = hash.wrapping_mul(16_777_619); - } // Modulo of a u32 by a u16's value is guaranteed < u16::MAX, so try_from never fails. - WEB_PORT_BASE + u16::try_from(hash % u32::from(WEB_PORT_RANGE)).unwrap_or(0) + WEB_PORT_BASE + u16::try_from(fnv1a(name) % u32::from(WEB_PORT_RANGE)).unwrap_or(0) +} + +/// Deterministic IPv4 address for an agent inside an isolated subnet. +/// +/// Parses `subnet_cidr` as `/` (e.g. +/// `"10.42.0.0/24"`), then computes: +/// +/// ```text +/// host_count = 2^(32 - prefix_len) +/// usable = host_count - 3 // skip .0 (network), .1 (gateway), .255 (broadcast) +/// offset = FNV-1a(name) % usable + 2 // .2 is the first agent slot +/// agent_ip = network_base_u32 + offset +/// ``` +/// +/// Returns `None` when `subnet_cidr` can't be parsed (invalid format, +/// prefix out of range, etc.) so callers can fall back gracefully. +/// Collisions are possible (birthday paradox) and the operator resolves +/// them by renaming an agent, same as for port collisions. +#[must_use] +pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option { + let (ip_str, prefix_str) = subnet_cidr.split_once('/')?; + let prefix_len: u32 = prefix_str.parse().ok()?; + if prefix_len > 30 { + // /31 and /32 have no room for agents; /30 has 1 usable slot. + return None; + } + // Parse dotted-decimal IPv4. + let octets: Vec = ip_str + .split('.') + .map(|o| o.parse::().ok()) + .collect::>>()?; + if octets.len() != 4 { + return None; + } + let base_u32 = u32::from_be_bytes([octets[0], octets[1], octets[2], octets[3]]); + // Mask off host bits to get the true network address. + let mask = if prefix_len == 0 { 0u32 } else { !0u32 << (32 - prefix_len) }; + let network_base = base_u32 & mask; + let host_count: u32 = 1u32.checked_shl(32 - prefix_len).unwrap_or(0); + // `.0` = network, `.1` = bridge gateway, last = broadcast → 3 reserved. + let usable = host_count.saturating_sub(3); + if usable == 0 { + return None; + } + let offset = fnv1a(name) % usable + 2; // +2: skip .0 and .1 + let ip_u32 = network_base + offset; + let [a, b, c, d] = ip_u32.to_be_bytes(); + Some(format!("{a}.{b}.{c}.{d}")) } #[must_use] @@ -1120,8 +1174,34 @@ async fn set_nspawn_flags( } binds.push(BindMount { host_path: socket_dir.to_string_lossy().into_owned(), container_path: socket_dir.to_string_lossy().into_owned(), read_only: false }); + // Network isolation: when HIVE_NETWORK_ISOLATION=1 is set (by the + // hive-network.nix module's `isolateContainers` option), flip the + // container to a private network namespace with a veth pair attached + // to the host bridge. Applies to all containers including the manager + // (all hive-c0re<->agent comms go through bind-mounted UDS, not TCP). + let isolation = { + let isolate = std::env::var("HIVE_NETWORK_ISOLATION").ok().as_deref() == Some("1"); + let bridge = std::env::var("HIVE_NETWORK_BRIDGE").unwrap_or_default(); + let subnet = std::env::var("HIVE_NETWORK_SUBNET").unwrap_or_default(); + if isolate && !bridge.is_empty() && !subnet.is_empty() { + let Some(agent_ip) = agent_network_ip(agent_name, &subnet) else { + tracing::warn!( + %agent_name, %subnet, + "HIVE_NETWORK_SUBNET is set but could not derive a valid IP for agent \ + (bad CIDR? prefix too narrow?); skipping PRIVATE_NETWORK write to \ + avoid misconfigured isolation" + ); + return crate::priv_client::write_nspawn_flags(container, &binds, None).await; + }; + tracing::info!(%agent_name, %agent_ip, %bridge, "network isolation: PRIVATE_NETWORK=1"); + Some(hive_sh4re::priv_proto::NetworkIsolation { agent_ip, bridge }) + } else { + None + } + }; + // Delegate the actual conf-file rewrite to hive-priv (runs as root). - crate::priv_client::write_nspawn_flags(container, &binds).await + crate::priv_client::write_nspawn_flags(container, &binds, isolation).await } /// Execute a container operation via hive-priv and integrate with @@ -1256,6 +1336,47 @@ mod tests { assert!(tracked.contains("flake.nix"), "flake.nix not committed"); } + #[test] + fn agent_network_ip_is_in_subnet() { + // Default subnet 10.42.0.0/24 — agents get .2 to .254. + let ip = agent_network_ip("alice", "10.42.0.0/24").expect("should produce an IP"); + let octets: Vec = ip.split('.').map(|o| o.parse().unwrap()).collect(); + assert_eq!(&octets[..3], &[10, 42, 0], "wrong /24 prefix"); + assert!(octets[3] >= 2 && octets[3] <= 254, "host byte {}", octets[3]); + } + + #[test] + fn agent_network_ip_stable() { + // Same name + subnet must always produce the same IP. + let a = agent_network_ip("damocles", "10.42.0.0/24"); + let b = agent_network_ip("damocles", "10.42.0.0/24"); + assert_eq!(a, b); + } + + #[test] + fn agent_network_ip_different_agents() { + // Different agent names very likely produce different IPs (not guaranteed, + // but for these two names the hashes don't collide). + let alice = agent_network_ip("alice", "10.42.0.0/24").unwrap(); + let bob = agent_network_ip("bob", "10.42.0.0/24").unwrap(); + assert_ne!(alice, bob, "alice and bob collide — rename one"); + } + + #[test] + fn agent_network_ip_different_subnet() { + let ip = agent_network_ip("alice", "192.168.5.0/24").expect("should produce an IP"); + let octets: Vec = ip.split('.').map(|o| o.parse().unwrap()).collect(); + assert_eq!(&octets[..3], &[192, 168, 5]); + } + + #[test] + fn agent_network_ip_rejects_bad_input() { + assert!(agent_network_ip("alice", "notanip/24").is_none()); + assert!(agent_network_ip("alice", "10.0.0.0/33").is_none()); // prefix > 32 + assert!(agent_network_ip("alice", "10.0.0.0/31").is_none()); // too small + assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix + } + /// `setup_proposed` is idempotent: calling it on an existing repo is a /// no-op (the fresh guard skips all writes). #[tokio::test] diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 34b52e6f..e9b73e45 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -7,7 +7,9 @@ //! a persistent connection. use anyhow::{Context as _, Result, bail}; -use hive_sh4re::priv_proto::{BindMount, JournalOutput, PRIV_SOCK, PrivRequest, PrivResponse}; +use hive_sh4re::priv_proto::{ + BindMount, JournalOutput, NetworkIsolation, PRIV_SOCK, PrivRequest, PrivResponse, +}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -112,10 +114,15 @@ pub async fn read_container_journal( ) } -pub async fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> { +pub async fn write_nspawn_flags( + container: &str, + binds: &[BindMount], + isolation: Option, +) -> Result<()> { ok(call(&PrivRequest::WriteNspawnFlags { container: container.to_owned(), binds: binds.to_vec(), + isolation, }) .await?) } diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index ef8316b6..26908e91 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -21,8 +21,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - AGENT_PREFIX, BindMount, JournalOutput, MANAGER_NAME, META_DIR, PRIV_SOCK, PrivRequest, - PrivResponse, SIBLING_CONTAINERS, + AGENT_PREFIX, BindMount, JournalOutput, MANAGER_NAME, META_DIR, NetworkIsolation, PRIV_SOCK, + PrivRequest, PrivResponse, SIBLING_CONTAINERS, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; @@ -207,13 +207,14 @@ async fn exec(req: PrivRequest) -> Result<(String, String)> { PrivRequest::WriteNspawnFlags { ref container, ref binds, + ref isolation, } => { 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)?; + write_nspawn_flags(container, binds, isolation.as_ref())?; Ok((String::new(), String::new())) } @@ -477,9 +478,15 @@ fn validate_bind_path(path: &str) -> Result<()> { /// 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<()> { +/// Update `/etc/nixos-containers/.conf`: strip old network vars, +/// write network isolation settings, then append `EXTRA_NSPAWN_FLAGS`. +/// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring; +/// when `None`, writes `PRIVATE_NETWORK=0`. +fn write_nspawn_flags( + container: &str, + binds: &[BindMount], + isolation: Option<&NetworkIsolation>, +) -> Result<()> { let path = format!("/etc/nixos-containers/{container}.conf"); let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; let lines: Vec<&str> = original @@ -499,12 +506,21 @@ fn write_nspawn_flags(container: &str, binds: &[BindMount]) -> Result<()> { 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"); + if let Some(iso) = isolation { + out.push_str("PRIVATE_NETWORK=1\n"); + out.push_str("HOST_ADDRESS=\n"); + out.push_str(&format!("LOCAL_ADDRESS={}\n", iso.agent_ip)); + out.push_str("HOST_ADDRESS6=\n"); + out.push_str("LOCAL_ADDRESS6=\n"); + out.push_str(&format!("HOST_BRIDGE={}\n", iso.bridge)); + } else { + 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| { diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 718781fe..7f864352 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -55,6 +55,17 @@ pub struct BindMount { pub read_only: bool, } +/// Network isolation parameters for `WriteNspawnFlags`. When `Some`, +/// hive-priv writes `PRIVATE_NETWORK=1` + veth bridge wiring instead +/// of the default `PRIVATE_NETWORK=0`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkIsolation { + /// Static IP address to assign to this container on the bridge subnet. + pub agent_ip: String, + /// Host bridge interface name (e.g. `hive0`). + pub bridge: String, +} + /// A request to the privileged helper. /// /// Wire format: one JSON object per line over `/run/hive/priv.sock`. @@ -128,12 +139,18 @@ pub enum PrivRequest { }, // --- Config file writes --- - /// Update `/etc/nixos-containers/.conf`: strip network-isolation - /// vars, force `PRIVATE_NETWORK=0`, and set `EXTRA_NSPAWN_FLAGS` from the - /// provided bind-mount list. Written by `lifecycle::set_nspawn_flags`. + /// Update `/etc/nixos-containers/.conf`: strip old network-isolation + /// vars, write `PRIVATE_NETWORK` + bridge settings, and set `EXTRA_NSPAWN_FLAGS` + /// from the provided bind-mount list. Written by `lifecycle::set_nspawn_flags`. + /// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring; + /// when `None`, writes `PRIVATE_NETWORK=0`. WriteNspawnFlags { container: String, binds: Vec, + /// `None` = host netns (PRIVATE_NETWORK=0). `Some` = private netns with + /// veth on the specified bridge (PRIVATE_NETWORK=1). + #[serde(default)] + isolation: Option, }, /// Write `/run/systemd/system/container@.service.d/hyperhive-limits.conf` From ec8fcb29ce059de726aed566af098c8901c5c1a3 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 31 May 2026 21:08:12 +0200 Subject: [PATCH 2/4] test: add normalization test for bridge-IP subnet form (atlas note) --- hive-c0re/src/lifecycle.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index d59d063c..5f1139eb 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -1377,6 +1377,21 @@ mod tests { assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix } + #[test] + fn agent_network_ip_normalizes_bridge_ip_subnet() { + // HIVE_NETWORK_SUBNET carries the bridge IP (10.42.0.1/24), not + // canonical network (10.42.0.0/24). Both must produce the same result + // after host-bit masking. + let from_bridge = agent_network_ip("alice", "10.42.0.1/24"); + let from_canonical = agent_network_ip("alice", "10.42.0.0/24"); + assert_eq!(from_bridge, from_canonical, + "bridge-IP and canonical-network form should normalize to the same result"); + // Result must still be in .2-.254. + let ip = from_bridge.unwrap(); + let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); + assert!(last >= 2 && last <= 254, "host byte {last}"); + } + /// `setup_proposed` is idempotent: calling it on an existing repo is a /// no-op (the fresh guard skips all writes). #[tokio::test] From 3c012efbead6ac7bfab2acacca8bc255599b6282 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 31 May 2026 21:17:12 +0200 Subject: [PATCH 3/4] fix(#883): warn + bail on unparseable subnet instead of silently writing empty LOCAL_ADDRESS; doc /0 corner case --- hive-c0re/src/lifecycle.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 5f1139eb..1cfdfa83 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -101,6 +101,8 @@ pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option { let prefix_len: u32 = prefix_str.parse().ok()?; if prefix_len > 30 { // /31 and /32 have no room for agents; /30 has 1 usable slot. + // /0 (the other extreme) is handled further down: host_count + // overflows checked_shl(32) → 0 → usable = 0 → None. return None; } // Parse dotted-decimal IPv4. From 0ac602884419d4d278488c03b57e41e402fc6105 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 3 Jun 2026 01:19:55 +0200 Subject: [PATCH 4/4] docs(#14): update network isolation docs - remove stale manager special-case claim --- docs/network.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/network.md b/docs/network.md index e7bb1f87..1b74a7e3 100644 --- a/docs/network.md +++ b/docs/network.md @@ -122,6 +122,21 @@ address arithmetic. `HOST_BRIDGE=` via `lifecycle::set_nspawn_flags` when creating or updating containers. Each agent gets a deterministic IP derived from its name so the address is reproducible across destroy/recreate. +This applies uniformly to all containers including the manager — no special +case. + +**Why isolation is safe for the manager**: all hive-c0re communication goes +through unix domain sockets (`/run/hive/mcp.sock` for agent requests, +`/run/hive/priv.sock` for privileged ops, per-agent manager sockets). +These are bind-mounted into containers via the nspawn conf. UDS paths +traverse the VFS, not the network stack, so `PRIVATE_NETWORK=1` does not +affect them. + +The nix side also enables IP forwarding + NAT (agents reach the internet +through the host) and drops bridge-subnet → loopback traffic (defence-in-depth +against a compromised agent reaching the c0re dashboard HTTP at +`127.0.0.1`). Agents have no legitimate reason to reach the dashboard over +loopback — the hive-c0re admin socket is a UDS, not TCP. ### Prerequisites before flipping on