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
167 lines
5.6 KiB
Python
167 lines
5.6 KiB
Python
"""
|
|
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
|