egress anti-spoof: renewals from the leased address pass, a stale lease delete is skipped
This commit is contained in:
parent
3fa926d7d6
commit
1cef99e992
1 changed files with 305 additions and 20 deletions
|
|
@ -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\
|
||||
|
|
|
|||
Loading…
Reference in a new issue