crates/appliance-pid1/src/dhcp.rs
//! **The PID-1 DHCPv4 client**: how the appliance gets an address when the
//! declaration says `<NET>=eth0:dhcp` instead of baking one in.
//!
//! One handshake, four datagrams, RFC 2131: DISCOVER → OFFER → REQUEST → ACK.
//! The lease that comes back is handed to [`crate::net::apply`] — the **same**
//! netlink writer the static path uses, so there is exactly one piece of code
//! that puts an address on an interface (one implementation), and this file never touches
//! `AF_NETLINK` at all.
//!
//! # Why the messages are hand-rolled rather than a crate
//!
//! The obvious crate is `dhcproto` (MIT, maintained, checked 2026-08-12:
//! 0.15.0). Its mandatory dependencies are `hickory-proto` — a complete DNS
//! protocol stack — plus `rand`, `ipnet`, `thiserror` and a proc-macro crate,
//! none of which the appliance graph carries today, all for the two fixed-shape
//! packets and one option walk below. That is the same trade `pid1.rs` already
//! settled for netlink ("a binding crate would be a dependency in the appliance
//! graph for eighteen integers"), and it lands the same way here. Nothing else
//! in the nordisk suite parses DHCP (grepped edda, draupnir, tunnr,
//! korp-collectors, 2026-08-12), so there is no in-house parser to reuse either.
//!
//! # The socket shape, and why it is not a raw socket
//!
//! A UDP socket bound to `0.0.0.0:68` with `SO_BROADCAST` and
//! `SO_BINDTODEVICE`, sending to `255.255.255.255:67`. `SO_BINDTODEVICE` is
//! what makes the limited broadcast routable while the interface has **no
//! address**: the kernel special-cases `255.255.255.255` with an explicit
//! output device and skips the FIB lookup that would otherwise return
//! `ENETUNREACH`. The DISCOVER/REQUEST carry the BROADCAST flag (RFC 2131
//! §4.1), so a compliant server broadcasts its replies too and the kernel
//! delivers them to this socket even though `10.0.2.15` is not yet a local
//! address. No `AF_PACKET`, no checksum arithmetic, no capability beyond what
//! PID 1 already has.
//!
//! `rustix` (the rest of pid1's syscall layer) has no `SO_BINDTODEVICE` helper
//! as of 1.1.4, so that one setsockopt comes from `socket2` — already resolved
//! in this tree under tokio/hyper, so the edge adds no new crate to the graph.
//!
//! # The resolver, and why a DHCP client writes a file
//!
//! The ACK carries option 6, the recursive resolvers, and until 2026-09-08 this
//! parser walked straight past it. The bill came in as
//! `job mirror-1 FAILED: … could not be reached for the advertisement: Dns` on
//! gunnar.rs: an appliance with an address, a route and no way to spell
//! `github.com`. The lease now carries [`Lease::dns`], and
//! [`crate::resolver`] puts it where libc looks — `/etc/resolv.conf` on the RAM
//! root, a directory this file's caller creates because korp-installer's
//! initramfs lays only `dev`, `proc`, `sys` and `init`.
//!
//! Writing a file feels like a layering violation in a module that has been
//! proud of touching no `AF_NETLINK`. It is the opposite. Every dialler in this
//! server — `ureq` in `mirror_http`, the russh dialler in `mirror_fetch`, the
//! billing client, the webhook sender — bottoms out in `getaddrinfo`, so libc
//! **is** the one resolver implementation on this box (one implementation). An in-process
//! resolver would be a second one, and it would only take effect at the dial
//! sites rewritten to use it: every one of them, forever, including the ones a
//! dependency owns. One 60-byte file makes all of them work with no new code
//! path at all. [`crate::resolver`]'s module doc argues that trade in full.
//!
//! # Renewal
//!
//! The lease is kept alive per RFC 2131 §4.4.5. The timers and transitions —
//! BOUND → RENEWING at T1 (unicast REQUEST to the lease's server) → REBINDING
//! at T2 (broadcast REQUEST) → re-DISCOVER at expiry, NAK anywhere ⇒ immediate
//! re-DISCOVER — live in [`renew`], a state machine that is pure over injected
//! time and an injected transport, so every transition is unit-tested without a
//! network or a sleep. This file stays what it was: packet builders, the
//! parser, and one bounded socket op per leg ([`WireTransport`]). The driver on
//! the tokio clock is `crate::net::dhcp_renewal`, and a renewed lease that *changes*
//! the address goes back through `crate::net::apply` — still the one netlink writer.
//! The single deliberate RFC deviation (what happens when the lease expires and
//! nobody answers) is documented where it lives, in [`renew`]'s module doc.
use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
// ── the wire constants (RFC 2131 / RFC 2132) ────────────────────────────────
const BOOTREQUEST: u8 = 1;
const BOOTREPLY: u8 = 2;
const HTYPE_ETHER: u8 = 1;
/// `flags` bit 15: "broadcast the reply, I cannot receive unicast yet".
const FLAG_BROADCAST: u16 = 0x8000;
/// 99.130.83.99, the four bytes that make a BOOTP packet a DHCP packet.
const MAGIC_COOKIE: [u8; 4] = [99, 130, 83, 99];
/// Options start here: 236 fixed BOOTP bytes + the cookie.
const OPTIONS_AT: usize = 240;
/// RFC 951 mandates a minimum packet; ancient relays drop shorter ones.
const MIN_PACKET: usize = 300;
const OPT_PAD: u8 = 0;
const OPT_SUBNET_MASK: u8 = 1;
const OPT_ROUTER: u8 = 3;
/// RFC 2132 §3.8: the recursive resolvers, in the server's order of
/// preference. Four bytes each, at least one. This is the option that decides
/// whether the appliance can spell a hostname at all — see [`Lease::dns`].
const OPT_DOMAIN_NAME_SERVERS: u8 = 6;
/// RFC 2132 §3.14: the name the server wants this host to carry.
const OPT_HOST_NAME: u8 = 12;
const OPT_REQUESTED_IP: u8 = 50;
const OPT_LEASE_TIME: u8 = 51;
const OPT_MESSAGE_TYPE: u8 = 53;
const OPT_SERVER_ID: u8 = 54;
const OPT_PARAM_REQUEST: u8 = 55;
const OPT_MESSAGE: u8 = 56;
const OPT_RENEWAL_T1: u8 = 58;
const OPT_REBINDING_T2: u8 = 59;
const OPT_CLIENT_ID: u8 = 61;
/// **Option 121, classless static routes (RFC 3442).** The option that tells a
/// client about a subnet its own prefix does not cover. See [`Lease::routes`].
const OPT_CLASSLESS_STATIC_ROUTES: u8 = 121;
const OPT_END: u8 = 255;
const DHCPDISCOVER: u8 = 1;
const DHCPOFFER: u8 = 2;
const DHCPREQUEST: u8 = 3;
const DHCPACK: u8 = 5;
const DHCPNAK: u8 = 6;
const SERVER_PORT: u16 = 67;
const CLIENT_PORT: u16 = 68;
/// Total worst-case wall time before the client gives up and the boot goes on
/// (unreachable, loudly — see `crate::net::bring_up_network`). Four attempts of up to
/// 3 s each: bounded, so a cable that goes nowhere costs ~12 s, not a hang.
const ATTEMPTS: u32 = 4;
const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(3);
// ── what a successful handshake yields ──────────────────────────────────────
/// The applied result of one DISCOVER→ACK exchange.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Lease {
/// `yiaddr` from the ACK.
pub address: Ipv4Addr,
/// From option 1 (subnet mask), converted; the ACK is refused without it.
pub prefix: u8,
/// First address of option 3 (router), if the server named one.
pub gateway: Option<Ipv4Addr>,
/// **Option 121, the classless static routes — why the appliance could not
/// tell the time.**
///
/// Option 3 names ONE gateway and option 1 names the interface's own
/// prefix. Between them they describe a flat network, and a cloud's private
/// network is not flat. MEASURED on gunnar.rs, 2026-09-20: the appliance's
/// utility NIC leases `10.13.8.99/22` and the front's leases
/// `10.13.7.210/22` — two DIFFERENT /22s, reachable from each other only
/// through `10.0.0.0/8 via 10.13.4.1`, which UpCloud sends as option 121
/// and which this client read and discarded.
///
/// The consequence was total and silent in one direction only. The front
/// (Ubuntu, systemd-networkd, which honours 121) reached the appliance's
/// gRPC port perfectly, so the network looked fine. The appliance's own
/// packets to `10.13.7.210` matched no route but the default one and left
/// through the PUBLIC interface, where a private destination is dropped.
/// So: the appliance never asked the front's `gunnar-clock` for signed time
/// and sat +7 198 668 ms out (a two-hour boot skew this estate has measured
/// before), which made every credential `7198751 ms away from this server's
/// clock; the window is ±300000 ms` and stopped the bring-up dead at
/// `pair`; and `crate::net::ship_boot_narration` shipped its datagrams into the
/// same hole, so the boot-narration sink — built that very morning to say
/// whether an install had stalled — recorded `NOTHING HAS EVER ARRIVED`.
///
/// Empty is not an error, for the same reason an empty `dns` is not: a
/// server that sends no 121 has said something true about a flat network.
pub routes: Vec<Route>,
/// Option 54, the server that granted the lease.
pub server: Ipv4Addr,
/// Option 51, seconds. The ACK is refused without it: a lease with no
/// duration is not a lease, and inventing "infinite" here would be exactly
/// the fabricated value this client exists to never produce.
pub lease_secs: u32,
/// Option 58 (T1), seconds, if the server sent one. When absent the RFC
/// default applies: 0.5 · lease ([`renew::Machine`] computes it).
pub t1_secs: Option<u32>,
/// Option 59 (T2), seconds, if the server sent one. Default 0.875 · lease.
pub t2_secs: Option<u32>,
/// Option 12 (host name), if the server offered one. Not required: a
/// lease with no name is still a lease; the box then names itself
/// (`crate::net::hostname_for`).
pub host_name: Option<String>,
/// Option 6 (domain name servers), in the server's order — empty when the
/// ACK named none.
///
/// **This field is why `mirror sync` could not reach github.** Measured on
/// gunnar.rs 2026-09-08, straight after the 1.4.0 re-image: `job mirror-1
/// FAILED: https://github.com/git/git.git/info/refs?service=git-upload-pack
/// could not be reached for the advertisement: Dns`. The lease was good,
/// the route was good, and the box could not spell a name — because this
/// client read options 1, 3, 51, 54, 58 and 59 out of the ACK and dropped
/// option 6 on the floor. Nothing under this binary writes
/// `/etc/resolv.conf` (there is no distro and no `/etc` at all on the RAM
/// root), so musl's `getaddrinfo` — which every dialler in this server
/// ends up in, `ureq` in `mirror_http`, the russh dialler in
/// `mirror_fetch`, the billing client and the webhook sender — had no
/// server to ask.
///
/// A `Vec` and not an `Option<Vec>`: "the server named none" and "the
/// server named an empty list" are the same fact and `is_empty()` says it
/// once. Ordering is load-bearing — RFC 2132 §3.8 lists them "in order of
/// preference" and `resolv.conf` reads them in file order, so the wire
/// order is preserved verbatim by [`crate::resolver::render`].
pub dns: Vec<Ipv4Addr>,
}
// ── pure builders ────────────────────────────────────────────────────────────
//
// `Vec<u8>` in, no socket touched, same doctrine as pid1's netlink encoders: a
// DHCP bug is a byte-layout bug, and the only honest assertion is against the
// bytes. The tests below hold offsets from RFC 2131 §2's figure, not from this
// code.
/// The 236-byte BOOTP header + magic cookie shared by every request.
///
/// `flags`/`ciaddr` are what tell the acquisition shape from the renewal
/// shape: DISCOVER/SELECTING carry the BROADCAST flag and a zero `ciaddr`
/// (no address to unicast a reply to yet); RENEWING/REBINDING carry a zero
/// flag and the held address in `ciaddr` (RFC 2131 §4.3.2, §4.4.5).
fn bootp_header(xid: u32, secs: u16, mac: [u8; 6], flags: u16, ciaddr: Ipv4Addr) -> Vec<u8> {
let mut p = Vec::with_capacity(MIN_PACKET);
p.push(BOOTREQUEST);
p.push(HTYPE_ETHER);
p.push(6); // hlen
p.push(0); // hops
p.extend_from_slice(&xid.to_be_bytes());
p.extend_from_slice(&secs.to_be_bytes());
p.extend_from_slice(&flags.to_be_bytes());
p.extend_from_slice(&ciaddr.octets());
p.extend_from_slice(&[0u8; 12]); // yiaddr, siaddr, giaddr: all zero
p.extend_from_slice(&mac);
p.extend_from_slice(&[0u8; 10]); // chaddr padding to 16
p.extend_from_slice(&[0u8; 64]); // sname
p.extend_from_slice(&[0u8; 128]); // file
p.extend_from_slice(&MAGIC_COOKIE);
p
}
fn option(p: &mut Vec<u8>, code: u8, payload: &[u8]) {
p.push(code);
p.push(payload.len() as u8);
p.extend_from_slice(payload);
}
/// Options every request carries: who we are and what we want to be told.
///
/// The parameter request list asks for exactly what [`Lease`] holds: mask,
/// router, DNS, lease time, server id, T1, T2. Asking for less makes servers
/// omit the mask; asking for more is cargo cult.
///
/// Option 6 is in the list because a server is entitled to send only what was
/// asked for (RFC 2131 §3.5: the client "SHOULD" list the parameters it wants,
/// and ISC dhcpd's `request` statement is exactly this list). Most servers
/// volunteer option 6 anyway — which is why the gap was invisible until a box
/// with a resolver-less lease had to spell `github.com`.
fn common_options(p: &mut Vec<u8>, mac: [u8; 6]) {
let mut client_id = [0u8; 7];
client_id[0] = HTYPE_ETHER;
client_id[1..].copy_from_slice(&mac);
option(p, OPT_CLIENT_ID, &client_id);
option(
p,
OPT_PARAM_REQUEST,
&[
OPT_SUBNET_MASK,
OPT_ROUTER,
OPT_DOMAIN_NAME_SERVERS,
OPT_LEASE_TIME,
OPT_SERVER_ID,
OPT_RENEWAL_T1,
OPT_HOST_NAME,
OPT_REBINDING_T2,
// Asked for by name, because a server is free to withhold an option
// nobody requested — the same reason option 6 is on this list.
OPT_CLASSLESS_STATIC_ROUTES,
],
);
}
fn finish(mut p: Vec<u8>) -> Vec<u8> {
p.push(OPT_END);
if p.len() < MIN_PACKET {
p.resize(MIN_PACKET, 0);
}
p
}
/// DHCPDISCOVER: "anyone out there?"
pub fn discover_message(xid: u32, secs: u16, mac: [u8; 6]) -> Vec<u8> {
let mut p = bootp_header(xid, secs, mac, FLAG_BROADCAST, Ipv4Addr::UNSPECIFIED);
option(&mut p, OPT_MESSAGE_TYPE, &[DHCPDISCOVER]);
common_options(&mut p, mac);
finish(p)
}
/// DHCPREQUEST in the SELECTING state: "I take the offer from *that* server."
///
/// Options 50 (requested address) and 54 (server id) are what make it
/// selecting; RFC 2131 §4.3.2 says both MUST be present here, and MUST both be
/// absent from the renewal shape below — the two REQUESTs are different
/// sentences spelled with the same message type.
pub fn request_message(
xid: u32,
secs: u16,
mac: [u8; 6],
requested: Ipv4Addr,
server: Ipv4Addr,
) -> Vec<u8> {
let mut p = bootp_header(xid, secs, mac, FLAG_BROADCAST, Ipv4Addr::UNSPECIFIED);
option(&mut p, OPT_MESSAGE_TYPE, &[DHCPREQUEST]);
option(&mut p, OPT_REQUESTED_IP, &requested.octets());
option(&mut p, OPT_SERVER_ID, &server.octets());
common_options(&mut p, mac);
finish(p)
}
/// DHCPREQUEST in the RENEWING/REBINDING states: "I still hold this address."
///
/// RFC 2131 §4.3.2: `ciaddr` MUST carry the client's address, options 50 and
/// 54 MUST NOT appear, and the BROADCAST flag is off — the client has a
/// working address to receive a unicast ACK on. RENEWING and REBINDING send
/// the *same* packet; where it is sent (unicast to the lease's server vs.
/// broadcast) is the whole difference, and that is the caller's line.
pub fn renew_message(xid: u32, secs: u16, mac: [u8; 6], ciaddr: Ipv4Addr) -> Vec<u8> {
let mut p = bootp_header(xid, secs, mac, 0, ciaddr);
option(&mut p, OPT_MESSAGE_TYPE, &[DHCPREQUEST]);
common_options(&mut p, mac);
finish(p)
}
// ── pure parser ──────────────────────────────────────────────────────────────
/// A server reply that passed the "is this for us" gate: right direction,
/// right xid, right hardware address, right cookie.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reply {
/// Option 53: [`DHCPOFFER`], [`DHCPACK`] or [`DHCPNAK`].
pub kind: u8,
/// `yiaddr` — the address being offered or acknowledged.
pub yiaddr: Ipv4Addr,
pub subnet_mask: Option<Ipv4Addr>,
pub router: Option<Ipv4Addr>,
/// Option 6, the resolvers, in wire order. Empty = the ACK named none.
pub dns: Vec<Ipv4Addr>,
/// Option 121, in wire order: `(destination, prefix, gateway)`.
pub routes: Vec<Route>,
pub server_id: Option<Ipv4Addr>,
pub lease_secs: Option<u32>,
pub t1_secs: Option<u32>,
pub t2_secs: Option<u32>,
/// Option 56, a server's human-readable reason on a NAK.
pub message: Option<String>,
/// Option 12, the host name the server assigns, trimmed; empty = absent.
pub host_name: Option<String>,
}
/// **One route out of option 121** — a destination prefix and the next hop that
/// reaches it. See [`Lease::routes`] for why this estate cannot work without it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Route {
/// The destination network, already masked to `prefix` bits.
pub destination: Ipv4Addr,
/// The prefix length, 0..=32. `0` is the default route.
pub prefix: u8,
/// The next hop. `0.0.0.0` means "on-link" (RFC 3442 allows it).
pub gateway: Ipv4Addr,
}
impl Route {
/// A default route — `0.0.0.0/0`. Only the interface that OWNS the default
/// route may install one, which is `pid1`'s decision and not this module's.
pub fn is_default(&self) -> bool {
self.prefix == 0
}
}
/// **Option 121's payload, decoded (RFC 3442).**
///
/// The encoding is deliberately compact and is the one part of DHCP that is
/// easy to get wrong: each entry is ONE prefix-length byte followed by only as
/// many destination bytes as that length needs — `ceil(prefix / 8)` of them,
/// so `0.0.0.0/0` contributes zero bytes and `10.0.0.0/8` exactly one — and
/// then four bytes of gateway. A decoder that assumes four destination bytes
/// reads the gateway as part of the network and every route after it is
/// garbage.
///
/// A malformed tail is dropped and the entries already read are kept: a server
/// that sent three good routes and a truncated fourth has still said three true
/// things, and refusing the lease over the fourth would leave the box with no
/// address at all.
fn opt_classless_routes(payload: &[u8]) -> Vec<Route> {
let mut out = Vec::new();
let mut at = 0usize;
while at < payload.len() {
let prefix = payload[at];
if prefix > 32 {
break; // not a prefix length; the rest cannot be trusted either
}
let dst_bytes = usize::from(prefix).div_ceil(8);
let Some(rest) = payload.get(at + 1..at + 1 + dst_bytes + 4) else {
break; // truncated entry — keep what was whole
};
let mut dst = [0u8; 4];
dst[..dst_bytes].copy_from_slice(&rest[..dst_bytes]);
let gw = &rest[dst_bytes..];
// Masked here, once, so nothing downstream has to wonder whether the
// host bits a server left in are meaningful. They are not.
let mask = u32::MAX.checked_shl(32 - u32::from(prefix)).unwrap_or(0);
out.push(Route {
destination: Ipv4Addr::from(u32::from_be_bytes(dst) & mask),
prefix,
gateway: Ipv4Addr::new(gw[0], gw[1], gw[2], gw[3]),
});
at += 1 + dst_bytes + 4;
}
out
}
fn opt_ipv4(payload: &[u8]) -> Option<Ipv4Addr> {
payload
.get(..4)
.map(|b| Ipv4Addr::new(b[0], b[1], b[2], b[3]))
}
/// Option 6's payload: a run of four-byte addresses (RFC 2132 §3.8).
///
/// **A length that is not a multiple of four is TRUNCATED, not refused**, and
/// the reason is a grading, not a shrug. `lease_from_ack` refuses an ACK with
/// no mask or no lease time because those are what an address *means* — a
/// guessed prefix is a silent unreachability. A resolver is not that: an
/// appliance with an address and no name service still serves git over `IP:port`,
/// which is how `gunnar-cli` talks to it anyway — no hostnames in `Endpoint`,
/// `IP:port` only. Throwing the whole lease away
/// over a server that padded option 6 wrong would cost the box its *address* to
/// punish a bad *hint*. So the complete addresses are kept in order and the
/// trailing partial one — which cannot be completed without inventing bytes,
/// measured, not assumed — is dropped.
///
/// `0.0.0.0` is dropped for the same reason `lease_from_ack` refuses a
/// `0.0.0.0` yiaddr: it is not an address. Servers pad short lists with it, and
/// a `nameserver 0.0.0.0` line is a lookup musl will actually attempt and
/// actually spend its timeout on.
fn opt_ipv4_list(payload: &[u8]) -> Vec<Ipv4Addr> {
payload
.chunks_exact(4)
.map(|b| Ipv4Addr::new(b[0], b[1], b[2], b[3]))
.filter(|a| !a.is_unspecified())
.collect()
}
fn opt_u32(payload: &[u8]) -> Option<u32> {
payload
.get(..4)
.map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
/// Parse one datagram from `0.0.0.0:68`'s receive queue.
///
/// `Err` means "not the reply to *our* request" as well as "malformed": the
/// caller's loop treats both the same way — keep listening until the deadline —
/// because port 68 is a party line and someone else's OFFER is not an error in
/// the world, only in this packet.
pub fn parse_reply(buf: &[u8], xid: u32, mac: [u8; 6]) -> Result<Reply> {
if buf.len() < OPTIONS_AT {
bail!("{} bytes is shorter than a BOOTP header", buf.len());
}
if buf[0] != BOOTREPLY {
bail!("op {} is not BOOTREPLY", buf[0]);
}
if buf[1] != HTYPE_ETHER || buf[2] != 6 {
bail!("htype/hlen {}/{} is not ethernet", buf[1], buf[2]);
}
let got_xid = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
if got_xid != xid {
bail!("xid {got_xid:#010x} is not ours ({xid:#010x})");
}
if buf[28..34] != mac {
bail!("chaddr is not this interface's MAC; a reply to somebody else");
}
if buf[236..240] != MAGIC_COOKIE {
bail!("no DHCP magic cookie; a plain BOOTP reply");
}
let yiaddr = Ipv4Addr::new(buf[16], buf[17], buf[18], buf[19]);
let mut reply = Reply {
kind: 0,
yiaddr,
subnet_mask: None,
router: None,
dns: Vec::new(),
routes: Vec::new(),
server_id: None,
lease_secs: None,
t1_secs: None,
t2_secs: None,
message: None,
host_name: None,
};
let mut at = OPTIONS_AT;
while at < buf.len() {
let code = buf[at];
match code {
OPT_PAD => {
at += 1;
continue;
}
OPT_END => break,
_ => {}
}
let len =
*buf.get(at + 1)
.with_context(|| format!("option {code} has no length byte"))? as usize;
let payload = buf
.get(at + 2..at + 2 + len)
.with_context(|| format!("option {code} claims {len} bytes past the packet end"))?;
match code {
OPT_MESSAGE_TYPE => reply.kind = *payload.first().unwrap_or(&0),
OPT_SUBNET_MASK => reply.subnet_mask = opt_ipv4(payload),
OPT_ROUTER => reply.router = opt_ipv4(payload),
OPT_DOMAIN_NAME_SERVERS => reply.dns = opt_ipv4_list(payload),
OPT_CLASSLESS_STATIC_ROUTES => reply.routes = opt_classless_routes(payload),
OPT_SERVER_ID => reply.server_id = opt_ipv4(payload),
OPT_LEASE_TIME => reply.lease_secs = opt_u32(payload),
OPT_RENEWAL_T1 => reply.t1_secs = opt_u32(payload),
OPT_REBINDING_T2 => reply.t2_secs = opt_u32(payload),
OPT_MESSAGE => reply.message = Some(String::from_utf8_lossy(payload).into_owned()),
OPT_HOST_NAME => {
let name = String::from_utf8_lossy(payload)
.trim_end_matches('\0')
.trim()
.to_owned();
reply.host_name = (!name.is_empty()).then_some(name);
}
_ => {}
}
at += 2 + len;
}
if reply.kind == 0 {
bail!("no message-type option; not a DHCP reply");
}
Ok(reply)
}
/// `255.255.255.0` → 24. Refuses a non-contiguous mask rather than rounding it:
/// an address applied under the wrong prefix is a subtle unreachability, which
/// is worse than the loud kind.
pub fn prefix_from_mask(mask: Ipv4Addr) -> Result<u8> {
let m = u32::from(mask);
let prefix = m.leading_ones() as u8;
if m != prefix_bits(prefix) {
bail!("subnet mask {mask} is not contiguous; refusing to guess a prefix");
}
Ok(prefix)
}
const fn prefix_bits(prefix: u8) -> u32 {
if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix as u32)
}
}
// ── the client ───────────────────────────────────────────────────────────────
/// A transaction id from the entropy std already has, not a `rand` dependency:
/// `RandomState` seeds from the OS per process. Uniqueness only has to hold
/// among concurrent clients on one broadcast domain.
fn fresh_xid() -> u32 {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u32(std::process::id());
h.finish() as u32
}
/// The socket described in the module doc: UDP `0.0.0.0:68`, broadcast on,
/// bound to `interface` so the limited broadcast has an output device before
/// the interface has an address.
fn client_socket(interface: &str) -> Result<UdpSocket> {
use socket2::{Domain, Protocol, Socket, Type};
let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))
.context("opening the DHCP client socket")?;
sock.set_broadcast(true).context("SO_BROADCAST")?;
sock.bind_device(Some(interface.as_bytes()))
.with_context(|| format!("SO_BINDTODEVICE {interface}"))?;
sock.bind(&SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, CLIENT_PORT).into())
.context("binding 0.0.0.0:68")?;
Ok(sock.into())
}
/// **The IPv4 + UDP frame of a client message**: 0.0.0.0:68 → 255.255.255.255:67, the
/// header checksum computed, the UDP checksum 0 (optional over IPv4). What
/// [`send_from_zero`] puts on the wire, so the source is 0.0.0.0 whatever else on the box
/// holds an address.
pub(crate) fn zero_source_frame(payload: &[u8]) -> Vec<u8> {
let udp_len = 8 + payload.len();
let total = 20 + udp_len;
let mut f = Vec::with_capacity(total);
f.extend_from_slice(&[0x45, 0x10]);
f.extend_from_slice(&(total as u16).to_be_bytes());
f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]);
f.extend_from_slice(&[0, 0, 0, 0]);
f.extend_from_slice(&[255, 255, 255, 255]);
let sum: u32 = f.chunks(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).sum();
let folded = (sum & 0xffff) + (sum >> 16);
let csum = !(((folded & 0xffff) + (folded >> 16)) as u16);
f[10..12].copy_from_slice(&csum.to_be_bytes());
f.extend_from_slice(&CLIENT_PORT.to_be_bytes());
f.extend_from_slice(&SERVER_PORT.to_be_bytes());
f.extend_from_slice(&(udp_len as u16).to_be_bytes());
f.extend_from_slice(&[0, 0]);
f.extend_from_slice(payload);
f
}
/// **Send a client message from source 0.0.0.0** on `interface`, through a packet socket
/// (the frame from [`zero_source_frame`], to the Ethernet broadcast). A UDP socket bound
/// to 0.0.0.0:68 only says "the kernel picks": once ANY interface holds an address (wg0,
/// the other NIC's lease) the kernel picks that one, and the DISCOVER of an interface
/// still without a lease leaves with a source nothing answers (estate-box D4, measured
/// 2026-10-01). Falls back to `udp`'s own send when no packet socket can be opened.
fn send_from_zero(udp: &UdpSocket, interface: &str, payload: &[u8]) -> Result<()> {
let raw = (|| -> Result<()> {
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
const ETH_P_IP: u16 = 0x0800;
// SIOCGIFINDEX in this process's own network namespace (crate::net), not
// /sys/class/net: sysfs shows the namespace it was mounted in, which need not be ours.
let ifindex = crate::net::interface_index(interface)? as i32;
let sock = Socket::new(Domain::PACKET, Type::DGRAM, Some(Protocol::from(ETH_P_IP.to_be() as i32))).context("opening a packet socket")?;
// struct sockaddr_ll: family, protocol (be), ifindex, hatype, pkttype, halen, addr[8].
let mut storage: libc_sockaddr_storage = unsafe { std::mem::zeroed() };
let ll = &mut storage as *mut libc_sockaddr_storage as *mut SockaddrLl;
// SAFETY: `storage` is zeroed, large and aligned enough for a sockaddr_ll.
unsafe {
(*ll).sll_family = 17; // AF_PACKET
(*ll).sll_protocol = ETH_P_IP.to_be();
(*ll).sll_ifindex = ifindex;
(*ll).sll_halen = 6;
(*ll).sll_addr = [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0, 0];
}
// SAFETY: the storage holds a valid sockaddr_ll of the length given.
let addr = unsafe { SockAddr::new(std::mem::transmute::<libc_sockaddr_storage, socket2::SockAddrStorage>(storage), std::mem::size_of::<SockaddrLl>() as u32) };
sock.send_to(&zero_source_frame(payload), &addr).context("sending on the packet socket")?;
Ok(())
})();
match raw {
Ok(()) => Ok(()),
Err(e) => {
tracing::debug!(interface, error = %format!("{e:#}"), "DHCP: no packet socket — sent from the UDP socket (the kernel picks the source)");
udp.send_to(payload, SocketAddrV4::new(Ipv4Addr::BROADCAST, SERVER_PORT)).map(|_| ()).context("sending to 255.255.255.255:67")
}
}
}
/// `struct sockaddr_ll` (linux/if_packet.h).
#[repr(C)]
struct SockaddrLl {
sll_family: u16,
sll_protocol: u16,
sll_ifindex: i32,
sll_hatype: u16,
sll_pkttype: u8,
sll_halen: u8,
sll_addr: [u8; 8],
}
/// `struct sockaddr_storage`'s size and alignment, for building a [`SockaddrLl`] in place.
#[repr(C, align(8))]
struct libc_sockaddr_storage([u8; 128]);
/// Wait (until `deadline`) for a reply of `wanted` kind to transaction `xid`.
///
/// A NAK for our xid is returned immediately *as a reply* — the server said
/// no, and waiting the timeout out after a "no" is just a slower "no". It
/// comes back as data rather than an error because the renewal loop must tell
/// "the server refused" (⇒ re-DISCOVER now) from "nobody answered" (⇒ retry,
/// then rebind); [`refuse_nak`] turns it into the error the handshake wants.
/// Everything that is not ours or not the wanted kind is skipped in silence.
fn await_reply(
sock: &UdpSocket,
xid: u32,
mac: [u8; 6],
wanted: u8,
deadline: Instant,
) -> Result<Reply> {
let mut buf = [0u8; 1536];
loop {
let left = deadline
.checked_duration_since(Instant::now())
.filter(|d| !d.is_zero())
.context("timed out waiting for a reply")?;
sock.set_read_timeout(Some(left))
.context("setting the receive timeout")?;
let n = match sock.recv(&mut buf) {
Ok(n) => n,
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
bail!("timed out waiting for a reply")
}
Err(e) => return Err(e).context("receiving on 0.0.0.0:68"),
};
let Ok(reply) = parse_reply(&buf[..n], xid, mac) else {
continue; // somebody else's conversation, or noise
};
if reply.kind == wanted || reply.kind == DHCPNAK {
return Ok(reply);
}
}
}
/// The handshake's reading of a NAK: an error naming the server's reason.
fn refuse_nak(reply: Reply) -> Result<Reply> {
if reply.kind == DHCPNAK {
bail!(
"the server answered DHCPNAK{}",
reply
.message
.as_deref()
.map(|m| format!(": {m}"))
.unwrap_or_default()
);
}
Ok(reply)
}
/// Hold an ACK to what a lease *means*, and convert it.
///
/// One reader for the initial handshake and every renewal leg (one implementation). A
/// missing mask or lease time is not defaulted (the identity-value trap: a
/// guessed /24 is indistinguishable from a real one right up until it isn't) —
/// it is a refusal.
fn lease_from_ack(ack: &Reply, fallback_server: Ipv4Addr) -> Result<Lease> {
if ack.yiaddr.is_unspecified() {
bail!("the ACK's yiaddr is 0.0.0.0; that is not an address");
}
let mask = ack
.subnet_mask
.context("the ACK carried no subnet mask (option 1)")?;
let lease_secs = ack
.lease_secs
.context("the ACK carried no lease time (option 51)")?;
Ok(Lease {
address: ack.yiaddr,
prefix: prefix_from_mask(mask)?,
gateway: ack.router,
server: ack.server_id.unwrap_or(fallback_server),
lease_secs,
t1_secs: ack.t1_secs,
t2_secs: ack.t2_secs,
host_name: ack.host_name.clone(),
// NOT refused when empty (see `opt_ipv4_list`): a lease with no
// resolver is still a lease. Who says so out loud, and once, is
// `crate::net::configure_via_dhcp` — the boot path, where "once" is a fact
// rather than a hope; every renewal comes back through here too.
dns: ack.dns.clone(),
// Same rule as `dns`: carried whole, in the server's order, and judged
// by the boot path. `pid1` decides which of them it may install.
routes: ack.routes.clone(),
})
}
/// Run the whole handshake on `interface` and return the lease.
///
/// Bounded: [`ATTEMPTS`] rounds of DISCOVER, each waiting [`ATTEMPT_TIMEOUT`]
/// for its OFFER and again for its ACK, worst case ~24 s, typical failure
/// (silent network) 12 s. The interface must already be UP — that is
/// `crate::net::bring_link_up`'s job, the same half the static path uses.
pub fn obtain(interface: &str, mac: [u8; 6]) -> Result<Lease> {
obtain_with(interface, mac, false)
}
/// **The level a failed attempt is said at** (Gunnar c040144f / feff3b82): INFO in the
/// boot's handshake (the boot's failure is said ONCE, at WARN, by the bring-up: "the
/// handshake FAILED at boot") and debug in the background retry (`quiet`), whose own
/// bounded lines come from [`crate::net::keep_asking`]. Never WARN: four per interface
/// per retry was the spam the retry exists to avoid.
pub fn attempt_failed_level(quiet: bool) -> tracing::Level {
if quiet {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
}
}
/// [`obtain`], with each failed attempt said at [`attempt_failed_level`]`(quiet)`.
pub fn obtain_with(interface: &str, mac: [u8; 6], quiet: bool) -> Result<Lease> {
let sock = client_socket(interface)?;
let started = Instant::now();
let mut last_err = None;
for attempt in 1..=ATTEMPTS {
let xid = fresh_xid();
let secs = started.elapsed().as_secs().min(u16::MAX as u64) as u16;
let round: Result<Lease> = (|| {
send_from_zero(&sock, interface, &discover_message(xid, secs, mac))
.context("sending DHCPDISCOVER to 255.255.255.255:67")?;
let deadline = Instant::now() + ATTEMPT_TIMEOUT;
let offer = await_reply(&sock, xid, mac, DHCPOFFER, deadline)
.and_then(refuse_nak)
.context("waiting for a DHCPOFFER")?;
let offered = offer.yiaddr;
let server = offer
.server_id
.context("the OFFER carried no server identifier (option 54)")?;
tracing::info!(
interface,
offered = %offered,
server = %server,
"DHCP OFFER received"
);
// Seen red before green (2026-08-12): with this REQUEST sabotaged
// to carry `xid ^ 1`, the appliance booted, received four OFFERs,
// matched no ACK, warned "handshake FAILED … no lease on eth0
// after 4 attempts (12 s)" on serial and served unreachable with
// NO address — the honest-failure path is real, not assumed.
send_from_zero(&sock, interface, &request_message(xid, secs, mac, offered, server))
.context("sending DHCPREQUEST")?;
let ack = await_reply(&sock, xid, mac, DHCPACK, deadline)
.and_then(refuse_nak)
.context("waiting for the DHCPACK")?;
// The ACK is held to what a lease means — `lease_from_ack`, the
// same reader every renewal leg uses. A refusal here keeps the
// boot honest-unreachable.
lease_from_ack(&ack, server)
})();
match round {
Ok(lease) => {
tracing::info!(
interface,
address = %lease.address,
prefix = lease.prefix,
gateway = ?lease.gateway,
server = %lease.server,
lease_secs = lease.lease_secs,
t1_secs = ?lease.t1_secs,
t2_secs = ?lease.t2_secs,
"DHCP ACK: lease taken. The renewal loop keeps it alive per RFC \
2131 §4.4.5 — unicast REQUEST at T1, broadcast at T2, \
re-DISCOVER at expiry — see the 'renewal scheduled' line"
);
return Ok(lease);
}
Err(e) => {
if attempt_failed_level(quiet) == tracing::Level::DEBUG {
tracing::debug!(interface, attempt, error = %format!("{e:#}"), "DHCP attempt failed");
} else {
tracing::info!(interface, attempt, error = %format!("{e:#}"), "DHCP attempt failed");
}
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no DHCP attempts ran"))).with_context(|| {
format!(
"no lease on {interface} after {ATTEMPTS} attempts ({:.0} s)",
started.elapsed().as_secs_f32()
)
})
}
// ── the renewal transport ────────────────────────────────────────────────────
pub mod renew;
/// [`renew::Transport`] over the real socket: one bounded REQUEST leg per
/// call, on the same `client_socket` shape the handshake uses.
///
/// Each leg opens its own socket rather than holding one across the hours
/// between T1s: a bound-forever `0.0.0.0:68` buys nothing (nothing competes
/// for the port on this appliance) and a per-leg open means a leg can never
/// inherit a socket some earlier failure left in a strange state. Blocking by
/// design, like all of this file — the driver (`crate::net::dhcp_renewal`) runs legs
/// in `spawn_blocking`, which is the shape the existing handshake code already
/// has; making these few short socket ops async would fork the socket code
/// into two dialects for no bound that `ATTEMPT_TIMEOUT` does not already give.
pub struct WireTransport {
/// The interface the lease lives on.
pub interface: String,
/// Its MAC, for `chaddr` and reply filtering.
pub mac: [u8; 6],
}
impl WireTransport {
fn one_leg(&self, lease: &Lease, broadcast: bool) -> Result<Reply> {
let sock = client_socket(&self.interface)?;
let xid = fresh_xid();
let to = if broadcast {
SocketAddrV4::new(Ipv4Addr::BROADCAST, SERVER_PORT)
} else {
SocketAddrV4::new(lease.server, SERVER_PORT)
};
tracing::info!(
interface = %self.interface,
address = %lease.address,
to = %to,
state = if broadcast { "REBINDING" } else { "RENEWING" },
"DHCP renewal: sending {} REQUEST",
if broadcast { "broadcast" } else { "unicast" }
);
sock.send_to(&renew_message(xid, 0, self.mac, lease.address), to)
.context("sending the renewal DHCPREQUEST")?;
await_reply(
&sock,
xid,
self.mac,
DHCPACK,
Instant::now() + ATTEMPT_TIMEOUT,
)
}
}
impl renew::Transport for WireTransport {
fn request(&mut self, lease: &Lease, broadcast: bool) -> renew::Outcome {
match self.one_leg(lease, broadcast) {
Ok(reply) if reply.kind == DHCPNAK => renew::Outcome::Nak(reply.message),
Ok(reply) => match lease_from_ack(&reply, lease.server) {
Ok(granted) => renew::Outcome::Ack(granted),
Err(err) => {
// An ACK that does not amount to a lease is treated as no
// answer: the timers keep running and the retry ladder
// decides, exactly as if the packet had been lost.
tracing::warn!(
interface = %self.interface,
error = %format!("{err:#}"),
"DHCP renewal ACK was malformed; treating it as no reply"
);
renew::Outcome::NoReply
}
},
Err(err) => {
tracing::debug!(
interface = %self.interface,
error = %format!("{err:#}"),
"DHCP renewal leg got no usable reply"
);
renew::Outcome::NoReply
}
}
}
fn rediscover(&mut self) -> Result<Lease> {
obtain(&self.interface, self.mac)
}
}
#[cfg(test)]
mod tests {
use super::*;
const MAC: [u8; 6] = [0x52, 0x54, 0x00, 0x12, 0x34, 0x56];
// ── the builders, against RFC 2131 §2's figure, not against the code ────
#[test]
fn discover_has_the_bootp_shape_at_the_rfc_offsets() {
let p = discover_message(0x1234_5678, 7, MAC);
assert!(p.len() >= MIN_PACKET, "RFC 951 minimum packet");
assert_eq!(p[0], 1, "op = BOOTREQUEST");
assert_eq!((p[1], p[2], p[3]), (1, 6, 0), "htype/hlen/hops");
assert_eq!(&p[4..8], &[0x12, 0x34, 0x56, 0x78], "xid, network order");
assert_eq!(&p[8..10], &[0, 7], "secs");
assert_eq!(
&p[10..12],
&[0x80, 0x00],
"the BROADCAST flag must be set: without it a server unicasts its \
reply to an address the kernel does not have yet, and the reply is \
dropped before this socket ever sees it"
);
assert_eq!(
&p[12..28],
&[0u8; 16],
"ciaddr/yiaddr/siaddr/giaddr all zero"
);
assert_eq!(&p[28..34], &MAC, "chaddr");
assert_eq!(&p[34..44], &[0u8; 10], "chaddr padding");
assert_eq!(&p[236..240], &MAGIC_COOKIE, "the cookie after sname+file");
assert_eq!(
&p[240..243],
&[OPT_MESSAGE_TYPE, 1, DHCPDISCOVER],
"first option: message type DISCOVER"
);
let end = p.iter().skip(240).position(|&b| b == OPT_END);
assert!(end.is_some(), "options must be terminated");
}
#[test]
fn request_names_the_offered_address_and_the_chosen_server() {
let p = request_message(
1,
0,
MAC,
Ipv4Addr::new(10, 0, 2, 15),
Ipv4Addr::new(10, 0, 2, 2),
);
assert_eq!(&p[240..243], &[OPT_MESSAGE_TYPE, 1, DHCPREQUEST]);
assert_eq!(
&p[243..249],
&[OPT_REQUESTED_IP, 4, 10, 0, 2, 15],
"option 50: RFC 2131 §4.3.2, MUST in SELECTING"
);
assert_eq!(
&p[249..255],
&[OPT_SERVER_ID, 4, 10, 0, 2, 2],
"option 54: which server's offer is being taken"
);
}
/// RFC 2131 §4.3.2's renewal column: `ciaddr` filled, options 50/54
/// absent, BROADCAST flag off. A renewal REQUEST that carries option 54
/// is a SELECTING REQUEST — servers are entitled to treat it as one, and
/// some NAK it — so the absences are asserted, not assumed.
#[test]
fn a_renewal_request_fills_ciaddr_and_omits_the_selecting_options() {
let p = renew_message(9, 0, MAC, Ipv4Addr::new(10, 0, 2, 15));
assert_eq!(
&p[10..12],
&[0, 0],
"the BROADCAST flag must be OFF: this client holds a working \
address and asks for its ACK unicast"
);
assert_eq!(
&p[12..16],
&[10, 0, 2, 15],
"ciaddr carries the held address"
);
assert_eq!(&p[16..28], &[0u8; 12], "yiaddr/siaddr/giaddr stay zero");
assert_eq!(&p[240..243], &[OPT_MESSAGE_TYPE, 1, DHCPREQUEST]);
let options = &p[240..];
let mut at = 0;
while at < options.len() && options[at] != OPT_END {
let code = options[at];
if code == OPT_PAD {
at += 1;
continue;
}
assert_ne!(
code, OPT_REQUESTED_IP,
"option 50 MUST NOT appear when renewing"
);
assert_ne!(
code, OPT_SERVER_ID,
"option 54 MUST NOT appear when renewing"
);
at += 2 + options[at + 1] as usize;
}
}
// ── a synthetic server, so parse is proven against bytes we did not
// build with the parser's own vocabulary ─────────────────────────────
fn reply_packet(kind: u8, xid: u32, mac: [u8; 6], yiaddr: [u8; 4], opts: &[u8]) -> Vec<u8> {
let mut p = vec![0u8; 240];
p[0] = BOOTREPLY;
p[1] = HTYPE_ETHER;
p[2] = 6;
p[4..8].copy_from_slice(&xid.to_be_bytes());
p[16..20].copy_from_slice(&yiaddr);
p[28..34].copy_from_slice(&mac);
p[236..240].copy_from_slice(&MAGIC_COOKIE);
p.extend_from_slice(&[OPT_MESSAGE_TYPE, 1, kind]);
p.extend_from_slice(opts);
p.push(OPT_END);
p
}
/// QEMU user-net's actual answer, byte for byte in the fields that matter.
#[test]
fn an_ack_parses_into_the_whole_lease() {
let p = reply_packet(
DHCPACK,
0xabcd,
MAC,
[10, 0, 2, 15],
&[
OPT_SERVER_ID,
4,
10,
0,
2,
2, //
OPT_SUBNET_MASK,
4,
255,
255,
255,
0, //
OPT_ROUTER,
4,
10,
0,
2,
2, //
OPT_LEASE_TIME,
4,
0,
1,
0x51,
0x80, // 86400
OPT_RENEWAL_T1,
4,
0,
0,
0xa8,
0xc0, // 43200
OPT_REBINDING_T2,
4,
0,
1,
0x27,
0x50, // 75600
],
);
let r = parse_reply(&p, 0xabcd, MAC).expect("a well-formed ACK");
assert_eq!(r.kind, DHCPACK);
assert_eq!(r.yiaddr, Ipv4Addr::new(10, 0, 2, 15));
assert_eq!(r.subnet_mask, Some(Ipv4Addr::new(255, 255, 255, 0)));
assert_eq!(r.router, Some(Ipv4Addr::new(10, 0, 2, 2)));
assert_eq!(r.server_id, Some(Ipv4Addr::new(10, 0, 2, 2)));
assert_eq!(r.lease_secs, Some(86400));
assert_eq!(r.t1_secs, Some(43200));
assert_eq!(r.t2_secs, Some(75600));
}
/// The option that was on the floor until 2026-09-08. `mirror sync` on
/// gunnar.rs failed with `could not be reached … : Dns` because this walk
/// had no arm for code 6, so a perfectly good ACK produced a lease that
/// could not name anything.
#[test]
fn option_6_reaches_the_lease_with_both_servers_in_the_servers_order() {
let p = reply_packet(
DHCPACK,
0x99,
MAC,
[10, 0, 2, 15],
&[
OPT_SERVER_ID,
4,
10,
0,
2,
2, //
OPT_SUBNET_MASK,
4,
255,
255,
255,
0, //
OPT_LEASE_TIME,
4,
0,
0,
0x0e,
0x10, // 3600
OPT_DOMAIN_NAME_SERVERS,
8,
10,
0,
2,
3, //
1,
1,
1,
1,
],
);
let r = parse_reply(&p, 0x99, MAC).expect("a well-formed ACK");
assert_eq!(
r.dns,
vec![Ipv4Addr::new(10, 0, 2, 3), Ipv4Addr::new(1, 1, 1, 1)],
"both servers, in the order the server sent them (RFC 2132 §3.8 \
calls that order preference, and resolv.conf honours file order)"
);
let lease = lease_from_ack(&r, Ipv4Addr::new(10, 0, 2, 2)).expect("a lease");
assert_eq!(
lease.dns,
vec![Ipv4Addr::new(10, 0, 2, 3), Ipv4Addr::new(1, 1, 1, 1)],
"the parse is only half of it: the LEASE is what pid1 publishes, \
and a Reply field that never reaches it fixes nothing"
);
}
/// Some servers simply do not send option 6. That is a box with an address
/// and no name service — worth saying out loud once, never worth refusing.
#[test]
fn an_ack_with_no_option_6_yields_an_empty_set_and_a_perfectly_good_lease() {
let p = reply_packet(
DHCPACK,
0x9a,
MAC,
[10, 0, 2, 15],
&[
OPT_SERVER_ID,
4,
10,
0,
2,
2, //
OPT_SUBNET_MASK,
4,
255,
255,
255,
0, //
OPT_LEASE_TIME,
4,
0,
0,
0x0e,
0x10,
],
);
let r = parse_reply(&p, 0x9a, MAC).expect("an ACK without option 6 is well-formed");
assert!(
r.dns.is_empty(),
"no option 6 means no servers, not an error"
);
let lease = lease_from_ack(&r, Ipv4Addr::new(10, 0, 2, 2))
.expect("the ABSENCE of a resolver must never cost the box its address");
assert!(lease.dns.is_empty());
assert_eq!(lease.address, Ipv4Addr::new(10, 0, 2, 15));
}
/// TRUNCATED, not refused — and the grading is the point of the test.
/// Option 6 is a hint; options 1 and 51 are what an address MEANS. Throwing
/// the lease away over a server that padded its resolver list wrong would
/// leave the appliance with no address at all, which is strictly worse than
/// the one resolver it could still be told about.
#[test]
fn a_malformed_option_6_length_is_truncated_to_whole_addresses_not_refused() {
let p = reply_packet(
DHCPACK,
0x9b,
MAC,
[10, 0, 2, 15],
&[
OPT_SERVER_ID,
4,
10,
0,
2,
2, //
OPT_SUBNET_MASK,
4,
255,
255,
255,
0, //
OPT_LEASE_TIME,
4,
0,
0,
0x0e,
0x10, //
OPT_DOMAIN_NAME_SERVERS,
6, // six bytes: one address and half of another
10,
0,
2,
3, //
1,
1,
],
);
let r = parse_reply(&p, 0x9b, MAC).expect("the ACK stands; only the hint is malformed");
assert_eq!(
r.dns,
vec![Ipv4Addr::new(10, 0, 2, 3)],
"the complete address is kept and the two orphan bytes are dropped: \
completing them would mean inventing the other two (measured, not asserted)"
);
// Under four bytes there is no complete address at all, and that is an
// empty set — never a `1.1.0.0` assembled out of padding.
let short = reply_packet(
DHCPACK,
0x9c,
MAC,
[10, 0, 2, 15],
&[OPT_DOMAIN_NAME_SERVERS, 2, 1, 1],
);
let r = parse_reply(&short, 0x9c, MAC).expect("still a parseable reply");
assert!(r.dns.is_empty());
}
/// Servers pad a short list with zeros. `nameserver 0.0.0.0` is a line musl
/// will actually try, and actually spend its timeout on.
#[test]
fn padding_zeros_in_option_6_are_not_addresses() {
let p = reply_packet(
DHCPACK,
0x9d,
MAC,
[10, 0, 2, 15],
&[
OPT_DOMAIN_NAME_SERVERS,
8,
10,
0,
2,
3, //
0,
0,
0,
0,
],
);
let r = parse_reply(&p, 0x9d, MAC).expect("a parseable reply");
assert_eq!(r.dns, vec![Ipv4Addr::new(10, 0, 2, 3)]);
}
/// A server is entitled to answer with only what was asked for, so the ask
/// is part of the fix and not an ornament on it.
#[test]
fn the_parameter_request_list_asks_for_option_6() {
let p = discover_message(1, 0, MAC);
let options = &p[240..];
let mut at = 0;
let mut asked = None;
while at < options.len() && options[at] != OPT_END {
if options[at] == OPT_PAD {
at += 1;
continue;
}
let len = options[at + 1] as usize;
if options[at] == OPT_PARAM_REQUEST {
asked = Some(options[at + 2..at + 2 + len].to_vec());
}
at += 2 + len;
}
let asked = asked.expect("every request carries a parameter request list");
assert!(
asked.contains(&OPT_DOMAIN_NAME_SERVERS),
"option 55 must name option 6, or a server that sends strictly what \
was requested leaves this appliance with no resolver: {asked:?}"
);
assert!(
asked.contains(&OPT_CLASSLESS_STATIC_ROUTES),
"option 55 must name option 121, or a server that sends strictly what was requested \
leaves this appliance reaching only its own prefix: {asked:?}"
);
}
/// **RFC 3442's variable-length destination, which is the whole trap.**
///
/// Each entry is one prefix byte, then only `ceil(prefix/8)` destination
/// bytes, then four gateway bytes. A decoder that assumes four destination
/// bytes reads the gateway as network and loses every route after it. The
/// vector below is the real one from UpCloud's utility network — the route
/// whose absence left the appliance two hours out of time — followed by a
/// `/24`, a `/32` and a `/0` so all four length classes are exercised in
/// one pass.
#[test]
fn option_121_decodes_a_variable_length_destination_and_keeps_the_order() {
let payload = [
8, 10, 10, 13, 4, 1, // 10.0.0.0/8 via 10.13.4.1
24, 192, 168, 9, 10, 13, 4, 1, // 192.168.9.0/24 via 10.13.4.1
32, 169, 254, 169, 254, 10, 13, 4, 1, // 169.254.169.254/32 via 10.13.4.1
0, 10, 13, 4, 1, // 0.0.0.0/0 via 10.13.4.1
];
let gw = Ipv4Addr::new(10, 13, 4, 1);
assert_eq!(
opt_classless_routes(&payload),
vec![
Route { destination: Ipv4Addr::new(10, 0, 0, 0), prefix: 8, gateway: gw },
Route { destination: Ipv4Addr::new(192, 168, 9, 0), prefix: 24, gateway: gw },
Route { destination: Ipv4Addr::new(169, 254, 169, 254), prefix: 32, gateway: gw },
Route { destination: Ipv4Addr::UNSPECIFIED, prefix: 0, gateway: gw },
]
);
assert!(opt_classless_routes(&payload)[3].is_default());
assert!(!opt_classless_routes(&payload)[0].is_default());
}
/// Host bits a server leaves in the destination are not meaningful, and are
/// masked off ONCE, here, so nothing downstream has to wonder.
#[test]
fn option_121_masks_the_destination_to_its_prefix() {
let r = opt_classless_routes(&[24, 192, 168, 9, 10, 13, 4, 1]);
assert_eq!(r[0].destination, Ipv4Addr::new(192, 168, 9, 0));
let r = opt_classless_routes(&[12, 10, 0xFF, 10, 13, 4, 1]);
assert_eq!(r[0].destination, Ipv4Addr::new(10, 240, 0, 0), "a /12 keeps twelve bits");
}
/// A truncated tail loses the tail and NOTHING ELSE. A server that said
/// three true things and then ran out of bytes has still said three true
/// things, and refusing the lease over the fourth would leave the box with
/// no address at all.
#[test]
fn a_truncated_option_121_keeps_the_routes_that_were_whole() {
let good = [8, 10, 10, 13, 4, 1];
let mut truncated = good.to_vec();
truncated.extend_from_slice(&[24, 192, 168, 9, 10]); // one gateway byte short
let r = opt_classless_routes(&truncated);
assert_eq!(r.len(), 1, "the whole entry survives, the truncated one does not: {r:?}");
assert_eq!(r[0].destination, Ipv4Addr::new(10, 0, 0, 0));
// …and a byte that cannot be a prefix length stops the walk rather than
// being read as one.
assert!(opt_classless_routes(&[33, 1, 2, 3, 4, 5]).is_empty());
assert!(opt_classless_routes(&[]).is_empty());
}
/// An ACK carrying option 121 lands the routes on the LEASE, in order —
/// the whole point being that `pid1` can then install them.
#[test]
fn option_121_reaches_the_lease() {
let opts = [OPT_CLASSLESS_STATIC_ROUTES, 6, 8, 10, 10, 13, 4, 1];
let p = reply_packet(DHCPACK, 1, MAC, [10, 13, 8, 99], &opts);
let r = parse_reply(&p, 1, MAC).expect("the ACK parses");
assert_eq!(r.routes.len(), 1);
assert_eq!(r.routes[0].gateway, Ipv4Addr::new(10, 13, 4, 1));
}
#[test]
fn pad_options_are_stepped_over_not_read_as_zero_length_options() {
let mut p = reply_packet(DHCPOFFER, 1, MAC, [10, 0, 2, 15], &[]);
// Splice pads before the end marker.
let end = p.len() - 1;
p.splice(end..end, [OPT_PAD, OPT_PAD, OPT_PAD]);
let r = parse_reply(&p, 1, MAC).expect("pads are legal filler");
assert_eq!(r.kind, DHCPOFFER);
}
/// Measure, never assert on the "is this for us" gate: every wrong field must reject,
/// or the gate is decoration and any broadcast on port 68 becomes ours.
#[test]
fn replies_that_are_not_ours_are_refused() {
let good = reply_packet(DHCPACK, 7, MAC, [10, 0, 2, 15], &[]);
assert!(
parse_reply(&good, 7, MAC).is_ok(),
"the baseline must parse"
);
assert!(
parse_reply(&good, 8, MAC).is_err(),
"wrong xid accepted: another client's lease would be applied here"
);
let mut other_mac = good.clone();
other_mac[30] ^= 0xff;
assert!(
parse_reply(&other_mac, 7, MAC).is_err(),
"wrong chaddr accepted"
);
let mut not_reply = good.clone();
not_reply[0] = BOOTREQUEST;
assert!(
parse_reply(¬_reply, 7, MAC).is_err(),
"our own broadcast echoed back would be accepted as a reply"
);
let mut no_cookie = good.clone();
no_cookie[236] = 0;
assert!(
parse_reply(&no_cookie, 7, MAC).is_err(),
"plain BOOTP accepted"
);
assert!(
parse_reply(&good[..200], 7, MAC).is_err(),
"a truncated header must not parse"
);
let no_type = {
let mut p = reply_packet(DHCPACK, 7, MAC, [10, 0, 2, 15], &[]);
p[241] = 0; // zero out the message-type length ⇒ type stays 0
p[240] = OPT_PAD;
p
};
assert!(
parse_reply(&no_type, 7, MAC).is_err(),
"a reply with no message type is not a DHCP reply"
);
}
#[test]
fn an_option_running_past_the_packet_end_is_an_error_not_a_panic() {
let mut p = reply_packet(DHCPACK, 7, MAC, [10, 0, 2, 15], &[]);
p.pop(); // drop OPT_END
p.extend_from_slice(&[OPT_SERVER_ID, 200]); // claims 200 bytes, has none
assert!(parse_reply(&p, 7, MAC).is_err());
// And a lone code byte with no length byte at the very end:
let mut q = reply_packet(DHCPACK, 7, MAC, [10, 0, 2, 15], &[]);
q.pop();
q.push(OPT_SERVER_ID);
assert!(parse_reply(&q, 7, MAC).is_err());
}
#[test]
fn masks_convert_to_prefixes_and_holes_are_refused() {
for (mask, prefix) in [
([255, 255, 255, 0], 24u8),
([255, 255, 0, 0], 16),
([255, 255, 255, 255], 32),
([0, 0, 0, 0], 0),
([255, 255, 255, 252], 30),
] {
assert_eq!(
prefix_from_mask(Ipv4Addr::from(mask)).unwrap(),
prefix,
"{mask:?}"
);
}
assert!(
prefix_from_mask(Ipv4Addr::new(255, 0, 255, 0)).is_err(),
"a non-contiguous mask must be refused, not rounded to /16 or /24"
);
assert!(prefix_from_mask(Ipv4Addr::new(0, 255, 255, 255)).is_err());
}
}
#[cfg(test)]
mod zero_source_tests {
use super::*;
/// **A DISCOVER leaves from 0.0.0.0, whatever else holds an address** (estate-box D4
/// 2026-10-01: once wg0 held 10.44.0.3, the twin's retried DISCOVERs from a UDP socket
/// bound to 0.0.0.0:68 left with source 10.44.0.3 and nothing answered — measured in a
/// namespace: from 0.0.0.0 received, with another address held not received). The
/// frame is built here: IPv4 + UDP, 0.0.0.0:68 → 255.255.255.255:67, a valid header
/// checksum, the payload as given.
#[test]
fn a_dhcp_frame_carries_source_zero_and_a_valid_ip_checksum() {
let payload = b"BOOTP";
let f = zero_source_frame(payload);
assert_eq!(f.len(), 20 + 8 + payload.len());
assert_eq!(f[0], 0x45, "IPv4, 20-byte header");
assert_eq!(u16::from_be_bytes([f[2], f[3]]) as usize, f.len(), "total length");
assert_eq!(f[9], 17, "UDP");
assert_eq!(&f[12..16], &[0, 0, 0, 0], "source 0.0.0.0");
assert_eq!(&f[16..20], &[255, 255, 255, 255], "limited broadcast");
assert_eq!(u16::from_be_bytes([f[20], f[21]]), 68);
assert_eq!(u16::from_be_bytes([f[22], f[23]]), 67);
assert_eq!(u16::from_be_bytes([f[24], f[25]]) as usize, 8 + payload.len(), "UDP length");
let sum: u32 = f[..20].chunks(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).sum();
let folded = (sum & 0xffff) + (sum >> 16);
assert_eq!(((folded & 0xffff) + (folded >> 16)) as u16, 0xffff, "the header checksum verifies");
assert_eq!(&f[28..], payload);
}
}
/// **Two interfaces, the first leased first** (the ONE box 2026-10-17: eth0 took
/// 81.27.107.63, then eth1's four DISCOVERs got no OFFER and the box ran without its
/// utility address). Run for real in a user + network namespace of its own: `eth0` a
/// dummy holding the public lease, `eth1` one end of a veth pair, and on the other end
/// (`util`) a DHCP server that behaves as UpCloud's utility network does — source-IP
/// filtering: a frame whose source is not 0.0.0.0 (nor the interface's own lease) is
/// dropped. `obtain("eth1")` must come back with eth1's own lease.
#[cfg(test)]
mod two_interface_tests {
use super::*;
use std::ffi::CString;
use std::net::Ipv4Addr;
use std::sync::{Arc, Mutex};
const CHILD: &str = "APPLIANCE_PID1_NETNS_CHILD";
const PUBLIC: Ipv4Addr = Ipv4Addr::new(81, 27, 107, 63);
const SERVER: [u8; 4] = [10, 13, 8, 1];
const UTILITY: [u8; 4] = [10, 13, 8, 78];
const ETH_P_IP: u16 = 0x0800;
#[test]
fn eth1_gets_its_own_lease_while_eth0_already_holds_one() {
match std::env::var(CHILD).as_deref() {
Ok("eth1") => in_the_namespace(),
Ok(_) => {}
Err(_) => run_in_a_namespace_of_its_own("dhcp::two_interface_tests::eth1_gets_its_own_lease_while_eth0_already_holds_one", "eth1"),
}
}
/// **A live-update RE-EXEC** (the ONE box 2026-10-17): the new PID 1 starts while the
/// kernel still holds eth0's lease (address and default route). `apply` sent
/// CREATE|EXCL, the kernel said EEXIST, the boot said "eth0 NOT up" and NO renewal loop
/// ran for eth0. The same lease again is applied, and its session (the renewal) comes back.
#[test]
fn a_re_exec_with_the_lease_still_on_eth0_brings_eth0_up_and_keeps_its_renewal() {
match std::env::var(CHILD).as_deref() {
Ok("reexec") => re_exec_in_the_namespace(),
Ok(_) => {}
Err(_) => run_in_a_namespace_of_its_own("dhcp::two_interface_tests::a_re_exec_with_the_lease_still_on_eth0_brings_eth0_up_and_keeps_its_renewal", "reexec"),
}
}
/// Re-run ONE test in a fresh user + net + uts namespace (unshare in the forked child,
/// single-threaded there, then mapped to root of its own namespace); the child says
/// `NETNS-LEASE-OK` when it got there.
fn run_in_a_namespace_of_its_own(test: &str, which: &str) {
use std::os::unix::process::CommandExt as _;
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
let files: Vec<(CString, Vec<u8>)> = vec![
(CString::new("/proc/self/setgroups").unwrap(), b"deny".to_vec()),
(CString::new("/proc/self/uid_map").unwrap(), format!("0 {uid} 1").into_bytes()),
(CString::new("/proc/self/gid_map").unwrap(), format!("0 {gid} 1").into_bytes()),
];
let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
cmd.args([test, "--exact", "--nocapture", "--test-threads=1"]).env(CHILD, which);
// SAFETY: between fork and exec only async-signal-safe libc calls on data built before the fork.
unsafe {
cmd.pre_exec(move || {
if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET | libc::CLONE_NEWUTS) != 0 {
return Err(std::io::Error::last_os_error());
}
for (path, body) in &files {
let fd = libc::open(path.as_ptr(), libc::O_WRONLY);
if fd < 0 || libc::write(fd, body.as_ptr().cast(), body.len()) != body.len() as isize {
return Err(std::io::Error::last_os_error());
}
libc::close(fd);
}
Ok(())
});
}
let out = match cmd.output() {
Ok(o) => o,
Err(e) => {
eprintln!("SKIPPED {test}: no user + net namespace on this machine ({e})");
return;
}
};
let said = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
if let Some(l) = said.lines().find(|l| l.contains("NETNS-SKIPPED")) {
eprintln!("SKIPPED {test}: {l}");
return;
}
assert!(out.status.success() && said.contains("NETNS-LEASE-OK"), "in the namespace:\n{said}");
eprintln!("{}", said.lines().find(|l| l.contains("NETNS-LEASE-OK")).unwrap_or_default().trim_start_matches(|c: char| c != 'N'));
}
/// The box's rp_filter (0) in the namespace, else `false` (said as SKIPPED).
fn box_rp_filter() -> bool {
let rp = |f: &str| std::fs::write(format!("/proc/sys/net/ipv4/conf/{f}/rp_filter"), "0");
if let Err(e) = rp("all").and_then(|_| rp("default")) {
let now = std::fs::read_to_string("/proc/sys/net/ipv4/conf/all/rp_filter").unwrap_or_default();
if now.trim() != "0" {
println!("NETNS-SKIPPED: rp_filter is {} and /proc/sys is not writable here ({e}) — run with /proc/sys writable (podman --security-opt unmask=/proc/sys)", now.trim());
return false;
}
}
true
}
/// `RTM_NEWLINK` for `name` of `linkinfo`'s kind.
fn new_link(nl: &rustix::fd::OwnedFd, seq: u32, name: &str, linkinfo: Vec<u8>) {
let mut body = vec![0u8; 16];
body.extend(crate::net::rtattr(3, &[name.as_bytes(), &[0]].concat())); // IFLA_IFNAME
body.extend(crate::net::rtattr(18, &linkinfo)); // IFLA_LINKINFO
let m = crate::net::netlink_message(16, 0x1 | 0x4 | 0x200 | 0x400, seq, &body); // RTM_NEWLINK, REQUEST|ACK|EXCL|CREATE
crate::net::request(nl, &m, &format!("create {name}")).unwrap();
}
/// A veth pair `name` <-> `peer`.
fn veth(nl: &rustix::fd::OwnedFd, seq: u32, name: &str, peer: &str) {
let mut p = vec![0u8; 16];
p.extend(crate::net::rtattr(3, &[peer.as_bytes(), &[0]].concat()));
new_link(nl, seq, name, [crate::net::rtattr(1, b"veth"), crate::net::rtattr(2, &crate::net::rtattr(1, &p))].concat());
}
/// The utility-style DHCP server on `util`, on a thread; what it dropped.
fn start_server() -> Arc<Mutex<Vec<Ipv4Addr>>> {
let dropped = Arc::new(Mutex::new(Vec::<Ipv4Addr>::new()));
let util = packet_socket("util");
let d = dropped.clone();
std::thread::spawn(move || serve(util, &d));
dropped
}
fn re_exec_in_the_namespace() {
if !box_rp_filter() {
return;
}
let nl = crate::net::netlink_socket().unwrap();
veth(&nl, 11, "eth0", "util");
let _ = std::fs::write("/proc/sys/net/ipv4/conf/util/rp_filter", "0");
let _ = std::fs::write("/proc/sys/net/ipv4/conf/eth0/rp_filter", "0");
crate::net::bring_link_up("util").unwrap();
// What the kernel still holds when the new PID 1 starts: the lease's address and
// default route, put there by the PID 1 before the re-exec (the same writer).
let before = crate::net::NetPlan { interface: "eth0".into(), address: Ipv4Addr::from(UTILITY), prefix: 22, gateway: Some(Ipv4Addr::from(SERVER)) };
crate::net::apply(&before).unwrap();
let _dropped = start_server();
let narrated = Mutex::new(Vec::<String>::new());
let narrate = |l: &str| narrated.lock().unwrap().push(l.to_string());
let how = crate::net::Bringup { hostname_prefix: "t", resolver_probe: None, narrate: &narrate };
let sessions = crate::net::bring_up_network(crate::net::NetConfig::parse_list("eth0:dhcp").unwrap(), &how).unwrap();
let said = narrated.lock().unwrap().join(" | ");
assert_eq!(sessions.len(), 1, "eth0's session (its renewal) after a re-exec; narrated: {said}");
assert!(said.contains("network up: eth0") && !said.contains("NOT up"), "{said}");
assert_eq!(crate::net::interface_ipv4("eth0").unwrap(), Some(Ipv4Addr::from(UTILITY)));
println!("NETNS-LEASE-OK eth0 {} re-applied after a re-exec, renewal session kept", sessions[0].lease.address);
}
fn in_the_namespace() {
if !box_rp_filter() {
return;
}
let nl = crate::net::netlink_socket().unwrap();
new_link(&nl, 11, "eth0", crate::net::rtattr(1, b"dummy"));
veth(&nl, 12, "eth1", "util");
for f in ["eth1", "util"] {
std::fs::write(format!("/proc/sys/net/ipv4/conf/{f}/rp_filter"), "0").unwrap();
}
// eth0 is leased first: up, with the public address and its default route.
let eth0 = crate::net::bring_link_up("eth0").unwrap();
crate::net::request(&nl, &crate::net::address_message(30, eth0, PUBLIC, 23), "eth0 address").unwrap();
crate::net::request(&nl, &crate::net::default_route_message(31, eth0, Ipv4Addr::new(81, 27, 106, 1)), "eth0 default route").unwrap();
crate::net::bring_link_up("util").unwrap();
crate::net::bring_link_up("eth1").unwrap();
let dropped = start_server();
let mac = crate::net::interface_mac("eth1").unwrap();
match obtain("eth1", mac) {
Ok(lease) => {
assert_eq!(lease.address, Ipv4Addr::from(UTILITY), "eth1's own lease");
println!("NETNS-LEASE-OK eth1 {}", lease.address);
}
Err(e) => panic!("eth1 got no lease: {e:#}; the utility server dropped DISCOVER/REQUEST from {:?} (source-IP filtering)", dropped.lock().unwrap()),
}
}
fn packet_socket(name: &str) -> i32 {
let ifindex = crate::net::interface_index(name).unwrap() as i32;
unsafe {
let fd = libc::socket(libc::AF_PACKET, libc::SOCK_DGRAM, (ETH_P_IP.to_be()) as i32);
assert!(fd >= 0, "packet socket: {}", std::io::Error::last_os_error());
let mut ll: libc::sockaddr_ll = std::mem::zeroed();
ll.sll_family = libc::AF_PACKET as u16;
ll.sll_protocol = ETH_P_IP.to_be();
ll.sll_ifindex = ifindex;
assert_eq!(libc::bind(fd, (&ll as *const libc::sockaddr_ll).cast(), std::mem::size_of::<libc::sockaddr_ll>() as u32), 0);
fd
}
}
/// The utility network's DHCP server: OFFER/ACK to a DISCOVER/REQUEST from 0.0.0.0,
/// anything from another source dropped (and noted).
fn serve(fd: i32, dropped: &Mutex<Vec<Ipv4Addr>>) {
let ifindex = crate::net::interface_index("util").unwrap() as i32;
let mut buf = [0u8; 2048];
loop {
let n = unsafe { libc::recv(fd, buf.as_mut_ptr().cast(), buf.len(), 0) };
if n < 28 + 240 {
continue;
}
let f = &buf[..n as usize];
if f[9] != 17 || u16::from_be_bytes([f[22], f[23]]) != SERVER_PORT {
continue;
}
let src = Ipv4Addr::new(f[12], f[13], f[14], f[15]);
if !src.is_unspecified() {
dropped.lock().unwrap().push(src);
continue;
}
let p = &f[28..];
let xid = u32::from_be_bytes([p[4], p[5], p[6], p[7]]);
let mut mac = [0u8; 6];
mac.copy_from_slice(&p[28..34]);
let kind = p[240..].windows(3).find(|w| w[0] == OPT_MESSAGE_TYPE && w[1] == 1).map(|w| w[2]);
let answer = match kind {
Some(DHCPDISCOVER) => DHCPOFFER,
Some(DHCPREQUEST) => DHCPACK,
_ => continue,
};
let reply = reply(answer, xid, mac);
let frame = frame_from_server(&reply);
unsafe {
let mut ll: libc::sockaddr_ll = std::mem::zeroed();
ll.sll_family = libc::AF_PACKET as u16;
ll.sll_protocol = ETH_P_IP.to_be();
ll.sll_ifindex = ifindex;
ll.sll_halen = 6;
ll.sll_addr[..6].copy_from_slice(&[0xff; 6]);
libc::sendto(fd, frame.as_ptr().cast(), frame.len(), 0, (&ll as *const libc::sockaddr_ll).cast(), std::mem::size_of::<libc::sockaddr_ll>() as u32);
}
}
}
fn reply(kind: u8, xid: u32, mac: [u8; 6]) -> Vec<u8> {
let mut p = vec![0u8; 240];
p[0] = 2; // BOOTREPLY
p[1] = 1;
p[2] = 6;
p[4..8].copy_from_slice(&xid.to_be_bytes());
p[16..20].copy_from_slice(&UTILITY);
p[28..34].copy_from_slice(&mac);
p[236..240].copy_from_slice(&[99, 130, 83, 99]);
p.extend_from_slice(&[OPT_MESSAGE_TYPE, 1, kind]);
p.extend_from_slice(&[54, 4, SERVER[0], SERVER[1], SERVER[2], SERVER[3]]); // server id
p.extend_from_slice(&[1, 4, 255, 255, 252, 0]); // mask /22
p.extend_from_slice(&[3, 4, SERVER[0], SERVER[1], SERVER[2], SERVER[3]]); // router
p.extend_from_slice(&[51, 4, 0, 1, 81, 128]); // 86400 s
p.push(255);
p
}
/// IPv4 + UDP, 10.13.8.1:67 → 255.255.255.255:68.
fn frame_from_server(payload: &[u8]) -> Vec<u8> {
let total = 28 + payload.len();
let mut f = vec![0x45, 0x10];
f.extend_from_slice(&(total as u16).to_be_bytes());
f.extend_from_slice(&[0, 0, 0, 0, 64, 17, 0, 0]);
f.extend_from_slice(&SERVER);
f.extend_from_slice(&[255, 255, 255, 255]);
let sum: u32 = f.chunks(2).map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).sum();
let folded = (sum & 0xffff) + (sum >> 16);
let csum = !(((folded & 0xffff) + (folded >> 16)) as u16);
f[10..12].copy_from_slice(&csum.to_be_bytes());
f.extend_from_slice(&SERVER_PORT.to_be_bytes());
f.extend_from_slice(&CLIENT_PORT.to_be_bytes());
f.extend_from_slice(&((8 + payload.len()) as u16).to_be_bytes());
f.extend_from_slice(&[0, 0]);
f.extend_from_slice(payload);
f
}
}
#[cfg(test)]
mod quiet_retry_tests {
use super::*;
/// ★ **Row 28: one WARN at boot, then quiet retries** (Gunnar c040144f / feff3b82,
/// estate-box D4 2026-10-01). Each failed attempt of the boot's handshake is INFO (the
/// boot's failure is said ONCE, at WARN, by the bring-up: "the handshake FAILED at
/// boot"), and the background retry's attempts are debug (`obtain_with` quiet): four
/// WARN "DHCP attempt failed" per retry was the spam the D12 retry made, 7 a minute.
#[test]
fn a_failed_attempt_is_info_at_boot_and_debug_in_the_retry_never_warn() {
assert_eq!(attempt_failed_level(false), tracing::Level::INFO);
assert_eq!(attempt_failed_level(true), tracing::Level::DEBUG);
let _: fn(&str, [u8; 6], bool) -> Result<Lease> = obtain_with;
}
}