Watch
0
0
Fork
You've already forked hyperhive
0

egress anti-spoof: renewals from the leased address pass, a stale lease delete is skipped

This commit is contained in:
atlas 2026-11-14 20:55:07 +01:00 • committed by mara
commit 1cef99e992

View file

@ -440,6 +440,77 @@ pub(crate) fn lease_script(
}
}
/// Whether a `del` event is dnsmasq pruning a lease that has run out on the
/// wall clock, as `expires` (`DNSMASQ_LEASE_EXPIRES`, seconds since the epoch)
/// is at or before `now`. A release or decline has an expiry in the future.
/// Without a parseable expiry, or with 0 (a lease without end), the event is
/// not stale: the pair is deleted.
///
/// dnsmasq's clock is wall time and keeps running through a suspend, so after a
/// resume it prunes every lease at once. The pair's nft timeout and the
/// container's own lease count awake time and have not run out; leaving the
/// pair to its timeout keeps it for as long as the container's lease lasts.
pub(crate) fn del_is_stale(expires: Option<&str>, now: u64) -> bool {
expires
.and_then(|v| v.trim().parse::<u64>().ok())
.is_some_and(|expires| expires != 0 && expires <= now)
}
/// The one log line of a lease event the script acts on, as `egress-lease:
/// <action> mac=… ip=… port=… expires=… timeout=…` and, for `del`, `applied` or
/// `skipped (stale)` at the end. A value that is not known is `-`.
pub(crate) fn lease_log_line(event: &LeaseEvent<'_>, port: Option<&str>) -> String {
let LeaseEvent {
action,
mac,
ip,
expires,
remaining_secs,
now,
} = *event;
let mut line = format!(
"{LEASE}: {action} mac={mac} ip={ip} port={} expires={}",
port.unwrap_or("-"),
expires.unwrap_or("-")
);
if action == "del" {
let outcome = if del_is_stale(expires, now) {
"skipped (stale)"
} else {
"applied"
};
let _ = write!(line, " now={now} {outcome}");
} else {
let _ = write!(
line,
" timeout={}s",
remaining_secs.saturating_add(LEASE_MARGIN_SECS)
);
}
line
}
/// The nft input to run for `event`: [`lease_script`], except that a stale
/// `del` ([`del_is_stale`]) runs nothing.
pub(crate) fn script_to_run(event: &LeaseEvent<'_>) -> Option<String> {
if event.action == "del" && del_is_stale(event.expires, event.now) {
return None;
}
lease_script(event.action, event.mac, event.ip, event.remaining_secs)
}
/// What dnsmasq passed to `egress-lease` for one lease event.
#[derive(Clone, Copy)]
pub(crate) struct LeaseEvent<'a> {
pub(crate) action: &'a str,
pub(crate) mac: &'a str,
pub(crate) ip: &'a str,
/// `DNSMASQ_LEASE_EXPIRES`, as dnsmasq spells it.
pub(crate) expires: Option<&'a str>,
pub(crate) remaining_secs: u64,
pub(crate) now: u64,
}
/// The nft input that loads the leases of a dnsmasq lease file that have not
/// run out at `now` (seconds since the epoch). A line is `<expiry> <mac> <ip>
/// <host> <client-id>`; expiry 0 is a lease without end.
@ -793,7 +864,10 @@ fn load(args: &[String]) -> Result<()> {
}
/// `egress-lease <action> [<mac> <ip> [<host>]]`. Nothing is loaded for an event
/// that changes no lease.
/// that changes no lease, and a `del` for a lease that ran out by the wall
/// clock ([`del_is_stale`]) leaves its pair to the pair's own timeout. Every
/// event that is acted on or skipped leaves one line ([`lease_log_line`]) on
/// stderr, which dnsmasq passes on to its log.
fn lease(args: &[String]) -> Result<()> {
let [action, mac, ip, ..] = args else {
return Ok(());
@ -802,7 +876,25 @@ fn lease(args: &[String]) -> Result<()> {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_LEASE_SECS);
match lease_script(action, mac, ip, remaining) {
let expires = std::env::var("DNSMASQ_LEASE_EXPIRES").ok();
let now = now_secs();
if lease_script(action, mac, ip, remaining).is_none() {
return Ok(());
}
let port = Mac::parse(mac).and_then(|mac| {
let pins = parse_state(&std::fs::read_to_string(state_path()).unwrap_or_default());
pins.into_iter().find(|p| p.mac == mac).map(|p| p.port)
});
let event = LeaseEvent {
action,
mac,
ip,
expires: expires.as_deref(),
remaining_secs: remaining,
now,
};
eprintln!("{}", lease_log_line(&event, port.as_deref()));
match script_to_run(&event) {
Some(script) => nft(&script),
None => Ok(()),
}
@ -1285,6 +1377,72 @@ mod tests {
assert!(script.contains(&format!("timeout {}s", u64::MAX)));
}
fn event<'a>(action: &'a str, expires: Option<&'a str>, now: u64) -> LeaseEvent<'a> {
LeaseEvent {
action,
mac: "02:00:00:00:00:0a",
ip: "10.42.0.10",
expires,
remaining_secs: 90,
now,
}
}
#[test]
fn a_del_for_a_lease_that_ran_out_by_the_wall_clock_is_stale() {
assert!(del_is_stale(Some("1000"), 1000), "expiry equal to now");
assert!(
del_is_stale(Some("1000"), 8200),
"a clock that jumped ahead"
);
assert_eq!(script_to_run(&event("del", Some("1000"), 8200)), None);
}
#[test]
fn a_del_for_a_lease_that_has_not_run_out_deletes_the_pair() {
assert!(!del_is_stale(Some("1001"), 1000));
let script = script_to_run(&event("del", Some("1900"), 1000)).unwrap();
assert!(script.contains("delete element") && !script.contains("timeout"));
}
#[test]
fn a_del_without_a_usable_expiry_deletes_the_pair() {
for expires in [None, Some(""), Some("soon"), Some("-5"), Some("0")] {
assert!(!del_is_stale(expires, 8200), "{expires:?}");
let script = script_to_run(&event("del", expires, 8200)).unwrap();
assert!(script.contains("delete element"), "{expires:?}");
}
}
#[test]
fn only_a_del_is_ever_stale() {
for action in ["add", "old"] {
let script = script_to_run(&event(action, Some("1000"), 8200)).unwrap();
assert!(script.ends_with("timeout 390s }\n"), "{action}");
}
}
#[test]
fn every_acted_on_event_has_a_log_line_with_its_expiry_and_outcome() {
let add = lease_log_line(&event("add", Some("5000"), 1000), Some("vb-a"));
assert_eq!(
add,
"egress-lease: add mac=02:00:00:00:00:0a ip=10.42.0.10 port=vb-a expires=5000 timeout=390s"
);
let old = lease_log_line(&event("old", None, 1000), None);
assert_eq!(
old,
"egress-lease: old mac=02:00:00:00:00:0a ip=10.42.0.10 port=- expires=- timeout=390s"
);
let stale = lease_log_line(&event("del", Some("1000"), 8200), Some("vb-a"));
assert!(
stale.ends_with("expires=1000 now=8200 skipped (stale)"),
"{stale}"
);
let fresh = lease_log_line(&event("del", Some("9000"), 8200), Some("vb-a"));
assert!(fresh.ends_with("expires=9000 now=8200 applied"), "{fresh}");
}
#[test]
fn seeding_skips_expired_and_malformed_leases() {
let leases = "2000 02:00:00:00:00:0a 10.42.0.10 a *\n\