feat(#2363): full-DHCP for all agents — drop static agent_network_ip

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
This commit is contained in:
atlas 2026-07-13 11:18:42 +02:00 committed by mara
commit 4cdbbafc44
8 changed files with 37 additions and 244 deletions

View file

@ -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 `<network_ip>/<prefix_len>` (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<String> {
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<u8> = ip_str
.split('.')
.map(|o| o.parse::<u8>().ok())
.collect::<Option<Vec<_>>>()?;
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<String> {
pub fn bridge_gateway_ip(subnet_cidr: &str) -> Option<String> {
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;