diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 011e363b..9185ea55 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -130,6 +130,42 @@ pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option { 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 +/// `/` stripped, or `None` if the input isn't a valid +/// `/` 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 { + 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 = ip_str + .split('.') + .map(|o| o.parse::().ok()) + .collect::>>()?; + 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()); diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index edf8c6a6..1fa655f6 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -857,7 +857,15 @@ fn write_nspawn_flags( } if let Some(iso) = isolation { out.push_str("PRIVATE_NETWORK=1\n"); - out.push_str("HOST_ADDRESS=\n"); + // HOST_ADDRESS = the bridge gateway IP. nixos-container's + // container-side setup only installs a default route + // (`ip route add default via $HOST_ADDRESS`) when HOST_ADDRESS is + // non-empty; leaving it blank gave the container an address but no + // route off the bridge subnet (no internet, no api.anthropic.com). + // In bridge mode (HOST_BRIDGE set) the host-side address/route + // 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); out.push_str("HOST_ADDRESS6=\n"); out.push_str("LOCAL_ADDRESS6=\n"); @@ -879,5 +887,41 @@ fn write_nspawn_flags( .collect(); let flags_joined = flags.join(" "); let _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\""); - std::fs::write(&path, out).with_context(|| format!("write {path}")) + std::fs::write(&path, out).with_context(|| format!("write {path}"))?; + + // DNS marker for the in-container resolver oneshot. nixos-container + // copies the *host's* /etc/resolv.conf into the container at every + // start (its host resolver — e.g. 127.0.0.53 — is unreachable from a + // private netns, and isn't authoritative for the hive's own zones + // anyway). The `hyperhive-isolated-dns` oneshot in harness-base.nix + // rewrites resolv.conf to point at the bridge resolver, but only when + // this marker exists; it carries the gateway IP so the container + // doesn't have to re-derive it. Written on isolate, removed otherwise, + // so the same shared container toplevel behaves correctly in both modes. + write_bridge_dns_marker(container, isolation)?; + Ok(()) +} + +/// Path to the in-container DNS marker (the container's own `/etc`). +fn bridge_dns_marker_path(container: &str) -> String { + format!("/var/lib/nixos-containers/{container}/etc/hyperhive-bridge-dns") +} + +/// Write (isolated) or remove (host-netns) the bridge-DNS marker the +/// `hyperhive-isolated-dns` oneshot keys off. The marker file contains +/// just the gateway IP. Best-effort on removal (absence is the goal). +fn write_bridge_dns_marker(container: &str, isolation: Option<&NetworkIsolation>) -> Result<()> { + let path = bridge_dns_marker_path(container); + match isolation { + Some(iso) => { + std::fs::write(&path, format!("{}\n", iso.gateway_ip)) + .with_context(|| format!("write bridge-DNS marker {path}"))?; + } + None => match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("remove bridge-DNS marker {path}")), + }, + } + Ok(()) } diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 1b3c5a14..e036d95e 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -105,6 +105,15 @@ pub struct NetworkIsolation { 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). + pub gateway_ip: String, } /// A request to the privileged helper. diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 6f563dd9..18278a49 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -1025,6 +1025,46 @@ in # One-shot: tea config.yml from the seeded forge token. Shape # contract (always exit 0, no set -e, skip-silently, re-runnable): # docs/conventions.md::Best-effort oneshot services. + # Point resolv.conf at the hive bridge resolver when the container is + # network-isolated. nixos-container copies the *host's* /etc/resolv.conf + # into the container at every start — but 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 (forge. etc.). The + # bridge dnsmasq (gateway IP) is. hive-priv drops the marker + # `/etc/hyperhive-bridge-dns` (containing the gateway IP) only when + # isolation is on, so this oneshot is inert in shared-netns mode — the + # same shared container toplevel does the right thing in both modes. + # Ordered before the first DNS consumer (tea-login) and the network + # targets so name resolution works for the very first turn. + systemd.services.hyperhive-isolated-dns = { + description = "point resolv.conf at the hive bridge resolver (isolated containers)"; + wantedBy = [ "multi-user.target" ]; + after = [ "local-fs.target" ]; + before = [ + "network-online.target" + "tea-login.service" + ]; + unitConfig.ConditionPathExists = "/etc/hyperhive-bridge-dns"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.coreutils ]; + script = '' + set -eu + gw=$(tr -d '[:space:]' < /etc/hyperhive-bridge-dns) + if [ -z "$gw" ]; then + echo "hyperhive-isolated-dns: empty marker; leaving resolv.conf as-is" + exit 0 + fi + # resolv.conf is a regular file copied from the host by + # nixos-container; replace it (rm first in case it's a symlink). + rm -f /etc/resolv.conf + printf 'nameserver %s\n' "$gw" > /etc/resolv.conf + echo "hyperhive-isolated-dns: resolv.conf -> nameserver $gw" + ''; + }; + systemd.services.tea-login = { description = "configure tea CLI from hive-forge token (best-effort)"; wantedBy = [ "multi-user.target" ];