crates/gunnar-server/tests/pid1_edda_netns.rs
//! **Gunnar on edda's PID 1: the two behaviours that changed, proven on Gunnar's box
//! shapes** (lane L10, appliance-pid1 0.1.4). Each runs FOR REAL in a user + network
//! namespace of its own (`gunnar_server::sandbox::enter`, the build sandbox's door):
//! links made over netlink, the declaration handed to edda's
//! `net::bring_up_network` exactly as `boot::bring_up_network` hands it, the
//! kernel's routing table read back off `/proc/net/route`.
//!
//! (a) **On EEXIST `apply` REPLACES the default route with the plan's gateway**
//! (Gunnar's own `apply` kept whatever default was there). Only the FIRST entry of
//! `GUNNAR_NET` carries a gateway into `apply` (a static gateway on a later entry is
//! dropped, a later DHCP lease's router is never taken), so the route that can be
//! replaced is eth0's and the one it is replaced with is eth0's own. Shown on every
//! shape: the estate's front / main / twin (`eth0` public with its gateway, `eth1`
//! utility with none), the same with a utility gateway declared, a re-exec (the kernel
//! still holding everything), a stray default through the utility NIC, and the bench
//! KVM mock (`eth0:10.0.2.15/24:10.0.2.2`).
//!
//! (b) **The DHCP packet socket takes its interface index from `SIOCGIFINDEX`**
//! (this process's own namespace) instead of `/sys/class/net` (the namespace sysfs
//! was mounted in). Shown: outside a namespace the two agree for every interface;
//! inside one, the index is the link's own (created with a chosen index), and a
//! DISCOVER for `eth1` leaves on `eth1`'s wire from `0.0.0.0` while `eth0` already
//! holds the public address — the estate's D4 shape, through the packet socket.
use std::net::Ipv4Addr;
const CHILD: &str = "GUNNAR_PID1_NETNS_CHILD";
const PUBLIC: &str = "81.27.107.63/23";
const PUBLIC_GW: Ipv4Addr = Ipv4Addr::new(81, 27, 106, 1);
const UTILITY: &str = "10.13.8.78/22";
const UTILITY_GW: Ipv4Addr = Ipv4Addr::new(10, 13, 8, 1);
// ── the harness: one scenario, one fresh namespace ─────────────────────────────
/// Re-run this test binary's `test` in a fresh user + network namespace with
/// `CHILD=scenario`; the child prints `NETNS-OK <scenario>` when it got there.
fn in_a_namespace(test: &str, scenario: &str) {
if let Ok(s) = std::env::var(CHILD) {
if s == scenario {
run(scenario);
println!("NETNS-OK {scenario}");
}
return;
}
if let Err(e) = gunnar_server::sandbox::available() {
eprintln!("SKIPPED {test}: no unprivileged user + network namespace here ({e:?})");
return;
}
use std::os::unix::process::CommandExt as _;
let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
cmd.args([test, "--exact", "--nocapture", "--test-threads=1"])
.env(CHILD, scenario);
let enter = || {
// SAFETY: `pre_exec` runs in the forked, single-threaded child before `exec`,
// which is `enter`'s whole requirement.
unsafe { gunnar_server::sandbox::enter() }
.map_err(|e| std::io::Error::other(format!("{e:?}")))
};
// SAFETY: the hook only calls `sandbox::enter`, which allocates nothing and takes no lock.
unsafe {
cmd.pre_exec(enter);
}
let out = cmd.output().expect("re-running the test in a namespace");
let said = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
out.status.success() && said.contains(&format!("NETNS-OK {scenario}")),
"in the namespace ({scenario}):\n{said}"
);
for l in said
.lines()
.filter_map(|l| l.find("NETNS-").map(|i| &l[i..]))
{
eprintln!("{l}");
}
}
fn run(scenario: &str) {
match scenario {
"estate-fresh" => estate_fresh(),
"estate-reexec" => estate_reexec(),
"estate-utility-gateway" => estate_utility_gateway(),
"estate-stray-default" => estate_stray_default(),
"estate-renewal-new-gateway" => estate_renewal_new_gateway(),
"kvm-mock" => kvm_mock(),
"ifindex" => ifindex_in_a_namespace(),
#[cfg(feature = "dhcp-client")]
"discover-on-eth1" => discover_on_eth1(),
other => panic!("no scenario {other}"),
}
}
// ── netlink, just enough to make links ─────────────────────────────────────────
fn align4(n: usize) -> usize {
(n + 3) & !3
}
fn rtattr(kind: u16, payload: &[u8]) -> Vec<u8> {
let len = 4 + payload.len();
let mut out = Vec::with_capacity(align4(len));
out.extend_from_slice(&(len as u16).to_ne_bytes());
out.extend_from_slice(&kind.to_ne_bytes());
out.extend_from_slice(payload);
out.resize(align4(len), 0);
out
}
/// `ifinfomsg` asking for index `index` (0: the kernel picks).
fn ifinfomsg(index: i32) -> Vec<u8> {
let mut b = vec![0u8; 16];
b[4..8].copy_from_slice(&index.to_ne_bytes());
b
}
/// `RTM_NEWLINK` (REQUEST|ACK|EXCL|CREATE) for `name` with `linkinfo`, at `index`; the ack checked.
fn new_link(name: &str, index: i32, linkinfo: Vec<u8>) {
use rustix::net::{recv, send, socket, AddressFamily, RecvFlags, SendFlags, SocketType};
let mut body = ifinfomsg(index);
body.extend(rtattr(3, &[name.as_bytes(), &[0]].concat())); // IFLA_IFNAME
body.extend(rtattr(18, &linkinfo)); // IFLA_LINKINFO
let len = 16 + body.len();
let mut msg = Vec::new();
msg.extend_from_slice(&(len as u32).to_ne_bytes());
msg.extend_from_slice(&16u16.to_ne_bytes()); // RTM_NEWLINK
msg.extend_from_slice(&(0x1u16 | 0x4 | 0x200 | 0x400).to_ne_bytes());
msg.extend_from_slice(&1u32.to_ne_bytes());
msg.extend_from_slice(&0u32.to_ne_bytes());
msg.extend_from_slice(&body);
let sock = socket(AddressFamily::NETLINK, SocketType::RAW, None).unwrap();
send(&sock, &msg, SendFlags::empty()).unwrap();
let mut buf = [0u8; 4096];
let (n, _) = recv(&sock, &mut buf[..], RecvFlags::empty()).unwrap();
assert!(
n >= 20 && u16::from_ne_bytes([buf[4], buf[5]]) == 2,
"no NLMSG_ERROR ack creating {name}"
);
let errno = i32::from_ne_bytes([buf[16], buf[17], buf[18], buf[19]]);
assert_eq!(errno, 0, "creating {name}: errno {}", -errno);
}
fn dummy(name: &str, index: i32) {
new_link(name, index, rtattr(1, b"dummy"));
}
/// A veth pair `name` (at `index`) <-> `peer`.
fn veth(name: &str, index: i32, peer: &str) {
let mut p = ifinfomsg(0);
p.extend(rtattr(3, &[peer.as_bytes(), &[0]].concat()));
new_link(
name,
index,
[rtattr(1, b"veth"), rtattr(2, &rtattr(1, &p))].concat(),
);
}
// ── what the kernel holds ──────────────────────────────────────────────────────
/// Every default route in the main table: `(interface, gateway)`, off `/proc/net/route`
/// (which is this process's network namespace).
fn defaults() -> Vec<(String, Ipv4Addr)> {
let table = std::fs::read_to_string("/proc/net/route").unwrap();
table
.lines()
.skip(1)
.filter_map(|l| {
let c: Vec<&str> = l.split_whitespace().collect();
(c.len() > 7 && c[1] == "00000000" && c[7] == "00000000").then(|| {
let gw = u32::from_str_radix(c[2], 16).unwrap();
(c[0].to_string(), Ipv4Addr::from(gw.to_le_bytes()))
})
})
.collect()
}
/// edda's bring-up over `spec`, the way `boot::bring_up_network` calls it (Gunnar's
/// host-name prefix; no resolver probe — no DNS in a namespace; narration collected).
fn bring_up(spec: &str) -> Vec<String> {
let said = std::sync::Mutex::new(Vec::<String>::new());
let narrate = |l: &str| said.lock().unwrap().push(l.to_string());
let how = appliance_pid1::net::Bringup {
hostname_prefix: gunnar_server::boot::HOSTNAME_PREFIX,
resolver_probe: None,
narrate: &narrate,
};
let plans = appliance_pid1::net::NetConfig::parse_list(spec).unwrap();
let sessions = appliance_pid1::net::bring_up_network(plans, &how)
.unwrap_or_else(|e| panic!("bring-up of {spec}: {e:#}"));
assert!(sessions.is_empty(), "static plans hold no lease");
let said = said.into_inner().unwrap();
assert!(
said.iter().all(|l| l.starts_with("network up:")),
"{said:?}"
);
said
}
/// The estate's two NICs (front, main and twin are the same shape).
fn estate_links() {
dummy("eth0", 0);
dummy("eth1", 0);
}
/// `GUNNAR_NET` for an estate box as its leases come back: eth0 public with the
/// router, eth1 utility. (Over DHCP the utility lease's router is dropped before
/// `apply` — `configure_via_dhcp`'s `owns_default_route` — so `apply` sees exactly this.)
fn estate_spec() -> String {
format!("eth0:{PUBLIC}:{PUBLIC_GW},eth1:{UTILITY}")
}
fn assert_public_default(when: &str) {
assert_eq!(
defaults(),
[("eth0".to_string(), PUBLIC_GW)],
"{when}: ONE default route, eth0's public gateway"
);
}
// ── (a) the default route on every Gunnar box shape ─────────────────────────────
fn estate_fresh() {
estate_links();
bring_up(&estate_spec());
assert_public_default("a fresh estate box");
assert_eq!(
appliance_pid1::net::interface_ipv4("eth1").unwrap(),
Some("10.13.8.78".parse().unwrap())
);
}
fn estate_reexec() {
estate_links();
bring_up(&estate_spec());
// A live update's re-exec: the kernel still holds both addresses and the route.
bring_up(&estate_spec());
assert_public_default("after a re-exec");
}
fn estate_utility_gateway() {
estate_links();
bring_up(&estate_spec());
// Even a declaration that names a gateway on the utility NIC never moves egress.
bring_up(&format!(
"eth0:{PUBLIC}:{PUBLIC_GW},eth1:{UTILITY}:{UTILITY_GW}"
));
assert_public_default("a utility gateway declared");
}
fn estate_stray_default() {
estate_links();
// A default through the utility NIC already there (nothing in Gunnar makes one;
// this is the worst case EEXIST can meet): Gunnar used to KEEP it, and the box had
// no way out. The plan's public gateway replaces it.
appliance_pid1::net::apply(&appliance_pid1::net::NetPlan {
interface: "eth1".into(),
address: "10.13.8.78".parse().unwrap(),
prefix: 22,
gateway: Some(UTILITY_GW),
})
.unwrap();
assert_eq!(defaults(), [("eth1".to_string(), UTILITY_GW)]);
bring_up(&estate_spec());
assert_public_default("a stray utility default met");
}
fn estate_renewal_new_gateway() {
estate_links();
bring_up(&estate_spec());
// A renewal whose lease names another router in the same public net: the lease's
// router stands (Gunnar used to keep the old one, possibly gone).
let moved = Ipv4Addr::new(81, 27, 106, 2);
bring_up(&format!("eth0:{PUBLIC}:{moved},eth1:{UTILITY}"));
assert_eq!(
defaults(),
[("eth0".to_string(), moved)],
"the lease's new public router"
);
}
fn kvm_mock() {
dummy("eth0", 0);
bring_up("eth0:10.0.2.15/24:10.0.2.2");
bring_up("eth0:10.0.2.15/24:10.0.2.2");
assert_eq!(
defaults(),
[("eth0".to_string(), Ipv4Addr::new(10, 0, 2, 2))],
"the KVM mock's slirp gateway, once"
);
}
#[test]
fn a_fresh_estate_box_takes_eth0s_public_gateway_and_eth1_none() {
in_a_namespace(
"a_fresh_estate_box_takes_eth0s_public_gateway_and_eth1_none",
"estate-fresh",
);
}
#[test]
fn a_re_exec_keeps_the_one_public_default_route() {
in_a_namespace(
"a_re_exec_keeps_the_one_public_default_route",
"estate-reexec",
);
}
#[test]
fn a_utility_gateway_never_replaces_the_public_default() {
in_a_namespace(
"a_utility_gateway_never_replaces_the_public_default",
"estate-utility-gateway",
);
}
#[test]
fn a_stray_utility_default_is_replaced_by_the_public_one() {
in_a_namespace(
"a_stray_utility_default_is_replaced_by_the_public_one",
"estate-stray-default",
);
}
#[test]
fn a_renewal_with_a_new_public_router_takes_it() {
in_a_namespace(
"a_renewal_with_a_new_public_router_takes_it",
"estate-renewal-new-gateway",
);
}
#[test]
fn the_kvm_mock_keeps_its_one_slirp_default_route() {
in_a_namespace("the_kvm_mock_keeps_its_one_slirp_default_route", "kvm-mock");
}
// ── (b) the interface index ────────────────────────────────────────────────────
/// Outside any namespace of our own (the appliance's case: PID 1 in the init
/// namespace, sysfs mounted for it) `SIOCGIFINDEX` and `/sys/class/net` agree on
/// every interface there is.
#[test]
fn siocgifindex_and_sysfs_agree_where_sysfs_is_this_namespaces() {
use rustix::net::{netdevice, socket, AddressFamily, SocketType};
let probe = socket(AddressFamily::INET, SocketType::DGRAM, None).unwrap();
let mut seen = 0;
for e in std::fs::read_dir("/sys/class/net").unwrap() {
let name = e.unwrap().file_name().into_string().unwrap();
let Ok(sys) = std::fs::read_to_string(format!("/sys/class/net/{name}/ifindex")) else {
continue;
};
let Ok(ioctl) = netdevice::name_to_index(&probe, &name) else {
continue;
};
assert_eq!(sys.trim().parse::<u32>().unwrap(), ioctl, "{name}");
seen += 1;
}
assert!(seen > 0, "no interface compared");
}
fn ifindex_in_a_namespace() {
dummy("eth0", 7);
veth("eth1", 9, "util");
// edda's lookup (`bring_link_up` returns it; the DHCP packet socket uses the same
// `interface_index`) names the links this namespace made, at the indices asked for.
assert_eq!(appliance_pid1::net::bring_link_up("eth0").unwrap(), 7);
assert_eq!(appliance_pid1::net::bring_link_up("eth1").unwrap(), 9);
// sysfs still shows the namespace it was mounted in: it may not know eth1 at all,
// or know another eth1 — the reason the lookup moved.
let sys = std::fs::read_to_string("/sys/class/net/eth1/ifindex").ok();
println!("NETNS-IFINDEX eth1: SIOCGIFINDEX 9, /sys/class/net says {sys:?}");
}
#[test]
fn the_interface_index_is_this_namespaces_own() {
in_a_namespace("the_interface_index_is_this_namespaces_own", "ifindex");
}
/// The estate's D4 shape: eth0 holds the public address, eth1 has none yet; a
/// DISCOVER for eth1 must leave on eth1's wire from 0.0.0.0 — the packet socket at
/// the `SIOCGIFINDEX` index. (The UDP fallback would leave from eth0's address.)
#[cfg(feature = "dhcp-client")]
fn discover_on_eth1() {
dummy("eth0", 0);
veth("eth1", 0, "util");
appliance_pid1::net::apply(&appliance_pid1::net::NetPlan {
interface: "eth0".into(),
address: "81.27.107.63".parse().unwrap(),
prefix: 23,
gateway: Some(PUBLIC_GW),
})
.unwrap();
appliance_pid1::net::bring_link_up("util").unwrap();
appliance_pid1::net::bring_link_up("eth1").unwrap();
let server = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::DGRAM, None).unwrap();
server.set_reuse_address(true).unwrap();
server.bind_device(Some(b"util")).unwrap();
server
.bind(&std::net::SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 67).into())
.unwrap();
server
.set_read_timeout(Some(std::time::Duration::from_secs(15)))
.unwrap();
let mac = appliance_pid1::net::interface_mac("eth1").unwrap();
// No server answers: the client gives up on its own; this only listens for the DISCOVER.
std::thread::spawn(move || {
let _ = appliance_pid1::dhcp::obtain_with("eth1", mac, true);
});
let server: std::net::UdpSocket = server.into();
let mut buf = [0u8; 1500];
let (n, from) = server
.recv_from(&mut buf)
.expect("a DISCOVER on util, eth1's wire");
assert!(n > 240, "a BOOTP message");
assert_eq!(&buf[28..34], &mac, "chaddr is eth1's MAC");
assert_eq!(
from.ip(),
std::net::IpAddr::V4(Ipv4Addr::UNSPECIFIED),
"from 0.0.0.0 although eth0 holds an address: the packet socket, at eth1's index"
);
println!(
"NETNS-DISCOVER eth1 {} from {from}",
mac.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
);
}
#[cfg(feature = "dhcp-client")]
#[test]
fn a_discover_for_eth1_leaves_on_eth1_from_zero_while_eth0_holds_an_address() {
in_a_namespace(
"a_discover_for_eth1_leaves_on_eth1_from_zero_while_eth0_holds_an_address",
"discover-on-eth1",
);
}