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