NIC guard: dnsmasq never serves DHCP/PXE on the GE network

pxe-nic-check classifies each NIC from what it hears: LLDP/CDP/802.1X
from managed switches, foreign DHCP offers and their domain (one
DHCPDISCOVER on the imaging NIC; the GE NIC's networkd lease is read
instead), STP and ARP hosts for information. A kernel BPF filter keeps
bulk SMB traffic out of the capture loop; bridge ports are listened on
too, since LLDP and EAPOL stop at the port.

- dnsmasq drop-in runs --guard as ExecStartPre: exit 1 on a ge or
  foreign-dhcp imaging verdict, fail open (loudly) on bugs or no link
- pxe-nic-check.timer runs --watch every 5 min: stops dnsmasq on a bad
  imaging verdict, never starts it
- /run/pxe-nic-status.json (0644, atomic) for the webapp
- playbook installs it all and writes /etc/pxe-server/nic.conf from
  pxe_imaging_mac, pxe_ge_mac, pxe_corp_domain_suffixes,
  pxe_nic_allowed_switches
- unit tests plus a no-root network-namespace lab (veth pairs, a raw
  socket DHCP/LLDP peer and real dnsmasq); docs/pxe-nic-guard.md has the
  operator notes and the two floor tests
This commit is contained in:
cproudlock
2026-10-14 17:18:53 -04:00
parent 045a1fb97f
commit bc422649e9
11 changed files with 2634 additions and 0 deletions

158
docs/pxe-nic-guard.md Normal file
View File

@@ -0,0 +1,158 @@
# PXE NIC guard
The PXE server's DHCP/PXE (dnsmasq) must never serve on the GE corporate
network. Each server has two NICs:
| Role | Network | Address |
|---------|-----------------------------|----------------------------------|
| imaging | isolated imaging switch | static 172.16.9.1/24, dnsmasq |
| ge | GE corporate network | DHCP client (systemd-networkd) |
`/etc/dnsmasq.d/pxe-nic-only.conf` pins dnsmasq to the imaging NIC by name.
That stops dnsmasq answering on the GE NIC, but not on the imaging NIC when
that NIC itself ends up on the wrong network: a cable swap, an imaging
switch uplinked into a GE port, or a second PXE server on the same switch.
The NIC guard catches those.
## What is installed
| File | Purpose |
|-----------------------------------------------------------|---------------------------------|
| `/usr/local/sbin/pxe-nic-check` | the classifier |
| `/etc/pxe-server/nic.conf` | which NIC is which |
| `/etc/systemd/system/dnsmasq.service.d/nic-guard.conf` | guard before every dnsmasq start|
| `/etc/systemd/system/pxe-nic-check.{service,timer}` | watch every 5 min |
| `/run/pxe-nic-status.json` | last result, read by the webapp |
Sources: `playbook/pxe-server-helpers/`, installed by `pxe_server_setup.yml`
(vars `pxe_imaging_mac`, `pxe_ge_mac`, `pxe_corp_domain_suffixes`,
`pxe_nic_allowed_switches`).
## How it decides
It listens on each NIC (raw socket, sends nothing) for:
- LLDP and CDP: managed switches announce themselves. GE switches do; the
unmanaged imaging switch does not.
- 802.1X EAPOL: GE ports ask for port authentication.
- DHCP OFFER/ACK from any other server.
- STP and the count of distinct ARP hosts: reported, never decisive.
On the imaging NIC it also sends one DHCPDISCOVER and collects offers for
6 s. On the GE NIC it reads the networkd lease instead (no traffic).
| Verdict | Meaning |
|----------------|------------------------------------------------------------|
| `isolated` | no foreign DHCP, no LLDP/CDP/EAPOL |
| `ge` | LLDP, CDP or EAPOL seen, or a DHCP server whose domain ends in a corp suffix (`geaerospace.net`, `ge.com`; whole labels, so `notgeaerospace.net` does not count) |
| `foreign-dhcp` | another DHCP server, no corp domain |
| `no-link` | no carrier |
| `error` | the check itself failed |
Healthy: imaging NIC `isolated` (or `no-link`), GE NIC `ge`.
Our own dnsmasq never counts. An offer whose server id is our own address
but comes from a MAC that is not ours is a second PXE server and DOES count.
If the imaging switch is itself a managed switch that sends LLDP/CDP, put
its LLDP system name, CDP device id or chassis MAC in `allowed_switches`
(`pxe_nic_allowed_switches`), or the guard will block dnsmasq. 802.1X is
never allowed.
## Modes
- `pxe-nic-check` - check every NIC (60 s), print a summary. `--json`,
`--write`, `--probe` (imaging DHCP probe), `--iface NAME`, `--window S`.
- `pxe-nic-check --guard` - dnsmasq `ExecStartPre`. Imaging NIC only,
about 6 s. Exit 1 = do not serve: dnsmasq does not start. A bug or no
link exits 0 (fail open) with an error line in the journal.
- `pxe-nic-check --watch` - the timer. On a bad imaging verdict it runs
`systemctl stop dnsmasq` and logs CRIT. It never starts dnsmasq.
Every start or restart of dnsmasq runs the guard, so `systemctl restart
dnsmasq` and the `@reboot` cron restart take about 6 s longer than before.
## Reading it
sudo pxe-nic-check # full summary, 60 s
cat /run/pxe-nic-status.json # last guard/watch result
journalctl -u dnsmasq -b | grep pxe-nic-check
journalctl -u pxe-nic-check -b
## dnsmasq will not start
1. `journalctl -u dnsmasq -b | grep pxe-nic-check` shows the evidence, e.g.
`REFUSING to start dnsmasq: imaging NIC br-pxe verdict ge: LLDP from
WJ-IDF2 port Gi1/0/14`.
2. Check the imaging cabling: the imaging NIC must go to the imaging
switch only, and that switch must not be uplinked anywhere.
3. `sudo pxe-nic-check` until the imaging NIC reads `isolated`.
4. `sudo systemctl start dnsmasq` (runs the guard again).
## Floor tests (run with the floor idle)
Both tests stop imaging for a few minutes. Have console or GE-side SSH
access to the server; do not rely on the imaging network for the session.
### (a) Rogue DHCP on the imaging switch: expect the alarm
1. Confirm the baseline: `sudo pxe-nic-check --probe` shows the imaging
NIC `isolated` and `dnsmasq_bound` true.
2. Plug a device that runs a DHCP server into a free port of the IMAGING
switch (a spare home router with DHCP on, LAN port only, nothing in its
WAN port; or a laptop running dnsmasq).
3. Run the watch now instead of waiting up to 5 min:
`sudo systemctl start pxe-nic-check.service`
4. Expect:
- `journalctl -u pxe-nic-check -n 20` has a CRIT line `imaging NIC is
NOT isolated, stopping dnsmasq: ... verdict foreign-dhcp: DHCP from
192.168.x.1, ...` (or `ge` if the router sends LLDP).
- `systemctl is-active dnsmasq` is `inactive`.
- `/run/pxe-nic-status.json` has `"dhcp_guard": {"state": "blocked", ...}`.
5. Prove the guard too: `sudo systemctl start dnsmasq` must FAIL, and
`journalctl -u dnsmasq -n 20` shows `REFUSING to start dnsmasq`.
6. Unplug the rogue device. `sudo pxe-nic-check --probe` shows `isolated`.
`sudo systemctl start dnsmasq` starts. PXE-boot one client to confirm.
### (b) Cable swap into a GE port: expect the guard to refuse dnsmasq
dnsmasq must be stopped BEFORE the cable moves, so it never serves on GE.
1. `sudo systemctl stop dnsmasq`; `systemctl is-active dnsmasq` must say
`inactive`. Do not reboot during the test (the `@reboot` cron restarts
dnsmasq; the guard would still block it, but do not test that way).
2. Move the IMAGING NIC's cable from the imaging switch into a live GE
network port. Wait for link: `ip -br link` shows the imaging NIC `UP`.
3. Run the guard by hand first:
`sudo pxe-nic-check --guard; echo "exit $?"`
Expect `exit 1` and a `REFUSING to start dnsmasq` line naming LLDP/CDP,
802.1X, or `DHCP from 10.x.x.x, domain ....geaerospace.net`.
This sends one DHCPDISCOVER on the GE port, the same as any PC being
plugged in. To send nothing, use `--no-probe --window 60` instead and
rely on LLDP/CDP/802.1X (switches announce every 30-60 s).
**If it exits 0, STOP: do not start dnsmasq. Move the cable back and
send the output of `sudo pxe-nic-check --probe --json`.**
4. Only after step 3 exited 1: `sudo systemctl start dnsmasq`. Expect
`Job for dnsmasq.service failed`; `systemctl is-active dnsmasq` says
`failed`; `journalctl -u dnsmasq -n 20` shows `REFUSING`.
5. Move the cable back to the imaging switch. `sudo pxe-nic-check --probe`
shows the imaging NIC `isolated`. `sudo systemctl start dnsmasq`
starts. PXE-boot one client to confirm.
## Status file
`/run/pxe-nic-status.json`, mode 0644, replaced atomically:
{"checked_at": "2026-10-14T16:47:09-04:00", "duration_s": 60,
"dhcp_guard": {"state": "allowed"|"blocked"|"unknown", "reason": "..."},
"interfaces": [{"iface": "br-pxe", "mac": "aa:bb:..", "role": "imaging"|"ge"|"other",
"link": "up"|"down", "speed_mbps": 5000, "addrs": ["172.16.9.1/24"],
"verdict": "isolated"|"ge"|"foreign-dhcp"|"no-link"|"error", "evidence": ["..."],
"dhcp_servers": [{"server": "10.1.2.3", "domain": "x.geaerospace.net", "own": false}],
"switch": {"lldp": [{"system": "...", "port": "...", "vlan": null}],
"cdp": [{"device": "...", "port": "..."}], "eapol": false, "stp": false},
"arp_hosts": 3, "dnsmasq_bound": true}]}
`dnsmasq_bound` is null when it cannot be told (no `ss`). A bridge
(br-pxe) is reported as one interface; its ports are listened on too,
because LLDP and 802.1X frames stop at the port and never reach the bridge.

View File

@@ -0,0 +1,18 @@
# dnsmasq drop-in: refuse to start DHCP/PXE unless the imaging NIC is on
# the isolated imaging network.
#
# Installed as /etc/systemd/system/dnsmasq.service.d/nic-guard.conf by
# pxe_server_setup.yml. pxe-nic-check --guard listens on the imaging NIC
# for 5 s and sends one DHCPDISCOVER (~6 s total), then exits:
# 0 safe to serve (isolated, no link, or the check itself broke - it
# fails open on bugs and says so loudly in the journal)
# 1 DO NOT serve: a foreign DHCP server or a managed/corporate switch
# (LLDP, CDP, 802.1X) is on the imaging NIC. dnsmasq does not start.
#
# Why did dnsmasq not start? journalctl -u dnsmasq -b | grep pxe-nic-check
# Full picture: pxe-nic-check (or the webapp status page)
# Guard does not run on "systemctl reload"; every start/restart runs it,
# including the @reboot cron restart.
[Service]
ExecStartPre=/usr/local/sbin/pxe-nic-check --guard

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
# Periodic imaging-NIC watch (pxe-nic-check.timer runs this every 5 min).
# Listens 60 s on each NIC, probes the imaging NIC for foreign DHCP, writes
# /run/pxe-nic-status.json for the webapp, and STOPS dnsmasq if the imaging
# NIC turns out to be on the GE network or next to another DHCP server.
# It never starts dnsmasq: after fixing the cabling, start it by hand
# (systemctl start dnsmasq), which re-runs the guard.
[Unit]
Description=PXE NIC watch: keep dnsmasq off the GE corporate network
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/pxe-nic-check --watch
TimeoutStartSec=180
Nice=5

View File

@@ -0,0 +1,11 @@
[Unit]
Description=PXE NIC watch every 5 minutes
[Timer]
OnBootSec=2min
OnActiveSec=1min
OnUnitActiveSec=5min
AccuracySec=15s
[Install]
WantedBy=timers.target

View File

@@ -55,6 +55,20 @@
# _media/<pctype> links the base image's entries, so also look in the
# image roots (on a rebuild the _media views may not exist yet).
zscaler_ca_share_glob: "{{ samba_share }}/_media/*/Deploy/Applications/ZscalerCommercialCertificate-2048-SHA256.crt {{ samba_share }}/gea-*/Deploy/Applications/ZscalerCommercialCertificate-2048-SHA256.crt"
# NIC guard (pxe-nic-check): dnsmasq must never serve DHCP/PXE on the GE
# corporate network. Pin the two NICs by MAC (ip -br link); left blank,
# the imaging NIC is the interface= of /etc/dnsmasq.d/pxe-nic-only.conf
# or the holder of 172.16.9.1, and the GE NIC is the one with a DHCP
# lease. A MAC of a bridge port resolves to the bridge (br-pxe).
pxe_imaging_mac: ""
pxe_ge_mac: ""
# A foreign DHCP server handing out one of these domains (whole labels:
# wjs.geaerospace.net matches, notgeaerospace.net does not) = GE network.
pxe_corp_domain_suffixes: "geaerospace.net, ge.com"
# Only if the IMAGING switch is itself managed and sends LLDP/CDP: its
# LLDP system name / CDP device id / chassis MAC, comma separated, so
# it does not count as a corporate switch. 802.1X always counts.
pxe_nic_allowed_switches: ""
image_types:
- gea-standard
- gea-engineer
@@ -206,6 +220,77 @@
# Script runs as root by default (dnsmasq --dhcp-scriptuser default).
dhcp-script=/usr/local/sbin/pxe-dhcp-hook.sh
# --- NIC guard: keep dnsmasq off the GE corporate network ---
# pxe-nic-check classifies each NIC from what it hears (LLDP/CDP/802.1X
# from managed switches, foreign DHCP offers and their domain). The
# dnsmasq drop-in runs it as ExecStartPre (--guard: dnsmasq refuses to
# start if the imaging NIC is on the GE network or next to another DHCP
# server); the timer re-checks every 5 min (--watch: stops dnsmasq) and
# writes /run/pxe-nic-status.json for the webapp. Operator notes and
# the floor tests: docs/pxe-nic-guard.md.
- name: "NIC guard: deploy pxe-nic-check"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/pxe-nic-check"
dest: /usr/local/sbin/pxe-nic-check
mode: '0755'
- name: "NIC guard: create /etc/pxe-server"
file:
path: /etc/pxe-server
state: directory
mode: '0755'
- name: "NIC guard: write /etc/pxe-server/nic.conf"
copy:
dest: /etc/pxe-server/nic.conf
mode: '0644'
content: |
# Managed by pxe_server_setup.yml (vars pxe_imaging_mac, pxe_ge_mac,
# pxe_corp_domain_suffixes). Read by /usr/local/sbin/pxe-nic-check.
[nic]
{% if pxe_imaging_mac %}
imaging_mac = {{ pxe_imaging_mac | lower }}
{% else %}
# imaging_mac unset: falls back to interface= in
# /etc/dnsmasq.d/pxe-nic-only.conf, then the holder of own_ip
{% endif %}
{% if pxe_ge_mac %}
ge_mac = {{ pxe_ge_mac | lower }}
{% else %}
# ge_mac unset: falls back to the interface holding a DHCP lease
{% endif %}
corp_domain_suffixes = {{ pxe_corp_domain_suffixes }}
own_ip = 172.16.9.1
allowed_switches = {{ pxe_nic_allowed_switches }}
- name: "NIC guard: create dnsmasq drop-in directory"
file:
path: /etc/systemd/system/dnsmasq.service.d
state: directory
mode: '0755'
- name: "NIC guard: install dnsmasq ExecStartPre drop-in"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/dnsmasq.service.d/nic-guard.conf"
dest: /etc/systemd/system/dnsmasq.service.d/nic-guard.conf
mode: '0644'
- name: "NIC guard: install watch service and timer"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: '0644'
loop:
- pxe-nic-check.service
- pxe-nic-check.timer
- name: "NIC guard: enable watch timer"
systemd:
name: pxe-nic-check.timer
state: started
enabled: yes
daemon_reload: yes
- name: "Create TFTP directory"
file:
path: "{{ tftp_dir }}"

View File

@@ -0,0 +1,50 @@
# pxe-nic-check tests
Tests for `playbook/pxe-server-helpers/pxe-nic-check`. Neither needs root
or touches a real network.
## Unit tests
python3 -m unittest discover -s tests/pxe-nic-check -v
Synthesized LLDP, CDP, EAPOL, STP and ARP frames and DHCPOFFER/ACK packets
(`frames.py`) fed to the parsers; the verdict table; own-server and
cloned-PXE-server handling; networkd lease parsing; config fallbacks; the
dnsmasq_bound logic; the status JSON shape and atomic 0644 write.
## Namespace lab
python3 tests/pxe-nic-check/lab.py # all scenarios, ~5 min
python3 tests/pxe-nic-check/lab.py rogue br-lldp # some
Re-runs itself under `unshare --user --map-root-user --net --mount`: a
private network namespace with veth pairs, so raw sockets work without sudo
and every frame stays inside it. Each scenario runs the real script in
check (`--json --probe`), `--guard` and `--watch` modes (watch with
`--stop-cmd "touch <marker>"` instead of `systemctl stop dnsmasq`) and
asserts the imaging verdict, the guard exit code, whether watch "stopped
dnsmasq", the evidence text and the status JSON shape.
Peers: `lab_peer.py` (a raw-socket DHCP responder and LLDP/EAPOL sender;
refuses to run unless `PXE_NIC_LAB=1`, which only the lab sets) and real
`dnsmasq` if installed (started with `--user= --group=`, since a user
namespace cannot drop privileges; scenarios are skipped without it).
| Scenario | Peer | Expect | Guard |
|-----------------|-----------------------------------------------|---------------|-------|
| isolated | none | isolated | 0 |
| rogue | DHCP, no domain | foreign-dhcp | 1 |
| fake-ge | DHCP domain test.geaerospace.net + LLDP | ge | 1 |
| not-corp-domain | DHCP domain notgeaerospace.net | foreign-dhcp | 1 |
| lldp-only | LLDP | ge | 1 |
| eapol-only | 802.1X EAPOL | ge | 1 |
| own-server | DHCP with server id 172.16.9.1 from a local MAC | isolated | 0 |
| cloned-pxe | DHCP with server id 172.16.9.1, foreign MAC | foreign-dhcp | 1 |
| no-link | peer end down | no-link | 0 |
| dnsmasq-corp | real dnsmasq, domain lab.geaerospace.net | ge | 1 |
| dnsmasq-rogue | real dnsmasq, no domain | foreign-dhcp | 1 |
| own-dnsmasq | real dnsmasq serving the imaging NIC itself | isolated, dnsmasq_bound true | 0 |
| br-* | same, imaging NIC is a bridge (like br-pxe), named in nic.conf by a port's MAC | | |
The GE NIC (g0) is checked from a fake networkd lease with domain
test.geaerospace.net and must read `ge` in every scenario.

View File

@@ -0,0 +1,166 @@
"""
frames.py - synthesize the Ethernet frames pxe-nic-check looks for.
Shared by the unit tests (fed straight to the parsers) and the namespace
lab (sent on a veth by lab_peer.py). Plain struct packing, no scapy.
"""
import os
import struct
import socket
import importlib.machinery
import importlib.util
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.join(HERE, "..", "..", "playbook", "pxe-server-helpers",
"pxe-nic-check")
def load_script():
"""Import the extensionless script as a module (no __pycache__ next
to it: that directory is copied to the server)."""
import sys
sys.dont_write_bytecode = True
loader = importlib.machinery.SourceFileLoader("pxe_nic_check", SCRIPT)
spec = importlib.util.spec_from_loader("pxe_nic_check", loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
return mod
def mac(s):
return bytes.fromhex(s.replace(":", ""))
def eth(dst, src, etype, payload):
return mac(dst) + mac(src) + struct.pack("!H", etype) + payload
def _tlv(t, value):
return struct.pack("!H", (t << 9) | len(value)) + value
def lldp(src, system="WJ-IDF2", port="Gi1/0/14", vlan=None,
chassis_mac="00:11:22:33:44:55"):
body = _tlv(1, b"\x04" + mac(chassis_mac)) # chassis: MAC
body += _tlv(2, b"\x05" + port.encode()) # port: ifName
body += _tlv(3, struct.pack("!H", 120)) # TTL
if system is not None:
body += _tlv(5, system.encode())
if vlan is not None:
body += _tlv(127, b"\x00\x80\xc2\x01" + struct.pack("!H", vlan))
body += _tlv(0, b"")
return eth("01:80:c2:00:00:0e", src, 0x88CC, body)
def cdp(src, device="WJ-CORE1.wjs.geaerospace.net", port="GigabitEthernet1/0/3",
vlan=None):
def t(code, val):
return struct.pack("!HH", code, 4 + len(val)) + val
tlvs = t(1, device.encode()) + t(3, port.encode())
if vlan is not None:
tlvs += t(0x0A, struct.pack("!H", vlan))
body = bytes([2, 180]) + b"\x00\x00" + tlvs
llc = b"\xaa\xaa\x03" + b"\x00\x00\x0c" + b"\x20\x00" + body
return mac("01:00:0c:cc:cc:cc") + mac(src) + struct.pack("!H", len(llc)) \
+ llc
def eapol(src):
# EAPOL v2, type 0 (EAP packet), EAP Request/Identity
eap = bytes([1, 1]) + struct.pack("!H", 5) + bytes([1])
body = bytes([2, 0]) + struct.pack("!H", len(eap)) + eap
return eth("01:80:c2:00:00:03", src, 0x888E, body)
def stp(src):
bpdu = b"\x00\x00\x00\x00\x00" + b"\x80\x00" + mac(src) + b"\x00" * 23
llc = b"\x42\x42\x03" + bpdu
return mac("01:80:c2:00:00:00") + mac(src) + \
struct.pack("!H", len(llc)) + llc
def arp(src, sender_ip="172.16.9.50", target_ip="172.16.9.1"):
body = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 1) + mac(src) + \
socket.inet_aton(sender_ip) + b"\x00" * 6 + socket.inet_aton(target_ip)
return eth("ff:ff:ff:ff:ff:ff", src, 0x0806, body)
def _dns_encode(names):
out = b""
for n in names:
for label in n.split("."):
out += bytes([len(label)]) + label.encode()
out += b"\x00"
return out
def _ip_checksum(hdr):
s = 0
for i in range(0, len(hdr), 2):
s += (hdr[i] << 8) + hdr[i + 1]
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return (~s) & 0xFFFF
def dhcp_reply(src, server_ip, xid, chaddr, yiaddr="10.99.0.50",
domain=None, search=None, msgtype=2, server_id=None,
search_raw=None):
"""DHCPOFFER (msgtype 2) or ACK (5), broadcast."""
sid = server_ip if server_id is None else server_id
bootp = struct.pack("!BBBBIHH", 2, 1, 6, 0, xid, 0, 0x8000)
bootp += b"\x00" * 4 + socket.inet_aton(yiaddr) + \
socket.inet_aton(server_ip) + b"\x00" * 4
bootp += mac(chaddr) + b"\x00" * 10 + b"\x00" * 64 + b"\x00" * 128
opts = b"\x63\x82\x53\x63" + bytes([53, 1, msgtype])
if sid:
opts += bytes([54, 4]) + socket.inet_aton(sid)
opts += bytes([1, 4]) + socket.inet_aton("255.255.255.0")
opts += bytes([51, 4]) + struct.pack("!I", 3600)
if domain:
opts += bytes([15, len(domain)]) + domain.encode()
if search or search_raw:
raw = search_raw if search_raw is not None else _dns_encode(search)
opts += bytes([119, len(raw)]) + raw
opts += b"\xff"
bootp += opts
if len(bootp) < 300:
bootp += b"\x00" * (300 - len(bootp))
udp = struct.pack("!HHHH", 67, 68, 8 + len(bootp), 0) + bootp
ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(udp), 1, 0, 64, 17,
0, socket.inet_aton(server_ip),
socket.inet_aton("255.255.255.255"))
ip = ip[:10] + struct.pack("!H", _ip_checksum(ip)) + ip[12:]
return eth("ff:ff:ff:ff:ff:ff", src, 0x0800, ip + udp)
def parse_discover(frame):
"""(xid, chaddr) if frame is a DHCPDISCOVER, else None."""
if len(frame) < 14 + 20 + 8 + 240 or frame[12:14] != b"\x08\x00":
return None
ip = frame[14:]
ihl = (ip[0] & 0x0F) * 4
if ip[9] != 17:
return None
sport, dport = struct.unpack("!HH", ip[ihl:ihl + 4])
if (sport, dport) != (68, 67):
return None
b = ip[ihl + 8:]
if b[0] != 1 or b[236:240] != b"\x63\x82\x53\x63":
return None
opts = b[240:]
i = 0
mtype = None
while i < len(opts) and opts[i] != 255:
if opts[i] == 0:
i += 1
continue
if opts[i] == 53:
mtype = opts[i + 2]
i += 2 + opts[i + 1]
if mtype != 1:
return None
xid = struct.unpack("!I", b[4:8])[0]
chaddr = ":".join("%02x" % x for x in b[28:34])
return xid, chaddr

313
tests/pxe-nic-check/lab.py Executable file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
lab.py - run the real pxe-nic-check against fake networks, no root needed.
Re-executes itself inside a private user + network + mount namespace
(unshare --user --map-root-user --net --mount), so every frame stays on
veth pairs that exist only in that namespace. Nothing touches a real NIC.
Topology inside the namespace:
v0 (172.16.9.1/24, "imaging NIC") <-veth-> v1 (lab_peer.py / dnsmasq)
g0 (10.200.0.7/24, "GE NIC") <-veth-> g1 (quiet), plus a fake
networkd lease for g0
br0 (172.16.9.1/24, bridge) port b0 <-veth-> b1 (lab_peer.py), the
br-pxe layout of the live box
Each scenario starts a peer on v1, runs the script in check (--json),
--guard and --watch modes, and asserts the verdict, the guard exit code,
whether --watch called its stop command, and the status JSON shape.
python3 tests/pxe-nic-check/lab.py # all scenarios
python3 tests/pxe-nic-check/lab.py rogue # one scenario
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
SCRIPT = os.path.normpath(os.path.join(
HERE, "..", "..", "playbook", "pxe-server-helpers", "pxe-nic-check"))
PEER = os.path.join(HERE, "lab_peer.py")
ENTRY_KEYS = ["iface", "mac", "role", "link", "speed_mbps", "addrs",
"verdict", "evidence", "dhcp_servers", "switch", "arp_hosts",
"dnsmasq_bound"]
# name, peer (None = no peer, "down" = cable pulled, "dnsmasq:<domain>" =
# real dnsmasq on v1 as a foreign server, "own-dnsmasq" = real dnsmasq on
# v0 as our own server, else lab_peer.py args), expected imaging verdict,
# expected guard rc, evidence substring, expected dnsmasq_bound on v0
SCENARIOS = [
("isolated", None, "isolated", 0, "no DHCP server, no switch", False),
("rogue", ["--dhcp", "10.99.0.1"], "foreign-dhcp", 1,
"DHCP from 10.99.0.1, no domain"),
("fake-ge", ["--dhcp", "10.134.48.1", "--domain", "test.geaerospace.net",
"--lldp", "WJ-IDF2"], "ge", 1,
"DHCP from 10.134.48.1, domain test.geaerospace.net"),
("not-corp-domain", ["--dhcp", "10.99.0.1", "--domain",
"notgeaerospace.net"], "foreign-dhcp", 1,
"domain notgeaerospace.net"),
("lldp-only", ["--lldp", "WJ-IDF2"], "ge", 1,
"LLDP from WJ-IDF2 port Gi1/0/14"),
("eapol-only", ["--eapol"], "ge", 1, "EAPOL"),
("own-server", ["--dhcp", "172.16.9.1"], "isolated", 0,
"own DHCP server 172.16.9.1 answered (ignored)"),
("cloned-pxe", ["--dhcp", "172.16.9.1", "--mac", "02:cc:00:00:00:01"],
"foreign-dhcp", 1, "CLAIMS OUR ADDRESS"),
("no-link", "down", "no-link", 0, "no carrier"),
("dnsmasq-corp", "dnsmasq:lab.geaerospace.net", "ge", 1,
"domain lab.geaerospace.net"),
("dnsmasq-rogue", "dnsmasq:", "foreign-dhcp", 1, "no domain"),
# our own dnsmasq serving v0 never hears our own probe and must not
# trip the guard; dnsmasq_bound must say it is serving v0
("own-dnsmasq", "own-dnsmasq", "isolated", 0, "no DHCP server", True),
]
# Same idea with the imaging NIC as a bridge (br0, port b0 <-veth-> b1),
# like br-pxe on the live server, and nic.conf naming it by a PORT's MAC.
# LLDP/EAPOL stop at the bridge port and never reach br0, so these prove
# the script also listens on the bridge's ports.
BRIDGE_SCENARIOS = [
("br-isolated", None, "isolated", 0, "no DHCP server, no switch"),
("br-lldp", ["--lldp", "WJ-IDF2"], "ge", 1, "LLDP from WJ-IDF2"),
("br-eapol", ["--eapol"], "ge", 1, "EAPOL"),
("br-rogue", ["--dhcp", "10.99.0.1"], "foreign-dhcp", 1,
"DHCP from 10.99.0.1, no domain"),
("br-fake-ge", ["--dhcp", "10.134.48.1", "--domain",
"test.geaerospace.net"], "ge", 1,
"domain test.geaerospace.net"),
]
def sh(*cmd):
subprocess.run(cmd, check=True)
def setup(tmp):
sh("mount", "-t", "sysfs", "sysfs", "/sys")
sh("ip", "link", "set", "lo", "up")
for a, b in (("v0", "v1"), ("g0", "g1")):
sh("ip", "link", "add", a, "type", "veth", "peer", "name", b)
sh("ip", "addr", "add", "172.16.9.1/24", "dev", "v0")
sh("ip", "addr", "add", "10.200.0.7/24", "dev", "g0")
sh("ip", "link", "add", "b0", "type", "veth", "peer", "name", "b1")
sh("ip", "link", "add", "br0", "type", "bridge", "stp_state", "0")
sh("ip", "link", "set", "b0", "master", "br0")
sh("ip", "addr", "add", "172.16.9.1/24", "dev", "br0")
for i in ("v0", "v1", "g0", "g1", "b0", "b1", "br0"):
sh("ip", "link", "set", i, "up")
leases = os.path.join(tmp, "leases")
os.mkdir(leases)
with open("/sys/class/net/g0/ifindex") as f:
idx = f.read().strip()
with open(os.path.join(leases, idx), "w") as f:
f.write("ADDRESS=10.200.0.7\nSERVER_ADDRESS=10.200.0.1\n"
"DOMAINNAME=test.geaerospace.net\n")
conf = os.path.join(tmp, "nic.conf")
with open(conf, "w") as f:
f.write("[nic]\nimaging_iface = v0\nge_iface = g0\n")
with open("/sys/class/net/b0/address") as f:
b0_mac = f.read().strip()
conf_br = os.path.join(tmp, "nic-bridge.conf")
with open(conf_br, "w") as f:
f.write("# lab\nimaging_mac = %s\nge_mac = %s\n" % (
b0_mac.upper(), open("/sys/class/net/g0/address").read().strip()))
time.sleep(1.5) # let carrier settle
return conf, conf_br, leases
def start_peer(spec, tmp, peer_if="v1"):
env = dict(os.environ, PXE_NIC_LAB="1")
if spec is None:
return None
if spec == "down":
sh("ip", "link", "set", peer_if, "down")
time.sleep(0.5)
return None
if isinstance(spec, str) and (spec == "own-dnsmasq"
or spec.startswith("dnsmasq:")):
if not shutil.which("dnsmasq"):
raise Skip("dnsmasq not installed")
if spec == "own-dnsmasq":
domain, dev, rng = "", "v0", "172.16.9.10,172.16.9.100,1h"
else:
domain, dev, rng = spec.split(":", 1)[1], "v1", \
"10.99.0.10,10.99.0.20,1h"
sh("ip", "addr", "replace", "10.99.0.1/24", "dev", "v1")
# --user= --group= : skip the privilege drop, which a user
# namespace with setgroups denied cannot do
cmd = ["dnsmasq", "-k", "-C", "/dev/null", "--port=0",
"--user=", "--group=", "--interface=" + dev,
"--bind-interfaces", "--dhcp-range=" + rng,
"--dhcp-leasefile=" + os.path.join(tmp, "dm.leases"),
"--pid-file=" + os.path.join(tmp, "dm.pid"),
"--log-facility=-", "--log-dhcp"]
if domain:
cmd.append("--dhcp-option=15," + domain)
log = open(os.path.join(tmp, "dnsmasq.log"), "w")
p = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT)
time.sleep(1.0)
if p.poll() is not None:
stop_peer(None, spec)
with open(os.path.join(tmp, "dnsmasq.log")) as f:
raise Skip("dnsmasq would not start in the userns: "
+ f.read().strip()[-300:])
return p
p = subprocess.Popen([sys.executable, PEER, "--iface", peer_if] + spec,
stdout=subprocess.PIPE, text=True, env=env)
line = p.stdout.readline()
if line.strip() != "ready":
raise RuntimeError("peer did not start: %r" % line)
return p
def stop_peer(p, spec):
if p is not None:
p.terminate()
try:
p.wait(5)
except subprocess.TimeoutExpired:
p.kill()
if spec == "down":
sh("ip", "link", "set", "v1", "up")
time.sleep(1.0)
if isinstance(spec, str) and spec.startswith("dnsmasq:"):
subprocess.run(["ip", "addr", "del", "10.99.0.1/24", "dev", "v1"],
stderr=subprocess.DEVNULL)
class Skip(Exception):
pass
def run_script(args, conf, leases):
cmd = [sys.executable, SCRIPT, "--config", conf, "--dnsmasq-nic-conf",
"/nonexistent", "--lease-dir", leases] + args
return subprocess.run(cmd, capture_output=True, text=True, timeout=120)
def check_shape(st):
assert sorted(st) == ["checked_at", "dhcp_guard", "duration_s",
"interfaces"], sorted(st)
assert isinstance(st["duration_s"], int)
assert st["dhcp_guard"]["state"] in ("allowed", "blocked", "unknown")
assert sorted(st["dhcp_guard"]) == ["reason", "state"]
for e in st["interfaces"]:
assert sorted(e) == sorted(ENTRY_KEYS), sorted(e)
assert sorted(e["switch"]) == ["cdp", "eapol", "lldp", "stp"]
for d in e["dhcp_servers"]:
assert sorted(d) == ["domain", "own", "server"], d
for x in e["switch"]["lldp"]:
assert sorted(x) == ["port", "system", "vlan"], x
for x in e["switch"]["cdp"]:
assert sorted(x) == ["device", "port"], x
def scenario(name, spec, want, want_rc, needle, bound, conf, leases, tmp,
im_if="v0", peer_if="v1"):
p = start_peer(spec, tmp, peer_if)
fails = []
try:
status = os.path.join(tmp, name + ".json")
# 1. check mode with probe, short window
r = run_script(["--json", "--probe", "--window", "3"], conf, leases)
st = json.loads(r.stdout)
check_shape(st)
by = {e["iface"]: e for e in st["interfaces"]}
im = by[im_if]
if im["role"] != "imaging" or im["verdict"] != want:
fails.append("check: imaging verdict %s (want %s) %s"
% (im["verdict"], want, im["evidence"]))
if not any(needle in ev for ev in im["evidence"]):
fails.append("check: evidence lacks %r: %s"
% (needle, im["evidence"]))
if bound is not None and im["dnsmasq_bound"] != bound:
fails.append("check: v0 dnsmasq_bound %s (want %s)"
% (im["dnsmasq_bound"], bound))
ge = by.get("g0")
if not ge or ge["role"] != "ge" or ge["verdict"] != "ge":
fails.append("check: g0 not ge: %s" % ge)
# 2. guard
r = run_script(["--guard", "--window", "2", "--write", status],
conf, leases)
if "no BPF filter" in r.stderr:
fails.append("BPF filter did not attach: %s" % r.stderr.strip())
if r.returncode != want_rc:
fails.append("guard rc %d (want %d): %s"
% (r.returncode, want_rc, r.stderr.strip()))
with open(status) as f:
gst = json.load(f)
check_shape(gst)
if os.stat(status).st_mode & 0o777 != 0o644:
fails.append("status file not 0644")
want_state = "blocked" if want_rc else "allowed"
if gst["dhcp_guard"]["state"] != want_state:
fails.append("guard state %s" % gst["dhcp_guard"])
# 3. watch, with a marker file standing in for systemctl stop
marker = os.path.join(tmp, name + ".stopped")
r = run_script(["--watch", "--window", "3", "--write", status,
"--stop-cmd", "touch " + marker], conf, leases)
stopped = os.path.exists(marker)
if stopped != bool(want_rc):
fails.append("watch stop called=%s (want %s): %s"
% (stopped, bool(want_rc), r.stderr.strip()))
if want_rc and "<2>" not in r.stderr and "CRIT" not in r.stderr:
fails.append("watch did not log CRIT")
finally:
stop_peer(p, spec)
return fails, im
def inside(selected):
tmp = tempfile.mkdtemp(prefix="pxe-nic-lab-")
conf, conf_br, leases = setup(tmp)
results = []
runs = [(sc, conf, "v0", "v1") for sc in SCENARIOS] + \
[(sc, conf_br, "br0", "b1") for sc in BRIDGE_SCENARIOS]
for sc, sconf, im_if, peer_if in runs:
name, spec, want, rc, needle = sc[:5]
bound = sc[5] if len(sc) > 5 else None
if selected and name not in selected:
continue
t0 = time.monotonic()
try:
fails, im = scenario(name, spec, want, rc, needle, bound, sconf,
leases, tmp, im_if, peer_if)
except Skip as e:
results.append((name, "SKIP", str(e)))
print("SKIP %-16s %s" % (name, e), flush=True)
continue
except Exception as e: # report and carry on
fails, im = ["exception: %r" % e], None
state = "FAIL" if fails else "PASS"
results.append((name, state, fails))
print("%s %-16s %-13s guard_rc=%d (%.0f s)" % (
state, name, want, rc, time.monotonic() - t0), flush=True)
for f in fails:
print(" " + f, flush=True)
if im and not fails:
print(" " + " | ".join(im["evidence"]), flush=True)
n_pass = sum(1 for r in results if r[1] == "PASS")
n_fail = sum(1 for r in results if r[1] == "FAIL")
n_skip = sum(1 for r in results if r[1] == "SKIP")
print("\nlab: %d passed, %d failed, %d skipped" % (n_pass, n_fail, n_skip))
return 1 if n_fail else 0
def main():
args = sys.argv[1:]
if args[:1] == ["--inside"]:
sys.exit(inside(args[1:]))
cmd = ["unshare", "--user", "--map-root-user", "--net", "--mount",
sys.executable, os.path.abspath(__file__), "--inside"] + args
sys.exit(subprocess.run(cmd).returncode)
if __name__ == "__main__":
main()

81
tests/pxe-nic-check/lab_peer.py Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
lab_peer.py - the "other end of the cable" for the namespace lab.
Runs on one end of a veth pair inside a private network namespace and
plays a switch and/or a DHCP server with raw AF_PACKET sockets:
--dhcp IP answer every DHCPDISCOVER with a DHCPOFFER from IP
--server-id IP option 54 to put in the offer (default = --dhcp IP)
--domain NAME option 15 in the offer
--lldp SYSNAME send an LLDP frame every second
--eapol send an EAPOL Request/Identity every second
--mac MAC source MAC for the frames (default: the iface's MAC)
Never run this outside the lab netns: it is a rogue DHCP server.
"""
import argparse
import os
import select
import socket
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import frames as F # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--iface", required=True)
ap.add_argument("--dhcp")
ap.add_argument("--server-id")
ap.add_argument("--domain")
ap.add_argument("--lldp")
ap.add_argument("--eapol", action="store_true")
ap.add_argument("--mac")
ap.add_argument("--duration", type=float, default=120)
args = ap.parse_args()
if os.environ.get("PXE_NIC_LAB") != "1":
sys.exit("lab_peer.py: refusing to run outside the lab (PXE_NIC_LAB)")
with open("/sys/class/net/%s/address" % args.iface) as f:
mac = args.mac or f.read().strip()
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(3))
s.bind((args.iface, 0))
end = time.monotonic() + args.duration
next_beacon = 0.0
answered = set()
sys.stdout.write("ready\n")
sys.stdout.flush()
while time.monotonic() < end:
now = time.monotonic()
if now >= next_beacon:
if args.lldp:
s.send(F.lldp(mac, system=args.lldp, port="Gi1/0/14",
vlan=309))
if args.eapol:
s.send(F.eapol(mac))
next_beacon = now + 1.0
r, _, _ = select.select([s], [], [], 0.2)
if not r:
continue
frame, addr = s.recvfrom(65535)
if addr[2] == 4 or not args.dhcp: # our own outgoing frames
continue
d = F.parse_discover(frame)
if not d or d[0] in answered:
continue
answered.add(d[0])
xid, chaddr = d
s.send(F.dhcp_reply(mac, args.dhcp, xid, chaddr,
yiaddr="10.99.0.50", domain=args.domain,
server_id=args.server_id))
sys.stdout.write("offered xid %08x to %s\n" % (xid, chaddr))
sys.stdout.flush()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,499 @@
"""
Unit tests for playbook/pxe-server-helpers/pxe-nic-check. No root, no
network: frames are synthesized by frames.py and fed to the parsers.
python3 -m unittest discover -s tests/pxe-nic-check -v
"""
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import frames as F # noqa: E402
M = F.load_script()
SW = "00:11:22:33:44:55" # "switch" MAC
ME = "02:00:00:00:00:01" # this host
SUFFIXES = M.parse_suffixes(M.DEFAULT_SUFFIXES)
def fed(*frs, own=(ME,), xid=None):
o = M.Observations()
for fr in frs:
o.feed(fr, set(own), xid)
return o
class DomainMatch(unittest.TestCase):
def test_table(self):
cases = {
"wjs.geaerospace.net": True,
"xyz.geaerospace.net": True,
"geaerospace.net": True,
"GEAEROSPACE.NET.": True,
"notgeaerospace.net": False,
"geaerospace.net.evil.com": False,
"rd.ds.ge.com": True,
"ge.com": True,
"huge.com": False,
"": False,
None: False,
}
for dom, want in cases.items():
self.assertEqual(M.domain_matches(dom, SUFFIXES), want, dom)
def test_suffix_parsing(self):
self.assertEqual(M.parse_suffixes(" geaerospace.net, .ge.com ;x.org,"),
["geaerospace.net", "ge.com", "x.org"])
class Frames(unittest.TestCase):
def test_lldp(self):
o = fed(F.lldp(SW, system="WJ-IDF2", port="Gi1/0/14", vlan=309))
(info,) = o.lldp.values()
self.assertEqual(info["system"], "WJ-IDF2")
self.assertEqual(info["port"], "Gi1/0/14")
self.assertEqual(info["chassis"], SW)
self.assertEqual(info["vlan"], 309)
def test_lldp_no_sysname_no_vlan(self):
o = fed(F.lldp(SW, system=None, port="7"))
(info,) = o.lldp.values()
self.assertIsNone(info["system"])
self.assertIsNone(info["vlan"])
def test_lldp_vlan_tagged_frame(self):
fr = F.lldp(SW)
tagged = fr[:12] + b"\x81\x00\x01\x35" + fr[12:]
self.assertEqual(len(fed(tagged).lldp), 1)
def test_cdp(self):
o = fed(F.cdp(SW, device="WJ-CORE1", port="Gi1/0/3", vlan=12))
(info,) = o.cdp.values()
self.assertEqual((info["device"], info["port"], info["vlan"]),
("WJ-CORE1", "Gi1/0/3", 12))
self.assertFalse(o.stp)
def test_eapol(self):
o = fed(F.eapol(SW))
self.assertTrue(o.eapol)
def test_stp(self):
o = fed(F.stp(SW))
self.assertTrue(o.stp)
self.assertFalse(o.lldp or o.cdp or o.eapol)
def test_arp_counts_distinct_and_skips_own(self):
o = fed(F.arp("02:aa:00:00:00:01"), F.arp("02:aa:00:00:00:01"),
F.arp("02:aa:00:00:00:02"), F.arp(ME))
self.assertEqual(len(o.arp_macs), 2)
def test_truncated_garbage_does_not_raise(self):
for junk in (b"", b"\x00" * 13, F.lldp(SW)[:20], F.cdp(SW)[:30],
F.dhcp_reply(SW, "10.1.1.1", 1, ME)[:100]):
fed(junk)
class Dhcp(unittest.TestCase):
def offer(self, **kw):
return F.dhcp_reply(SW, "10.134.48.1", 42, ME, **kw)
def test_offer_domains(self):
for dom in ("wjs.geaerospace.net", "xyz.geaerospace.net",
"notgeaerospace.net", "rd.ds.ge.com", None):
o = fed(self.offer(domain=dom), xid=42)
(d,) = o.dhcp.values()
self.assertEqual(d["domain"], dom)
self.assertEqual(d["server"], "10.134.48.1")
self.assertEqual(d["yiaddr"], "10.99.0.50")
self.assertEqual(d["how"], "probe")
def test_passive_ack_is_recorded(self):
o = fed(self.offer(msgtype=5), xid=7)
(d,) = o.dhcp.values()
self.assertEqual(d["how"], "passive")
def test_option_119_with_compression(self):
# "a.geaerospace.net" then "b" + pointer to "geaerospace.net" (off 2)
raw = F._dns_encode(["a.geaerospace.net"])
raw += b"\x01b\xc0\x02"
o = fed(self.offer(search_raw=raw))
(d,) = o.dhcp.values()
self.assertEqual(d["search"], ["a.geaerospace.net", "b.geaerospace.net"])
def test_option_119_plain(self):
o = fed(self.offer(search=["rd.ds.ge.com", "x.example"]))
(d,) = o.dhcp.values()
self.assertEqual(d["search"], ["rd.ds.ge.com", "x.example"])
def test_missing_server_id_uses_source_ip(self):
o = fed(self.offer(server_id=""))
(d,) = o.dhcp.values()
self.assertEqual(d["server"], "10.134.48.1")
def test_discover_roundtrip(self):
fr = M.build_discover(F.mac(ME), 0xDEADBEEF)
self.assertEqual(F.parse_discover(fr), (0xDEADBEEF, ME))
# IPv4 header checksum verifies to zero
self.assertEqual(M._ip_checksum(fr[14:34]), 0)
# our own discover is not mistaken for a server
self.assertEqual(fed(fr).dhcp, {})
def run_bpf(insns, pkt):
"""Minimal classic-BPF interpreter for the opcodes pxe-nic-check uses.
Out-of-bounds loads reject, as in the kernel."""
import struct
A = X = 0
pc = 0
while True:
code, jt, jf, k = insns[pc]
pc += 1
if code == 0x28: # ldh [k]
if k + 2 > len(pkt):
return 0
A = struct.unpack("!H", pkt[k:k + 2])[0]
elif code == 0x30: # ldb [k]
if k + 1 > len(pkt):
return 0
A = pkt[k]
elif code == 0x48: # ldh [x + k]
if X + k + 2 > len(pkt):
return 0
A = struct.unpack("!H", pkt[X + k:X + k + 2])[0]
elif code == 0xB1: # ldxb 4*([k]&0xf)
if k + 1 > len(pkt):
return 0
X = 4 * (pkt[k] & 0x0F)
elif code == 0x15: # jeq #k
pc += jt if A == k else jf
elif code == 0x45: # jset #k
pc += jt if A & k else jf
elif code == 0x06: # ret #k
return k
else:
raise AssertionError("opcode %#x" % code)
class Bpf(unittest.TestCase):
def test_filter_passes_what_we_parse_and_drops_bulk(self):
insns = M.assemble_bpf()
keep = {
"lldp": F.lldp(SW), "cdp": F.cdp(SW), "eapol": F.eapol(SW),
"stp": F.stp(SW), "arp": F.arp(SW),
"offer": F.dhcp_reply(SW, "10.1.1.1", 1, ME),
"tagged lldp": F.lldp(SW)[:12] + b"\x81\x00\x00\x05"
+ F.lldp(SW)[12:],
}
for name, fr in keep.items():
self.assertTrue(run_bpf(insns, fr), name)
# TCP/445 SMB, our own DISCOVER (sport 68), IPv6, short junk
import struct
ip = bytearray(F.dhcp_reply(SW, "10.1.1.1", 1, ME))
smb = bytes(ip[:23]) + b"\x06" + bytes(ip[24:34]) + \
struct.pack("!HH", 445, 50000) + bytes(ip[38:])
drop = {
"smb": smb,
"discover": M.build_discover(F.mac(ME), 5),
"ipv6": F.eth("33:33:00:00:00:01", SW, 0x86DD, b"\x60"
+ b"\x00" * 60),
"junk": b"\x00" * 10,
}
for name, fr in drop.items():
self.assertFalse(run_bpf(insns, fr), name)
class OwnServer(unittest.TestCase):
def test_own_needs_own_ip_and_local_mac(self):
o = M.Observations()
o.feed(F.dhcp_reply(ME, "172.16.9.1", 1, "02:bb:00:00:00:09",
yiaddr="172.16.9.20"), set(), 1)
lst = M.dhcp_list(o, {"172.16.9.1"}, {ME})
self.assertTrue(lst[0]["own"])
v, ev = M.classify(True, None, o, lst, SUFFIXES, 60, True, False)
self.assertEqual(v, "isolated")
self.assertTrue(any("own DHCP server 172.16.9.1" in e for e in ev))
def test_cloned_pxe_server_is_foreign(self):
o = M.Observations()
o.feed(F.dhcp_reply("02:cc:00:00:00:01", "172.16.9.1", 1, ME,
yiaddr="172.16.9.20"), set(), 1)
lst = M.dhcp_list(o, {"172.16.9.1"}, {ME})
self.assertFalse(lst[0]["own"])
v, ev = M.classify(True, None, o, lst, SUFFIXES, 60, True, False)
self.assertEqual(v, "foreign-dhcp")
self.assertTrue(any("CLAIMS OUR ADDRESS" in e for e in ev))
class Verdicts(unittest.TestCase):
def run_case(self, frs, link=True, error=None, own_ips=()):
o = fed(*frs) if frs is not None else None
lst = M.dhcp_list(o, set(own_ips), {ME})
return M.classify(link, error, o, lst, SUFFIXES, 60, True, False)
def test_table(self):
def offer(dom):
return F.dhcp_reply(SW, "10.134.48.1", 1, ME, domain=dom)
table = [
("quiet", [], "isolated"),
("arp only", [F.arp("02:aa:00:00:00:05")], "isolated"),
("stp only", [F.stp(SW)], "isolated"),
("lldp", [F.lldp(SW)], "ge"),
("cdp", [F.cdp(SW)], "ge"),
("eapol", [F.eapol(SW)], "ge"),
("dhcp wjs", [offer("wjs.geaerospace.net")], "ge"),
("dhcp xyz", [offer("xyz.geaerospace.net")], "ge"),
("dhcp rd.ds.ge.com", [offer("rd.ds.ge.com")], "ge"),
("dhcp notgeaerospace", [offer("notgeaerospace.net")],
"foreign-dhcp"),
("dhcp no domain", [offer(None)], "foreign-dhcp"),
("rogue + lldp", [offer(None), F.lldp(SW)], "ge"),
("search list only",
[F.dhcp_reply(SW, "10.1.1.1", 1, ME,
search=["wjs.geaerospace.net"])], "ge"),
]
for name, frs, want in table:
v, ev = self.run_case(frs)
self.assertEqual(v, want, "%s: %s" % (name, ev))
def test_allowed_imaging_switch(self):
o = fed(F.lldp(SW, system="IMG-SW1"), F.cdp(SW, device="IMG-SW1"))
v, ev = M.classify(True, None, o, [], SUFFIXES, 60, True, False,
M.allowed_list("IMG-SW1"))
self.assertEqual(v, "isolated", ev)
self.assertTrue(any("allowed imaging switch" in e for e in ev))
# by chassis MAC, any case/separator
o = fed(F.lldp(SW, system=None))
cfg_allowed = M.allowed_list("00-11-22-33-44-55")
v, _ = M.classify(True, None, o, [], SUFFIXES, 60, True, False,
cfg_allowed)
self.assertEqual(v, "isolated")
# another switch, or EAPOL, still counts
o = fed(F.lldp(SW, system="IMG-SW1"),
F.lldp("02:00:00:00:00:99", system="WJ-IDF2",
chassis_mac="02:00:00:00:00:99"))
v, _ = M.classify(True, None, o, [], SUFFIXES, 60, True, False,
M.allowed_list("IMG-SW1"))
self.assertEqual(v, "ge")
o = fed(F.lldp(SW, system="IMG-SW1"), F.eapol(SW))
v, _ = M.classify(True, None, o, [], SUFFIXES, 60, True, False,
M.allowed_list("IMG-SW1"))
self.assertEqual(v, "ge")
def test_no_link_and_error(self):
self.assertEqual(self.run_case([], link=False)[0], "no-link")
self.assertEqual(self.run_case([], error="boom")[0], "error")
def test_evidence_strings(self):
_, ev = self.run_case([F.dhcp_reply(SW, "10.134.48.1", 1, ME,
domain="wjs.geaerospace.net")])
self.assertTrue(any(e.startswith(
"DHCP from 10.134.48.1, domain wjs.geaerospace.net") for e in ev))
_, ev = self.run_case([F.lldp(SW, system="WJ-IDF2", port="Gi1/0/14")])
self.assertIn("LLDP from WJ-IDF2 port Gi1/0/14", ev)
_, ev = self.run_case([])
self.assertIn("no DHCP server, no switch announcements in 60 s", ev)
def test_guard_state(self):
e = {"iface": "br-pxe", "verdict": "foreign-dhcp",
"evidence": ["DHCP from 10.0.0.1, no domain", "3 ARP hosts seen"]}
st, why = M.guard_state(e)
self.assertEqual(st, "blocked")
self.assertNotIn("ARP", why)
for v, want in (("ge", "blocked"), ("isolated", "allowed"),
("no-link", "allowed"), ("error", "unknown")):
e["verdict"] = v
self.assertEqual(M.guard_state(e)[0], want, v)
self.assertEqual(M.guard_state(None)[0], "unknown")
class Lease(unittest.TestCase):
SAMPLE = """# This is private data. Do not parse.
ADDRESS=10.134.49.77
NETMASK=255.255.252.0
ROUTER=10.134.48.1
SERVER_ADDRESS=10.134.48.10
NEXT_SERVER=0.0.0.0
DNS=10.134.48.11 10.134.48.12
DOMAINNAME=wjs.geaerospace.net
DOMAIN_SEARCH_LIST=wjs.geaerospace.net rd.ds.ge.com
LIFETIME=86400
"""
def test_parse(self):
d = M.parse_lease(self.SAMPLE)
self.assertEqual(d["SERVER_ADDRESS"], "10.134.48.10")
self.assertEqual(d["DOMAINNAME"], "wjs.geaerospace.net")
lst = M.lease_servers(d)
self.assertEqual(lst[0]["server"], "10.134.48.10")
self.assertEqual(lst[0]["search"],
["wjs.geaerospace.net", "rd.ds.ge.com"])
v, ev = M.classify(True, None, None, lst, SUFFIXES, 0, False, True)
self.assertEqual(v, "ge")
self.assertTrue(ev[0].startswith(
"DHCP from 10.134.48.10, domain wjs.geaerospace.net"))
def test_lease_without_domain(self):
lst = M.lease_servers(M.parse_lease("ADDRESS=192.168.1.5\n"
"SERVER_ADDRESS=192.168.1.1\n"))
v, _ = M.classify(True, None, None, lst, SUFFIXES, 0, False, True)
self.assertEqual(v, "foreign-dhcp")
def test_lease_falls_back_to_router(self):
lst = M.lease_servers({"ROUTER": "10.0.0.1"})
self.assertEqual(lst[0]["server"], "10.0.0.1")
def test_read_lease_missing(self):
self.assertIsNone(M.read_lease("no-such-iface-xyz", "/nonexistent"))
class Config(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def path(self, name, text):
p = os.path.join(self.tmp, name)
with open(p, "w") as f:
f.write(text)
return p
def test_defaults(self):
cfg = M.load_config(os.path.join(self.tmp, "missing"),
os.path.join(self.tmp, "missing2"))
self.assertEqual(cfg["own_ip"], "172.16.9.1")
self.assertEqual(cfg["suffixes"], ["geaerospace.net", "ge.com"])
self.assertEqual(cfg["dnsmasq_ifaces"], [])
def test_sectionless_ini_and_mac_normalising(self):
p = self.path("nic.conf", "imaging_mac = AA-BB-CC-00-11-22\n"
"ge_iface = enp1s0 # corp\n"
"corp_domain_suffixes = example.org\n")
cfg = M.load_config(p, "/nonexistent")
self.assertEqual(cfg["imaging_mac"], "aa:bb:cc:00:11:22")
self.assertEqual(cfg["ge_iface"], "enp1s0")
self.assertEqual(cfg["suffixes"], ["example.org"])
def test_allowed_switches(self):
p = self.path("nic.conf", "allowed_switches = IMG-SW1, 00-11-22-33-44-55\n")
cfg = M.load_config(p, "/nonexistent")
self.assertIn("img-sw1", cfg["allowed"])
self.assertIn("00:11:22:33:44:55", cfg["allowed"])
def test_section_ini(self):
p = self.path("nic.conf", "# managed by ansible\n[nic]\n"
"imaging_iface = br-pxe\n"
"own_ip = 172.16.9.2\n")
cfg = M.load_config(p, "/nonexistent")
self.assertEqual(cfg["imaging_iface"], "br-pxe")
self.assertEqual(cfg["own_ip"], "172.16.9.2")
def test_empty_values_keep_defaults(self):
p = self.path("nic.conf", "corp_domain_suffixes =\nimaging_mac =\n")
cfg = M.load_config(p, "/nonexistent")
self.assertEqual(cfg["suffixes"], ["geaerospace.net", "ge.com"])
def test_dnsmasq_interface_fallback(self):
d = self.path("pxe-nic-only.conf",
"# pin dnsmasq\ninterface=br-pxe\n"
"except-interface=enp1s0\nno-dhcp-interface=enp1s0\n")
cfg = M.load_config(os.path.join(self.tmp, "missing"), d)
self.assertEqual(cfg["dnsmasq_ifaces"], ["br-pxe"])
im, ge, notes = M.resolve_roles(cfg, ["lo", "br-pxe", "enp1s0"], {},
"/nonexistent")
self.assertEqual(im, "br-pxe")
def test_own_ip_fallback(self):
cfg = M.load_config("/nonexistent", "/nonexistent")
im, ge, _ = M.resolve_roles(
cfg, ["lo", "eno1", "enx1"],
{"eno1": ["10.134.49.7/22"], "enx1": ["172.16.9.1/24"]},
"/nonexistent")
self.assertEqual(im, "enx1")
self.assertIsNone(ge)
def test_configured_iface_wins(self):
cfg = M.load_config("/nonexistent", "/nonexistent")
cfg["imaging_iface"] = "eno1"
cfg["ge_iface"] = "enx1"
im, ge, _ = M.resolve_roles(
cfg, ["eno1", "enx1"], {"enx1": ["172.16.9.1/24"]}, "/nonexistent")
self.assertEqual((im, ge), ("eno1", "enx1"))
class DnsmasqBound(unittest.TestCase):
RULES = {"interface": {"br-pxe"}, "except": {"enp1s0"},
"no_dhcp": {"enp1s0"}, "listen": {"172.16.9.1"}}
def test_unknown_and_not_running(self):
self.assertIsNone(M.dnsmasq_bound("x", [], None, self.RULES))
self.assertFalse(M.dnsmasq_bound("x", [], [], self.RULES))
self.assertFalse(M.dnsmasq_bound(
"x", [], [("0.0.0.0", None, "dhclient")], self.RULES))
def test_device_bound(self):
socks = [("0.0.0.0", "br-pxe", "dnsmasq")]
self.assertTrue(M.dnsmasq_bound("br-pxe", [], socks, self.RULES))
self.assertFalse(M.dnsmasq_bound("enp1s0", [], socks, self.RULES))
def test_wildcard_uses_config(self):
socks = [("0.0.0.0", None, "dnsmasq")]
self.assertTrue(M.dnsmasq_bound("br-pxe", ["172.16.9.1/24"], socks,
self.RULES))
self.assertFalse(M.dnsmasq_bound("enp1s0", ["10.134.49.7/22"], socks,
self.RULES))
open_rules = {"interface": set(), "except": set(), "no_dhcp": set(),
"listen": set()}
self.assertTrue(M.dnsmasq_bound("enp1s0", ["10.134.49.7/22"], socks,
open_rules))
class StatusShape(unittest.TestCase):
KEYS = ["iface", "mac", "role", "link", "speed_mbps", "addrs", "verdict",
"evidence", "dhcp_servers", "switch", "arp_hosts",
"dnsmasq_bound"]
def test_shape_and_atomic_write(self):
entry = {k: None for k in self.KEYS}
st = M.build_status([entry], 5.4, ("allowed", "ok"))
self.assertEqual(sorted(st), ["checked_at", "dhcp_guard",
"duration_s", "interfaces"])
self.assertEqual(st["duration_s"], 5)
self.assertEqual(st["dhcp_guard"], {"state": "allowed",
"reason": "ok"})
# ISO 8601 with a UTC offset
self.assertRegex(st["checked_at"],
r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d[+-]\d\d:\d\d$")
d = tempfile.mkdtemp()
p = os.path.join(d, "s.json")
M.write_status(p, st)
self.assertEqual(os.stat(p).st_mode & 0o777, 0o644)
with open(p) as f:
self.assertEqual(json.load(f), st)
self.assertEqual(os.listdir(d), ["s.json"])
class CommandLine(unittest.TestCase):
"""main() without root: capture fails, the guard must fail open."""
def test_guard_fails_open_when_nothing_resolves(self):
d = tempfile.mkdtemp()
out = os.path.join(d, "st.json")
rc = M.main(["--guard", "--config", "/nonexistent",
"--dnsmasq-nic-conf", "/nonexistent",
"--iface", "lo", "--window", "0.1", "--no-probe",
"--write", out])
self.assertEqual(rc, 0)
with open(out) as f:
st = json.load(f)
self.assertEqual(st["dhcp_guard"]["state"], "unknown")
(e,) = st["interfaces"]
self.assertEqual(sorted(e), sorted(StatusShape.KEYS))
self.assertEqual(sorted(e["switch"]), ["cdp", "eapol", "lldp", "stp"])
if __name__ == "__main__":
unittest.main()