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:
158
docs/pxe-nic-guard.md
Normal file
158
docs/pxe-nic-guard.md
Normal 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.
|
||||
18
playbook/pxe-server-helpers/dnsmasq.service.d/nic-guard.conf
Normal file
18
playbook/pxe-server-helpers/dnsmasq.service.d/nic-guard.conf
Normal 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
|
||||
1237
playbook/pxe-server-helpers/pxe-nic-check
Executable file
1237
playbook/pxe-server-helpers/pxe-nic-check
Executable file
File diff suppressed because it is too large
Load Diff
16
playbook/pxe-server-helpers/pxe-nic-check.service
Normal file
16
playbook/pxe-server-helpers/pxe-nic-check.service
Normal 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
|
||||
11
playbook/pxe-server-helpers/pxe-nic-check.timer
Normal file
11
playbook/pxe-server-helpers/pxe-nic-check.timer
Normal 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
|
||||
@@ -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 }}"
|
||||
|
||||
50
tests/pxe-nic-check/README.md
Normal file
50
tests/pxe-nic-check/README.md
Normal 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.
|
||||
166
tests/pxe-nic-check/frames.py
Normal file
166
tests/pxe-nic-check/frames.py
Normal 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
313
tests/pxe-nic-check/lab.py
Executable 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
81
tests/pxe-nic-check/lab_peer.py
Executable 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()
|
||||
499
tests/pxe-nic-check/test_pxe_nic_check.py
Normal file
499
tests/pxe-nic-check/test_pxe_nic_check.py
Normal 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()
|
||||
Reference in New Issue
Block a user