crates/appliance-pid1/src/net.rs
//! **The network, because nothing else brings the link up** (feature `net`).
//!
//! Moved AS IS from gunnar-server's `pid1.rs` (gunnar `gunnar-live` 18a44e3,
//! 2026-09-27), where it was written, measured and proven on the appliance and
//! on UpCloud; holger's PID 1 needs the same, and one writer of addresses per
//! suite is the rule. What was gunnar's by name is now the caller's: the
//! environment variable ([`NetConfig::from_env`]), the host-name prefix, the
//! name the resolver is proven on and where the "network up" line goes
//! ([`Bringup`]).
//!
//! Measured on the reference desktop, 2026-08-03: `CONFIG_IP_PNP is not set` in
//! `/boot/config-7.0.0-28-generic`, and the same in `-22`. The comfortable plan
//! — hand the kernel `ip=10.0.2.15::10.0.2.2:255.255.255.0::eth0:off` and let
//! it configure the interface before `init` runs — **does not work on this
//! kernel**, and it would have been a plan that failed only once there was an
//! ISO to boot. So the appliance configures its own interface, over
//! `AF_NETLINK`, in process:
//!
//! ```text
//! RTM_NEWLINK lo IFF_UP (the kernel then adds 127.0.0.1/8 itself)
//! RTM_NEWLINK eth0 IFF_UP
//! RTM_NEWADDR eth0 10.0.2.15/24
//! RTM_NEWROUTE default via 10.0.2.2 dev eth0
//! ```
//!
//! No `ip`, no `ifconfig`, no `busybox`: those are subprocesses, and the no-OS
//! rule bans them. `rustix` gives the socket calls without `unsafe` and
//! without a libc: on `x86_64-linux` it is the `linux_raw` backend.
//!
//! ## Why the message builders are pure functions
//!
//! [`link_up_message`], [`address_message`] and [`default_route_message`]
//! return `Vec<u8>` and touch no socket. A netlink bug is a **byte-layout**
//! bug: a `u16` where the kernel wants a `u8`, a `rtattr` that forgot its
//! four-byte padding. The only honest assertion is against the bytes.
//!
//! # What is configured, and from where
//!
//! A net spec, from the kernel command line through the environment: Linux
//! passes every unrecognised `key=value` boot parameter to `init` as an
//! environment variable (`init/main.c`, `unknown_bootoption`). Two forms per
//! entry, comma-separated, the FIRST owning the default route:
//!
//! * `eth0:10.0.2.15/24:10.0.2.2` — static, applied directly over netlink;
//! * `eth0:dhcp` — the [`crate::dhcp`] client runs the DISCOVER→ACK handshake
//! and the lease is applied through the **same** [`apply`] the static form
//! uses. One writer puts addresses on interfaces, whichever way the address
//! was learned.
//!
//! `eth0:dhcp,eth1:dhcp` is the cloud shape: the public NIC and the utility one.
use std::net::Ipv4Addr;
use anyhow::{bail, Context, Result};
// ── netlink wire constants (uapi/linux/{netlink,rtnetlink,if_addr}.h) ────────
//
// Written out rather than pulled from a binding crate: there are eighteen of
// them, they are frozen ABI, and a binding crate would be a dependency in the
// appliance graph for eighteen integers.
pub(crate) const NLMSG_ERROR: u16 = 2;
pub(crate) const NLM_F_REQUEST: u16 = 0x001;
pub(crate) const NLM_F_ACK: u16 = 0x004;
/// `NLM_F_DUMP` = `NLM_F_ROOT | NLM_F_MATCH` — ask for the whole table rather
/// than one row. The only request in this file that wants many answers.
const NLM_F_DUMP: u16 = 0x100 | 0x200;
const NLM_F_EXCL: u16 = 0x200;
const NLM_F_REPLACE: u16 = 0x100;
const NLM_F_CREATE: u16 = 0x400;
const RTM_NEWLINK: u16 = 16;
const RTM_GETLINK: u16 = 18;
const RTM_NEWADDR: u16 = 20;
const RTM_GETADDR: u16 = 22;
const RTM_NEWROUTE: u16 = 24;
/// `IFLA_ADDRESS`, the link-layer address attribute on an `RTM_NEWLINK`.
const IFLA_ADDRESS: u16 = 1;
const AF_INET: u8 = 2;
const IFF_UP: u32 = 1;
const IFA_ADDRESS: u16 = 1;
const IFA_LOCAL: u16 = 2;
const IFA_BROADCAST: u16 = 4;
const RTA_DST: u16 = 1;
const RTA_OIF: u16 = 4;
const RTA_GATEWAY: u16 = 5;
const RT_TABLE_MAIN: u8 = 254;
const RTPROT_BOOT: u8 = 3;
const RT_SCOPE_UNIVERSE: u8 = 0;
/// `RT_SCOPE_LINK` — the destination is reachable on this link with no next
/// hop. What an option-121 route with a `0.0.0.0` gateway means.
const RT_SCOPE_LINK: u8 = 253;
const RTN_UNICAST: u8 = 1;
/// `struct nlmsghdr`, 16 bytes; the four `rtnetlink` payloads below are sized
/// against it in the golden tests.
const NLMSGHDR_LEN: usize = 16;
/// Where an `NLMSG_ERROR`'s errno sits: right after the 16-byte header.
const NLMSG_ERROR_ERRNO_AT: usize = NLMSGHDR_LEN;
/// `NLMSG_DONE` — the last message of a dump. A reader that stops at the end of
/// a *datagram* instead of at this type reads whatever the first `recv` happened
/// to hold, which on a box with many interfaces is a subset that looks complete.
const NLMSG_DONE: u16 = 3;
// ── is this actually PID 1 ──────────────────────────────────────────────────
/// True when this process is the init of its PID namespace.
///
/// Used only to decide how *loud* to be. The signal handling and the reaper are
/// installed either way: they are correct for an ordinary process too, and a
/// behaviour that only exists at pid 1 is a behaviour no test on a developer
/// machine ever exercises.
pub fn is_init() -> bool {
std::process::id() == 1
}
/// The appliance gate: true when this kernel was booted by korp-start.
///
/// `korp.entry=` in `/proc/cmdline` is korp-start's boot contract — only the
/// appliance's own kernel carries it, while a container reads the HOST
/// kernel's cmdline, which does not. The gate for behaviour that must exist
/// only on the appliance (the DHCP T1 test seam, `crate::runtime`).
pub fn booted_by_korp_start() -> bool {
std::fs::read_to_string("/proc/cmdline")
.map(|cmdline| cmdline.contains("korp.entry="))
.unwrap_or(false)
}
// ── 3. the network ──────────────────────────────────────────────────────────
/// One interface, one address, one optional default route.
///
/// Deliberately not a general network model. An appliance has one link and one
/// route; anything richer is a configuration language, and a configuration
/// language is a parser, a file and a place for a mistake to hide.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetPlan {
/// The interface name, e.g. `eth0`.
pub interface: String,
/// The address to assign.
pub address: Ipv4Addr,
/// Its prefix length, 0..=32.
pub prefix: u8,
/// The default gateway, if there is to be one.
pub gateway: Option<Ipv4Addr>,
}
impl NetPlan {
/// Parse `iface:addr/prefix[:gateway]`, e.g. `eth0:10.0.2.15/24:10.0.2.2`.
///
/// Colon-separated and not JSON or TOML because this value's only transport
/// is a kernel command line, where a space ends the parameter.
pub fn parse(spec: &str) -> Result<NetPlan> {
let mut parts = spec.split(':');
let interface = parts.next().unwrap_or_default().trim().to_string();
if interface.is_empty() {
bail!("net spec {spec:?}: no interface name before the first `:`");
}
let cidr = parts
.next()
.with_context(|| format!("net spec {spec:?}: expected <iface>:<addr>/<prefix>"))?;
let (addr, prefix) = cidr
.split_once('/')
.with_context(|| format!("net spec {spec:?}: {cidr:?} is not <addr>/<prefix>"))?;
let address: Ipv4Addr = addr
.parse()
.with_context(|| format!("net spec {spec:?}: {addr:?} is not an IPv4 address"))?;
let prefix: u8 = prefix
.parse()
.with_context(|| format!("net spec {spec:?}: {prefix:?} is not a prefix length"))?;
if prefix > 32 {
bail!("net spec {spec:?}: /{prefix} is not a legal IPv4 prefix length");
}
let gateway = match parts.next().map(str::trim).filter(|g| !g.is_empty()) {
Some(g) => Some(g.parse::<Ipv4Addr>().with_context(|| {
format!("net spec {spec:?}: {g:?} is not an IPv4 gateway address")
})?),
None => None,
};
if parts.next().is_some() {
bail!("net spec {spec:?}: more than three `:`-separated fields");
}
Ok(NetPlan {
interface,
address,
prefix,
gateway,
})
}
}
/// What the net spec asks for: a fixed address, or a DHCP-learned one.
///
/// Two variants and no third, mirroring the two things an appliance can
/// honestly be told at boot: "be this address" or "ask the network". Everything
/// downstream of the *answer* is shared — both variants end in [`apply`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NetConfig {
/// `iface:addr/prefix[:gateway]`, applied verbatim.
Static(NetPlan),
/// `iface:dhcp` — run the `crate::dhcp` handshake on this interface.
///
/// Behind `dhcp`: with the feature off there is no handshake to
/// run, so there is no state for this variant to hold. The `:dhcp`
/// SPELLING is still recognised by [`NetConfig::parse`] — and refused by
/// name there.
#[cfg(feature = "dhcp")]
Dhcp {
/// The interface to configure, e.g. `eth0`.
interface: String,
},
}
impl NetConfig {
/// **Parse the whole of the net spec: a comma-separated list of specs.**
///
/// One spec is the historical spelling and parses to a one-element list,
/// byte-for-byte what it always did. Two are the cloud shape:
/// `eth0:dhcp,eth1:dhcp` is a public NIC and a private one, which is what
/// every UpCloud server with a utility network has and what the appliance
/// could not previously configure — measured 2026-08-31, the box holds
/// UpCloud's `10.20.6.152` on interface index 2 and nothing on the box had
/// ever brought it up, so the address did not answer a ping from the front
/// on the same /22.
///
/// **Order is load-bearing.** The FIRST spec owns the default route; the
/// rest are address-only. A second default route through a utility network
/// with no way out is not a richer configuration, it is a coin flip over
/// which one the kernel picks for egress.
pub fn parse_list(spec: &str) -> Result<Vec<NetConfig>> {
let mut out = Vec::new();
let mut seen: Vec<String> = Vec::new();
for part in spec.split(',') {
let part = part.trim();
if part.is_empty() {
bail!(
"net spec {spec:?}: an empty entry between commas. Every entry must be <iface>:dhcp or <iface>:<addr>/<prefix>[:<gateway>]."
);
}
let config = NetConfig::parse(part)?;
let name = config.interface().to_owned();
if seen.contains(&name) {
bail!(
"net spec {spec:?}: {name:?} appears twice. Two plans for one interface is two answers to one question; the second would silently win or the kernel would refuse the duplicate address."
);
}
seen.push(name);
out.push(config);
}
if out.is_empty() {
bail!("net spec {spec:?}: no interface named");
}
Ok(out)
}
/// The interface this plan configures, whichever variant it is.
#[must_use]
pub fn interface(&self) -> &str {
match self {
NetConfig::Static(plan) => &plan.interface,
#[cfg(feature = "dhcp")]
NetConfig::Dhcp { interface } => interface,
}
}
/// Parse the net spec: `iface:dhcp` (case-insensitive on the keyword) or
/// the static form, byte-compatible with what every existing declaration
/// says. Anything else is refused, never defaulted.
pub fn parse(spec: &str) -> Result<NetConfig> {
if let Some((interface, rest)) = spec.split_once(':') {
let interface = interface.trim();
if rest.trim().eq_ignore_ascii_case("dhcp") {
if interface.is_empty() {
bail!("net spec {spec:?}: no interface name before `:dhcp`");
}
#[cfg(feature = "dhcp")]
return Ok(NetConfig::Dhcp {
interface: interface.to_string(),
});
// **The honest refusal.** The keyword is still RECOGNISED with
// `dhcp` off — deliberately, because the alternative is
// worse in both available directions: falling through to
// `NetPlan::parse` would report "`\"dhcp\"` is not
// <addr>/<prefix>", which blames the operator's spelling for a
// decision the BUILD made; and treating it as "no network"
// would boot an appliance dark, unreachable, with nothing in
// the log naming the cause. So it names the missing feature and
// stops the boot — the same grade every other lying declaration
// gets here.
#[cfg(not(feature = "dhcp"))]
bail!(
"net spec {spec:?} asks for DHCP on {interface:?}, but this binary was \
built WITHOUT the `dhcp` feature — it contains no DHCP client and \
cannot obtain a lease. Give a STATIC address instead \
(<NET>={interface}:<addr>/<prefix>[:<gateway>]), or use a build with \
--features dhcp."
);
}
}
Ok(NetConfig::Static(NetPlan::parse(spec)?))
}
/// The configuration named by the environment variable `var` (gunnar's
/// `GUNNAR_NET`, holger's `HOLGER_NET` — the product names its own), if
/// the environment names one. Linux hands every unrecognised `key=value`
/// boot parameter to `init` as an environment variable, so the kernel
/// command line is the channel.
pub fn from_env(var: &str) -> Result<Vec<NetConfig>> {
match std::env::var(var) {
Ok(spec) if !spec.trim().is_empty() => NetConfig::parse_list(spec.trim()),
_ => Ok(Vec::new()),
}
}
}
// ── the encoders ────────────────────────────────────────────────────────────
//
// `netlink_message`, `rtattr`, `align4` and `netlink_socket` are public (lane G6): a
// product that READS the route table itself (Gunnar's dashboard probe, its `RTM_GETADDR`
// dump) frames its requests with these instead of keeping a copy.
/// A netlink message: a 16-byte `nlmsghdr` with `nlmsg_len` filled in from the
/// finished body, then the family payload, then the attributes.
///
/// `nlmsg_pid` is left 0: that field is the *destination* port for a request,
/// and 0 is the kernel.
pub fn netlink_message(kind: u16, flags: u16, seq: u32, body: &[u8]) -> Vec<u8> {
let len = NLMSGHDR_LEN + body.len();
let mut msg = Vec::with_capacity(align4(len));
msg.extend_from_slice(&(len as u32).to_ne_bytes());
msg.extend_from_slice(&kind.to_ne_bytes());
msg.extend_from_slice(&flags.to_ne_bytes());
msg.extend_from_slice(&seq.to_ne_bytes());
msg.extend_from_slice(&0u32.to_ne_bytes());
msg.extend_from_slice(body);
// The header's length field counts the unpadded message; the *buffer* is
// padded to four so a following message would be aligned. The kernel
// tolerates either for a single message and this keeps the two rules
// separate rather than conflated.
msg.resize(align4(len), 0);
msg
}
/// `struct rtattr`: `{ u16 len, u16 type }` then the payload, padded to four.
///
/// `rta_len` counts the header and the payload but **not** the padding, which
/// is the single most common way to get netlink subtly wrong: the kernel reads
/// `rta_len` for the value and `RTA_ALIGN(rta_len)` for the step to the next
/// attribute.
pub 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
}
/// `NLMSG_ALIGN` / `RTA_ALIGN`: `n` rounded up to a multiple of four.
pub const fn align4(n: usize) -> usize {
(n + 3) & !3
}
/// `ip link set <index> up`, as `RTM_NEWLINK` with `ifi_flags = ifi_change = IFF_UP`.
///
/// `ifi_change` is the mask of which flag bits this message is allowed to
/// touch. A message with `ifi_flags = IFF_UP` and `ifi_change = 0` changes
/// **nothing** and still returns success, which is the shape of a green that
/// has never been red.
pub fn link_up_message(seq: u32, ifindex: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.push(0); // ifi_family, AF_UNSPEC for a link
body.push(0); // __ifi_pad
body.extend_from_slice(&0u16.to_ne_bytes()); // ifi_type, ignored on set
body.extend_from_slice(&(ifindex as i32).to_ne_bytes());
body.extend_from_slice(&IFF_UP.to_ne_bytes()); // ifi_flags
body.extend_from_slice(&IFF_UP.to_ne_bytes()); // ifi_change
netlink_message(RTM_NEWLINK, NLM_F_REQUEST | NLM_F_ACK, seq, &body)
}
/// `RTM_NEWADDR`: `ip addr add <addr>/<prefix> dev <index>`.
///
/// Both `IFA_LOCAL` and `IFA_ADDRESS` are sent with the same value. On a
/// broadcast link they are the same thing; on a point-to-point link `IFA_LOCAL`
/// is this end and `IFA_ADDRESS` is the peer. Sending only one of them is
/// accepted and then means something different from what was intended.
pub fn address_message(seq: u32, ifindex: u32, address: Ipv4Addr, prefix: u8) -> Vec<u8> {
let mut body = Vec::with_capacity(8 + 3 * 8);
body.push(AF_INET);
body.push(prefix);
body.push(0); // ifa_flags
body.push(RT_SCOPE_UNIVERSE); // ifa_scope
body.extend_from_slice(&ifindex.to_ne_bytes());
body.extend_from_slice(&rtattr(IFA_LOCAL, &address.octets()));
body.extend_from_slice(&rtattr(IFA_ADDRESS, &address.octets()));
// The broadcast address, computed rather than assumed: without it the
// kernel leaves the interface with none, and a subnet-directed broadcast
// silently goes nowhere.
if prefix <= 30 {
let host_bits = 32 - u32::from(prefix);
let broadcast = Ipv4Addr::from(
u32::from(address) | (u32::MAX.checked_shr(u32::from(prefix)).unwrap_or(0)),
);
let _ = host_bits;
body.extend_from_slice(&rtattr(IFA_BROADCAST, &broadcast.octets()));
}
netlink_message(
RTM_NEWADDR,
NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL,
seq,
&body,
)
}
/// `ip route add default via <gw> dev <index>` — `RTM_NEWROUTE`.
///
/// `rtm_dst_len = 0` with no `RTA_DST` **is** the default route; there is no
/// separate "default" flag.
pub fn default_route_message(seq: u32, ifindex: u32, gateway: Ipv4Addr) -> Vec<u8> {
route_message(seq, ifindex, Ipv4Addr::UNSPECIFIED, 0, gateway)
}
/// `ip route add <dst>/<len> via <gw> dev <index>` — `RTM_NEWROUTE`, for ANY
/// destination, which is what option 121 hands out.
///
/// The only differences from the default route are `rtm_dst_len` and an
/// `RTA_DST` attribute, and that attribute must be ABSENT for `/0`: a
/// zero-length destination with a four-byte `RTA_DST` of zeros is a different
/// message, and the kernel is within its rights to read it differently. An
/// unspecified gateway means the destination is on-link (RFC 3442 permits it),
/// and then `RTA_GATEWAY` is left out and the scope is `RT_SCOPE_LINK` —
/// `via 0.0.0.0` is not a next hop, it is the absence of one.
pub fn route_message(
seq: u32,
ifindex: u32,
destination: Ipv4Addr,
prefix: u8,
gateway: Ipv4Addr,
) -> Vec<u8> {
let on_link = gateway.is_unspecified();
let mut body = Vec::with_capacity(12 + 24);
body.push(AF_INET);
body.push(prefix); // rtm_dst_len
body.push(0); // rtm_src_len
body.push(0); // rtm_tos
body.push(RT_TABLE_MAIN);
body.push(RTPROT_BOOT);
body.push(if on_link {
RT_SCOPE_LINK
} else {
RT_SCOPE_UNIVERSE
});
body.push(RTN_UNICAST);
body.extend_from_slice(&0u32.to_ne_bytes()); // rtm_flags
if prefix > 0 {
body.extend_from_slice(&rtattr(RTA_DST, &destination.octets()));
}
if !on_link {
body.extend_from_slice(&rtattr(RTA_GATEWAY, &gateway.octets()));
}
body.extend_from_slice(&rtattr(RTA_OIF, &ifindex.to_ne_bytes()));
netlink_message(
RTM_NEWROUTE,
NLM_F_REQUEST | NLM_F_ACK | NLM_F_CREATE | NLM_F_EXCL,
seq,
&body,
)
}
/// `ip link show <index>`, as `RTM_GETLINK` for one interface.
///
/// Not a dump: the index is already known (SIOCGIFNAME found it), so the
/// kernel answers with a single `RTM_NEWLINK` for exactly that link rather than
/// a multipart walk of every interface in the namespace.
pub fn get_link_message(seq: u32, ifindex: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.push(0); // ifi_family
body.push(0); // __ifi_pad
body.extend_from_slice(&0u16.to_ne_bytes()); // ifi_type
body.extend_from_slice(&(ifindex as i32).to_ne_bytes());
body.extend_from_slice(&0u32.to_ne_bytes()); // ifi_flags
body.extend_from_slice(&0u32.to_ne_bytes()); // ifi_change
netlink_message(RTM_GETLINK, NLM_F_REQUEST, seq, &body)
}
/// The `IFLA_ADDRESS` out of an `RTM_GETLINK` answer: the interface's MAC.
///
/// Pure over the reply bytes for the same reason the encoders are pure over
/// theirs: the attribute walk (`rta_len` for the value, `RTA_ALIGN` for the
/// step) is exactly where netlink parsing goes wrong, and a MAC is six bytes —
/// `RTA_ALIGN(4+6) = 12 ≠ 10`, so unlike the all-IPv4 messages above, THIS walk
/// actually exercises the padding rule and a test can see it red.
pub fn mac_from_getlink_reply(buf: &[u8]) -> Result<[u8; 6]> {
if buf.len() < NLMSGHDR_LEN {
bail!(
"netlink answered {} bytes, shorter than a header",
buf.len()
);
}
let kind = u16::from_ne_bytes([buf[4], buf[5]]);
if kind == NLMSG_ERROR {
// Found red before it was green: the first version indexed 16..20
// unconditionally and a truncated error message panicked the test —
// which at runtime is PID 1 panicking on a short kernel answer.
let Some(e) = buf.get(NLMSG_ERROR_ERRNO_AT..NLMSG_ERROR_ERRNO_AT + 4) else {
bail!(
"a truncated NLMSG_ERROR ({} bytes) answered RTM_GETLINK",
buf.len()
);
};
let errno = i32::from_ne_bytes([e[0], e[1], e[2], e[3]]);
bail!(
"RTM_GETLINK refused by the kernel: errno {} ({})",
-errno,
std::io::Error::from_raw_os_error(-errno)
);
}
if kind != RTM_NEWLINK {
bail!("expected an RTM_NEWLINK answer, got message type {kind}");
}
let msg_len = (u32::from_ne_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize).min(buf.len());
// nlmsghdr (16) + ifinfomsg (16), then attributes until the declared end.
let mut at = NLMSGHDR_LEN + 16;
while at + 4 <= msg_len {
let rta_len = u16::from_ne_bytes([buf[at], buf[at + 1]]) as usize;
let rta_type = u16::from_ne_bytes([buf[at + 2], buf[at + 3]]);
if rta_len < 4 || at + rta_len > msg_len {
bail!("malformed rtattr at offset {at}: rta_len {rta_len}");
}
if rta_type == IFLA_ADDRESS {
let payload = &buf[at + 4..at + rta_len];
let mac: [u8; 6] = payload.try_into().with_context(|| {
format!("IFLA_ADDRESS is {} bytes, not a 6-byte MAC", payload.len())
})?;
return Ok(mac);
}
at += align4(rta_len);
}
bail!("the RTM_NEWLINK answer carried no IFLA_ADDRESS attribute");
}
// ── the socket ──────────────────────────────────────────────────────────────
/// Send one request and read its `NLMSG_ERROR` acknowledgement.
///
/// Every message carries `NLM_F_ACK`, so the kernel answers *every* one, with
/// `errno == 0` for success. A netlink caller that does not read the ack
/// cannot distinguish "applied" from "rejected", because `send` succeeds
/// either way. That is the whole reason this returns a `Result` at all.
pub(crate) fn request(sock: &rustix::fd::OwnedFd, message: &[u8], what: &str) -> Result<()> {
use rustix::net::{recv, send, RecvFlags, SendFlags};
send(sock, message, SendFlags::empty()).with_context(|| format!("netlink send ({what})"))?;
let mut buf = [0u8; 4096];
let (n, _truncated) = recv(sock, &mut buf[..], RecvFlags::empty())
.with_context(|| format!("netlink recv ({what})"))?;
if n < NLMSGHDR_LEN + 4 {
bail!("netlink ({what}): the kernel answered {n} bytes, too short for an acknowledgement");
}
let kind = u16::from_ne_bytes([buf[4], buf[5]]);
if kind != NLMSG_ERROR {
bail!("netlink ({what}): expected NLMSG_ERROR (2), got message type {kind}");
}
let errno = i32::from_ne_bytes([buf[16], buf[17], buf[18], buf[19]]);
if errno != 0 {
bail!(
"netlink ({what}) refused by the kernel: errno {} ({})",
-errno,
std::io::Error::from_raw_os_error(-errno)
);
}
Ok(())
}
/// What an `NLMSG_ERROR` acknowledgement means for a request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ack {
/// Applied.
Applied,
/// `EEXIST` on a request that tolerates it: the address or route is there already (a
/// live update's re-exec: the kernel kept the network the old process laid).
AlreadyThere,
}
/// Pure: **the acknowledgement's errno, judged** — 0 is applied; `EEXIST` (17) is "already
/// there" where `exists_ok`; anything else is the kernel's refusal (Gunnar's `ack_verdict`).
pub fn ack_verdict(errno: i32, exists_ok: bool) -> std::result::Result<Ack, i32> {
match -errno {
0 => Ok(Ack::Applied),
17 if exists_ok => Ok(Ack::AlreadyThere),
e => Err(e),
}
}
/// [`request`], with `EEXIST` read as "already there" ([`ack_verdict`]).
pub fn request_or_there(sock: &rustix::fd::OwnedFd, message: &[u8], what: &str) -> Result<Ack> {
match request(sock, message, what) {
Ok(()) => Ok(Ack::Applied),
Err(e) if is_eexist(&e) => Ok(Ack::AlreadyThere),
Err(e) => Err(e),
}
}
/// An `AF_NETLINK`/`NETLINK_ROUTE` socket.
///
/// Not `bind`-ed: an unbound netlink socket is auto-bound by the kernel on
/// first send, with a port id the kernel picks. Binding by hand would mean
/// choosing that id, and two gunnars on one host choosing the same one is a
/// failure mode invented for no gain.
pub fn netlink_socket() -> Result<rustix::fd::OwnedFd> {
use rustix::net::{socket, AddressFamily, SocketType};
// `None` for the protocol is NETLINK_ROUTE (0); rustix models the protocol
// as a `NonZero`, so the default cannot be spelled any other way.
socket(AddressFamily::NETLINK, SocketType::RAW, None)
.context("opening an AF_NETLINK/NETLINK_ROUTE socket")
}
/// The index of `name`, via `SIOCGIFNAME` on a throwaway datagram socket.
pub(crate) fn interface_index(name: &str) -> Result<u32> {
use rustix::net::{netdevice, socket, AddressFamily, SocketType};
let probe = socket(AddressFamily::INET, SocketType::DGRAM, None)
.context("opening a socket to look an interface index up")?;
netdevice::name_to_index(&probe, name)
.with_context(|| format!("no interface named {name:?}; is the driver in the kernel?"))
}
/// Send one request and read back a **data** answer (not an ack).
///
/// The twin of [`request`] for the one query this file makes: `RTM_GETLINK`
/// carries no `NLM_F_ACK`, so the kernel's answer *is* the payload. Kept
/// separate rather than parameterising `request`, because "expects
/// `NLMSG_ERROR`" and "expects data" are opposite assertions and a function
/// that accepts either verifies neither.
pub(crate) fn query(sock: &rustix::fd::OwnedFd, message: &[u8], what: &str) -> Result<Vec<u8>> {
use rustix::net::{recv, send, RecvFlags, SendFlags};
send(sock, message, SendFlags::empty()).with_context(|| format!("netlink send ({what})"))?;
let mut buf = vec![0u8; 8192];
let (n, _truncated) = recv(sock, &mut buf[..], RecvFlags::empty())
.with_context(|| format!("netlink recv ({what})"))?;
buf.truncate(n);
Ok(buf)
}
/// The MAC of `name`, over the same `NETLINK_ROUTE` socket family everything
/// else here uses. The DHCP client needs it for `chaddr`: on the appliance
/// `/sys` is a mount point with nothing mounted on it (korp-start mounts only
/// `/proc`), so the sysfs shortcut does not exist and netlink is the front
/// door anyway.
pub fn interface_mac(name: &str) -> Result<[u8; 6]> {
let index = interface_index(name)?;
let sock = netlink_socket()?;
let answer = query(
&sock,
&get_link_message(1, index),
&format!("getlink {name}"),
)?;
mac_from_getlink_reply(&answer)
.with_context(|| format!("reading the MAC address of {name} over netlink"))
}
/// **`RTM_GETADDR` for the whole IPv4 address table**, as a dump request.
///
/// No `NLM_F_ACK`: like `RTM_GETLINK`, the kernel's answer IS the payload, and
/// asking for an ack as well would mean reading two different shapes off one
/// socket.
pub fn get_addr_dump_message(seq: u32) -> Vec<u8> {
// `struct ifaddrmsg` — family, prefixlen, flags, scope, index. Only the
// family is a filter on a dump; the kernel ignores the rest and we send
// zeros rather than a plausible-looking index that does nothing.
let mut body = Vec::with_capacity(8);
body.push(AF_INET);
body.push(0); // ifa_prefixlen
body.push(0); // ifa_flags
body.push(0); // ifa_scope
body.extend_from_slice(&0u32.to_ne_bytes()); // ifa_index
netlink_message(RTM_GETADDR, NLM_F_REQUEST | NLM_F_DUMP, seq, &body)
}
/// **The IPv4 address `ifindex` holds, out of an `RTM_GETADDR` dump.**
///
/// Split from the socket so the parser is testable against bytes: every dump
/// this function has ever been wrong about can be replayed as a `&[u8]`.
///
/// `IFA_LOCAL` is preferred over `IFA_ADDRESS` and that is not a style choice:
/// on a point-to-point link `IFA_ADDRESS` is the PEER's address, so a reader
/// that took whichever came first would bind a listener to the other end of
/// the tunnel. On a broadcast link they are equal and the preference is inert.
///
/// The first match wins, and `None` means the table carried no IPv4 for that
/// index — never `0.0.0.0`, never a guess.
pub fn ipv4_from_addr_dump(buf: &[u8], ifindex: u32) -> Option<Ipv4Addr> {
let mut off = 0usize;
while off + NLMSGHDR_LEN <= buf.len() {
let len = u32::from_ne_bytes(buf[off..off + 4].try_into().ok()?) as usize;
let kind = u16::from_ne_bytes(buf[off + 4..off + 6].try_into().ok()?);
if len < NLMSGHDR_LEN || off + len > buf.len() {
return None;
}
if kind == NLMSG_DONE || kind == NLMSG_ERROR {
return None;
}
if kind == RTM_NEWADDR {
let body = &buf[off + NLMSGHDR_LEN..off + len];
// `struct ifaddrmsg` is 8 bytes: family, prefixlen, flags, scope,
// index(u32).
if body.len() >= 8 && body[0] == AF_INET {
let index = u32::from_ne_bytes(body[4..8].try_into().ok()?);
if index == ifindex {
let mut attrs = &body[8..];
let mut fallback: Option<Ipv4Addr> = None;
while attrs.len() >= 4 {
let rta_len =
u16::from_ne_bytes(attrs[0..2].try_into().ok()?) as usize;
let rta_type = u16::from_ne_bytes(attrs[2..4].try_into().ok()?);
if rta_len < 4 || rta_len > attrs.len() {
break;
}
let payload = &attrs[4..rta_len];
if payload.len() == 4 {
let octets: [u8; 4] = payload.try_into().ok()?;
match rta_type {
IFA_LOCAL => return Some(Ipv4Addr::from(octets)),
IFA_ADDRESS => fallback = Some(Ipv4Addr::from(octets)),
_ => {}
}
}
let step = align4(rta_len);
if step == 0 || step > attrs.len() {
break;
}
attrs = &attrs[step..];
}
if let Some(addr) = fallback {
return Some(addr);
}
}
}
}
let step = align4(len);
if step == 0 {
return None;
}
off += step;
}
None
}
/// **What IPv4 address does this interface hold right now?**
///
/// The lookup behind `--grpc-bind <iface>:<port>`
/// ([`gunnar_common::bindspec::BindSpec`]). Over the same `NETLINK_ROUTE`
/// socket everything else in this file uses, because on the appliance `/sys`
/// is a mount point with nothing mounted on it — there is no sysfs shortcut
/// and there is no `ip(8)`.
///
/// `Ok(None)` is the honest answer for "the interface is there and has no
/// address" — which is what a DHCP handshake that has not completed looks
/// like. `Err` is "there is no such interface", or netlink refused. The caller
/// tells the two apart in its refusal sentence; neither is ever an address.
pub fn interface_ipv4(name: &str) -> Result<Option<Ipv4Addr>> {
let index = interface_index(name)?;
let sock = netlink_socket()?;
let dump = query_dump(&sock, &get_addr_dump_message(1), &format!("getaddr {name}"))?;
Ok(ipv4_from_addr_dump(&dump, index))
}
/// Send one dump request and read every answer until `NLMSG_DONE`.
///
/// [`query`] reads ONE datagram, which is right for `RTM_GETLINK` (one answer)
/// and wrong for a dump: the kernel splits a table across as many datagrams as
/// it needs, and a reader that took the first would silently see a prefix of
/// the table. On a two-NIC appliance the missing row is exactly the one being
/// looked for.
pub(crate) fn query_dump(
sock: &rustix::fd::OwnedFd,
message: &[u8],
what: &str,
) -> Result<Vec<u8>> {
use rustix::net::{recv, send, RecvFlags, SendFlags};
send(sock, message, SendFlags::empty()).with_context(|| format!("netlink send ({what})"))?;
let mut out = Vec::new();
// A dump of a machine's address table is small; this cap exists so a
// misparse can never spin forever on a socket that keeps answering.
for _ in 0..64 {
let mut buf = vec![0u8; 32 * 1024];
let (n, _truncated) = recv(sock, &mut buf[..], RecvFlags::empty())
.with_context(|| format!("netlink recv ({what})"))?;
buf.truncate(n);
let done = dump_is_done(&buf);
out.extend_from_slice(&buf);
if done || n == 0 {
return Ok(out);
}
}
Ok(out)
}
/// Does this datagram carry the `NLMSG_DONE` that ends a dump?
fn dump_is_done(buf: &[u8]) -> bool {
let mut off = 0usize;
while off + NLMSGHDR_LEN <= buf.len() {
let Ok(len_bytes) = buf[off..off + 4].try_into() else {
return true;
};
let len = u32::from_ne_bytes(len_bytes) as usize;
let Ok(kind_bytes) = buf[off + 4..off + 6].try_into() else {
return true;
};
let kind = u16::from_ne_bytes(kind_bytes);
if kind == NLMSG_DONE || kind == NLMSG_ERROR {
return true;
}
if len < NLMSGHDR_LEN {
return true;
}
off += align4(len);
}
false
}
pub fn bring_up_loopback() -> Result<()> {
let index = interface_index("lo")?;
let sock = netlink_socket()?;
request(&sock, &link_up_message(1, index), "lo up")?;
Ok(())
}
/// Bring `interface` up and return its index.
///
/// The half of [`apply`] the DHCP path needs *before* it has an address: a
/// DISCOVER cannot leave a downed link. Factored out rather than duplicated so
/// "who sets IFF_UP" has one answer; `apply` re-sends it, which is idempotent
/// (`ifi_change` masks only the UP bit and up-to-up is a no-op).
pub fn bring_link_up(interface: &str) -> Result<u32> {
let index = interface_index(interface)?;
let sock = netlink_socket()?;
request(
&sock,
&link_up_message(1, index),
&format!("{interface} up"),
)?;
Ok(index)
}
/// Apply a [`NetPlan`]: link up, address, default route.
///
/// The ONE writer of addresses and routes, whichever way the plan was learned:
/// the static the net spec form arrives here parsed, and the DHCP lease arrives
/// here converted ([`bring_up_network`]'s Dhcp arm). There is deliberately no
/// second "apply the lease" path to drift against this one.
pub fn apply(plan: &NetPlan) -> Result<()> {
let index = bring_link_up(&plan.interface)?;
let sock = netlink_socket()?;
// A live-update RE-EXEC starts the new PID 1 while the kernel still holds the lease
// (the ONE box 2026-10-17: EEXIST, "eth0 NOT up", no renewal loop). The kernel says
// EEXIST for an address only when THIS address is already on the interface: applied.
let what = format!("{}/{} on {}", plan.address, plan.prefix, plan.interface);
if request_or_there(&sock, &address_message(2, index, plan.address, plan.prefix), &what)? == Ack::AlreadyThere {
tracing::info!(interface = %plan.interface, address = %what, "the address was already on the interface (a re-exec keeps the kernel's network): applied");
}
if let Some(gateway) = plan.gateway {
// A default route is EEXIST whatever its gateway, so it is REPLACED, never assumed:
// the plan's gateway is the one that stands.
let what = format!("default via {gateway}");
if request_or_there(&sock, &default_route_message(3, index, gateway), &what)? == Ack::AlreadyThere {
request(&sock, &with_replace(default_route_message(4, index, gateway)), &what)?;
tracing::info!(interface = %plan.interface, route = %what, "a default route was already there (a re-exec): replaced by the plan's");
}
}
Ok(())
}
/// `EEXIST` from [`request`] (its errors carry the kernel's errno as text).
pub(crate) fn is_eexist(e: &anyhow::Error) -> bool {
let text = format!("{e:#}");
text.contains("File exists") || text.contains("EEXIST")
}
/// The same message with `NLM_F_REPLACE` in place of `NLM_F_EXCL`.
pub(crate) fn with_replace(mut msg: Vec<u8>) -> Vec<u8> {
let flags = u16::from_ne_bytes([msg[6], msg[7]]);
msg[6..8].copy_from_slice(&((flags & !NLM_F_EXCL) | NLM_F_REPLACE).to_ne_bytes());
msg
}
/// A DHCP lease in hand, and what is needed to keep it alive: the caller
/// spawns [`dhcp_renewal`] with this once the runtime is up.
#[cfg(feature = "dhcp")]
#[derive(Debug, Clone)]
pub struct DhcpSession {
/// The interface the lease is applied to.
pub interface: String,
/// Its MAC — `chaddr` for every renewal REQUEST.
pub mac: [u8; 6],
/// The lease as granted at boot.
pub lease: crate::dhcp::Lease,
/// Whether this is the FIRST entry of the net spec, and so the one that
/// owns the box-wide singletons: the default route, the hostname — and now
/// `/etc/resolv.conf`.
///
/// Carried on the session because the renewal loop needs the same answer
/// the boot path had, and there is exactly one resolver file for however
/// many interfaces the declaration names. `eth0:dhcp,eth1:dhcp` is a public
/// NIC and a utility one; letting the utility network's lease overwrite the
/// resolver on its own T1 would be the same coin flip over egress that
/// `NetConfig::parse_list` refuses for the default route, except it would
/// happen hours after boot with nobody watching.
pub owns_default_route: bool,
}
/// **With `dhcp` off there is no such thing as a lease**, and this type
/// says so in the type system rather than in a comment: it is UNINHABITED.
///
/// It exists so [`bring_up_network`] keeps ONE signature across both builds
/// (one implementation — the alternative is two copies of a forty-line function that differ
/// by one arm). Its `Option<DhcpSession>` is then provably `None`, and every
/// downstream `Some(session)` branch is a `match session {}` the compiler
/// proves unreachable — a by-construction guarantee, not a runtime check.
#[cfg(not(feature = "dhcp"))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DhcpSession {}
/// **What the product says about itself to the bring-up** — the three
/// things gunnar-server hard-wired (its host-name prefix, the name it proves
/// the resolver on, and where the "network up" line goes), as values.
pub struct Bringup<'a> {
/// A box with no lease name is called `<prefix>-<last three MAC bytes>`.
pub hostname_prefix: &'a str,
/// One `getaddrinfo` of this name once the resolver is published, to prove
/// it answers, asked again for about 30 s when the first try misses
/// ([`prove_with`], [`RESOLVER_RETRY_WAITS`]); `None`: no proof asked.
pub resolver_probe: Option<&'a str>,
/// Handed each "network up" / "network NOT up" line — the moment the
/// boot narration can leave ([`crate::narration::ship_boot_narration`]).
pub narrate: &'a dyn Fn(&str),
}
/// **What the product says about a lease that comes AFTER the boot** (lane G6): a
/// boot DHCP that failed is asked again on a thread of its own (lane D12), long after
/// [`bring_up_network`] returned and its [`Bringup`] borrow ended. So what that thread
/// needs is `'static`: where the "network up" line goes, and the product's T1 test
/// seam. The host-name prefix and the resolver probe are copied off the [`Bringup`].
#[derive(Debug, Clone, Copy)]
pub struct LateLease {
/// Handed the late lease's "network up" line — the boot lease's own line
/// ([`Bringup::narrate`]'s), with the retry's count after it.
pub narrate: fn(&str),
/// The T1 test seam's variable (`crate::runtime::dhcp_t1_override`), read when the
/// late lease lands, as the boot's sessions are; `None`: no seam.
pub t1_override_var: Option<&'static str>,
}
impl LateLease {
/// A late lease said nowhere and renewed on the server's clock: what
/// [`bring_up_network`] (no [`LateLease`] given) does.
pub const QUIET: LateLease = LateLease { narrate: say_nothing, t1_override_var: None };
}
fn say_nothing(_: &str) {}
/// Everything PID 1 owes the system before the listeners bind.
///
/// Called by the product's PID 1 before its listeners bind. The failure grades are deliberate and
/// different:
///
/// * a **malformed** the net spec, or a static plan the kernel refuses, is an
/// error — a declaration that lies must stop the boot, the same treatment
/// `Readiness::RefuseToServe` gives a broken identity;
/// * a **DHCP timeout** is a WARN and the boot goes on unreachable, exactly
/// the posture of a missing the net spec: the world failed to answer, the
/// declaration did not lie, and no address is ever invented to paper over
/// the difference.
///
/// Returns `Some` exactly when a DHCP lease was taken — the one case with a
/// follow-up duty (renewal). Static and unconfigured paths return `None` and
/// stay exactly what they were.
pub fn bring_up_network(plans: Vec<NetConfig>, how: &Bringup<'_>) -> Result<Vec<DhcpSession>> {
bring_up_network_with(plans, how, LateLease::QUIET)
}
/// [`bring_up_network`], and a boot DHCP that failed and is asked again in the
/// background lands as `late` says (lane G6): its "network up" line said through
/// `late.narrate`, its renewal on the product's T1 seam, its resolver proven with
/// [`Bringup::resolver_probe`] — as the boot's own lease is.
pub fn bring_up_network_with(plans: Vec<NetConfig>, how: &Bringup<'_>, late: LateLease) -> Result<Vec<DhcpSession>> {
if plans.is_empty() {
if is_init() {
tracing::warn!(
"running as pid 1 with no net spec: no interface will be \
configured, and unless something else already did it this server is \
unreachable. Pass <NET>=<iface>:<addr>/<prefix>[:<gateway>] or \
<NET>=<iface>:dhcp on the kernel command line."
);
}
return Ok(Vec::new());
}
bring_up_loopback().context("bringing the loopback interface up")?;
// A box laid with fewer NICs than the spec names (the lean estate's main and twin: a
// utility interface alone): the NICs it has, the first of them owning the default route.
let (plans, skipped) = present_only(plans, interface_exists);
for name in &skipped {
tracing::info!(interface = %name, "the net spec names {name}, which this box was not laid with: skipped (a box behind its front has its utility NIC alone)");
(how.narrate)(&format!("network: {name} is not on this box, skipped"));
}
let mut sessions = Vec::new();
for (position, config) in plans.into_iter().enumerate() {
// **Only the first plan may install a default route.** See
// `NetConfig::parse_list`: a second default through a utility network
// with no way out is a coin flip over egress, not a richer setup.
let owns_default_route = position == 0;
if let Some(session) = bring_up_one(config, owns_default_route, how, late)? {
sessions.push(session);
}
}
Ok(sessions)
}
/// Pure: **the net spec's entries for the NICs this box has** (owner 2026-10-23, the lean
/// estate: a box laid with a utility interface alone boots the same two-NIC spec as every other
/// box). `exists` says whether an interface is there; the entries it denies are returned by
/// name, to be said once. A spec none of whose interfaces exist is kept whole: the boot goes on
/// as before and says what failed.
pub fn present_only(plans: Vec<NetConfig>, exists: impl Fn(&str) -> bool) -> (Vec<NetConfig>, Vec<String>) {
if !plans.iter().any(|c| exists(c.interface())) {
return (plans, Vec::new());
}
let (kept, gone): (Vec<NetConfig>, Vec<NetConfig>) = plans.into_iter().partition(|c| exists(c.interface()));
(kept, gone.iter().map(|c| c.interface().to_string()).collect())
}
/// Whether the kernel has a network interface of this name (`/sys/class/net/<name>`), asked a
/// few times over two seconds: a virtio NIC is probed before init runs, so one that is not
/// there by then is a NIC the box was not laid with.
fn interface_exists(name: &str) -> bool {
let at = std::path::Path::new("/sys/class/net").join(name);
for _ in 0..10 {
if at.exists() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
/// One entry of the net spec. Split out of [`bring_up_network`] so the loop
/// over entries and the per-entry failure grades stay separate things.
fn bring_up_one(config: NetConfig, owns_default_route: bool, how: &Bringup<'_>, late: LateLease) -> Result<Option<DhcpSession>> {
#[cfg(not(feature = "dhcp"))]
let _ = late;
match config {
NetConfig::Static(mut plan) => {
if !owns_default_route && plan.gateway.take().is_some() {
tracing::info!(
interface = %plan.interface,
"the net spec names a gateway on a non-first interface; the address is \
applied and the route is NOT — the first entry owns the default route"
);
}
apply(&plan).with_context(|| format!("configuring {}", plan.interface))?;
tracing::info!(
interface = %plan.interface,
address = %plan.address,
prefix = plan.prefix,
gateway = ?plan.gateway,
"network configured from the net spec over netlink; no ip(8), no shell"
);
(how.narrate)(&format!(
"network up: {} {}/{} gw {:?} (static)",
plan.interface, plan.address, plan.prefix, plan.gateway
));
Ok(None)
}
#[cfg(feature = "dhcp")]
NetConfig::Dhcp { interface } => match configure_via_dhcp(&interface, owns_default_route, how, false) {
Ok(session) => {
tracing::info!(
interface = %interface,
address = %session.lease.address,
prefix = session.lease.prefix,
gateway = ?session.lease.gateway,
server = %session.lease.server,
lease_secs = session.lease.lease_secs,
"network configured from the net spec over DHCP + netlink; no ip(8), \
no shell, no dhclient"
);
// The box gets its name here, from the lease when the server
// named it (option 12) and from its own MAC otherwise — the
// kernel boots as "(none)" and nothing else ever sets it.
if owns_default_route {
apply_hostname(&hostname_for(
how.hostname_prefix,
session.lease.host_name.as_deref(),
session.mac,
));
}
// THE moment the debug channel exists: the lease is on the
// interface, so korp-start's spooled narration can leave now.
(how.narrate)(&lease_note(&session));
Ok(Some(session))
}
Err(err) => {
tracing::warn!(
interface = %interface,
"<NET>={interface}:dhcp and the handshake FAILED at boot: {err:#}. No \
address is configured yet — a made-up one would be worse — and PID 1 \
asks again in the background ({} s doubling to {} s, until a lease \
comes); a step that needs {interface}'s address waits for it \
(dhcp_pending).",
DHCP_RETRY_FIRST.as_secs(),
DHCP_RETRY_CEILING.as_secs()
);
retry_dhcp_in_background(&interface, owns_default_route, how.hostname_prefix, how.resolver_probe, late);
// Attempted anyway: without an address the sends fail and are
// swallowed, but a link something else addressed still carries it.
(how.narrate)(&format!(
"network NOT up: DHCP on {interface} failed: {err:#}"
));
Ok(None)
}
},
}
}
/// The first wait before a failed boot DHCP is asked again (lane D12; Gunnar's
/// `pid1::DHCP_RETRY_FIRST`, chaos g7): doubling to [`DHCP_RETRY_CEILING`], forever.
pub const DHCP_RETRY_FIRST: std::time::Duration = std::time::Duration::from_secs(2);
/// The longest wait between two asks.
pub const DHCP_RETRY_CEILING: std::time::Duration = std::time::Duration::from_secs(30);
/// **Ask until it answers** — the retry of a DHCP that failed at boot (Gunnar's
/// `keep_asking`): `ask` after a wait of [`DHCP_RETRY_FIRST`] doubling to
/// [`DHCP_RETRY_CEILING`], forever; a failure said at INFO on the 1st, 2nd, 4th, 8th … ask,
/// the lease said once. Returns the lease and how many asks it took.
pub fn keep_asking<T, E: std::fmt::Display>(mut ask: impl FnMut() -> Result<T, E>, mut sleep: impl FnMut(std::time::Duration), mut say: impl FnMut(bool, String)) -> (T, u32) {
let mut wait = DHCP_RETRY_FIRST;
let mut failed = 0u32;
let mut waited = std::time::Duration::ZERO;
loop {
sleep(wait);
waited += wait;
match ask() {
Ok(v) => {
say(false, format!("lease taken after {} ask(s) over {} s — the boot's DHCP had failed", failed + 1, waited.as_secs()));
return (v, failed + 1);
}
Err(e) => {
failed += 1;
if failed.is_power_of_two() {
say(false, format!("still no lease after {failed} ask(s) over {} s ({e}) — asking again, every {} s at most", waited.as_secs(), DHCP_RETRY_CEILING.as_secs()));
}
}
}
wait = (wait * 2).min(DHCP_RETRY_CEILING);
}
}
/// The interfaces whose boot DHCP failed and that PID 1 still asks for in the background.
static DHCP_PENDING: std::sync::Mutex<std::collections::BTreeSet<String>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
/// **Is `interface`'s lease still being asked for in the background?** A step that needs
/// that interface's address waits while this is true (Gunnar's `dhcp_pending`).
pub fn dhcp_pending(interface: &str) -> bool {
DHCP_PENDING.lock().unwrap_or_else(|p| p.into_inner()).contains(interface)
}
/// **Keep asking for `interface`'s lease on a thread of its own** (lane D12): `ask` through
/// [`keep_asking`] with `sleep`, `interface` [`dhcp_pending`] until the lease comes, then
/// `took` with it (the caller keeps it alive). The seam the real retry runs through.
pub fn retry_in_background<T: Send + 'static, E: std::fmt::Display + 'static>(
interface: &str,
ask: impl FnMut() -> Result<T, E> + Send + 'static,
sleep: impl FnMut(std::time::Duration) + Send + 'static,
took: impl FnOnce(T, u32) + Send + 'static,
) -> std::io::Result<std::thread::JoinHandle<()>> {
let name = interface.to_string();
DHCP_PENDING.lock().unwrap_or_else(|p| p.into_inner()).insert(name.clone());
// A32-14, stays a thread: PID 1's DHCP retry for one interface, ended by its lease; it runs before any job ledger exists.
let spawned = std::thread::Builder::new().name(format!("dhcp-retry-{interface}")).spawn(move || {
let (lease, tries) = keep_asking(ask, sleep, |_, line| tracing::info!(interface = %name, "DHCP: {line}"));
DHCP_PENDING.lock().unwrap_or_else(|p| p.into_inner()).remove(&name);
took(lease, tries);
});
if spawned.is_err() {
DHCP_PENDING.lock().unwrap_or_else(|p| p.into_inner()).remove(interface);
}
spawned
}
/// **The real retry of a boot DHCP that failed** (lane D12): [`retry_in_background`] over
/// [`configure_via_dhcp`] on the wire, the late lease applied as the boot's would have been
/// (address, the lease's routes, the resolver), then landed as the boot's lease lands
/// ([`late_landing`], lane G6): the hostname when this NIC owns the default route, the
/// resolver proven, the boot's "network up" line said through `late.narrate`, and the
/// lease kept alive by [`crate::runtime::dhcp_renewal`] on the product's T1 seam.
#[cfg(feature = "dhcp")]
fn retry_dhcp_in_background(interface: &str, owns_default_route: bool, hostname_prefix: &str, resolver_probe: Option<&str>, late: LateLease) {
let (iface, prefix, probe) = (interface.to_string(), hostname_prefix.to_string(), resolver_probe.map(str::to_string));
let spawned = retry_in_background(
interface,
move || {
let quiet = |_: &str| {};
// The probe runs once the lease LANDS (below), not inside every ask.
let how = Bringup { hostname_prefix: &prefix, resolver_probe: None, narrate: &quiet };
// Quiet (row 28): the boot said its failure once at WARN; keep_asking says
// its own bounded lines, each attempt only at debug.
configure_via_dhcp(&iface, owns_default_route, &how, true).map(|session| (session, prefix.clone(), probe.clone())).map_err(|e| format!("{e:#}"))
},
std::thread::sleep,
move |(session, prefix, probe): (DhcpSession, String, Option<String>), tries| {
#[cfg(feature = "runtime")]
let t1 = |var: &str| crate::runtime::dhcp_t1_override(var);
#[cfg(not(feature = "runtime"))]
let t1 = |_: &str| None;
let landing = late_landing(&session, tries, &prefix, probe.as_deref(), &late, t1);
if let Some(name) = landing.resolver_probe.as_deref() {
prove_resolver(name);
}
if let Some(name) = landing.hostname.as_deref() {
apply_hostname(name);
}
tracing::info!(
interface = %session.interface,
address = %session.lease.address,
prefix = session.lease.prefix,
gateway = ?session.lease.gateway,
server = %session.lease.server,
lease_secs = session.lease.lease_secs,
asks = tries,
"network up: configured over DHCP after the boot's failure; no ip(8), no shell, no dhclient"
);
(late.narrate)(&landing.note);
#[cfg(feature = "runtime")]
match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(rt) => rt.block_on(crate::runtime::dhcp_renewal(session, landing.t1_override)),
Err(e) => tracing::error!(error = %e, "DHCP: no runtime to renew the late lease on — it lapses at its expiry"),
}
#[cfg(not(feature = "runtime"))]
tracing::warn!(interface = %session.interface, "DHCP: this build has no renewal runtime — the late lease lapses at its expiry");
},
);
if let Err(e) = spawned {
tracing::error!(interface = %interface, error = %e, "DHCP: the background retry could not start — this interface stays without an address");
}
}
/// **The "network up" line of a DHCP lease**, said once it is on the interface — one
/// wording for the boot's lease and a late one ([`late_landing`] adds the count).
#[cfg(feature = "dhcp")]
fn lease_note(session: &DhcpSession) -> String {
format!(
"network up: {} {}/{} gw {:?} via DHCP from {} (lease {}s)",
session.interface, session.lease.address, session.lease.prefix, session.lease.gateway, session.lease.server, session.lease.lease_secs
)
}
/// What a late lease does once it lands (pure; [`retry_dhcp_in_background`] does it).
#[cfg(feature = "dhcp")]
#[derive(Debug, Clone, PartialEq, Eq)]
struct LateLanding {
/// The boot lease's line, with the retry's count after it.
note: String,
/// The box's name, when this NIC owns the default route.
hostname: Option<String>,
/// The name the published resolver is proven on, when this NIC published it.
resolver_probe: Option<String>,
/// The renewal's T1, from the product's seam.
t1_override: Option<u32>,
}
/// **How a late lease lands** (lane G6), the way a boot lease does: said with the boot's
/// line, the box named and the resolver proven when this NIC owns the default route (only
/// that one publishes the resolver), and renewed on the product's T1 seam (`t1` reads it:
/// `crate::runtime::dhcp_t1_override` on the wire).
#[cfg(feature = "dhcp")]
fn late_landing(session: &DhcpSession, asks: u32, hostname_prefix: &str, resolver_probe: Option<&str>, late: &LateLease, t1: impl FnOnce(&str) -> Option<u32>) -> LateLanding {
let owns = session.owns_default_route;
LateLanding {
note: format!("{} — after the boot's failure, {asks} asks", lease_note(session)),
hostname: owns.then(|| hostname_for(hostname_prefix, session.lease.host_name.as_deref(), session.mac)),
resolver_probe: resolver_probe.filter(|_| owns).map(str::to_string),
t1_override: late.t1_override_var.and_then(t1),
}
}
/// The Dhcp arm of [`bring_up_network`]: link up, ask, apply what was granted.
///
/// The lease is turned into a plain [`NetPlan`] and pushed through [`apply`] —
/// the same netlink writer the static path uses, so DHCP adds a *source* of
/// addresses, not a second way of setting them.
#[cfg(feature = "dhcp")]
fn configure_via_dhcp(interface: &str, owns_default_route: bool, how: &Bringup<'_>, quiet: bool) -> Result<DhcpSession> {
bring_link_up(interface).with_context(|| format!("bringing {interface} up for DHCP"))?;
let mac = interface_mac(interface)?;
let lease = crate::dhcp::obtain_with(interface, mac, quiet)?;
let plan = NetPlan {
interface: interface.to_string(),
address: lease.address,
prefix: lease.prefix,
// A utility network's DHCP server offers a router option like any
// other. Taking it would give the box a second default route through
// a network that does not reach the internet.
gateway: if owns_default_route {
lease.gateway
} else {
None
},
};
apply(&plan).with_context(|| format!("applying the DHCP lease to {interface}"))?;
// THE ROUTES THE LEASE NAMED. Option 3 and option 1 together describe a
// flat network; a cloud's private network is not one. Without this the
// appliance's utility NIC knew only its own /22 and sent everything else
// out the PUBLIC interface, where a private destination dies — which is
// exactly how it came up two hours out of time with nothing to say about
// it. See `dhcp::Lease::routes` for the measurement.
apply_leased_routes(interface, &lease.routes, owns_default_route);
// The resolver comes AFTER the address is on the interface and is NEVER
// allowed to undo it. A box with an address and no name service still
// serves git over IP:port; a box that refused its lease because a file
// write failed serves nothing at all. So this is a separate statement with
// its own failure grade, not a `?` in the chain above.
if owns_default_route {
publish_resolvers(interface, &lease.dns);
// ★ Prove it, once, on the console: one lookup through the same
// libc every dial site uses. A resolver file that exists and a
// resolver that answers are two different facts, and the 2026-09-08
// re-image to add the file could not tell which one it was still
// missing. The name is gunnar's own forge; nothing waits on this
// beyond the 2 s x 2 the file itself sets.
if let Some(name) = how.resolver_probe {
prove_resolver(name);
}
}
Ok(DhcpSession {
interface: interface.to_string(),
mac,
lease,
owns_default_route,
})
}
/// **The option-121 routes, onto the interface that leased them.**
///
/// Never returns an error, on the same grading as [`publish_resolvers`]: the
/// address is already on the interface and taking the network away because one
/// route out of four was refused would be worse than the missing route. Every
/// outcome gets a line instead, because a silently skipped route is precisely
/// the shape of the defect this function closes.
///
/// Two rules, and both are about not letting a private network decide egress:
///
/// * a `/0` from option 121 is a DEFAULT route, and only the interface that
/// owns the default route may install one. The utility network's DHCP server
/// is as free to offer one as it is to offer option 3, and
/// `configure_via_dhcp` already refuses option 3 on that interface for the
/// same reason: it does not reach the internet.
/// * a route is installed on the interface whose lease named it, never
/// box-wide, so two `dhcp` entries in the net spec cannot fight.
///
/// `EEXIST` is success: the route is there, which is the whole ask, and a
/// renewal that re-applies an unchanged lease must not turn red.
#[cfg(feature = "dhcp")]
fn apply_leased_routes(interface: &str, routes: &[crate::dhcp::Route], owns_default_route: bool) {
if routes.is_empty() {
tracing::info!(
interface,
"the lease named no classless static routes (option 121); this interface reaches only \
its own prefix"
);
return;
}
let index = match bring_link_up(interface) {
Ok(i) => i,
Err(e) => {
tracing::warn!(interface, error = %e, "cannot install the leased routes: no interface index");
return;
}
};
let sock = match netlink_socket() {
Ok(s) => s,
Err(e) => {
tracing::warn!(interface, error = %e, "cannot install the leased routes: no netlink socket");
return;
}
};
// Sequence numbers start well past the ones `apply` used on its own socket,
// so a reply is never matched to the wrong request while reading a log.
let mut seq = 100u32;
for r in routes {
if r.is_default() && !owns_default_route {
tracing::info!(
interface,
gateway = %r.gateway,
"option 121 offered a DEFAULT route on an interface that does not own egress — not taken"
);
continue;
}
seq += 1;
let msg = route_message(seq, index, r.destination, r.prefix, r.gateway);
let what = if r.gateway.is_unspecified() {
format!("{}/{} on-link via {interface}", r.destination, r.prefix)
} else {
format!("{}/{} via {} dev {interface}", r.destination, r.prefix, r.gateway)
};
match request(&sock, &msg, &what) {
Ok(()) => tracing::info!(interface, route = %what, "route from the lease (option 121)"),
Err(e) => {
if is_eexist(&e) {
tracing::info!(interface, route = %what, "route from the lease was already there");
} else {
tracing::warn!(interface, route = %what, error = %format!("{e:#}"), "a leased route could not be installed");
}
}
}
}
}
/// Put the lease's option-6 servers where libc looks, and **say what happened**.
///
/// Never returns an error, and that is the whole design of the function: it is
/// called with a working lease already on the interface, and there is no
/// failure here that is worth taking the network away for. What there is
/// instead is a log line for every outcome, because the alternative — a silent
/// skip — is precisely the shape of the bug this fix exists to close: an
/// appliance that boots, looks healthy, and cannot say `github.com`.
///
/// `boot` splits "say it once" from "say it on every renewal": at boot every
/// outcome is narrated, including the two quiet ones, because that is the
/// operator's one chance to learn that this box has no resolver. On a renewal
/// only a *changed* file is news; an unchanged one fires twice a day for the
/// life of the appliance.
#[cfg(feature = "dhcp")]
pub(crate) fn publish_resolvers_at(interface: &str, servers: &[std::net::Ipv4Addr], boot: bool) {
use crate::resolver::Outcome;
match crate::resolver::publish(servers) {
Ok(Outcome::Wrote { path, servers }) => {
let list = servers
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(", ");
tracing::info!(
interface,
path = %path.display(),
nameservers = %list,
"resolver published from the DHCP lease (option 6): every \
getaddrinfo in this process now has a server to ask"
);
}
Ok(Outcome::Unchanged) => {
if boot {
tracing::info!(
interface,
path = crate::resolver::RESOLV_CONF,
"the resolver file already named exactly this lease's servers; \
nothing rewritten"
);
}
}
Ok(Outcome::NoServers) => {
if boot {
tracing::warn!(
interface,
"the DHCP lease on {interface} carried NO domain name servers \
(option 6). The address and route are applied and this server \
is reachable by IP, but nothing in it can resolve a hostname: \
`mirror sync` from an https:// or ssh:// URL will fail with a \
DNS error until the DHCP server sends option 6. No resolver \
was invented and no existing one was taken away."
);
}
}
Err(err) => tracing::error!(
interface,
path = crate::resolver::RESOLV_CONF,
error = %format!("{err:#}"),
"FAILED to write the resolver file on the RAM root. The DHCP lease \
IS applied and this server is reachable by IP — but no hostname \
will resolve in this process until this write succeeds."
),
}
}
/// The boot call. See [`publish_resolvers_at`].
#[cfg(feature = "dhcp")]
fn publish_resolvers(interface: &str, servers: &[std::net::Ipv4Addr]) {
publish_resolvers_at(interface, servers, true);
}
/// **The appliance's host name.** The DHCP server's option 12 when it sent
/// one (trimmed to what a host name may hold), else `<prefix>-<last three MAC
/// bytes>` — stable across reboots, unique per NIC, never a fabricated
/// "localhost". Pure: the caller decides whether to apply it.
pub fn hostname_for(prefix: &str, lease_name: Option<&str>, mac: [u8; 6]) -> String {
let clean: String = lease_name
.unwrap_or("")
.chars()
.take(63)
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '.')
.collect();
let clean = clean.trim_matches(|c| c == '-' || c == '.').to_owned();
if clean.is_empty() {
format!("{prefix}-{:02x}{:02x}{:02x}", mac[3], mac[4], mac[5])
} else {
clean
}
}
/// Write the name into the kernel — PID 1 only, and only while the kernel
/// still says "(none)": a developer box or a container keeps the name it has.
/// `/proc/sys/kernel/hostname` is `sethostname(2)` through procfs, so no new
/// syscall binding is needed. Never fatal: a name is a convenience, the
/// dashboard shows "(none)" honestly if this fails.
pub fn apply_hostname(name: &str) {
if !is_init() {
return;
}
let current = std::fs::read_to_string("/proc/sys/kernel/hostname").unwrap_or_default();
let current = current.trim();
if !current.is_empty() && current != "(none)" && current != "localhost" {
return;
}
match std::fs::write("/proc/sys/kernel/hostname", format!("{name}\n")) {
Ok(()) => tracing::info!(hostname = name, "the box is named"),
Err(e) => tracing::warn!(hostname = name, error = %e, "could not set the host name"),
}
}
#[cfg(test)]
mod hostname_tests {
use super::hostname_for;
#[test]
fn the_lease_name_wins_and_is_cleaned() {
assert_eq!(hostname_for("box", Some("gitgunnar"), [0; 6]), "gitgunnar");
assert_eq!(
hostname_for("box", Some(" git gunnar.cloud\0"), [0; 6]),
"gitgunnar.cloud"
);
assert_eq!(hostname_for("box", Some("-bad-"), [0; 6]), "bad");
}
#[test]
fn no_name_means_the_mac_names_the_box_never_localhost() {
let mac = [0x52, 0x54, 0x00, 0xab, 0xcd, 0xef];
assert_eq!(hostname_for("box", None, mac), "box-abcdef");
assert_eq!(hostname_for("box", Some(""), mac), "box-abcdef");
assert_eq!(hostname_for("box", Some("---"), mac), "box-abcdef");
}
}
/// One `getaddrinfo` through libc, narrated: the boot-time proof that the
/// resolver `publish_resolvers` wrote is a resolver something answers on. The
/// error text is libc's own — "Name does not resolve" is a file nobody wrote or
/// a name nobody has; "Temporary failure in name resolution" is a server that
/// did not answer, i.e. UDP 53 not coming back — and that distinction is the
/// whole reason this exists.
///
/// Public, and on `net` alone (lane G6): a product that publishes or inherits its
/// resolver without edda's DHCP client proves it the same way.
pub fn prove_resolver(name: &str) {
let started = std::time::Instant::now();
match lookup_once(name) {
Ok(list) => tracing::info!(
name,
answered_in_ms = started.elapsed().as_millis() as u64,
addresses = %list,
"resolver proven: libc resolved a name through the published file"
),
// A first query can miss (a resolver not warm yet, a lost datagram): MEASURED on
// test2's twin (Gunnar cf05cce1) — `Try again` 2 s after the provider's resolvers
// were published, the front proving the same ones in 1 ms. Not an ERROR yet: asked
// again in the background for about 30 s, and only the whole window failing is one.
Err(err) => {
tracing::info!(name, after_ms = started.elapsed().as_millis() as u64, error = %err, "resolver not proven on the first try; asking again for about 30 s");
let owned = name.to_owned();
// A32-14, stays a thread: PID 1's resolver proof, asked again for about 30 s, before any job ledger exists.
let spawned = std::thread::Builder::new().name("resolver-proof".into()).spawn(move || {
let name = owned;
let t0 = std::time::Instant::now();
match prove_with(|| lookup_once(&name), std::thread::sleep, RESOLVER_RETRY_WAITS) {
Ok((tries, list)) => tracing::info!(
name = %name,
tries = tries + 1,
after_ms = t0.elapsed().as_millis() as u64,
addresses = %list,
"resolver proven after {} tries: libc resolved a name through the published file",
tries + 1
),
Err((tries, err)) => tracing::error!(
name = %name,
tries = tries + 1,
after_ms = t0.elapsed().as_millis() as u64,
error = %err,
"resolver NOT proven: libc could not resolve a name in {} tries over about 30 s; mirrors, webhooks and \
every outbound https call will fail the same way until this is fixed",
tries + 1
),
}
});
if let Err(e) = spawned {
tracing::error!(name, error = %e, first = %err, "resolver NOT proven, and the retry could not start");
}
}
}
}
/// One `getaddrinfo`: the addresses, joined, or libc's own words.
fn lookup_once(name: &str) -> Result<String, String> {
use std::net::ToSocketAddrs;
(name, 443u16).to_socket_addrs().map(|a| a.map(|a| a.ip().to_string()).collect::<Vec<_>>().join(", ")).map_err(|e| e.to_string())
}
/// The waits between the resolver proof's tries: about 30 s in all (Gunnar cf05cce1).
pub const RESOLVER_RETRY_WAITS: &[std::time::Duration] = &[
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(2),
std::time::Duration::from_secs(4),
std::time::Duration::from_secs(8),
std::time::Duration::from_secs(15),
];
/// **Try `lookup` until it answers**, waiting `waits` between tries (pure over its
/// inputs). `Ok((tries, answer))` / `Err((tries, last error))`, `tries` counted from 1.
pub fn prove_with(mut lookup: impl FnMut() -> Result<String, String>, mut sleep: impl FnMut(std::time::Duration), waits: &[std::time::Duration]) -> Result<(u32, String), (u32, String)> {
let mut tries = 0u32;
let mut last = String::new();
for i in 0..=waits.len() {
tries += 1;
match lookup() {
Ok(a) => return Ok((tries, a)),
Err(e) => last = e,
}
if let Some(w) = waits.get(i) {
sleep(*w);
}
}
Err((tries, last))
}
#[cfg(test)]
mod tests {
use super::*;
// ── the lean estate: a box with one NIC (2026-10-23) ───────────────────
/// ★ **A box laid with one NIC runs the two-NIC spec** (owner 2026-10-23, the lean estate:
/// main and the twin sit behind the front with a utility address only; the medium is the
/// same for every box and says `eth0:dhcp,eth1:dhcp`). The NIC the box has is configured —
/// and, being first, it owns the default route — and the NIC it does not have is skipped,
/// said once, never asked for in the background for ever. A spec none of whose NICs exist
/// is kept whole (something is wrong; the boot says so as before).
#[test]
fn a_nic_the_box_does_not_have_is_skipped_and_the_first_present_owns_the_route() {
let spec = NetConfig::parse_list("eth0:10.0.2.15/24:10.0.2.2,eth1:10.6.0.2/22").unwrap();
let (kept, skipped) = present_only(spec.clone(), |i| i == "eth0");
assert_eq!(kept.iter().map(|c| c.interface()).collect::<Vec<_>>(), ["eth0"]);
assert_eq!(skipped, ["eth1"]);
let (kept, skipped) = present_only(spec.clone(), |_| true);
assert_eq!((kept, skipped.len()), (spec.clone(), 0), "a two-NIC box: unchanged");
let (kept, skipped) = present_only(spec.clone(), |_| false);
assert_eq!((kept, skipped.len()), (spec, 0), "no NIC at all: kept whole");
}
// ── the plan parser ─────────────────────────────────────────────────────
#[test]
fn a_full_spec_parses_into_every_field() {
let plan = NetPlan::parse("eth0:10.0.2.15/24:10.0.2.2").expect("a legal spec");
assert_eq!(
plan,
NetPlan {
interface: "eth0".into(),
address: Ipv4Addr::new(10, 0, 2, 15),
prefix: 24,
gateway: Some(Ipv4Addr::new(10, 0, 2, 2)),
}
);
}
#[test]
fn the_gateway_is_optional_and_its_absence_is_not_an_error() {
let plan = NetPlan::parse("eth0:192.168.5.9/16").expect("a legal spec");
assert_eq!(plan.gateway, None);
assert_eq!(plan.prefix, 16);
}
/// Measure, never assert on the parser itself: it must reject, or "parsed" means nothing.
#[test]
fn a_malformed_spec_is_refused_rather_than_defaulted() {
for bad in [
"",
"eth0",
"eth0:10.0.2.15",
"eth0:10.0.2.15/33:10.0.2.2",
"eth0:not-an-address/24",
"eth0:10.0.2.15/24:not-a-gateway",
"eth0:10.0.2.15/24:10.0.2.2:extra",
] {
assert!(
NetPlan::parse(bad).is_err(),
"NetPlan::parse({bad:?}) was accepted; a parser that accepts everything \
configures nothing and reports success"
);
}
}
// ── the two-form the net spec parser ──────────────────────────────────────
#[cfg(feature = "dhcp")]
#[test]
fn the_dhcp_form_parses_and_names_its_interface() {
assert_eq!(
NetConfig::parse("eth0:dhcp").expect("the declared form"),
NetConfig::Dhcp {
interface: "eth0".into()
}
);
// Case on the keyword is operator input, not identity.
assert_eq!(
NetConfig::parse("ens3:DHCP").expect("case-insensitive keyword"),
NetConfig::Dhcp {
interface: "ens3".into()
}
);
}
#[test]
fn the_static_form_is_byte_compatible_through_netconfig() {
// The exact string every declaration carried before the dhcp form
// existed must keep meaning exactly what it meant.
assert_eq!(
NetConfig::parse("eth0:10.0.2.15/24:10.0.2.2").expect("the historical form"),
NetConfig::Static(NetPlan::parse("eth0:10.0.2.15/24:10.0.2.2").unwrap())
);
}
/// **The other half of `dhcp`: an appliance that cannot DHCP says
/// so, loudly, instead of booting dark.**
///
/// A binary built without the DHCP client still has to READ
/// `<NET>=eth0:dhcp` — the string is baked into media and boot
/// entries by declarations written before anyone unticked the box. The two
/// silent outcomes were both available and both wrong: fall through to
/// `NetPlan::parse` and blame the operator's spelling ("`\"dhcp\"` is not
/// <addr>/<prefix>") for a decision the BUILD made, or return "no network"
/// and serve on an interface nobody configured. This asserts the third
/// one: refuse, name the feature, and name the way out.
///
/// Compiled only in the OFF build, which is the only build in which the
/// refusal exists. `cargo test -p gunnar-server --no-default-features
/// --features server,engine-znippy` is where it runs.
///
/// PROVED RED 2026-08-22 by deleting the refusal and letting `:dhcp` fall
/// through to the static parser — the exact defect above. The failure is
/// `the refusal must NAME the missing feature ...: the net spec "eth0:dhcp":
/// "dhcp" is not <addr>/<prefix>`, which is both a red guard and a
/// measurement of what the silent path would have told the operator.
#[cfg(not(feature = "dhcp"))]
#[test]
fn a_dhcp_spec_is_refused_by_name_when_the_client_is_not_compiled_in() {
let err = NetConfig::parse("eth0:dhcp").expect_err(
"a binary with no DHCP client must REFUSE eth0:dhcp — treating it as \
'no network' boots an appliance that is unreachable and says nothing",
);
let text = format!("{err:#}");
assert!(
text.contains("`dhcp`"),
"the refusal must NAME the missing feature, or an operator reading a \
serial console cannot tell a build decision from a typo: {text}"
);
assert!(
text.contains("eth0"),
"the refusal must name the interface it was asked about: {text}"
);
assert!(
text.contains("<addr>/<prefix>"),
"the refusal must name the way OUT — the static spelling this build \
does support: {text}"
);
// Case on the keyword is operator input, not identity: the refusal has
// to cover the same spellings the acceptance did.
assert!(
NetConfig::parse("ens3:DHCP").is_err(),
"the case-insensitive keyword must be refused too"
);
// ...and the STATIC form is untouched by the gate. Without this the
// test above would pass just as well on a parser that refused
// everything, which is a guard that cannot report red.
assert_eq!(
NetConfig::parse("eth0:10.0.2.15/24:10.0.2.2").expect("the static form still parses"),
NetConfig::Static(NetPlan::parse("eth0:10.0.2.15/24:10.0.2.2").unwrap())
);
}
/// Measure, never assert on the extended parser: `dhcp` must not have widened what is
/// accepted anywhere else.
#[test]
fn dhcp_shaped_garbage_is_refused_rather_than_defaulted() {
for bad in [
"dhcp", // no interface at all
":dhcp", // an empty interface name
"eth0:dhcp:extra", // trailing field the dhcp form does not have
"eth0:dhcp/24", // half static, half dhcp
"eth0:dhc", // a typo must not silently become static-parse noise that
// reads like a dhcp attempt — it errors either way
] {
assert!(
NetConfig::parse(bad).is_err(),
"NetConfig::parse({bad:?}) was accepted"
);
}
}
// ── the golden vectors ──────────────────────────────────────────────────
//
// Little-endian, which is what `to_ne_bytes` produces on the only
// architecture this appliance is built for. The tests assert the whole
// buffer, not a length and a field: a `rtattr` that forgot its padding has
// the right length in its header and the wrong length on the wire.
#[test]
fn link_up_is_thirty_two_bytes_and_sets_the_change_mask() {
let msg = link_up_message(1, 2);
assert_eq!(
msg,
vec![
// nlmsghdr: len=32, type=RTM_NEWLINK(16), flags=REQUEST|ACK(5), seq=1, pid=0
32, 0, 0, 0, 16, 0, 5, 0, 1, 0, 0, 0, 0, 0, 0, 0,
// ifinfomsg: family=0, pad=0, type=0, index=2, flags=IFF_UP, change=IFF_UP
0, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0,
],
"the RTM_NEWLINK encoding moved"
);
// The bug this vector exists to catch, stated as its own assertion:
// ifi_change is the last four bytes and a zero there is a message that
// changes nothing and still returns success.
assert_eq!(
&msg[28..32],
&IFF_UP.to_ne_bytes(),
"ifi_change is zero, so this message would be acknowledged without bringing the \
link up"
);
}
#[test]
fn an_address_carries_local_address_and_broadcast() {
let msg = address_message(2, 2, Ipv4Addr::new(10, 0, 2, 15), 24);
assert_eq!(
msg,
vec![
// nlmsghdr: len=48, type=RTM_NEWADDR(20), flags=REQUEST|ACK|EXCL|CREATE(0x605)
48, 0, 0, 0, 20, 0, 5, 6, 2, 0, 0, 0, 0, 0, 0, 0,
// ifaddrmsg: AF_INET, /24, flags=0, scope=UNIVERSE, index=2
2, 24, 0, 0, 2, 0, 0, 0, // IFA_LOCAL 10.0.2.15
8, 0, 2, 0, 10, 0, 2, 15, // IFA_ADDRESS 10.0.2.15
8, 0, 1, 0, 10, 0, 2, 15, // IFA_BROADCAST 10.0.2.255
8, 0, 4, 0, 10, 0, 2, 255,
],
"the RTM_NEWADDR encoding moved"
);
}
#[test]
fn the_broadcast_address_is_computed_from_the_prefix_not_assumed() {
// A /16 must produce x.x.255.255, not x.x.x.255. An implementation that
// hardcoded the last octet passes the /24 vector above and fails here.
let msg = address_message(2, 2, Ipv4Addr::new(172, 16, 4, 9), 16);
let tail = &msg[msg.len() - 4..];
assert_eq!(
tail,
&[172, 16, 255, 255],
"the broadcast for 172.16.4.9/16 is 172.16.255.255"
);
}
#[test]
fn a_thirty_two_prefix_gets_no_broadcast_attribute() {
let msg = address_message(2, 2, Ipv4Addr::new(10, 0, 2, 15), 32);
assert_eq!(
msg.len(),
40,
"a /32 is a single host: a broadcast attribute there is meaningless"
);
}
#[test]
fn the_default_route_has_a_zero_destination_length() {
let msg = default_route_message(3, 2, Ipv4Addr::new(10, 0, 2, 2));
assert_eq!(
msg,
vec![
// nlmsghdr: len=44, type=RTM_NEWROUTE(24), flags=REQUEST|ACK|EXCL|CREATE
44, 0, 0, 0, 24, 0, 5, 6, 3, 0, 0, 0, 0, 0, 0, 0,
// rtmsg: AF_INET, dst_len=0, src_len=0, tos=0,
// table=MAIN(254), proto=BOOT(3), scope=UNIVERSE(0), type=UNICAST(1)
2, 0, 0, 0, 254, 3, 0, 1, 0, 0, 0, 0, // RTA_GATEWAY 10.0.2.2
8, 0, 5, 0, 10, 0, 2, 2, // RTA_OIF 2
8, 0, 4, 0, 2, 0, 0, 0,
],
"the RTM_NEWROUTE encoding moved"
);
assert_eq!(
msg[17], 0,
"rtm_dst_len must be 0; that is what makes it the default route"
);
}
/// **The route that reaches the front.** `10.0.0.0/8 via 10.13.4.1 dev
/// eth1` — the exact route option 121 hands out on UpCloud's utility
/// network, and the one whose absence left the appliance unable to ask the
/// time. Unlike the default route this one carries `RTA_DST`, and
/// `rtm_dst_len` is the prefix rather than zero.
#[test]
fn a_leased_route_carries_its_destination_and_its_prefix() {
let msg = route_message(101, 3, Ipv4Addr::new(10, 0, 0, 0), 8, Ipv4Addr::new(10, 13, 4, 1));
assert_eq!(msg[17], 8, "rtm_dst_len must be the prefix length");
assert_eq!(msg[16], AF_INET);
assert_eq!(msg[22], RT_SCOPE_UNIVERSE, "a route with a next hop is universe-scoped");
// RTA_DST 10.0.0.0, RTA_GATEWAY 10.13.4.1, RTA_OIF 3 — in that order.
assert_eq!(
msg[28..],
[8, 0, 1, 0, 10, 0, 0, 0, 8, 0, 5, 0, 10, 13, 4, 1, 8, 0, 4, 0, 3, 0, 0, 0],
"the leased-route encoding moved"
);
// …and the default route is still the same bytes it always was, which
// is what makes sharing one encoder safe.
assert_eq!(
default_route_message(3, 2, Ipv4Addr::new(10, 0, 2, 2)),
route_message(3, 2, Ipv4Addr::UNSPECIFIED, 0, Ipv4Addr::new(10, 0, 2, 2))
);
}
/// An on-link route (`via 0.0.0.0`, which RFC 3442 permits) carries NO
/// `RTA_GATEWAY` and is link-scoped. `via 0.0.0.0` is not a next hop.
#[test]
fn an_on_link_leased_route_names_no_gateway() {
let msg = route_message(102, 3, Ipv4Addr::new(192, 168, 9, 0), 24, Ipv4Addr::UNSPECIFIED);
assert_eq!(msg[22], RT_SCOPE_LINK);
assert_eq!(
msg[28..],
[8, 0, 1, 0, 192, 168, 9, 0, 8, 0, 4, 0, 3, 0, 0, 0],
"an on-link route must carry RTA_DST and RTA_OIF and nothing else"
);
}
/// **A guard that cannot fire, found by trying to make it.**
///
/// The three golden vectors above were asserted against an `rtattr` encoder
/// deliberately broken to put the *padded* length in `rta_len` — the classic
/// netlink mistake — and all three stayed **green**. Every attribute gunnar
/// sends carries a four-byte payload, so `rta_len` is 8, `RTA_ALIGN(8)` is
/// 8, and the two rules are indistinguishable in every message this file
/// produces. The vectors are still worth having; they simply cannot see
/// this class of bug, and believing otherwise is how a rule table ends up
/// trusted for something it never checked.
///
/// So the rule is asserted where it is actually visible: on a payload whose
/// length is not a multiple of four.
#[test]
fn rta_len_counts_the_payload_and_not_the_padding() {
let attr = rtattr(IFA_LOCAL, &[1, 2, 3]);
assert_eq!(
attr,
vec![7, 0, 2, 0, 1, 2, 3, 0],
"rta_len must be 7 (4 header + 3 payload) in an 8-byte padded buffer"
);
assert_eq!(
u16::from_ne_bytes([attr[0], attr[1]]),
7,
"rta_len is the padded length, so the kernel would read one byte of padding as data"
);
assert_eq!(
attr.len(),
8,
"the buffer itself must be padded to RTA_ALIGN"
);
}
#[test]
fn getlink_is_a_plain_request_with_the_index_and_no_ack_flag() {
let msg = get_link_message(1, 2);
assert_eq!(
msg,
vec![
// nlmsghdr: len=32, type=RTM_GETLINK(18), flags=REQUEST(1), seq=1, pid=0
32, 0, 0, 0, 18, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0,
// ifinfomsg: family=0, pad=0, type=0, index=2, flags=0, change=0
0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
],
"the RTM_GETLINK encoding moved"
);
// NLM_F_ACK here would make the kernel send an error message AFTER the
// data, and a reader that stops at the first message would still work —
// until a slow day reorders the reads. The flag byte is the guard.
assert_eq!(msg[6], 1, "flags must be NLM_F_REQUEST alone");
}
/// A synthetic `RTM_NEWLINK` answer with the attribute walk's hard case:
/// the MAC payload is 6 bytes, so `rta_len` is 10 and the step to the next
/// attribute is 12 — the first message in this file where `rta_len` and
/// `RTA_ALIGN(rta_len)` actually differ (see
/// `rta_len_counts_the_payload_and_not_the_padding`).
fn getlink_reply(attrs: &[u8]) -> Vec<u8> {
let mut b = Vec::new();
let len = 32 + attrs.len();
b.extend_from_slice(&(len as u32).to_ne_bytes());
b.extend_from_slice(&RTM_NEWLINK.to_ne_bytes());
b.extend_from_slice(&0u16.to_ne_bytes()); // flags
b.extend_from_slice(&1u32.to_ne_bytes()); // seq
b.extend_from_slice(&0u32.to_ne_bytes()); // pid
b.extend_from_slice(&[0u8; 16]); // ifinfomsg
b.extend_from_slice(attrs);
b
}
// ── the net spec as a LIST ───────────────────────────────────────────────
/// The historical single-entry spelling must parse to exactly one plan.
/// Every medium in the constellation carries it; a list parser that
/// changed what one entry means would change every one of them at once.
// Spelled with `:dhcp` entries: a build without the client refuses them by name.
#[cfg(feature = "dhcp")]
#[test]
fn one_entry_still_means_exactly_what_it_always_did() {
let list = NetConfig::parse_list("eth0:dhcp").expect("the declared form");
assert_eq!(list.len(), 1);
assert_eq!(list[0], NetConfig::parse("eth0:dhcp").unwrap());
let stat = NetConfig::parse_list("eth0:10.0.2.15/24:10.0.2.2").expect("the static form");
assert_eq!(stat.len(), 1);
assert_eq!(stat[0], NetConfig::parse("eth0:10.0.2.15/24:10.0.2.2").unwrap());
}
/// The cloud shape: a public NIC and a utility NIC, in that order.
// Spelled with `:dhcp` entries: a build without the client refuses them by name.
#[cfg(feature = "dhcp")]
#[test]
fn two_entries_parse_in_order_and_name_two_interfaces() {
let list = NetConfig::parse_list("eth0:dhcp,eth1:dhcp").expect("the cloud form");
assert_eq!(list.len(), 2);
assert_eq!(list[0].interface(), "eth0");
assert_eq!(
list[1].interface(),
"eth1",
"order is load-bearing: the FIRST entry owns the default route"
);
}
/// Whitespace around an entry is an operator's spacing, not a new spec.
// Spelled with `:dhcp` entries: a build without the client refuses them by name.
#[cfg(feature = "dhcp")]
#[test]
fn entries_may_be_spaced_and_may_mix_static_with_dhcp() {
let list = NetConfig::parse_list("eth0:dhcp, eth1:10.20.6.152/22")
.expect("a mixed list");
assert_eq!(list.len(), 2);
assert_eq!(list[1], NetConfig::parse("eth1:10.20.6.152/22").unwrap());
}
/// The refusals. A list parser that shrugged at these would boot an
/// appliance with half a network and nothing in the log naming which half.
#[test]
fn a_malformed_list_is_refused_rather_than_partially_applied() {
for bad in [
"eth0:dhcp,", // trailing comma — an empty entry
",eth0:dhcp", // leading comma
"eth0:dhcp,,eth1:dhcp", // a hole in the middle
"eth0:dhcp,eth0:dhcp", // TWO plans for one interface
"eth0:dhcp,eth1:nonsense",
"eth0:dhcp,eth1",
] {
assert!(
NetConfig::parse_list(bad).is_err(),
"net spec {bad:?} was ACCEPTED"
);
}
}
/// One interface named twice is two answers to one question. The refusal
/// must say which name, because a cmdline is read off a serial console.
#[test]
fn a_repeated_interface_is_named_in_the_refusal() {
let err = NetConfig::parse_list("eth1:dhcp,eth1:10.0.0.1/24")
.expect_err("a duplicate interface must be refused");
assert!(
err.to_string().contains("eth1"),
"the refusal must name the interface: {err:#}"
);
}
// ── the address dump behind `--grpc-bind <iface>:<port>` ───────────────
/// One `RTM_NEWADDR` row as the kernel lays it out: 16-byte nlmsghdr, then
/// `struct ifaddrmsg`, then attributes.
fn addr_row(ifindex: u32, family: u8, attrs: &[u8]) -> Vec<u8> {
let mut body = Vec::new();
body.push(family);
body.push(24); // ifa_prefixlen
body.push(0); // ifa_flags
body.push(0); // ifa_scope
body.extend_from_slice(&ifindex.to_ne_bytes());
body.extend_from_slice(attrs);
netlink_message(RTM_NEWADDR, 0, 0, &body)
}
fn done_row() -> Vec<u8> {
netlink_message(NLMSG_DONE, 0, 0, &0u32.to_ne_bytes())
}
#[test]
fn the_dump_reader_finds_the_row_for_the_index_it_was_asked_about() {
let mut dump = Vec::new();
dump.extend_from_slice(&addr_row(1, AF_INET, &rtattr(IFA_LOCAL, &[127, 0, 0, 1])));
dump.extend_from_slice(&addr_row(2, AF_INET, &rtattr(IFA_LOCAL, &[212, 147, 225, 55])));
dump.extend_from_slice(&addr_row(3, AF_INET, &rtattr(IFA_LOCAL, &[10, 13, 6, 152])));
dump.extend_from_slice(&done_row());
// Every index, not just the interesting one: a reader that returned the
// FIRST address in the table would pass a test that only asked for
// index 1, and would bind the public interface on the appliance.
assert_eq!(ipv4_from_addr_dump(&dump, 1), Some(Ipv4Addr::new(127, 0, 0, 1)));
assert_eq!(
ipv4_from_addr_dump(&dump, 2),
Some(Ipv4Addr::new(212, 147, 225, 55))
);
assert_eq!(
ipv4_from_addr_dump(&dump, 3),
Some(Ipv4Addr::new(10, 13, 6, 152)),
"index 3 is the LAST row: a reader that stopped early would miss the utility NIC"
);
}
/// An interface that is in the dump with no address of its own must read
/// as `None` — the answer that makes `BindSpec::resolve_with` refuse — and
/// never as the address of some other interface.
#[test]
fn an_index_with_no_row_reads_as_none_and_not_as_a_neighbour() {
let mut dump = Vec::new();
dump.extend_from_slice(&addr_row(1, AF_INET, &rtattr(IFA_LOCAL, &[127, 0, 0, 1])));
dump.extend_from_slice(&done_row());
assert_eq!(ipv4_from_addr_dump(&dump, 2), None);
}
/// `IFA_ADDRESS` is the PEER on a point-to-point link. A reader that took
/// whichever attribute came first would bind the far end of a tunnel, so
/// `IFA_LOCAL` wins even when it is second in the attribute list.
#[test]
fn ifa_local_wins_over_ifa_address_whichever_order_they_arrive_in() {
let mut attrs = Vec::new();
attrs.extend_from_slice(&rtattr(IFA_ADDRESS, &[10, 9, 9, 9])); // the peer
attrs.extend_from_slice(&rtattr(IFA_LOCAL, &[10, 13, 6, 152])); // this end
let mut dump = addr_row(7, AF_INET, &attrs);
dump.extend_from_slice(&done_row());
assert_eq!(
ipv4_from_addr_dump(&dump, 7),
Some(Ipv4Addr::new(10, 13, 6, 152))
);
// And with only the peer attribute present, it is still better than
// nothing — a broadcast link sends both with the same value.
let mut only_peer = addr_row(7, AF_INET, &rtattr(IFA_ADDRESS, &[10, 9, 9, 9]));
only_peer.extend_from_slice(&done_row());
assert_eq!(
ipv4_from_addr_dump(&only_peer, 7),
Some(Ipv4Addr::new(10, 9, 9, 9))
);
}
/// An IPv6 row for the same index must not be read as four bytes of IPv4.
#[test]
fn a_non_inet_family_row_is_skipped_rather_than_reinterpreted() {
const AF_INET6: u8 = 10;
let mut dump = addr_row(4, AF_INET6, &rtattr(IFA_LOCAL, &[0u8; 16]));
dump.extend_from_slice(&done_row());
assert_eq!(ipv4_from_addr_dump(&dump, 4), None);
}
/// **The live half.** Every Linux host has a loopback interface with `127.0.0.1` on it,
/// so the whole path — `interface_index`, the netlink socket, the dump, the
/// parse — has one answer that is known in advance on every Linux box this
/// ever runs on. Without this the parser above is proven only against bytes
/// this file wrote itself.
#[test]
fn interface_ipv4_reads_the_real_loopback_address_off_a_real_kernel() {
let got = interface_ipv4("lo").expect("querying lo over netlink");
assert_eq!(
got,
Some(Ipv4Addr::LOCALHOST),
"lo must read as 127.0.0.1 over netlink; got {got:?}"
);
}
/// The live test above is satisfied by a reader that returns the FIRST
/// address in the table, because `lo` is index 1 and is first. This one is
/// not: it asks a NIC that is not `lo` and refuses `127.0.0.1` as its
/// answer. Both were RED together under a mutation that dropped the
/// `index == ifindex` filter; with only the `lo` test, that mutation was
/// green on a real kernel.
///
/// Skipped rather than failed where `/sys/class/net` offers no second
/// interface — a container with only loopback is not a defect in this code.
#[test]
fn interface_ipv4_does_not_answer_a_second_nic_with_the_loopback_address() {
let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
eprintln!("no /sys/class/net on this host; nothing to ask");
return;
};
let mut asked = 0usize;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name == "lo" {
continue;
}
let Ok(answer) = interface_ipv4(&name) else {
continue;
};
asked += 1;
assert_ne!(
answer,
Some(Ipv4Addr::LOCALHOST),
"{name} was answered with the LOOPBACK address — the dump reader is not \
filtering by interface index"
);
}
if asked == 0 {
eprintln!("only loopback on this host; nothing to ask");
}
}
/// And an interface that does not exist is an ERROR, not `Ok(None)` — the
/// two are different sentences in the refusal and a caller that conflated
/// them would tell an operator to check DHCP for a typo'd NIC name.
#[test]
fn a_nonexistent_interface_is_an_error_and_not_an_empty_answer() {
let err = interface_ipv4("zz-no-such-nic").expect_err("no such interface");
assert!(
err.to_string().contains("zz-no-such-nic"),
"the error must name the interface: {err:#}"
);
}
#[test]
fn the_dump_request_asks_for_inet_and_sets_the_dump_flags() {
let msg = get_addr_dump_message(1);
assert_eq!(u16::from_ne_bytes(msg[4..6].try_into().unwrap()), RTM_GETADDR);
let flags = u16::from_ne_bytes(msg[6..8].try_into().unwrap());
assert_eq!(
flags,
NLM_F_REQUEST | NLM_F_DUMP,
"without NLM_F_DUMP the kernel answers with one row, or nothing"
);
assert_eq!(msg[NLMSGHDR_LEN], AF_INET, "the dump must be filtered to IPv4");
}
#[test]
fn the_mac_is_read_out_of_a_getlink_answer_across_padded_attributes() {
// IFLA_IFNAME("eth0\0") first — 5-byte payload, rta_len 9, step 12 —
// so reaching IFLA_ADDRESS at all proves the walk aligns correctly.
let mut attrs = Vec::new();
attrs.extend_from_slice(&rtattr(3, b"eth0\0")); // IFLA_IFNAME = 3
attrs.extend_from_slice(&rtattr(IFLA_ADDRESS, &[0x52, 0x54, 0, 0x12, 0x34, 0x56]));
let mac = mac_from_getlink_reply(&getlink_reply(&attrs)).expect("a well-formed answer");
assert_eq!(mac, [0x52, 0x54, 0, 0x12, 0x34, 0x56]);
}
/// Measure, never assert: every way the answer can be wrong must be refused, or
/// `interface_mac` would hand the DHCP client a `chaddr` of garbage and
/// every reply-matching gate downstream would filter on that garbage.
#[test]
fn getlink_answers_that_carry_no_mac_are_refused() {
// No IFLA_ADDRESS at all.
let no_addr = getlink_reply(&rtattr(3, b"eth0\0"));
assert!(mac_from_getlink_reply(&no_addr).is_err());
// An IFLA_ADDRESS that is not 6 bytes (e.g. a tunnel device).
let short = getlink_reply(&rtattr(IFLA_ADDRESS, &[1, 2, 3, 4]));
assert!(mac_from_getlink_reply(&short).is_err());
// The kernel said error: errno must surface, not parse as attributes.
let mut err = Vec::new();
err.extend_from_slice(&20u32.to_ne_bytes());
err.extend_from_slice(&NLMSG_ERROR.to_ne_bytes());
err.extend_from_slice(&[0u8; 10]); // flags, seq, pid
err.extend_from_slice(&(-19i32).to_ne_bytes()); // -ENODEV
let e = mac_from_getlink_reply(&err).expect_err("an NLMSG_ERROR is not a link");
assert!(
format!("{e:#}").contains("errno 19"),
"the kernel's errno must be named, got: {e:#}"
);
// An NLMSG_ERROR cut off before its errno: must be an error, not a
// panic. This case found the panic — the fixture above was first
// written two bytes short and the parser indexed past the buffer.
assert!(
mac_from_getlink_reply(&err[..18]).is_err(),
"a truncated NLMSG_ERROR must be refused"
);
// Truncated garbage.
assert!(mac_from_getlink_reply(&[0, 1, 2]).is_err());
}
/// Every message's `nlmsg_len` must agree with the buffer it is in. A
/// mismatch is accepted by `send` and then either truncated or rejected by
/// the kernel with `EINVAL`, which is a runtime failure for a compile-time
/// mistake.
#[test]
fn every_message_declares_its_own_length() {
for (name, msg) in [
("link", link_up_message(1, 2)),
(
"addr",
address_message(2, 2, Ipv4Addr::new(10, 0, 2, 15), 24),
),
(
"route",
default_route_message(3, 2, Ipv4Addr::new(10, 0, 2, 2)),
),
] {
let declared = u32::from_ne_bytes([msg[0], msg[1], msg[2], msg[3]]) as usize;
assert_eq!(
declared,
msg.len(),
"{name}: nlmsg_len says {declared} and the buffer is {} bytes",
msg.len()
);
}
}
}
#[cfg(test)]
mod dhcp_retry_tests {
use super::*;
use std::time::Duration;
/// ★ **A DHCP that failed at boot is asked again, forever, with backoff** (lane D12,
/// test3-holger-rs r6: the front's eth1 got no lease on its first boot — UpCloud's DHCP
/// is slow then — and nothing ever asked again, so the boxes' WireGuard to the front's
/// utility address went unanswered for the whole life of the boot). Gunnar's law: 2 s
/// doubling to 30 s, retries said at powers of two, the lease said once.
#[test]
fn a_failed_boot_dhcp_is_asked_again_with_backoff_until_a_lease_comes() {
let mut left = 6;
let mut slept = Vec::new();
let mut said = Vec::new();
let (lease, tries) = keep_asking(
|| {
if left == 0 {
Ok("10.13.11.61/22")
} else {
left -= 1;
Err("no DHCPOFFER in 3 s")
}
},
|d| slept.push(d.as_secs()),
|warn, line| said.push((warn, line)),
);
assert_eq!((lease, tries), ("10.13.11.61/22", 7));
assert_eq!(slept, vec![2, 4, 8, 16, 30, 30, 30], "a wait before every ask: 2 s doubling to 30 s");
assert!(said.iter().all(|(warn, _)| !warn), "no WARN from the helper (the boot's failure was the one): {said:?}");
let retries = said.iter().filter(|(_, l)| l.contains("still no lease")).count();
assert_eq!(retries, 3, "failures 1, 2 and 4 said, not 3, 5, 6: {said:?}");
assert!(said.last().is_some_and(|(_, l)| l.contains("lease taken after 7")), "{said:?}");
}
/// ★ **A NIC whose first DHCP asks time out is pending until its lease comes, then not**
/// (lane D12): the background retry marks the interface [`dhcp_pending`] (a step that
/// needs its address waits), hands the lease over once it is taken, and clears the mark.
#[test]
fn a_nic_whose_first_asks_time_out_is_pending_until_its_lease_is_taken() {
let (go, gate) = std::sync::mpsc::channel::<()>();
let (tx, rx) = std::sync::mpsc::channel();
let mut left = 3;
let h = retry_in_background(
"d12test-eth1",
move || {
gate.recv().ok();
if left == 0 {
Ok("10.13.11.61/22")
} else {
left -= 1;
Err("no DHCPOFFER in 3 s")
}
},
|_| {},
move |lease, tries| tx.send((lease, tries)).unwrap(),
)
.expect("the retry thread starts");
assert!(dhcp_pending("d12test-eth1"), "pending while the asks time out");
for _ in 0..3 {
go.send(()).unwrap();
}
std::thread::sleep(Duration::from_millis(50));
assert!(dhcp_pending("d12test-eth1"), "three timeouts: still pending, still asking");
go.send(()).unwrap();
assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), ("10.13.11.61/22", 4));
h.join().unwrap();
assert!(!dhcp_pending("d12test-eth1"), "the lease is taken: no longer pending");
assert!(!dhcp_pending("eth9"), "an interface never asked for is not pending");
}
}
#[cfg(all(test, feature = "dhcp"))]
mod resolver_probe_tests {
use super::*;
use std::time::Duration;
/// ★ **Row 29: a resolver that misses its FIRST query is proven by a later one**
/// (Gunnar cf05cce1; release-3 test2 on UpCloud: the twin logged `ERROR resolver NOT
/// proven … Try again` 2 s after DHCP published the provider's resolvers, the front
/// proving the same ones in 1 ms). The probe asks again for about 30 s; a failed try
/// is not an ERROR; only the whole window failing is.
#[test]
fn a_resolver_that_fails_once_then_answers_is_proven_on_the_second_try() {
let mut calls = 0u32;
let mut slept = Duration::ZERO;
let r = prove_with(
|| {
calls += 1;
if calls == 1 { Err("failed to lookup address information: Try again".into()) } else { Ok("140.82.121.3".into()) }
},
|d| slept += d,
RESOLVER_RETRY_WAITS,
);
assert_eq!(r, Ok((2, "140.82.121.3".to_string())));
assert_eq!(slept, RESOLVER_RETRY_WAITS[0], "one wait between the two tries");
}
#[test]
fn a_resolver_that_never_answers_is_not_proven_after_the_whole_window() {
let mut slept = Duration::ZERO;
let r = prove_with(|| Err("Temporary failure in name resolution".into()), |d| slept += d, RESOLVER_RETRY_WAITS);
assert_eq!(r, Err((RESOLVER_RETRY_WAITS.len() as u32 + 1, "Temporary failure in name resolution".to_string())));
let window: Duration = RESOLVER_RETRY_WAITS.iter().sum();
assert!(window >= Duration::from_secs(25) && window <= Duration::from_secs(35), "about 30 s: {window:?}");
assert_eq!(slept, window);
}
}
#[cfg(test)]
mod ack_tests {
use super::*;
/// ★ **A live update's re-exec finds its addresses in place** (Gunnar; the dwarf mock
/// 2026-10-13: every DHCP apply after the re-exec was refused `errno 17 (File exists)`).
/// EEXIST is "already there" where it is tolerated; every other errno stays a refusal.
#[test]
fn an_address_already_there_is_not_a_refusal() {
assert_eq!(ack_verdict(0, false), Ok(Ack::Applied));
assert_eq!(ack_verdict(-17, true), Ok(Ack::AlreadyThere));
assert_eq!(ack_verdict(-17, false), Err(17), "where it is not tolerated, EEXIST stays a refusal");
assert_eq!(ack_verdict(-1, true), Err(1));
let _: fn(&rustix::fd::OwnedFd, &[u8], &str) -> Result<Ack> = request_or_there;
// edda's apply tolerates EEXIST for the address (kept) and REPLACES the default
// route (Gunnar's kept it as it was: edda's stands, the plan's gateway wins).
let src = include_str!("net.rs");
let apply = &src[src.find("pub fn apply(plan: &NetPlan)").unwrap()..];
let apply = &apply[..apply.find("\n}\n").unwrap()];
assert_eq!(apply.matches("request_or_there(").count(), 2, "the address and the route tolerate EEXIST: {apply}");
}
}
#[cfg(all(test, feature = "dhcp"))]
mod late_lease_tests {
use super::*;
fn session(interface: &str, owns_default_route: bool) -> DhcpSession {
DhcpSession {
interface: interface.into(),
mac: [0x02, 0, 0, 0xaa, 0xbb, 0xcc],
lease: crate::dhcp::Lease {
address: Ipv4Addr::new(10, 0, 2, 15),
prefix: 24,
gateway: Some(Ipv4Addr::new(10, 0, 2, 2)),
server: Ipv4Addr::new(10, 0, 2, 2),
lease_secs: 3600,
t1_secs: None,
t2_secs: None,
host_name: None,
dns: vec![Ipv4Addr::new(10, 0, 2, 3)],
routes: Vec::new(),
},
owns_default_route,
}
}
static SAID: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
fn keep(line: &str) {
SAID.lock().unwrap().push(line.to_string());
}
/// ★ **A late lease is said the way a boot lease is said** (lane G6, rickard-81 on
/// Gunnar 7e33c0f7): D12's background retry applied the lease and said it at INFO
/// only, so the boot narration — the installer's one window into a box with no
/// console — never heard that the network came up after all. The late line is the
/// boot's line, word for word, with the retry's count after it.
#[test]
fn a_late_lease_is_narrated_with_the_boot_leases_own_line() {
let s = session("eth0", true);
assert_eq!(lease_note(&s), "network up: eth0 10.0.2.15/24 gw Some(10.0.2.2) via DHCP from 10.0.2.2 (lease 3600s)");
let late = LateLease { narrate: keep, t1_override_var: None };
let landing = late_landing(&s, 7, "holger-front", Some("crates.io"), &late, |_| None);
assert!(landing.note.starts_with(&lease_note(&s)), "{}", landing.note);
assert!(landing.note.contains("after the boot's failure") && landing.note.contains("7 asks"), "{}", landing.note);
(late.narrate)(&landing.note);
assert_eq!(SAID.lock().unwrap().last(), Some(&landing.note));
// The boot's own arm says the same line (one wording, not two).
let src = include_str!("net.rs");
let arm = &src[src.find("NetConfig::Dhcp { interface } => match configure_via_dhcp").unwrap()..];
let arm = &arm[..arm.find("Err(err) =>").unwrap()];
assert!(arm.contains("(how.narrate)(&lease_note(&session))"), "the boot lease is said by lease_note: {arm}");
let retry = &src[src.find("fn retry_dhcp_in_background").unwrap()..];
let retry = &retry[..retry.find("\n}\n").unwrap()];
assert!(retry.contains("(late.narrate)(&landing.note)"), "the late lease is said: {retry}");
}
/// ★ **The late lease's renewal honours the T1 test seam** (lane G6): the boot's
/// sessions are renewed with `dhcp_t1_override(<product>_DHCP_T1_OVERRIDE_SECS)`, the
/// late one was renewed with `None` — a test ISO forcing T1 to 30 s saw the late NIC
/// renew on the server's clock instead. The seam's variable is the product's.
#[test]
fn a_late_lease_is_renewed_with_the_products_t1_seam() {
let s = session("eth1", false);
let late = LateLease { narrate: keep, t1_override_var: Some("G6_TEST_T1") };
let mut asked = Vec::new();
let landing = late_landing(&s, 1, "gunnar", None, &late, |var| {
asked.push(var.to_string());
(var == "G6_TEST_T1").then_some(30)
});
assert_eq!(asked, ["G6_TEST_T1"], "the product's variable is read");
assert_eq!(landing.t1_override, Some(30), "and its value renews the late lease");
let quiet = late_landing(&s, 1, "gunnar", None, &LateLease::QUIET, |_| panic!("no seam declared: nothing read"));
assert_eq!(quiet.t1_override, None);
let src = include_str!("net.rs");
let retry = &src[src.find("fn retry_dhcp_in_background").unwrap()..];
let retry = &retry[..retry.find("\n}\n").unwrap()];
assert!(retry.contains("dhcp_renewal(session, landing.t1_override)"), "the renewal takes the seam: {retry}");
}
/// ★ **The late lease's resolver is proven once it lands** (lane G6): the boot's lease
/// proves the published resolver (`prove_with`, about 30 s); the late one published the
/// file and proved nothing. Only the NIC that owns the default route publishes the
/// resolver, so only that one proves it; it is named by the product, as at boot.
#[test]
fn a_late_lease_proves_its_resolver_when_it_owns_the_default_route() {
let late = LateLease { narrate: keep, t1_override_var: None };
let main = late_landing(&session("eth0", true), 3, "gunnar", Some("github.com"), &late, |_| None);
assert_eq!(main.resolver_probe.as_deref(), Some("github.com"));
assert_eq!(main.hostname.as_deref(), Some("gunnar-aabbcc"), "the box is named as at boot");
let utility = late_landing(&session("eth1", false), 3, "gunnar", Some("github.com"), &late, |_| None);
assert_eq!((utility.resolver_probe, utility.hostname), (None, None), "a utility NIC publishes no resolver and names nothing");
let none = late_landing(&session("eth0", true), 3, "gunnar", None, &late, |_| None);
assert_eq!(none.resolver_probe, None, "no probe asked: none run");
let src = include_str!("net.rs");
let retry = &src[src.find("fn retry_dhcp_in_background").unwrap()..];
let retry = &retry[..retry.find("\n}\n").unwrap()];
assert!(retry.contains("prove_resolver(name)"), "the proof runs: {retry}");
}
}