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:
parent
8db8bd610f
commit
d993ad2c47
4 changed files with 177 additions and 4 deletions
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue