Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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",
    );
}