crates/appliance-pid1/src/dhcp/renew.rs
//! **Keeping the lease alive**: RFC 2131 §4.4.5 as a state machine that is
//! pure over injected time and an injected transport.
//!
//! ```text
//! BOUND ──T1──▶ RENEWING ──T2──▶ REBINDING ──expiry──▶ REDISCOVERING
//! ▲ (unicast REQUEST (broadcast REQUEST (full DISCOVER→ACK,
//! │ to the lease's to 255.255.255.255) retried forever)
//! │ server) │
//! └────────────────── any ACK refreshes ──────────────────┘
//! any NAK ⇒ REDISCOVERING immediately
//! ```
//!
//! [`Machine`] holds the timers and transitions and does **no** I/O and no
//! sleeping: `poll(now, transport)` is handed the current second on a clock
//! the caller owns and a [`Transport`] the caller owns, performs at most one
//! transport operation, and answers with when to call again plus what
//! happened. That is what makes every transition — T1 fires, ACK reschedules,
//! T1-silence becomes a T2 broadcast, NAK cuts to re-DISCOVER, expiry —
//! testable in microseconds against a mock, with no network and no sleep. The
//! real driver (`crate::net::dhcp_renewal`) supplies tokio's clock and
//! [`super::WireTransport`]; the tests supply integers and a `Vec` of scripted
//! outcomes.
//!
//! Timer defaults are the RFC's: T1 = 0.5 · lease, T2 = 0.875 · lease, used
//! only when the server named no option 58/59. While RENEWING or REBINDING
//! with no answer, retries back off at half the remaining time to the next
//! boundary with the RFC's 60 s floor (§4.4.5's retransmission rule).
//!
//! # The one deliberate deviation: expiry does not deconfigure
//!
//! RFC 2131 §4.4.5 says that when the lease expires the client "MUST
//! immediately stop using" the address. This machine does not, on purpose,
//! and this paragraph is the record of that decision. The appliance's whole
//! point is serving; its operator is attached over ssh **through this very
//! address**, and deconfiguring it at expiry would kill every session —
//! including the one somebody would use to fix the network — turning "the
//! DHCP server went away" into "the appliance went away". So at expiry the
//! machine moves to REDISCOVERING, re-runs the full handshake forever, and
//! the address is *kept but squatted*: every failed re-DISCOVER cycle emits
//! [`Event::RediscoverFailed`] with `squatting = true`, which the driver turns
//! into a console WARN naming the squat, every cycle, until a server answers.
//! Loud and serving beats silent and dead; the trade-off is honest because it
//! is announced on every retry, not once.
use std::net::Ipv4Addr;
use anyhow::Result;
use super::Lease;
use crate::net::NetPlan;
/// RFC 2131 §4.4.5's floor on the retransmission backoff, seconds.
const RETRY_FLOOR_SECS: u64 = 60;
/// How long to wait between full re-DISCOVER attempts once the lease is gone.
/// Each attempt is itself bounded (~12 s of DISCOVER rounds), so one cycle is
/// roughly a minute of quiet between two loud ones.
const REDISCOVER_RETRY_SECS: u64 = 60;
/// One lease-maintenance operation against the world, bounded by the caller.
///
/// The production implementation is [`super::WireTransport`]; the tests'
/// implementation is a script. Both are *driven* by [`Machine::poll`] — the
/// machine decides when and which, the transport only does.
pub trait Transport {
/// Send one renewal REQUEST — unicast to `lease.server` when `broadcast`
/// is false (RENEWING), limited broadcast when true (REBINDING) — and
/// wait one bounded timeout for the answer.
fn request(&mut self, lease: &Lease, broadcast: bool) -> Outcome;
/// Run the full DISCOVER→OFFER→REQUEST→ACK handshake from scratch.
fn rediscover(&mut self) -> Result<Lease>;
}
/// What one [`Transport::request`] leg came back with.
#[derive(Debug)]
pub enum Outcome {
/// An ACK that amounted to a lease.
Ack(Lease),
/// The server said no; the lease is dead where it stands.
Nak(Option<String>),
/// Nobody answered inside the leg's timeout (or the answer was unusable).
NoReply,
}
/// Which conversation produced a refreshed lease — for the log line only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Via {
/// Unicast REQUEST to the lease's server, before T2.
Renew,
/// Broadcast REQUEST, between T2 and expiry.
Rebind,
/// A full DISCOVER→ACK from scratch, after a NAK or expiry.
Rediscover,
}
impl Via {
/// The RFC's name for the state that got the ACK.
pub fn as_str(self) -> &'static str {
match self {
Via::Renew => "RENEWING",
Via::Rebind => "REBINDING",
Via::Rediscover => "REDISCOVERING",
}
}
}
/// Something the driver must tell the operator about (and, for a changed
/// plan, apply). Pure data: the machine never logs and never touches netlink.
#[derive(Debug)]
pub enum Event {
/// An ACK refreshed the lease. `changed` is `Some(plan)` **iff** the
/// address, prefix or gateway moved — the driver applies it through the
/// one netlink writer and logs it loudly; `None` (the normal case) means
/// nothing is re-applied and netlink is never touched.
Renewed {
/// Which state's REQUEST the ACK answered.
via: Via,
/// The new plan, only when it differs from what is on the interface.
changed: Option<NetPlan>,
/// When the next T1 fires, machine-clock seconds.
renew_at: u64,
/// When this lease runs out, machine-clock seconds.
expire_at: u64,
},
/// The server refused the renewal; re-DISCOVER starts immediately.
Nak {
/// Option 56, the server's stated reason, if it gave one.
message: Option<String>,
},
/// The lease ran out with no replacement. The address is now *squatted*
/// (kept against the RFC's word — the module doc owns that decision).
ExpiredSquatting {
/// When it expired, machine-clock seconds.
expired_at: u64,
},
/// A full re-DISCOVER attempt failed. With `squatting` true this fires
/// once per retry cycle, forever, and the driver WARNs every time.
RediscoverFailed {
/// The handshake's error, rendered.
error: String,
/// True once the old lease has expired under us.
squatting: bool,
},
}
/// What one [`Machine::poll`] resolved to.
#[derive(Debug)]
pub struct Turn {
/// The machine-clock second at which to call `poll` again. May be `<=`
/// now, which means "again immediately" (e.g. right after a NAK).
pub wake_at: u64,
/// What happened, in order. Usually empty (a timer that has not fired).
pub events: Vec<Event>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Bound,
Renewing,
Rebinding,
Rediscovering { expired: bool },
}
/// The timers and transitions of §4.4.5, over a caller-owned clock.
///
/// "Machine-clock seconds" throughout are seconds on whatever monotonic clock
/// the caller measures `now` on; the machine only ever compares and adds them.
#[derive(Debug)]
pub struct Machine {
interface: String,
lease: Lease,
/// What is actually on the interface, for change detection:
/// (address, prefix, gateway).
applied: (Ipv4Addr, u8, Option<Ipv4Addr>),
t1_at: u64,
t2_at: u64,
expiry_at: u64,
next_attempt_at: u64,
state: State,
/// TEST SEAM: a forced T1 (seconds after each ACK), see
/// `crate::net::dhcp_t1_override`. `None` in every shipped configuration.
t1_override: Option<u32>,
}
impl Machine {
/// A machine for a lease granted at machine-clock second `bound_at`.
pub fn new(
interface: String,
lease: Lease,
bound_at: u64,
t1_override: Option<u32>,
) -> Machine {
let applied = (lease.address, lease.prefix, lease.gateway);
let (t1_at, t2_at, expiry_at) = timers(&lease, bound_at, t1_override);
Machine {
interface,
lease,
applied,
t1_at,
t2_at,
expiry_at,
next_attempt_at: t1_at,
state: State::Bound,
t1_override,
}
}
/// When the next unicast renewal is due, machine-clock seconds.
pub fn renew_at(&self) -> u64 {
self.t1_at
}
/// When rebinding (broadcast) would start, machine-clock seconds.
pub fn rebind_at(&self) -> u64 {
self.t2_at
}
/// When the current lease runs out, machine-clock seconds.
pub fn expire_at(&self) -> u64 {
self.expiry_at
}
/// **The lease as it stands right now** — the one the last ACK granted, or
/// the boot lease if none has.
///
/// Exists for [`Lease::dns`], which is not part of what "changed" means to
/// this machine. `Event::Renewed { changed }` is about the *netlink* plan
/// (address, prefix, gateway) and stays that way: the resolver is a file,
/// not a route, and giving `changed` a second meaning would make the one
/// arm that applies a `NetPlan` fire for a reason that has no NetPlan in
/// it. The driver reads the resolvers off here instead and hands them to
/// `crate::resolver`, which decides on its own whether anything moved (by
/// reading the file, not by trusting a flag).
pub fn lease(&self) -> &Lease {
&self.lease
}
/// Advance to `now`: fire whatever is due, performing **at most one**
/// transport operation, and say when to come back.
///
/// One operation per poll is a correctness property, not tidiness: a
/// transport leg blocks (bounded) real time, so `now` is stale the moment
/// one returns, and a second operation would be scheduled against a clock
/// reading that is already wrong. The driver re-reads its clock between
/// polls; the tests advance their integer.
pub fn poll(&mut self, now: u64, transport: &mut impl Transport) -> Turn {
let mut events = Vec::new();
loop {
match self.state {
State::Bound => {
if now < self.t1_at {
return Turn {
wake_at: self.t1_at,
events,
};
}
self.state = State::Renewing;
self.next_attempt_at = now;
}
State::Renewing | State::Rebinding => {
if now >= self.expiry_at {
events.push(Event::ExpiredSquatting {
expired_at: self.expiry_at,
});
self.state = State::Rediscovering { expired: true };
self.next_attempt_at = now;
continue;
}
if self.state == State::Renewing && now >= self.t2_at {
self.state = State::Rebinding;
self.next_attempt_at = now;
continue;
}
let boundary = if self.state == State::Renewing {
self.t2_at
} else {
self.expiry_at
};
if now < self.next_attempt_at {
return Turn {
wake_at: self.next_attempt_at.min(boundary),
events,
};
}
let broadcast = self.state == State::Rebinding;
match transport.request(&self.lease, broadcast) {
Outcome::Ack(granted) => {
let via = if broadcast { Via::Rebind } else { Via::Renew };
events.push(self.refresh(now, granted, via));
return Turn {
wake_at: self.t1_at,
events,
};
}
Outcome::Nak(message) => {
events.push(Event::Nak { message });
self.state = State::Rediscovering { expired: false };
self.next_attempt_at = now;
return Turn {
wake_at: now,
events,
};
}
Outcome::NoReply => {
// §4.4.5: half the remaining time to the next
// boundary, floored at 60 s. The boundary itself
// wins the min so a floor longer than the gap
// cannot skip a state.
let half = boundary.saturating_sub(now) / 2;
self.next_attempt_at = now + half.max(RETRY_FLOOR_SECS);
return Turn {
wake_at: self.next_attempt_at.min(boundary),
events,
};
}
}
}
State::Rediscovering { expired } => {
if now < self.next_attempt_at {
return Turn {
wake_at: self.next_attempt_at,
events,
};
}
match transport.rediscover() {
Ok(granted) => {
events.push(self.refresh(now, granted, Via::Rediscover));
return Turn {
wake_at: self.t1_at,
events,
};
}
Err(err) => {
events.push(Event::RediscoverFailed {
error: format!("{err:#}"),
squatting: expired,
});
self.next_attempt_at = now + REDISCOVER_RETRY_SECS;
return Turn {
wake_at: self.next_attempt_at,
events,
};
}
}
}
}
}
}
/// An ACK became a lease: restart the timers from `now` and say whether
/// the plan on the interface has to change.
fn refresh(&mut self, now: u64, granted: Lease, via: Via) -> Event {
let (t1_at, t2_at, expiry_at) = timers(&granted, now, self.t1_override);
self.t1_at = t1_at;
self.t2_at = t2_at;
self.expiry_at = expiry_at;
self.next_attempt_at = t1_at;
self.state = State::Bound;
let granted_plan = (granted.address, granted.prefix, granted.gateway);
let changed = (granted_plan != self.applied).then(|| NetPlan {
interface: self.interface.clone(),
address: granted.address,
prefix: granted.prefix,
gateway: granted.gateway,
});
self.applied = granted_plan;
self.lease = granted;
Event::Renewed {
via,
changed,
renew_at: self.t1_at,
expire_at: self.expiry_at,
}
}
}
/// (T1, T2, expiry) as absolute machine-clock seconds, from a lease granted
/// at `bound_at`.
///
/// Server-sent T1/T2 (options 58/59) win; the RFC defaults (0.5 · lease,
/// 0.875 · lease) fill in. Clamped to T1 ≤ T2 ≤ lease so a server that emits
/// nonsense cannot order the states backwards.
fn timers(lease: &Lease, bound_at: u64, t1_override: Option<u32>) -> (u64, u64, u64) {
let lease_secs = u64::from(lease.lease_secs);
let t2 = match lease.t2_secs {
Some(secs) => u64::from(secs).min(lease_secs),
None => lease_secs * 7 / 8,
};
let t1 = match t1_override {
Some(secs) => u64::from(secs),
None => match lease.t1_secs {
Some(secs) => u64::from(secs),
None => lease_secs / 2,
},
}
.min(t2);
(bound_at + t1, bound_at + t2, bound_at + lease_secs)
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
use std::collections::VecDeque;
const SERVER: Ipv4Addr = Ipv4Addr::new(10, 0, 2, 2);
/// A one-hour lease with no server T1/T2: defaults put T1 at 1800,
/// T2 at 3150, expiry at 3600.
fn lease() -> Lease {
Lease {
address: Ipv4Addr::new(10, 0, 2, 15),
prefix: 24,
gateway: Some(SERVER),
server: SERVER,
lease_secs: 3600,
t1_secs: None,
t2_secs: None,
host_name: None,
dns: vec![Ipv4Addr::new(10, 0, 2, 3)],
routes: Vec::new(),
}
}
fn machine() -> Machine {
Machine::new("eth0".into(), lease(), 0, None)
}
/// The scripted transport: `poll` drives it, it records what it was told
/// to do, and answers from a queue. `NoReply` / `Err` when the script runs
/// dry, which is also the shape of a dead network.
#[derive(Default)]
struct Script {
outcomes: VecDeque<Outcome>,
rediscoveries: VecDeque<Result<Lease>>,
/// (server unicast would go to, broadcast?) per REQUEST leg.
requests: Vec<(Ipv4Addr, bool)>,
rediscover_calls: usize,
}
impl Transport for Script {
fn request(&mut self, lease: &Lease, broadcast: bool) -> Outcome {
self.requests.push((lease.server, broadcast));
self.outcomes.pop_front().unwrap_or(Outcome::NoReply)
}
fn rediscover(&mut self) -> Result<Lease> {
self.rediscover_calls += 1;
self.rediscoveries
.pop_front()
.unwrap_or_else(|| Err(anyhow!("no DHCP server answered")))
}
}
// ── BOUND → RENEWING ────────────────────────────────────────────────────
#[test]
fn bound_sleeps_to_t1_then_unicasts_a_request_to_the_recorded_server() {
let mut m = machine();
let mut t = Script::default();
// Before T1 nothing may touch the network — a renewal loop that
// chatters early is a renewal loop that chatters always.
let turn = m.poll(0, &mut t);
assert_eq!(turn.wake_at, 1800, "RFC default T1 is 0.5 · lease");
assert!(turn.events.is_empty());
assert!(t.requests.is_empty(), "no REQUEST before T1");
assert_eq!(m.poll(1799, &mut t).wake_at, 1800);
assert!(t.requests.is_empty(), "still no REQUEST one second early");
t.outcomes.push_back(Outcome::Ack(lease()));
m.poll(1800, &mut t);
assert_eq!(
t.requests,
vec![(SERVER, false)],
"T1 must produce exactly one unicast REQUEST to the lease's server"
);
}
#[test]
fn an_ack_refreshes_the_timers_and_t1_fires_again_a_lease_later() {
let mut m = machine();
let mut t = Script::default();
t.outcomes.push_back(Outcome::Ack(lease()));
let turn = m.poll(1800, &mut t);
assert!(
matches!(
turn.events.as_slice(),
[Event::Renewed {
via: Via::Renew,
changed: None,
..
}]
),
"an unchanged lease must renew without a plan: {:?}",
turn.events
);
assert_eq!(
turn.wake_at, 3600,
"the ACK at 1800 must reschedule T1 a full half-lease later"
);
// And the rescheduled T1 must actually fire: this is the assertion
// the sabotage (refresh not updating t1_at) turns red.
t.outcomes.push_back(Outcome::Ack(lease()));
m.poll(3600, &mut t);
assert_eq!(
t.requests,
vec![(SERVER, false), (SERVER, false)],
"the refreshed T1 must produce the second unicast REQUEST"
);
}
#[test]
fn server_named_t1_t2_win_over_the_rfc_defaults() {
let with_options = Lease {
t1_secs: Some(600),
t2_secs: Some(1200),
..lease()
};
let m = Machine::new("eth0".into(), with_options, 100, None);
assert_eq!(m.renew_at(), 700, "option 58 wins over 0.5 · lease");
assert_eq!(m.rebind_at(), 1300, "option 59 wins over 0.875 · lease");
assert_eq!(m.expire_at(), 3700);
let defaults = machine();
assert_eq!(defaults.renew_at(), 1800);
assert_eq!(defaults.rebind_at(), 3150, "0.875 · 3600");
assert_eq!(defaults.expire_at(), 3600);
}
// ── RENEWING → REBINDING ────────────────────────────────────────────────
#[test]
fn silence_at_t1_backs_off_then_broadcasts_after_t2() {
let mut m = machine();
let mut t = Script::default();
let turn = m.poll(1800, &mut t); // NoReply (script is empty)
assert_eq!(t.requests, vec![(SERVER, false)]);
assert_eq!(
turn.wake_at,
1800 + (3150 - 1800) / 2,
"the retry backs off half the remaining time to T2"
);
// Every retry until T2 stays unicast…
let turn = m.poll(turn.wake_at, &mut t);
assert!(t.requests.iter().all(|&(_, broadcast)| !broadcast));
assert!(turn.wake_at <= 3150, "the backoff must never sleep past T2");
// …and the first poll at T2 goes broadcast.
m.poll(3150, &mut t);
let last = t.requests.last().expect("T2 must send a REQUEST");
assert!(last.1, "past T2 the REQUEST must be broadcast (REBINDING)");
}
#[test]
fn a_rebind_ack_recovers_to_bound() {
let mut m = machine();
let mut t = Script::default();
m.poll(1800, &mut t); // T1, no reply
t.outcomes.push_back(Outcome::Ack(lease()));
let turn = m.poll(3150, &mut t); // T2, broadcast, ACK
assert!(
matches!(
turn.events.as_slice(),
[Event::Renewed {
via: Via::Rebind,
changed: None,
..
}]
),
"{:?}",
turn.events
);
assert_eq!(
turn.wake_at,
3150 + 1800,
"BOUND again, next T1 rescheduled"
);
}
// ── NAK ─────────────────────────────────────────────────────────────────
#[test]
fn a_nak_cuts_straight_to_rediscover() {
let mut m = machine();
let mut t = Script::default();
t.outcomes
.push_back(Outcome::Nak(Some("lease pool moved".into())));
let turn = m.poll(1800, &mut t);
assert!(
matches!(
turn.events.as_slice(),
[Event::Nak { message: Some(m) }] if m == "lease pool moved"
),
"{:?}",
turn.events
);
assert_eq!(turn.wake_at, 1800, "a NAK must not wait for expiry");
assert_eq!(t.rediscover_calls, 0, "one transport op per poll");
let fresh = Lease {
address: Ipv4Addr::new(10, 0, 2, 40),
..lease()
};
t.rediscoveries.push_back(Ok(fresh));
let turn = m.poll(1800, &mut t);
assert_eq!(t.rediscover_calls, 1, "the immediate re-poll re-DISCOVERs");
assert!(
matches!(
turn.events.as_slice(),
[Event::Renewed {
via: Via::Rediscover,
changed: Some(_),
..
}]
),
"{:?}",
turn.events
);
}
// ── expiry: the deliberate deviation ────────────────────────────────────
#[test]
fn expiry_on_a_dead_network_squats_loudly_and_keeps_asking() {
let mut m = machine();
let mut t = Script::default(); // nothing ever answers
let turn = m.poll(3600, &mut t); // straight to expiry
assert!(
matches!(
turn.events.as_slice(),
[
Event::ExpiredSquatting { expired_at: 3600 },
Event::RediscoverFailed {
squatting: true,
..
}
]
),
"expiry must announce the squat AND the failed re-DISCOVER: {:?}",
turn.events
);
assert_eq!(t.rediscover_calls, 1);
assert_eq!(turn.wake_at, 3600 + REDISCOVER_RETRY_SECS);
// Every later cycle warns again and asks again — forever, not once.
let turn = m.poll(turn.wake_at, &mut t);
assert!(
matches!(
turn.events.as_slice(),
[Event::RediscoverFailed {
squatting: true,
..
}]
),
"{:?}",
turn.events
);
assert_eq!(t.rediscover_calls, 2);
// And when a server finally answers, the squat ends in a normal BOUND.
t.rediscoveries.push_back(Ok(lease()));
let turn = m.poll(turn.wake_at, &mut t);
assert!(
matches!(
turn.events.as_slice(),
[Event::Renewed {
via: Via::Rediscover,
changed: None,
..
}]
),
"{:?}",
turn.events
);
}
// ── a lease that moves the address ──────────────────────────────────────
#[test]
fn a_changed_lease_emits_the_new_plan_exactly_once() {
let moved = Lease {
address: Ipv4Addr::new(192, 168, 7, 20),
prefix: 16,
gateway: Some(Ipv4Addr::new(192, 168, 0, 1)),
..lease()
};
let mut m = machine();
let mut t = Script::default();
t.outcomes.push_back(Outcome::Ack(moved.clone()));
let turn = m.poll(1800, &mut t);
let [Event::Renewed {
changed: Some(plan),
..
}] = turn.events.as_slice()
else {
panic!("a moved address must emit a plan: {:?}", turn.events);
};
assert_eq!(
*plan,
NetPlan {
interface: "eth0".into(),
address: Ipv4Addr::new(192, 168, 7, 20),
prefix: 16,
gateway: Some(Ipv4Addr::new(192, 168, 0, 1)),
},
"the plan must be the granted lease, on the machine's interface"
);
// The next ACK for the SAME address must not re-emit it: re-applying
// an unchanged plan every T1 is exactly the netlink churn this field
// exists to prevent.
t.outcomes.push_back(Outcome::Ack(moved));
let turn = m.poll(turn.wake_at, &mut t);
assert!(
matches!(
turn.events.as_slice(),
[Event::Renewed { changed: None, .. }]
),
"an unchanged renewal re-emitted a plan: {:?}",
turn.events
);
}
// ── the test seam ───────────────────────────────────────────────────────
#[test]
fn the_t1_override_shrinks_only_the_renewal_timer() {
let mut m = Machine::new("eth0".into(), lease(), 0, Some(20));
assert_eq!(m.renew_at(), 20, "the override replaces T1");
assert_eq!(m.rebind_at(), 3150, "…and touches nothing else");
assert_eq!(m.expire_at(), 3600);
// It survives a refresh: the seam exists so a live box renews every
// few seconds, not just once.
let mut t = Script::default();
t.outcomes.push_back(Outcome::Ack(lease()));
let turn = m.poll(20, &mut t);
assert_eq!(t.requests, vec![(SERVER, false)]);
assert_eq!(turn.wake_at, 40, "the refreshed T1 is override-sized again");
}
}