Files
pxe-server/docs/pxe-nic-guard.md
cproudlock ead3314f58 Two-NIC standard: pin imaging and GE NICs by MAC everywhere
Every server has an imaging NIC (static, the only one that serves
DHCP/TFTP/HTTP boot/SMB) and a GE corporate NIC (DHCP client, SSH and the
webapp on 9009). The playbook guessed the PXE NIC and the installer put
172.16.9.1/24 on every en* NIC, so a two-NIC box could answer on the GE
network.

- New per-site vars pxe_nic_mac / corp_nic_mac (required, from
  playbook/site-vars.yml or -e; template site-vars.example.yml), stable
  names pxe_nic_name / corp_nic_name (default pxe0 / corp0, overridable),
  pxe_server_ip, pxe_subnet, DHCP range. pxe_imaging_mac / pxe_ge_mac are
  derived from them. The play stops before touching anything if a MAC is
  unset, malformed, duplicated or not on the box (lists the box's NICs).
- Heuristic NIC detection and the single-NIC netplan task replaced by a
  two-NIC netplan template (match macaddress + set-name), installed by
  pxe-netplan-apply.sh: validate in a scratch root, back up, generate,
  rename, apply, restore the previous config on any failure. cloud-init
  network config disabled so it cannot rewrite it.
- dnsmasq: /etc/dnsmasq.d/pxe-nic-only.conf from the vars (interface=,
  except-interface=, no-dhcp-interface=); dnsmasq.conf IP/range from vars.
- UFW per interface: imaging NIC 22, 67/udp, 69/udp, 80, 4433, 445, 139,
  137/udp, 138/udp, 9009; GE NIC 22 and 9009 only. Old any-interface rules
  and the stale 9011 rule removed after the new ones are in.
- sysctl drop-in: IPv4/IPv6 forwarding off; ip_nonlocal_bind so Apache
  can bind the imaging IP before the address is up.
- Samba [global]: interfaces = 127.0.0.0/8 <imaging NIC>, bind interfaces
  only. Apache 80 and 4433 on the imaging IP only; 9009 stays on all (TODO:
  the webapp has no auth).
- autoinstall: DHCP only on en*, no static 172.16.9.1; apt geoip off and
  offline-install fallback now that the corp NIC may have a lease.
- Blancco UEFI/Secure Boot: blancco_kver (7.0.0-34-generic) drives the
  kernel, initramfs and modules builds instead of the running kernel;
  outputs vmlinuz-ubuntu-70, kexec-initrd-70.img, kmod-<kver>.tar.gz as the
  menu and blancco-init.sh expect; creates: guards keyed on the full kver.
  Ubuntu's shimx64.efi.signed published as blancco/shimx64.efi.
  download-packages.sh adds shim-signed, linux-generic-hwe-24.04 and the
  pinned linux-image/modules/modules-extra for BLANCCO_KVER.

Rendered with the live MACs and names, netplan generate output is
identical to the live config; pxe-nic-only.conf and the Samba binding
match live directive for directive.
2026-10-15 08:53:41 -04:00

162 lines
8.3 KiB
Markdown

# 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_nic_mac`, `corp_nic_mac`, `pxe_corp_domain_suffixes`,
`pxe_nic_allowed_switches`; `pxe_imaging_mac` / `pxe_ge_mac` are derived
from the first two). The same MACs pin the NICs in netplan
(`playbook/templates/netplan-pxe.yaml.j2`, names `pxe0` / `corp0`) and in
`/etc/dnsmasq.d/pxe-nic-only.conf`.
## 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.