From a3796890f5e39b445b1ac009503796292f1de4fb Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 13 Jul 2026 10:31:44 +0200 Subject: [PATCH 1/6] feat(#2363): DHCP pool for bridge service containers Add a DHCP pool to the gateway's dnsmasq so bridge-attached service containers (hive-ci and future equivalents) get their addresses from a proper DHCP server instead of a brittle static derivation. gateway (hive-gateway.nix): - Add IPv4 arithmetic helpers (ipToInt, intToIp, pow2) to compute the DHCP pool range at nix eval time from bridgeIp + bridgePrefixLength. - Reserve the last dhcpPoolSize (14) usable host addresses as the DHCP pool (e.g. .241-.254 on a /24 with 10.42.0.0 network). - Add dhcp-range and dhcp-leasefile to the dnsmasq settings block. The pool is active whenever services.hyperhive.network.enable is true. hive-ci (hive-ci.nix): - Remove the ciBridgeIp / ciBridgeOctets static derivation and the brittle top-of-/24 comment block. - Switch networking.interfaces.eth0 to useDHCP = true so hive-ci gets its address from the gateway DHCP pool. lifecycle (mod.rs, tests.rs): - Add DHCP_POOL_SIZE = 14 constant (must stay in sync with dhcpPoolSize in hive-gateway.nix). - Remap agents whose FNV-1a hash falls in the DHCP pool into the agent-only window [2, dhcp_start - 1]. Only the rare agent whose name hashes into the pool is affected; all others keep their IPs. - Update and extend tests: agent range is now .2-.240 on /24; add agent_network_ip_never_in_dhcp_pool covering 18 agent names. --- hive-c0re/src/lifecycle/mod.rs | 32 ++++++++++++++++++++-- hive-c0re/src/lifecycle/tests.rs | 47 ++++++++++++++++++++++++++++---- nix/modules/hive-ci.nix | 34 ++++------------------- nix/modules/hive-gateway.nix | 37 +++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 36 deletions(-) diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index ff0b9946..45f4d641 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -90,6 +90,12 @@ pub fn agent_web_port(name: &str) -> u16 { WEB_PORT_BASE + u16::try_from(fnv1a(name) % u32::from(WEB_PORT_RANGE)).unwrap_or(0) } +/// Number of IP addresses at the top of each subnet reserved for the DHCP +/// pool (bridge-attached service containers such as hive-ci). Agents are +/// excluded from this range by the remap in `agent_network_ip`. Must stay +/// in sync with `dhcpPoolSize` in `nix/modules/hive-gateway.nix`. +const DHCP_POOL_SIZE: u32 = 14; + /// Deterministic IPv4 address for an agent inside an isolated subnet. /// /// Parses `subnet_cidr` as `/` (e.g. @@ -97,11 +103,18 @@ pub fn agent_web_port(name: &str) -> u16 { /// /// ```text /// host_count = 2^(32 - prefix_len) -/// usable = host_count - 3 // skip .0 (network), .1 (gateway), .255 (broadcast) +/// usable = host_count - 3 // skip .0 (network), .1 (gateway), last (broadcast) /// offset = FNV-1a(name) % usable + 2 // .2 is the first agent slot /// agent_ip = network_base_u32 + offset /// ``` /// +/// The last `DHCP_POOL_SIZE` usable host addresses are reserved for the +/// bridge DHCP pool (service containers). When the primary hash falls in +/// that range the offset is remapped into the agent-only window `[2, +/// dhcp_start - 1]` so no agent is ever assigned a DHCP-pool address. +/// Only the rare agent whose name hashes into the pool is affected; all +/// other agents keep their original IPs. +/// /// 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 @@ -138,7 +151,22 @@ pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option { if usable == 0 { return None; } - let offset = fnv1a(name) % usable + 2; // +2: skip .0 and .1 + let mut offset = fnv1a(name) % usable + 2; // +2: skip .0 and .1 + + // Remap agents whose primary hash falls in the DHCP pool (the last + // DHCP_POOL_SIZE usable addresses). Only has an effect when the + // subnet is large enough to hold both agent slots AND a pool. + if usable > DHCP_POOL_SIZE { + // First offset that belongs to the DHCP pool. + let dhcp_start = usable + 2 - DHCP_POOL_SIZE; + if offset >= dhcp_start { + // Secondary hash into the agent-only window [2, dhcp_start - 1]. + // Result is guaranteed < dhcp_start (no overlap with pool). + let agent_only = dhcp_start - 2; + offset = fnv1a(name) % agent_only + 2; + } + } + let ip_u32 = network_base + offset; let [a, b, c, d] = ip_u32.to_be_bytes(); Some(format!("{a}.{b}.{c}.{d}")) diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs index af77f5bd..1da16580 100644 --- a/hive-c0re/src/lifecycle/tests.rs +++ b/hive-c0re/src/lifecycle/tests.rs @@ -39,17 +39,54 @@ async fn setup_proposed_seeds_flake_nix() { #[test] fn agent_network_ip_is_in_subnet() { - // Default subnet 10.42.0.0/24 — agents get .2 to .254. + // Default subnet 10.42.0.0/24 — agents get .2 to .240 (last 14 are DHCP pool). 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"); + // usable=253, dhcp_start=241 → agent range is .2-.240 assert!( - octets[3] >= 2 && octets[3] <= 254, - "host byte {}", + octets[3] >= 2 && octets[3] <= 240, + "host byte {} — expected in agent-only range [2,240]", octets[3] ); } +#[test] +fn agent_network_ip_never_in_dhcp_pool() { + // No agent should be assigned an address in the DHCP pool + // (.241-.254 on a /24 with DHCP_POOL_SIZE=14). + let subnet = "10.42.0.0/24"; + let names = [ + "alice", + "bob", + "carol", + "damocles", + "iris", + "argus", + "atlas", + "ruth", + "dmatrix", + "bitburner", + "lexis", + "sock", + "triage", + "janet", + "eve", + "frank", + "grace", + "heidi", + ]; + for name in names { + let ip = agent_network_ip(name, subnet).unwrap_or_else(|| panic!("{name} returned None")); + let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); + // dhcp_start_offset = usable(253) + 2 - DHCP_POOL_SIZE(14) = 241 + assert!( + last < 241, + "{name} got .{last} — inside the DHCP pool [.241-.254]" + ); + } +} + #[test] fn agent_network_ip_stable() { // Same name + subnet must always produce the same IP. @@ -121,10 +158,10 @@ fn agent_network_ip_normalizes_bridge_ip_subnet() { from_bridge, from_canonical, "bridge-IP and canonical-network form should normalize to the same result" ); - // Result must still be in .2-.254. + // Result must be in .2-.240 (DHCP pool .241-.254 is excluded). let ip = from_bridge.unwrap(); let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); - assert!((2..=254).contains(&last), "host byte {last}"); + assert!((2..=240).contains(&last), "host byte {last}"); } /// `setup_proposed` is idempotent: calling it on an existing repo is a diff --git a/nix/modules/hive-ci.nix b/nix/modules/hive-ci.nix index 986d468b..f9805c8a 100644 --- a/nix/modules/hive-ci.nix +++ b/nix/modules/hive-ci.nix @@ -11,19 +11,6 @@ let networkCfg = config.services.hyperhive.network; tlsCfg = config.services.hyperhive.tls; - # Static bridge address for the hive-ci container. The hive bridge has - # NO DHCP server: agent containers get deterministic static IPs - # (`lifecycle::agent_network_ip` hashes each name across .2..broadcast-1) - # and dnsmasq on the bridge is DNS-only. hive-ci is the one service - # container on the bridge, so it needs a static address too — `useDHCP` - # here only hangs the boot waiting for a lease nothing serves. Reserve the - # top host address of the (default /24) subnet; a clash with an agent that - # happens to hash here is the same rename-to-resolve case as any - # agent/agent IP collision. Operators on a non-/24 bridge (or with - # `bridgeIp` set to the top address) should pick a free host address. - ciBridgeOctets = lib.splitString "." networkCfg.bridgeIp; - ciBridgeIp = "${lib.elemAt ciBridgeOctets 0}.${lib.elemAt ciBridgeOctets 1}.${lib.elemAt ciBridgeOctets 2}.254"; - # Self-signed TLS is the gateway default (no operator cert / ACME). When # active, forgejo's ROOT_URL is `https://forge.` and the leaf is # signed by the host hive CA — so the runner's Node-based actions (e.g. @@ -422,22 +409,11 @@ in # registries, etc.). The bridge→loopback DROP rule does not # affect traffic destined for the bridge IP itself. networking.nameservers = [ networkCfg.bridgeIp ]; - # With privateNetwork=true + hostBridge the container's veth - # (eth0) is bridge-attached. There is no DHCP server on the hive - # bridge (dnsmasq is DNS-only; agents use static IPs), so assign a - # static address + default route via the bridge gateway rather than - # DHCP — `useDHCP` here just hangs boot on a lease that never - # arrives. See `ciBridgeIp` above. - networking.interfaces.eth0.ipv4.addresses = [ - { - address = ciBridgeIp; - prefixLength = networkCfg.bridgePrefixLength; - } - ]; - networking.defaultGateway = { - address = networkCfg.bridgeIp; - interface = "eth0"; - }; + # Bridge-attached via privateNetwork=true + hostBridge. The + # gateway's dnsmasq now serves a DHCP pool for service containers + # (see dhcp-range in hive-gateway.nix). hive-ci gets its address + # from that pool; no static address needed. + networking.interfaces.eth0.useDHCP = true; # nspawn containers can't create user-namespaces, so nix # sandboxing always fails. Fall back to unsandboxed builds. diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index f459d9f4..2e22cf02 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -11,6 +11,37 @@ let forgeCfg = config.services.hyperhive.forge; networkCfg = config.services.hyperhive.network; + # DHCP pool for bridge-attached service containers (hive-ci, etc.). + # Occupies the last dhcpPoolSize usable addresses of the subnet + # (e.g. .241-.254 on a /24). Agent containers use deterministic static + # IPs (lifecycle::agent_network_ip) and are excluded from this range + # by a remap in the Rust code. Must stay in sync with DHCP_POOL_SIZE + # in hive-c0re/src/lifecycle/mod.rs. + dhcpPoolSize = 14; + # IPv4 helpers — nix integers are 64-bit so all /0-/32 values are safe. + ipToInt = + ip: + builtins.foldl' (acc: x: acc * 256 + x) 0 ( + map lib.strings.toIntBase10 (lib.strings.splitString "." ip) + ); + intToIp = + n: + let + a = n / 16777216; + b = (n - a * 16777216) / 65536; + c = (n - a * 16777216 - b * 65536) / 256; + d = n - a * 16777216 - b * 65536 - c * 256; + in + "${toString a}.${toString b}.${toString c}.${toString d}"; + # 2^n via recursion (nix has no pow builtin). + pow2 = n: if n == 0 then 1 else 2 * (pow2 (n - 1)); + hostCount = pow2 (32 - networkCfg.bridgePrefixLength); + # Mask off host bits to get the network base address. + networkBase = builtins.bitAnd (ipToInt networkCfg.bridgeIp) (4294967295 - hostCount + 1); + # DHCP range: last dhcpPoolSize usable host addresses (broadcast - 1 down). + dhcpEnd = intToIp (networkBase + hostCount - 2); # last usable = broadcast - 1 + dhcpStart = intToIp (networkBase + hostCount - 1 - dhcpPoolSize); # dhcpEnd - poolSize + 1 + # Dashboard SPA dist, static-served by nginx below. Read in OUTER scope so # `config` is the host's (inside the container block it'd be the container's). dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; @@ -999,6 +1030,12 @@ in ++ lib.optional ( matrixCfg.enable && matrixCfg.gatewayHost != null ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}"; + # DHCP pool for bridge-attached service containers (hive-ci, etc.). + # Range is computed from the bridgeIp/bridgePrefixLength at eval + # time; the last dhcpPoolSize usable host addresses are reserved. + # Agent containers are excluded by agent_network_ip's DHCP remap. + dhcp-range = "${dhcpStart},${dhcpEnd},1h"; + dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases"; }; }; }; From 3068034463f4dcccb2c9b767a7136c50190d3e61 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 13 Jul 2026 10:56:00 +0200 Subject: [PATCH 2/6] refactor(#2363): single source of truth for DHCP pool size Move the DHCP pool size constant out of the two separate definitions (Nix literal + Rust const) into a shared data file: nix/dhcp-pool-size. - nix/dhcp-pool-size: new file, contains '14' - hive-gateway.nix: reads via builtins.readFile + toIntBase10 - lifecycle/mod.rs: parses via include_bytes! const block at compile time Cargo automatically tracks include_bytes! as a file dependency so a change to nix/dhcp-pool-size triggers recompilation without build.rs. --- hive-c0re/src/lifecycle/mod.rs | 21 ++++++++++++++++++--- nix/dhcp-pool-size | 1 + nix/modules/hive-gateway.nix | 8 +++++--- 3 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 nix/dhcp-pool-size diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 45f4d641..879c4021 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -92,9 +92,24 @@ pub fn agent_web_port(name: &str) -> u16 { /// Number of IP addresses at the top of each subnet reserved for the DHCP /// pool (bridge-attached service containers such as hive-ci). Agents are -/// excluded from this range by the remap in `agent_network_ip`. Must stay -/// in sync with `dhcpPoolSize` in `nix/modules/hive-gateway.nix`. -const DHCP_POOL_SIZE: u32 = 14; +/// excluded from this range by the remap in `agent_network_ip`. +/// +/// Single source of truth: `nix/dhcp-pool-size` (one integer, shared with +/// `nix/modules/hive-gateway.nix` which reads the same file via +/// `builtins.readFile`). Parsed at compile time via `include_bytes!`. +const DHCP_POOL_SIZE: u32 = { + let bytes = include_bytes!("../../../nix/dhcp-pool-size"); + let mut n: u32 = 0; + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b'0' <= b && b <= b'9' { + n = n * 10 + (b - b'0') as u32; + } + i += 1; + } + n +}; /// Deterministic IPv4 address for an agent inside an isolated subnet. /// diff --git a/nix/dhcp-pool-size b/nix/dhcp-pool-size new file mode 100644 index 00000000..8351c193 --- /dev/null +++ b/nix/dhcp-pool-size @@ -0,0 +1 @@ +14 diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 2e22cf02..dab02e3d 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -15,9 +15,11 @@ let # Occupies the last dhcpPoolSize usable addresses of the subnet # (e.g. .241-.254 on a /24). Agent containers use deterministic static # IPs (lifecycle::agent_network_ip) and are excluded from this range - # by a remap in the Rust code. Must stay in sync with DHCP_POOL_SIZE - # in hive-c0re/src/lifecycle/mod.rs. - dhcpPoolSize = 14; + # by a remap in the Rust code. + # + # Single source of truth: `nix/dhcp-pool-size` (one integer). + # Rust reads it at compile time via `include_bytes!` in lifecycle/mod.rs. + dhcpPoolSize = lib.strings.toIntBase10 (lib.strings.trim (builtins.readFile ../dhcp-pool-size)); # IPv4 helpers — nix integers are 64-bit so all /0-/32 values are safe. ipToInt = ip: From 9396918ffbee20c06ddf20c1c9bd5fd0d74ca27a Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 13 Jul 2026 11:01:16 +0200 Subject: [PATCH 3/6] simplify(#2363): drop remap, shrink modulus to exclude DHCP pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No need to preserve agent IPs across this deploy — nothing outside a container depends on a specific agent IP. Simpler approach: subtract DHCP_POOL_SIZE from the usable count before hashing so agents only ever land in [2, usable - DHCP_POOL_SIZE + 1], never in the DHCP pool. Removes the secondary-hash remap block (~10 lines). Returns None for subnets too small to hold both agent slots and the pool (edge case; practical subnets are /24). --- hive-c0re/src/lifecycle/mod.rs | 46 +++++++++++++------------------- hive-c0re/src/lifecycle/tests.rs | 4 +-- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 879c4021..6bf57444 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -92,7 +92,8 @@ pub fn agent_web_port(name: &str) -> u16 { /// Number of IP addresses at the top of each subnet reserved for the DHCP /// pool (bridge-attached service containers such as hive-ci). Agents are -/// excluded from this range by the remap in `agent_network_ip`. +/// excluded from this range by subtracting it from the usable count before +/// hashing, so agents only ever land in [2, usable - DHCP_POOL_SIZE + 1]. /// /// Single source of truth: `nix/dhcp-pool-size` (one integer, shared with /// `nix/modules/hive-gateway.nix` which reads the same file via @@ -117,21 +118,23 @@ const DHCP_POOL_SIZE: u32 = { /// `"10.42.0.0/24"`), then computes: /// /// ```text -/// host_count = 2^(32 - prefix_len) -/// usable = host_count - 3 // skip .0 (network), .1 (gateway), last (broadcast) -/// offset = FNV-1a(name) % usable + 2 // .2 is the first agent slot -/// agent_ip = network_base_u32 + offset +/// host_count = 2^(32 - prefix_len) +/// usable = host_count - 3 // skip .0 (network), .1 (gateway), last (broadcast) +/// agent_slots = usable - DHCP_POOL_SIZE // top of range reserved for bridge DHCP pool +/// offset = FNV-1a(name) % agent_slots + 2 // .2 is the first agent slot +/// agent_ip = network_base_u32 + offset /// ``` /// /// The last `DHCP_POOL_SIZE` usable host addresses are reserved for the -/// bridge DHCP pool (service containers). When the primary hash falls in -/// that range the offset is remapped into the agent-only window `[2, -/// dhcp_start - 1]` so no agent is ever assigned a DHCP-pool address. -/// Only the rare agent whose name hashes into the pool is affected; all -/// other agents keep their original IPs. +/// bridge DHCP pool (service containers such as hive-ci) and are never +/// assigned to agents. Agent IPs are stable as long as the agent name and +/// subnet don't change; a change to either (or to `DHCP_POOL_SIZE`) will +/// re-address agents, which is fine because nothing outside the container +/// depends on a specific agent IP — they reconnect on next rebuild. /// /// Returns `None` when `subnet_cidr` can't be parsed (invalid format, -/// prefix out of range, etc.) so callers can fall back gracefully. +/// prefix out of range, subnet too small for both agents and DHCP pool, +/// 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] @@ -163,24 +166,13 @@ pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option { 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 { + // Reserve the last DHCP_POOL_SIZE usable addresses for the bridge DHCP + // pool. Agents only hash into the remaining agent-only window. + let agent_slots = usable.saturating_sub(DHCP_POOL_SIZE); + if agent_slots == 0 { return None; } - let mut offset = fnv1a(name) % usable + 2; // +2: skip .0 and .1 - - // Remap agents whose primary hash falls in the DHCP pool (the last - // DHCP_POOL_SIZE usable addresses). Only has an effect when the - // subnet is large enough to hold both agent slots AND a pool. - if usable > DHCP_POOL_SIZE { - // First offset that belongs to the DHCP pool. - let dhcp_start = usable + 2 - DHCP_POOL_SIZE; - if offset >= dhcp_start { - // Secondary hash into the agent-only window [2, dhcp_start - 1]. - // Result is guaranteed < dhcp_start (no overlap with pool). - let agent_only = dhcp_start - 2; - offset = fnv1a(name) % agent_only + 2; - } - } + let offset = fnv1a(name) % agent_slots + 2; // +2: skip .0 and .1 let ip_u32 = network_base + offset; let [a, b, c, d] = ip_u32.to_be_bytes(); diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs index 1da16580..b9b380f0 100644 --- a/hive-c0re/src/lifecycle/tests.rs +++ b/hive-c0re/src/lifecycle/tests.rs @@ -40,10 +40,10 @@ async fn setup_proposed_seeds_flake_nix() { #[test] fn agent_network_ip_is_in_subnet() { // Default subnet 10.42.0.0/24 — agents get .2 to .240 (last 14 are DHCP pool). + // agent_slots = usable(253) - DHCP_POOL_SIZE(14) = 239 → offsets in [2, 240]. 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"); - // usable=253, dhcp_start=241 → agent range is .2-.240 assert!( octets[3] >= 2 && octets[3] <= 240, "host byte {} — expected in agent-only range [2,240]", @@ -55,6 +55,7 @@ fn agent_network_ip_is_in_subnet() { fn agent_network_ip_never_in_dhcp_pool() { // No agent should be assigned an address in the DHCP pool // (.241-.254 on a /24 with DHCP_POOL_SIZE=14). + // agent_slots = usable(253) - DHCP_POOL_SIZE(14) = 239 → offsets in [2, 240]. let subnet = "10.42.0.0/24"; let names = [ "alice", @@ -79,7 +80,6 @@ fn agent_network_ip_never_in_dhcp_pool() { for name in names { let ip = agent_network_ip(name, subnet).unwrap_or_else(|| panic!("{name} returned None")); let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); - // dhcp_start_offset = usable(253) + 2 - DHCP_POOL_SIZE(14) = 241 assert!( last < 241, "{name} got .{last} — inside the DHCP pool [.241-.254]" From eb94b2aae6369a9024e6216715d289f86cff2c35 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 13 Jul 2026 11:05:58 +0200 Subject: [PATCH 4/6] docs(#2363): fix stale 'remap' comments in hive-gateway.nix The DHCP pool exclusion is now by construction (agent_slots = usable - dhcpPoolSize), not a secondary-hash remap. Update two comment blocks that still referenced the old approach. --- nix/modules/hive-gateway.nix | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index dab02e3d..4793d5ae 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -13,9 +13,11 @@ let # DHCP pool for bridge-attached service containers (hive-ci, etc.). # Occupies the last dhcpPoolSize usable addresses of the subnet - # (e.g. .241-.254 on a /24). Agent containers use deterministic static + # (e.g. .241-.254 on a /24). Agent containers use hash-derived static # IPs (lifecycle::agent_network_ip) and are excluded from this range - # by a remap in the Rust code. + # by subtracting dhcpPoolSize from the usable count before hashing + # (agent_slots = usable - dhcpPoolSize), so agents only ever land in + # [.2, .(usable-dhcpPoolSize+1)] by construction. # # Single source of truth: `nix/dhcp-pool-size` (one integer). # Rust reads it at compile time via `include_bytes!` in lifecycle/mod.rs. @@ -1035,7 +1037,8 @@ in # DHCP pool for bridge-attached service containers (hive-ci, etc.). # Range is computed from the bridgeIp/bridgePrefixLength at eval # time; the last dhcpPoolSize usable host addresses are reserved. - # Agent containers are excluded by agent_network_ip's DHCP remap. + # Agent containers hash into agent_slots = usable - dhcpPoolSize + # so they never land here (excluded by construction, not remapping). dhcp-range = "${dhcpStart},${dhcpEnd},1h"; dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases"; }; From 4cdbbafc4403c496732cc6b5ae77b728aee45c0b Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 13 Jul 2026 11:18:42 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat(#2363):=20full-DHCP=20for=20all=20agen?= =?UTF-8?q?ts=20=E2=80=94=20drop=20static=20agent=5Fnetwork=5Fip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All agent containers now receive their bridge IP dynamically via DHCP from the dnsmasq pool instead of a hash-derived static address: - nix/templates/harness-base.nix: networking.useDHCP = true - nix/modules/hive-gateway.nix: expand DHCP pool to full usable range (.2 to .254 on /24) — was last-14-IPs-only - hive-sh4re/src/priv_proto.rs: remove agent_ip from NetworkIsolation - hive-c0re/src/lifecycle/mod.rs: drop agent_network_ip + DHCP_POOL_SIZE - hive-c0re/src/lifecycle/host_config.rs: remove agent_network_ip call - hive-priv/src/main.rs: LOCAL_ADDRESS= empty (DHCP assigns IP); HOST_ADDRESS still set so nixos-container installs default route before the DHCP lease arrives - nix/dhcp-pool-size: deleted (no longer needed) The nix/dhcp-pool-size single-source-of-truth file and all associated Rust/Nix dual-constant plumbing are gone — there is no static map. bridge_gateway_ip() is retained (still needed for HOST_ADDRESS). Closes #2363 --- hive-c0re/src/lifecycle/host_config.rs | 27 ++----- hive-c0re/src/lifecycle/mod.rs | 97 +------------------------ hive-c0re/src/lifecycle/tests.rs | 99 -------------------------- hive-priv/src/main.rs | 6 +- hive-sh4re/src/priv_proto.rs | 18 ++--- nix/dhcp-pool-size | 1 - nix/modules/hive-gateway.nix | 29 ++++---- nix/templates/harness-base.nix | 4 ++ 8 files changed, 37 insertions(+), 244 deletions(-) delete mode 100644 nix/dhcp-pool-size diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 73210e53..1610ce3b 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -10,7 +10,7 @@ use hive_sh4re::priv_proto::{BindMount, CredentialMount}; use crate::coordinator::{AgentPaths, HiveEnv}; use super::{ - AGENT_PREFIX, CONTAINER_RUNTIME_MOUNT, CONTAINER_SHARED_MOUNT, agent_network_ip, agent_uid_gid, + AGENT_PREFIX, CONTAINER_RUNTIME_MOUNT, CONTAINER_SHARED_MOUNT, agent_uid_gid, bridge_gateway_ip, container_claude_mount, container_name, validate, }; @@ -294,21 +294,6 @@ async fn set_nspawn_flags( 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, - &load_creds, - ) - .await; - }; let Some(gateway_ip) = bridge_gateway_ip(&subnet) else { tracing::warn!( %agent_name, %subnet, @@ -325,14 +310,10 @@ async fn set_nspawn_flags( .await; }; tracing::info!( - %agent_name, %agent_ip, %gateway_ip, %bridge, - "network isolation: PRIVATE_NETWORK=1" + %agent_name, %gateway_ip, %bridge, + "network isolation: PRIVATE_NETWORK=1 (DHCP)" ); - Some(hive_sh4re::priv_proto::NetworkIsolation { - agent_ip, - bridge, - gateway_ip, - }) + Some(hive_sh4re::priv_proto::NetworkIsolation { bridge, gateway_ip }) } else { None } diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 6bf57444..f9c59695 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -66,8 +66,7 @@ pub const CONTAINER_SHARED_MOUNT: &str = "/shared"; const WEB_PORT_BASE: u16 = 8100; const WEB_PORT_RANGE: u16 = 900; -/// FNV-1a hash of a string — shared by `agent_web_port` and -/// `agent_network_ip` so the derivation rule is identical. +/// FNV-1a hash of a string — used by `agent_web_port`. fn fnv1a(s: &str) -> u32 { let mut hash: u32 = 2_166_136_261; for b in s.bytes() { @@ -90,95 +89,6 @@ pub fn agent_web_port(name: &str) -> u16 { WEB_PORT_BASE + u16::try_from(fnv1a(name) % u32::from(WEB_PORT_RANGE)).unwrap_or(0) } -/// Number of IP addresses at the top of each subnet reserved for the DHCP -/// pool (bridge-attached service containers such as hive-ci). Agents are -/// excluded from this range by subtracting it from the usable count before -/// hashing, so agents only ever land in [2, usable - DHCP_POOL_SIZE + 1]. -/// -/// Single source of truth: `nix/dhcp-pool-size` (one integer, shared with -/// `nix/modules/hive-gateway.nix` which reads the same file via -/// `builtins.readFile`). Parsed at compile time via `include_bytes!`. -const DHCP_POOL_SIZE: u32 = { - let bytes = include_bytes!("../../../nix/dhcp-pool-size"); - let mut n: u32 = 0; - let mut i = 0; - while i < bytes.len() { - let b = bytes[i]; - if b'0' <= b && b <= b'9' { - n = n * 10 + (b - b'0') as u32; - } - i += 1; - } - n -}; - -/// 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), last (broadcast) -/// agent_slots = usable - DHCP_POOL_SIZE // top of range reserved for bridge DHCP pool -/// offset = FNV-1a(name) % agent_slots + 2 // .2 is the first agent slot -/// agent_ip = network_base_u32 + offset -/// ``` -/// -/// The last `DHCP_POOL_SIZE` usable host addresses are reserved for the -/// bridge DHCP pool (service containers such as hive-ci) and are never -/// assigned to agents. Agent IPs are stable as long as the agent name and -/// subnet don't change; a change to either (or to `DHCP_POOL_SIZE`) will -/// re-address agents, which is fine because nothing outside the container -/// depends on a specific agent IP — they reconnect on next rebuild. -/// -/// Returns `None` when `subnet_cidr` can't be parsed (invalid format, -/// prefix out of range, subnet too small for both agents and DHCP pool, -/// 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. - // /0 (the other extreme) is handled further down: host_count - // overflows checked_shl(32) → 0 → usable = 0 → None. - 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); - // Reserve the last DHCP_POOL_SIZE usable addresses for the bridge DHCP - // pool. Agents only hash into the remaining agent-only window. - let agent_slots = usable.saturating_sub(DHCP_POOL_SIZE); - if agent_slots == 0 { - return None; - } - let offset = fnv1a(name) % agent_slots + 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}")) -} - /// Extract the bridge gateway IP from `HIVE_NETWORK_SUBNET`. /// /// `HIVE_NETWORK_SUBNET` carries the host-side bridge address verbatim @@ -198,9 +108,8 @@ pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option { pub fn bridge_gateway_ip(subnet_cidr: &str) -> Option { let (ip_str, prefix_str) = subnet_cidr.split_once('/')?; // Validate the prefix is a sane IPv4 CIDR length and the address is - // dotted-decimal IPv4 — same shape `agent_network_ip` accepts — so a - // malformed `HIVE_NETWORK_SUBNET` can't smuggle a bogus HOST_ADDRESS - // into the nspawn conf. + // dotted-decimal IPv4 so a malformed `HIVE_NETWORK_SUBNET` can't + // smuggle a bogus HOST_ADDRESS into the nspawn conf. let prefix_len: u32 = prefix_str.parse().ok()?; if prefix_len > 32 { return None; diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs index b9b380f0..761ae999 100644 --- a/hive-c0re/src/lifecycle/tests.rs +++ b/hive-c0re/src/lifecycle/tests.rs @@ -37,80 +37,6 @@ async fn setup_proposed_seeds_flake_nix() { 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 .240 (last 14 are DHCP pool). - // agent_slots = usable(253) - DHCP_POOL_SIZE(14) = 239 → offsets in [2, 240]. - 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] <= 240, - "host byte {} — expected in agent-only range [2,240]", - octets[3] - ); -} - -#[test] -fn agent_network_ip_never_in_dhcp_pool() { - // No agent should be assigned an address in the DHCP pool - // (.241-.254 on a /24 with DHCP_POOL_SIZE=14). - // agent_slots = usable(253) - DHCP_POOL_SIZE(14) = 239 → offsets in [2, 240]. - let subnet = "10.42.0.0/24"; - let names = [ - "alice", - "bob", - "carol", - "damocles", - "iris", - "argus", - "atlas", - "ruth", - "dmatrix", - "bitburner", - "lexis", - "sock", - "triage", - "janet", - "eve", - "frank", - "grace", - "heidi", - ]; - for name in names { - let ip = agent_network_ip(name, subnet).unwrap_or_else(|| panic!("{name} returned None")); - let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); - assert!( - last < 241, - "{name} got .{last} — inside the DHCP pool [.241-.254]" - ); - } -} - -#[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 bridge_gateway_ip_extracts_verbatim_address() { // HIVE_NETWORK_SUBNET carries the bridge IP verbatim, not the @@ -139,31 +65,6 @@ fn bridge_gateway_ip_rejects_bad_input() { assert!(bridge_gateway_ip("10.42.0/24").is_none()); // 3 octets } -#[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 -} - -#[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 be in .2-.240 (DHCP pool .241-.254 is excluded). - let ip = from_bridge.unwrap(); - let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap(); - assert!((2..=240).contains(&last), "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] diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 2977f8e9..991ee147 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -1487,7 +1487,11 @@ fn write_nspawn_flags( // setup is skipped, so this only affects the container's route — // exactly what we want. let _ = writeln!(out, "HOST_ADDRESS={}", iso.gateway_ip); - let _ = writeln!(out, "LOCAL_ADDRESS={}", iso.agent_ip); + // LOCAL_ADDRESS is intentionally empty: agent containers receive their + // IP dynamically via DHCP from the bridge dnsmasq pool. HOST_ADDRESS + // (the gateway IP) is still written so nixos-container's container-side + // init installs a default route before the DHCP lease arrives. + out.push_str("LOCAL_ADDRESS=\n"); out.push_str("HOST_ADDRESS6=\n"); out.push_str("LOCAL_ADDRESS6=\n"); let _ = writeln!(out, "HOST_BRIDGE={}", iso.bridge); diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index bb4361e4..50101a45 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -208,21 +208,21 @@ pub struct CredentialMount { /// Network isolation parameters for `WriteNspawnFlags`. When `Some`, /// hive-priv writes `PRIVATE_NETWORK=1` + veth bridge wiring instead -/// of the default `PRIVATE_NETWORK=0`. +/// of the default `PRIVATE_NETWORK=0`. Containers receive their IP +/// dynamically via DHCP from the bridge dnsmasq pool (`networking.useDHCP` +/// in `harness-base.nix`); no static address is pre-assigned here. #[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, /// Bridge gateway IP (the host-side bridge address, e.g. `10.42.0.1`). /// Written as `HOST_ADDRESS=` in the nspawn conf so nixos-container's - /// container-side setup installs a default route (`default via `): - /// without it the container comes up with an address but no route off - /// the bridge subnet — no internet, no `api.anthropic.com`. The same IP - /// runs the hive dnsmasq resolver, so it's also written into the - /// container's `/etc/resolv.conf` (see the isolated-DNS oneshot in - /// `harness-base.nix`, gated on the marker hive-priv drops). + /// container-side setup installs a default route (`default via `) + /// before DHCP completes: without it the container has no route off + /// the bridge subnet until the DHCP lease arrives. The same IP runs the + /// hive dnsmasq resolver, so it's also written into the container's + /// `/etc/resolv.conf` (see the isolated-DNS oneshot in `harness-base.nix`, + /// gated on the marker hive-priv drops). pub gateway_ip: String, } diff --git a/nix/dhcp-pool-size b/nix/dhcp-pool-size deleted file mode 100644 index 8351c193..00000000 --- a/nix/dhcp-pool-size +++ /dev/null @@ -1 +0,0 @@ -14 diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index 4793d5ae..b3f13f13 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -11,17 +11,12 @@ let forgeCfg = config.services.hyperhive.forge; networkCfg = config.services.hyperhive.network; - # DHCP pool for bridge-attached service containers (hive-ci, etc.). - # Occupies the last dhcpPoolSize usable addresses of the subnet - # (e.g. .241-.254 on a /24). Agent containers use hash-derived static - # IPs (lifecycle::agent_network_ip) and are excluded from this range - # by subtracting dhcpPoolSize from the usable count before hashing - # (agent_slots = usable - dhcpPoolSize), so agents only ever land in - # [.2, .(usable-dhcpPoolSize+1)] by construction. + # DHCP pool covering all usable host addresses on the bridge subnet. + # All containers (agents and service containers such as hive-ci) receive + # their IPs dynamically; there are no hash-derived static assignments. + # Range: .2 to .(hostCount-2) — skipping .0 (network), .1 (gateway/host + # bridge), and the broadcast address. # - # Single source of truth: `nix/dhcp-pool-size` (one integer). - # Rust reads it at compile time via `include_bytes!` in lifecycle/mod.rs. - dhcpPoolSize = lib.strings.toIntBase10 (lib.strings.trim (builtins.readFile ../dhcp-pool-size)); # IPv4 helpers — nix integers are 64-bit so all /0-/32 values are safe. ipToInt = ip: @@ -42,9 +37,9 @@ let hostCount = pow2 (32 - networkCfg.bridgePrefixLength); # Mask off host bits to get the network base address. networkBase = builtins.bitAnd (ipToInt networkCfg.bridgeIp) (4294967295 - hostCount + 1); - # DHCP range: last dhcpPoolSize usable host addresses (broadcast - 1 down). + # DHCP range: .2 (first usable after gateway) to .(hostCount-2) (last usable). + dhcpStart = intToIp (networkBase + 2); # skip .0 (network) and .1 (gateway) dhcpEnd = intToIp (networkBase + hostCount - 2); # last usable = broadcast - 1 - dhcpStart = intToIp (networkBase + hostCount - 1 - dhcpPoolSize); # dhcpEnd - poolSize + 1 # Dashboard SPA dist, static-served by nginx below. Read in OUTER scope so # `config` is the host's (inside the container block it'd be the container's). @@ -1034,11 +1029,11 @@ in ++ lib.optional ( matrixCfg.enable && matrixCfg.gatewayHost != null ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}"; - # DHCP pool for bridge-attached service containers (hive-ci, etc.). - # Range is computed from the bridgeIp/bridgePrefixLength at eval - # time; the last dhcpPoolSize usable host addresses are reserved. - # Agent containers hash into agent_slots = usable - dhcpPoolSize - # so they never land here (excluded by construction, not remapping). + # DHCP pool covering all usable host addresses on the bridge subnet. + # Range is computed from bridgeIp/bridgePrefixLength at eval time: + # .2 (first after gateway) to .(hostCount-2) (last usable before + # broadcast). All containers — agents and service containers alike — + # receive their IPs dynamically from this pool. dhcp-range = "${dhcpStart},${dhcpEnd},1h"; dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases"; }; diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 84bc6045..10d4d242 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -1695,6 +1695,10 @@ in # disabling dhcpcd itself, so the veth still gets its address); then # the hyperhive-isolated-dns oneshot owns resolv.conf. (Same "take # resolvconf out of the loop" approach the matrix container uses.) + # All agent containers receive their bridge IP via DHCP from the hive + # dnsmasq pool (see hive-gateway.nix). useDHCP runs dhcpcd on every + # interface (just eth0 in practice — the nspawn bridge veth). + networking.useDHCP = true; networking.resolvconf.enable = false; networking.dhcpcd.extraConfig = "nohook resolv.conf"; From 2f8c1ec347badff5d7a93ff609d10c660db47e8c Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 13 Jul 2026 11:52:40 +0200 Subject: [PATCH 6/6] docs(#2363): update network.md + hive-ci.nix for full-DHCP model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/network.md: LOCAL_ADDRESS is now empty (not deterministic-IP); containers use dhcpcd + dnsmasq DHCP pool, not hash-derived static IPs - nix/modules/hive-ci.nix: 'service containers' → 'all containers' in the dnsmasq DHCP pool comment (agents also use the same pool) --- docs/network.md | 12 ++++++------ nix/modules/hive-ci.nix | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/network.md b/docs/network.md index e87e5802..7a14be0d 100644 --- a/docs/network.md +++ b/docs/network.md @@ -121,12 +121,12 @@ address arithmetic. ### What the Rust side does `hive-c0re` reads `HIVE_NETWORK_ISOLATION` and passes -`PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=`, -`HOST_ADDRESS=`, and `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 — no special case. +`PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=` (empty), `HOST_ADDRESS=`, +and `HOST_BRIDGE=` via `lifecycle::set_nspawn_flags` when +creating or updating containers. `LOCAL_ADDRESS` is left empty so the +container's dhcpcd acquires an address from the bridge dnsmasq pool +(`networking.useDHCP = true` in `harness-base.nix`). This applies uniformly +to all containers — agents and service containers alike. `HOST_ADDRESS` is the bridge gateway IP (the address part of `HIVE_NETWORK_SUBNET`, via `lifecycle::bridge_gateway_ip` — taken verbatim diff --git a/nix/modules/hive-ci.nix b/nix/modules/hive-ci.nix index f9805c8a..15251720 100644 --- a/nix/modules/hive-ci.nix +++ b/nix/modules/hive-ci.nix @@ -410,9 +410,9 @@ in # affect traffic destined for the bridge IP itself. networking.nameservers = [ networkCfg.bridgeIp ]; # Bridge-attached via privateNetwork=true + hostBridge. The - # gateway's dnsmasq now serves a DHCP pool for service containers - # (see dhcp-range in hive-gateway.nix). hive-ci gets its address - # from that pool; no static address needed. + # gateway's dnsmasq serves a DHCP pool covering all usable bridge + # addresses (see dhcp-range in hive-gateway.nix) — agents and + # service containers alike receive IPs dynamically. networking.interfaces.eth0.useDHCP = true; # nspawn containers can't create user-namespaces, so nix