From 1cef99e99207134704872d857a72fe5c2ae4d7bc Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 14 Nov 2026 20:55:07 +0100 Subject: [PATCH] egress anti-spoof: renewals from the leased address pass, a stale lease delete is skipped --- docs/4-implementation/networking/network.md | 25 ++- hive-priv/src/egress.rs | 162 +++++++++++++++++++- nix/host-modules/hive-egress.nix | 12 +- nix/vm-tests/hive-egress-antispoof.nix | 126 +++++++++++++-- 4 files changed, 305 insertions(+), 20 deletions(-) diff --git a/docs/4-implementation/networking/network.md b/docs/4-implementation/networking/network.md index c46dd9d04..505f71ae1 100644 --- a/docs/4-implementation/networking/network.md +++ b/docs/4-implementation/networking/network.md @@ -810,10 +810,11 @@ source check lives in that table and the host-side filtering stays in `inet hive_egress`. - **What a port may send.** The frame's MAC is the one bound to the port. - Then, by protocol: IPv4 from an address leased to that MAC, or DHCP from - `0.0.0.0` to `255.255.255.255`; ARP whose sender is the port's MAC and an - address leased to it, or an ARP probe from `0.0.0.0`; IPv6 from a link-local - address (and from `::` in neighbour solicitations and listener reports, which + Then, by protocol: IPv4 from an address leased to that MAC, or a DHCP client + message (UDP 68 to 67) to `255.255.255.255` or the bridge address, from any + source address, as long as its `chaddr` is the port's MAC; ARP whose sender is the + port's MAC and an address leased to it, or an ARP probe from `0.0.0.0`; IPv6 + from a link-local address (and from `::` in neighbour solicitations and listener reports, which precede an address). A message to UDP port 67 must carry the port's own MAC as its `chaddr`, so a container can't lease, renew or release an address for another. The table refuses a VLAN tag, and everything else counts as a spoof. @@ -841,6 +842,18 @@ source check lives in that table and the host-side filtering stays in dnsmasq run it on every renewal too, which loads the pair again with the new remaining time; without it a pair would leave the table when the first lease ended, while dnsmasq kept renewing the lease. + A client whose pair has left the set can still renew or rebind: the DHCP + client message passes from the leased address too, and dnsmasq's reply puts + the pair back. + dnsmasq counts lease time on the wall clock, and the pair's timeout and the + container's own lease count awake time. After a suspend dnsmasq expires every + lease at once and runs `egress-lease del` for each; a `del` whose + `DNSMASQ_LEASE_EXPIRES` lies in the past does nothing, and the pair leaves the + table at its own timeout, as the container's lease ends. A `del` for a lease + that hasn't expired (a release) deletes the pair at once, as does one without + a readable expiry. `egress-lease` logs each `add`, `old` and `del` to dnsmasq's + log with the prefix `egress-lease:`, the MAC, address, port, expiry, and for + `del` whether it was `applied` or `skipped (stale)`. - **The window.** The binding takes the MAC the container's link has when the command first looks, normally within a second of the veth appearing. Until then root in the container could change its own MAC, but in that time only @@ -870,7 +883,9 @@ source check lives in that table and the host-side filtering stays in A binding that failed shows as `hive-egress-pin` errors in the journal and `IN=` lines for that port. - **What it doesn't cover.** A container can still send from any IPv6 - link-local address and answer DHCP as a server on its own leased address. + link-local address and answer DHCP as a server on its own leased address. A + DHCP client message to the broadcast or bridge address carries any IPv4 source + address; the table looks only at its `chaddr`. Enforcement comes with later changes in the same series: the log only shows what such a change would cut. diff --git a/hive-priv/src/egress.rs b/hive-priv/src/egress.rs index 5f40d11ba..1158068b9 100644 --- a/hive-priv/src/egress.rs +++ b/hive-priv/src/egress.rs @@ -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::().ok()) + .is_some_and(|expires| expires != 0 && expires <= now) +} + +/// The one log line of a lease event the script acts on, as `egress-lease: +/// 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 { + 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 0 is a lease without end. @@ -793,7 +864,10 @@ fn load(args: &[String]) -> Result<()> { } /// `egress-lease [ []]`. 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\ diff --git a/nix/host-modules/hive-egress.nix b/nix/host-modules/hive-egress.nix index f038cf168..48ba1305f 100644 --- a/nix/host-modules/hive-egress.nix +++ b/nix/host-modules/hive-egress.nix @@ -205,8 +205,11 @@ let # duplicate address detection and multicast listener reports send before an # address exists. The bridge carries no other IPv6 traffic. # - DHCP: any message to port 67 must carry the sender's own MAC as `chaddr`, - # so a port cannot lease, renew or release another's address; a client with - # no address yet sends from 0.0.0.0 to the broadcast address. + # so a port cannot lease, renew or release another's address. A client + # message to the broadcast address or the bridge address then passes from + # any source address: a client with no address sends from 0.0.0.0, and one + # whose pair has left `lease_v4` renews (unicast) or rebinds (broadcast) + # from its own address. The reply puts the pair back. # - ARP: the sender's MAC is the port's, and its address 0.0.0.0 (a probe) or # leased to that MAC. # - A VLAN tag is refused: a tagged frame would carry its packet past the @@ -233,7 +236,7 @@ let chain l3_v4 { udp dport 67 iifname . @th,288,48 != @port_chaddr goto spoof - ip saddr 0.0.0.0 ip daddr 255.255.255.255 udp sport 68 udp dport 67 accept + ip daddr { 255.255.255.255, ${cfg.bridgeIp} } udp sport 68 udp dport 67 accept ether saddr . ip saddr @lease_v4 accept goto spoof } @@ -342,7 +345,8 @@ in the frame's MAC must be the one bound to the port when its container started, and its IPv4 address the one leased to that MAC. A port with no bound MAC sends nothing. Allowed besides the leased address: DHCP from - `0.0.0.0` whose `chaddr` is the port's MAC, ARP probes, and IPv6 link-local + a DHCP client message to the broadcast or bridge address from any source + address whose `chaddr` is the port's MAC, ARP probes, and IPv6 link-local sources (and `::` in neighbour discovery and listener reports). - `drop` (the default): the frame is dropped and logged with the prefix diff --git a/nix/vm-tests/hive-egress-antispoof.nix b/nix/vm-tests/hive-egress-antispoof.nix index a4eb537e2..aaafbce1e 100644 --- a/nix/vm-tests/hive-egress-antispoof.nix +++ b/nix/vm-tests/hive-egress-antispoof.nix @@ -2,8 +2,9 @@ # ../host-modules/hive-egress.nix on a real agent bridge, with two nspawn # containers on it that take their addresses from the hive's dnsmasq # (../host-modules/hive-gateway/dnsmasq.nix' lease script, the real -# `hive-priv egress-load`, `egress-pin` and `egress-lease`). `hive-squid` cannot host this: its -# stand-in bridge is one NIC, so every agent shares one port. +# `hive-priv egress-load`, `egress-pin` and `egress-lease`). `hive-squid` +# cannot host this: its stand-in bridge is one NIC, so every agent shares one +# port. # # What it pins: # - the default verdict is `drop`, and each container takes its first lease @@ -15,10 +16,14 @@ # - another container's IPv4 address, another container's MAC, an IPv6 address # that is not link-local and a DHCP message with another's `chaddr` are # dropped, and each leaves a `hive-egress-spoof ` line that names the port; -# - a container that keeps talking across several renewals of a 2-minute lease -# is never dropped, and its pair is still in `lease_v4` after the first -# lease and its margin would have ended; +# - a container that keeps talking across renewals of a 2-minute lease is +# never dropped, and its pair is still in `lease_v4` after the first lease +# and its margin would have ended; # - a DHCP renewal, a link-local IPv6 packet and a restored MAC go through; +# - a pair deleted by hand comes back at the next renewal and rebind; a DHCP +# client message with the port's chaddr passes from any source address, to +# the broadcast or bridge address only; a two-hour wall-clock step keeps +# the pairs dnsmasq prunes; a release removes its pair; # - a port with no bound MAC sends nothing, and the timer binds it again; # - `antiSpoof = "log"`, reached by a reload of the unit, brings the bindings # and the leases with the new table (nothing is dropped in between), @@ -28,16 +33,21 @@ let bridge = "hive0"; gateway = "10.42.0.1"; - # A DHCP DISCOVER by hand, unicast to dnsmasq, with the `chaddr` given as - # the argument. The container shares the host's store, so it runs from there. + # A DHCP DISCOVER by hand, with the `chaddr` given as the first argument, + # unicast to dnsmasq. A second argument names another destination, a third + # a source address, and a message with a source address leaves from port 68 + # like a client's. The container shares the host's store, so it runs from + # there. bootp = pkgs.writeText "bootp.pl" '' use IO::Socket::INET; use Socket; my $mac = pack("H12", join("", split(/:/, $ARGV[0]))); - my $s = IO::Socket::INET->new(Proto => "udp") or die "socket: $!"; + my ($dest, $src) = ($ARGV[1] || "${gateway}", $ARGV[2]); + my @bind = $src ? (LocalAddr => $src, LocalPort => 68, ReuseAddr => 1) : (); + my $s = IO::Socket::INET->new(Proto => "udp", @bind) or die "socket: $!"; my $head = pack("C4 N n n a4 a4 a4 a4", 1, 1, 6, 0, 4660, 0, 0, "", "", "", ""); my $msg = $head . $mac . ("\0" x 10) . ("\0" x 192) . pack("H8", "63825363") . pack("C4", 53, 1, 1, 255); - $s->send($msg, 0, pack_sockaddr_in(67, inet_aton("${gateway}"))) or die "send: $!"; + $s->send($msg, 0, pack_sockaddr_in(67, inet_aton($dest))) or die "send: $!"; ''; hiveStubs = @@ -59,6 +69,15 @@ let type = lib.types.bool; default = true; }; + bridgeIp = lib.mkOption { + type = lib.types.str; + default = gateway; + }; + # Set by hive-egress.nix; nothing here opens it. + exposeHostPorts = lib.mkOption { + type = lib.types.listOf lib.types.port; + default = [ ]; + }; bridgeName = lib.mkOption { type = lib.types.str; default = bridge; @@ -100,6 +119,8 @@ pkgs.testers.runNixOSTest { ../host-modules/hive-priv.nix ]; virtualisation.memorySize = 2048; + # Explicit: the tests below are about what `drop` does. + services.hyperhive.network.egress.antiSpoof = "drop"; networking.bridges.${bridge}.interfaces = [ ]; networking.interfaces.${bridge}.ipv4.addresses = [ { @@ -187,6 +208,15 @@ pkgs.testers.runNixOSTest { host.execute("systemctl stop rx.service") return got + def acks_of_cmd(mac_addr): + return "$(journalctl -u dnsmasq -o cat --no-pager | grep -c 'DHCPACK(${bridge}) .* " + mac_addr + "')" + + def acks_of(mac_addr): + return int(host.succeed("echo " + acks_of_cmd(mac_addr)).strip()) + + def stale_dels(): + return host.succeed("journalctl -u dnsmasq -o cat --no-pager | grep -c 'egress-lease: del .* skipped (stale)' || true").strip() + host.start() host.wait_for_unit("multi-user.target") host.wait_for_unit("dnsmasq.service") @@ -305,6 +335,84 @@ pkgs.testers.runNixOSTest { host.wait_until_succeeds("nixos-container run a -- ping -c1 -W2 ${gateway}") host.wait_until_succeeds("nixos-container run b -- ping -c1 -W2 ${gateway}") + with subtest("a pair that left the set comes back at the container's next renewal and its next rebind, and nothing is dropped"): + # The renewal is a unicast REQUEST from the leased address, the rebind a + # broadcast one from it. Neither can match the pair: it is gone. + for verb in ("renew", "rebind"): + host.succeed(f"nft delete element {TABLE} lease_v4 {{ {MAC_A} . {IP_A} }}") + host.fail(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_A} . {IP_A}'") + assert not ct_ok("a", "ping -c1 -W2 ${gateway}"), "control: a is cut off without its pair" + assert ct_ok("b", "ping -c1 -W2 ${gateway}"), "control: b is not" + drops_before = len(spoof_lines(r"IN=vb-a .*DPT=67 ")) + acks = ack_count(IP_A) + ct("a", f"dhcpcd --{verb} eth0") + host.wait_until_succeeds(f"test $(journalctl -u dnsmasq -o cat --no-pager | grep -c 'DHCPACK(${bridge}) {IP_A}') -gt {acks}", timeout=30) + host.wait_until_succeeds(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_A} . {IP_A}'", timeout=30) + assert ct_ok("a", "ping -c1 -W2 ${gateway}"), f"a reaches the host again after its {verb}" + assert len(spoof_lines(r"IN=vb-a .*DPT=67 ")) == drops_before, f"the {verb} was not dropped" + + with subtest("a DHCP client message with the port's own chaddr passes from any source address to the broadcast or bridge address only"): + host_mac = host.succeed("cat /sys/class/net/${bridge}/address").strip() + ct("a", f"ip neigh replace ${gateway} lladdr {host_mac} dev eth0 nud permanent") + ct("a", f"ip neigh replace {IP_B} lladdr {MAC_B} dev eth0 nud permanent") + ct("a", f"ip addr add {IP_B}/24 dev eth0") + discovers_a = host.succeed("journalctl -u dnsmasq -o cat --no-pager").count("DHCPDISCOVER(${bridge}) " + MAC_A) + ct("a", f"perl ${bootp} {MAC_A} ${gateway} {IP_B}") + host.wait_until_succeeds( + "test $(journalctl -u dnsmasq -o cat --no-pager | grep -c 'DHCPDISCOVER(${bridge}) " + MAC_A + "') -gt " + str(discovers_a), + timeout=30, + ) + seen = len(spoof_lines(r"IN=vb-a .*DST=" + IP_B.replace(".", r"\.") + " .*DPT=67 ")) + ct("a", f"perl ${bootp} {MAC_A} {IP_B} {IP_B}") + host.wait_until_succeeds("journalctl -k -o cat --no-pager | grep -E 'hive-egress-spoof .*IN=vb-a .*DST=" + IP_B.replace(".", "\\.") + " .*DPT=67 '", timeout=30) + assert len(spoof_lines(r"IN=vb-a .*DST=" + IP_B.replace(".", r"\.") + " .*DPT=67 ")) > seen, "to another container's address it is dropped" + ct("a", f"ip addr del {IP_B}/24 dev eth0") + ct("a", f"ip neigh del {IP_B} dev eth0") + ct("a", "ip neigh del ${gateway} dev eth0") + + with subtest("a step of the wall clock that makes dnsmasq prune every lease leaves the pairs in the set and the containers reaching the host"): + # The qemu guest has no suspend. A step of the wall clock is the same + # divergence: dnsmasq counts wall time and expires every lease, while + # the nft timeouts and the containers' leases count monotonic time. + assert ct_ok("a", "ping -c1 -W2 ${gateway}") and ct_ok("b", "ping -c1 -W2 ${gateway}") + drops_before = len(spoof_lines(r"IN=vb-[ab] ")) + skipped = int(stale_dels()) + host.succeed("date -s @$(( $(date +%s) + 7200 ))") + # Any DHCP packet makes dnsmasq prune: a DISCOVER with a's own chaddr. + ct("a", "perl ${bootp} " + MAC_A) + host.wait_until_succeeds("test $(journalctl -u dnsmasq -o cat --no-pager | grep -c 'egress-lease: del .* skipped (stale)') -ge " + str(skipped + 2), timeout=60) + host.succeed(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_A} . {IP_A}'") + host.succeed(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_B} . {IP_B}'") + assert ct_ok("a", "ping -c1 -W2 ${gateway}"), "a reaches the host after dnsmasq forgot its lease" + assert ct_ok("b", "ping -c1 -W2 ${gateway}"), "b reaches the host after dnsmasq forgot its lease" + assert not host.succeed("journalctl -u dnsmasq -o cat --no-pager | grep 'egress-lease: del .* applied' || true").strip(), "no delete was applied" + # Each container then renews against a dnsmasq that has forgotten its + # lease, and ends with a lease again. It keeps reaching the host. + acks_a, acks_b = acks_of(MAC_A), acks_of(MAC_B) + for name in ("a", "b"): + ct(name, "dhcpcd --renew eth0") + host.wait_until_succeeds(f"test {acks_of_cmd(MAC_A)} -gt {acks_a} && test {acks_of_cmd(MAC_B)} -gt {acks_b}", timeout=90) + IP_A, IP_B = ipv4("a"), ipv4("b") + host.wait_until_succeeds(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_A} . {IP_A}'", timeout=60) + host.wait_until_succeeds(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_B} . {IP_B}'", timeout=60) + assert len(spoof_lines(r"IN=vb-[ab] ")) == drops_before, "nothing of a or b was dropped" + + with subtest("a release still removes the pair at once"): + # A fresh lease first: the release must name an expiry in the future. + acks = ack_count(IP_A) + ct("a", "dhcpcd --rebind eth0") + host.wait_until_succeeds(f"test $(journalctl -u dnsmasq -o cat --no-pager | grep -c 'DHCPACK(${bridge}) {IP_A}') -gt {acks}", timeout=30) + host.succeed(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_A} . {IP_A}'") + ct("a", "dhcpcd -k eth0") + host.wait_until_fails(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_A} . {IP_A}'", timeout=30) + host.succeed(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_B} . {IP_B}'") + host.wait_until_succeeds("journalctl -u dnsmasq -o cat --no-pager | grep -E 'egress-lease: del mac=" + MAC_A + " .* applied'", timeout=30) + # a asks again, as its container would after a restart. + ct("a", "systemctl restart dhcpcd") + host.wait_until_succeeds("nixos-container run a -- ping -c1 -W2 ${gateway}", timeout=60) + IP_A = ipv4("a") + host.wait_until_succeeds(f"nft list set {TABLE} lease_v4 | grep -q '{MAC_A} . {IP_A}'", timeout=30) + with subtest("antiSpoof = log keeps the bindings and the leases, lets a spoofed datagram through and logs it"): host.succeed("/run/current-system/specialisation/logmode/bin/switch-to-configuration test") assert "counter accept" in host.succeed(f"nft list chain {TABLE} spoof"), "the table was reloaded in log mode"