fix(network): wire default route + bridge DNS for isolated containers

When isolateContainers=true, claude (and all egress) broke in every
container: agents came up with an IP but no way off the bridge subnet.

Two container-side gaps, both confirmed against nixpkgs
nixos-containers.nix:

1. No default route. hive-priv wrote HOST_ADDRESS= empty in the nspawn
   conf. nixos-container's container-side setup only installs
   `ip route add default via $HOST_ADDRESS` when HOST_ADDRESS is
   non-empty, so the container had an address but no gateway -> nothing
   off-subnet (incl. api.anthropic.com) was reachable. Fix: write
   HOST_ADDRESS=<bridge-ip>. In bridge mode the host-side address/route
   setup is skipped, so this only affects the container's default route.

2. No usable resolver. nixos-container copies the host's /etc/resolv.conf
   into the container at every start; the host resolver (e.g. 127.0.0.53)
   is unreachable from a private netns and isn't authoritative for the
   hive's own zones. Fix: hive-priv drops a marker carrying the gateway
   IP only when isolated, and a new harness-base oneshot
   (hyperhive-isolated-dns) rewrites resolv.conf to point at the bridge
   dnsmasq. Inert in shared-netns mode (no marker), so the shared
   container toplevel does the right thing in both modes.

The gateway IP is the address part of HIVE_NETWORK_SUBNET (the bridge IP
verbatim, honouring a non-.1 operator override), via a new validated
bridge_gateway_ip() helper with unit tests.

Unblocks defaulting isolation on.
This commit is contained in:
atlas 2026-06-10 20:52:12 +02:00 committed by mara
commit d993ad2c47
4 changed files with 177 additions and 4 deletions

View file

@ -130,6 +130,42 @@ pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option<String> {
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
/// (e.g. `10.42.0.1/24`), **not** the canonical network address — see
/// the note in `set_nspawn_flags` + `docs/network.md`. The IP part is
/// therefore the bridge IP itself: the host end of the bridge, the
/// default-route target for isolated containers, and the address the
/// hive dnsmasq resolver binds. Returns the dotted-decimal IP with the
/// `/<prefix>` stripped, or `None` if the input isn't a valid
/// `<ipv4>/<prefix>` pair.
///
/// Deliberately returns the operator-configured address verbatim rather
/// than deriving `network + 1`: an operator who sets `bridgeIp` to a
/// non-`.1` host address (e.g. `10.42.0.254`) runs the bridge + resolver
/// there, so that — not `.1` — is the real gateway.
#[must_use]
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.
let prefix_len: u32 = prefix_str.parse().ok()?;
if prefix_len > 32 {
return None;
}
let octets: Vec<u8> = ip_str
.split('.')
.map(|o| o.parse::<u8>().ok())
.collect::<Option<Vec<_>>>()?;
if octets.len() != 4 {
return None;
}
Some(ip_str.to_owned())
}
#[must_use]
pub fn container_name(name: &str) -> String {
format!("{AGENT_PREFIX}{name}")
@ -1309,8 +1345,24 @@ async fn set_nspawn_flags(
);
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 })
let Some(gateway_ip) = bridge_gateway_ip(&subnet) else {
tracing::warn!(
%agent_name, %subnet,
"HIVE_NETWORK_SUBNET is set but the bridge gateway IP is unparseable; \
skipping PRIVATE_NETWORK write to avoid an isolated container with no \
default route or resolver"
);
return crate::priv_client::write_nspawn_flags(container, &binds, None).await;
};
tracing::info!(
%agent_name, %agent_ip, %gateway_ip, %bridge,
"network isolation: PRIVATE_NETWORK=1"
);
Some(hive_sh4re::priv_proto::NetworkIsolation {
agent_ip,
bridge,
gateway_ip,
})
} else {
None
}
@ -1546,6 +1598,34 @@ mod tests {
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
// canonical network — the gateway is the address before the `/`.
assert_eq!(
bridge_gateway_ip("10.42.0.1/24").as_deref(),
Some("10.42.0.1")
);
// Non-`.1` operator override: the gateway is wherever the bridge is.
assert_eq!(
bridge_gateway_ip("10.42.0.254/24").as_deref(),
Some("10.42.0.254")
);
assert_eq!(
bridge_gateway_ip("172.30.0.1/16").as_deref(),
Some("172.30.0.1")
);
}
#[test]
fn bridge_gateway_ip_rejects_bad_input() {
assert!(bridge_gateway_ip("notanip/24").is_none());
assert!(bridge_gateway_ip("10.42.0.1").is_none()); // no prefix
assert!(bridge_gateway_ip("10.42.0.1/33").is_none()); // prefix > 32
assert!(bridge_gateway_ip("10.42.0.999/24").is_none()); // octet > 255
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());