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
4 changed files with 305 additions and 20 deletions

View file

@ -810,10 +810,11 @@ source check lives in that table and the host-side filtering stays in
`inet hive_egress`. `inet hive_egress`.
- **What a port may send.** The frame's MAC is the one bound to the port. - **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 Then, by protocol: IPv4 from an address leased to that MAC, or a DHCP client
`0.0.0.0` to `255.255.255.255`; ARP whose sender is the port's MAC and an message (UDP 68 to 67) to `255.255.255.255` or the bridge address, from any
address leased to it, or an ARP probe from `0.0.0.0`; IPv6 from a link-local source address, as long as its `chaddr` is the port's MAC; ARP whose sender is the
address (and from `::` in neighbour solicitations and listener reports, which 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 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 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. 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 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 new remaining time; without it a pair would leave the table when the first
lease ended, while dnsmasq kept renewing the lease. 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 - **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 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 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 A binding that failed shows as `hive-egress-pin` errors in the journal and
`IN=` lines for that port. `IN=` lines for that port.
- **What it doesn't cover.** A container can still send from any IPv6 - **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 Enforcement comes with later changes in the same series: the log only shows
what such a change would cut. what such a change would cut.

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 /// 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> /// run out at `now` (seconds since the epoch). A line is `<expiry> <mac> <ip>
/// <host> <client-id>`; expiry 0 is a lease without end. /// <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 /// `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<()> { fn lease(args: &[String]) -> Result<()> {
let [action, mac, ip, ..] = args else { let [action, mac, ip, ..] = args else {
return Ok(()); return Ok(());
@ -802,7 +876,25 @@ fn lease(args: &[String]) -> Result<()> {
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_LEASE_SECS); .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), Some(script) => nft(&script),
None => Ok(()), None => Ok(()),
} }
@ -1285,6 +1377,72 @@ mod tests {
assert!(script.contains(&format!("timeout {}s", u64::MAX))); 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] #[test]
fn seeding_skips_expired_and_malformed_leases() { fn seeding_skips_expired_and_malformed_leases() {
let leases = "2000 02:00:00:00:00:0a 10.42.0.10 a *\n\ let leases = "2000 02:00:00:00:00:0a 10.42.0.10 a *\n\

View file

@ -205,8 +205,11 @@ let
# duplicate address detection and multicast listener reports send before an # duplicate address detection and multicast listener reports send before an
# address exists. The bridge carries no other IPv6 traffic. # address exists. The bridge carries no other IPv6 traffic.
# - DHCP: any message to port 67 must carry the sender's own MAC as `chaddr`, # - 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 # so a port cannot lease, renew or release another's address. A client
# no address yet sends from 0.0.0.0 to the broadcast address. # 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 # - ARP: the sender's MAC is the port's, and its address 0.0.0.0 (a probe) or
# leased to that MAC. # leased to that MAC.
# - A VLAN tag is refused: a tagged frame would carry its packet past the # - A VLAN tag is refused: a tagged frame would carry its packet past the
@ -233,7 +236,7 @@ let
chain l3_v4 { chain l3_v4 {
udp dport 67 iifname . @th,288,48 != @port_chaddr goto spoof 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 ether saddr . ip saddr @lease_v4 accept
goto spoof goto spoof
} }
@ -342,7 +345,8 @@ in
the frame's MAC must be the one bound to the port when its container 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 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 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). sources (and `::` in neighbour discovery and listener reports).
- `drop` (the default): the frame is dropped and logged with the prefix - `drop` (the default): the frame is dropped and logged with the prefix

View file

@ -2,8 +2,9 @@
# ../host-modules/hive-egress.nix on a real agent bridge, with two nspawn # ../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 # containers on it that take their addresses from the hive's dnsmasq
# (../host-modules/hive-gateway/dnsmasq.nix' lease script, the real # (../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 # `hive-priv egress-load`, `egress-pin` and `egress-lease`). `hive-squid`
# stand-in bridge is one NIC, so every agent shares one port. # cannot host this: its stand-in bridge is one NIC, so every agent shares one
# port.
# #
# What it pins: # What it pins:
# - the default verdict is `drop`, and each container takes its first lease # - 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 # - 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 # 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; # 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 # - a container that keeps talking across renewals of a 2-minute lease is
# is never dropped, and its pair is still in `lease_v4` after the first # never dropped, and its pair is still in `lease_v4` after the first lease
# lease and its margin would have ended; # and its margin would have ended;
# - a DHCP renewal, a link-local IPv6 packet and a restored MAC go through; # - 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; # - 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 # - `antiSpoof = "log"`, reached by a reload of the unit, brings the bindings
# and the leases with the new table (nothing is dropped in between), # and the leases with the new table (nothing is dropped in between),
@ -28,16 +33,21 @@ let
bridge = "hive0"; bridge = "hive0";
gateway = "10.42.0.1"; gateway = "10.42.0.1";
# A DHCP DISCOVER by hand, unicast to dnsmasq, with the `chaddr` given as # A DHCP DISCOVER by hand, with the `chaddr` given as the first argument,
# the argument. The container shares the host's store, so it runs from there. # 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" '' bootp = pkgs.writeText "bootp.pl" ''
use IO::Socket::INET; use IO::Socket::INET;
use Socket; use Socket;
my $mac = pack("H12", join("", split(/:/, $ARGV[0]))); 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 $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); 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 = hiveStubs =
@ -59,6 +69,15 @@ let
type = lib.types.bool; type = lib.types.bool;
default = true; 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 { bridgeName = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = bridge; default = bridge;
@ -100,6 +119,8 @@ pkgs.testers.runNixOSTest {
../host-modules/hive-priv.nix ../host-modules/hive-priv.nix
]; ];
virtualisation.memorySize = 2048; virtualisation.memorySize = 2048;
# Explicit: the tests below are about what `drop` does.
services.hyperhive.network.egress.antiSpoof = "drop";
networking.bridges.${bridge}.interfaces = [ ]; networking.bridges.${bridge}.interfaces = [ ];
networking.interfaces.${bridge}.ipv4.addresses = [ networking.interfaces.${bridge}.ipv4.addresses = [
{ {
@ -187,6 +208,15 @@ pkgs.testers.runNixOSTest {
host.execute("systemctl stop rx.service") host.execute("systemctl stop rx.service")
return got 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.start()
host.wait_for_unit("multi-user.target") host.wait_for_unit("multi-user.target")
host.wait_for_unit("dnsmasq.service") 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 a -- ping -c1 -W2 ${gateway}")
host.wait_until_succeeds("nixos-container run b -- 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"): 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") 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" assert "counter accept" in host.succeed(f"nft list chain {TABLE} spoof"), "the table was reloaded in log mode"