- pxe-nic-check: count 802.1X only from an authenticator (EAPOL type 0 with EAP Request/Success/Failure). iPXE sends EAPOL-Start on every NIC open and Windows sends Start/Response, so a PXE-booting bay made the verdict 'ge' and the watch stopped dnsmasq for the floor. - pxe-nic-check: join the STP/PAE/LLDP/CDP/PVST+ multicast groups on every capture socket, so CDP-only ports are heard on a plain NIC. - pxe-nic-check --watch: if dnsmasq was (re)started inside the window, re-check (~6 s) before stopping it; skip when already stopped. New --state-cmd and --print-imaging. - pxe-nic-carrier (+ pxe-nic-carrier.service): networkd-dispatcher hook for the imaging NIC only. Carrier loss stops dnsmasq; carrier gain restarts it through its guard if it was running or the hook stopped it. - pxe-dhcp-hook.sh: clear only SMB (tcp 445/139) conntrack entries and sockets, not all traffic for the client, which raced iPXE's first HTTP fetch. - GetPxeScript.aspx: every boot line has a failure branch (iPXE 2.0 accepts an unsigned EFI kernel and fails at boot under Secure Boot, which aborted the script). - Tests: client EAPOL, packet_mreq packing, watch race, shell helpers on stub PATH, lab scenarios eapol-client, br-eapol-client, race, carrier.
640 lines
26 KiB
Python
640 lines
26 KiB
Python
"""
|
|
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_eapol_only_from_authenticator(self):
|
|
client = "02:aa:00:00:00:07"
|
|
for name, fr, want in [
|
|
("request", F.eapol(SW, code=1), True),
|
|
("success", F.eapol(SW, code=3), True),
|
|
("failure", F.eapol(SW, code=4), True),
|
|
("response", F.eapol(client, code=2), False),
|
|
("start", F.eapol_start(client), False),
|
|
("logoff", F.eapol_start(client, eapol_type=2), False),
|
|
("truncated", F.eapol(SW)[:17], False),
|
|
]:
|
|
o = fed(fr)
|
|
self.assertEqual(o.eapol, want, name)
|
|
self.assertEqual(o.eapol_client, 0 if want else 1, name)
|
|
|
|
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 Multicast(unittest.TestCase):
|
|
def test_packet_mreq_packing(self):
|
|
import struct
|
|
raw = M.packet_mreq(7, "01:80:C2:00:00:0E")
|
|
self.assertEqual(len(raw), 16) # sizeof(struct packet_mreq)
|
|
idx, typ, alen, addr = struct.unpack("iHH8s", raw)
|
|
self.assertEqual((idx, typ, alen), (7, M.PACKET_MR_MULTICAST, 6))
|
|
self.assertEqual(addr, bytes.fromhex("0180c200000e") + b"\0\0")
|
|
self.assertEqual(M.SOL_PACKET, 263)
|
|
self.assertEqual(M.PACKET_ADD_MEMBERSHIP, 1)
|
|
with self.assertRaises(ValueError):
|
|
M.packet_mreq(1, "01:80:c2")
|
|
|
|
def test_groups(self):
|
|
self.assertEqual(sorted(M.CAPTURE_GROUPS), sorted([
|
|
"01:80:c2:00:00:00", "01:80:c2:00:00:03", "01:80:c2:00:00:0e",
|
|
"01:00:0c:cc:cc:cc", "01:00:0c:cc:cc:cd"]))
|
|
|
|
def test_join_groups_calls_setsockopt_per_group(self):
|
|
calls = []
|
|
|
|
class FakeSock:
|
|
def setsockopt(self, level, opt, val):
|
|
calls.append((level, opt, val))
|
|
if val[8:14] == bytes.fromhex("01000ccccccd"):
|
|
raise OSError("nope")
|
|
|
|
import socket
|
|
real = socket.if_nametoindex
|
|
socket.if_nametoindex = lambda name: 3
|
|
try:
|
|
failed = M.join_groups(FakeSock(), "eth9")
|
|
finally:
|
|
socket.if_nametoindex = real
|
|
self.assertEqual(failed, ["01:00:0c:cc:cc:cd"])
|
|
self.assertEqual(len(calls), 5)
|
|
for level, opt, val in calls:
|
|
self.assertEqual((level, opt), (263, 1))
|
|
self.assertEqual(val[:4], M.packet_mreq(3, "01:80:c2:00:00:00")[:4])
|
|
self.assertEqual(M.join_groups(FakeSock(), "no-such-if-xyz"),
|
|
M.CAPTURE_GROUPS)
|
|
|
|
|
|
class WatchRace(unittest.TestCase):
|
|
def test_parse_unit_state(self):
|
|
self.assertEqual(M.parse_unit_state(
|
|
"ActiveState=active\nActiveEnterTimestampMonotonic=123456\n"),
|
|
("active", 123456))
|
|
self.assertEqual(M.parse_unit_state(
|
|
"ActiveEnterTimestampMonotonic=0\nActiveState=inactive\n"),
|
|
("inactive", 0))
|
|
self.assertEqual(M.parse_unit_state(""), (None, None))
|
|
|
|
def test_decision(self):
|
|
start = 1000000
|
|
self.assertEqual(M.watch_decision("active", 500, start), "stop")
|
|
self.assertEqual(M.watch_decision("active", start + 1, start),
|
|
"recheck")
|
|
self.assertEqual(M.watch_decision("activating", 0, start), "recheck")
|
|
self.assertEqual(M.watch_decision("inactive", 0, start), "skip")
|
|
self.assertEqual(M.watch_decision("failed", 0, start), "skip")
|
|
self.assertEqual(M.watch_decision(None, None, start), "stop")
|
|
self.assertEqual(M.watch_decision("active", None, start), "stop")
|
|
|
|
def _main_watch(self, state_out, recheck_state):
|
|
d = tempfile.mkdtemp()
|
|
marker = os.path.join(d, "stopped")
|
|
saved = M.run_check, M.recheck
|
|
blocked = {"checked_at": "x", "duration_s": 1, "interfaces": [],
|
|
"dhcp_guard": {"state": "blocked", "reason": "LLDP"}}
|
|
rechecked = []
|
|
M.run_check = lambda args, cfg: (blocked, None)
|
|
|
|
def fake_recheck(args, cfg):
|
|
rechecked.append(1)
|
|
return recheck_state, "re-check"
|
|
M.recheck = fake_recheck
|
|
try:
|
|
rc = M.main(["--watch", "--config", "/nonexistent",
|
|
"--write", os.path.join(d, "s.json"),
|
|
"--stop-cmd", "touch " + marker,
|
|
"--state-cmd", "printf '%s'" % state_out])
|
|
finally:
|
|
M.run_check, M.recheck = saved
|
|
self.assertEqual(rc, 0)
|
|
return os.path.exists(marker), bool(rechecked)
|
|
|
|
def test_watch_stops_when_dnsmasq_predates_window(self):
|
|
self.assertEqual(self._main_watch(
|
|
"ActiveState=active\nActiveEnterTimestampMonotonic=1\n",
|
|
"allowed"), (True, False))
|
|
|
|
def test_watch_rechecks_after_operator_restart(self):
|
|
late = "ActiveState=active\nActiveEnterTimestampMonotonic=%d\n" \
|
|
% (2 ** 62)
|
|
self.assertEqual(self._main_watch(late, "allowed"), (False, True))
|
|
self.assertEqual(self._main_watch(late, "blocked"), (True, True))
|
|
|
|
def test_watch_skips_stopped_dnsmasq(self):
|
|
self.assertEqual(self._main_watch(
|
|
"ActiveState=inactive\nActiveEnterTimestampMonotonic=0\n",
|
|
"allowed"), (False, False))
|
|
|
|
def test_watch_stops_when_state_unknown(self):
|
|
self.assertEqual(self._main_watch("", "allowed"), (True, False))
|
|
|
|
|
|
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_client_eapol_stays_isolated(self):
|
|
# a bay PXE-booting next to us: iPXE sends 3 EAPOL-Starts on every
|
|
# NIC open, Windows dot3svc sends Starts and EAP-Responses
|
|
client = "02:aa:00:00:00:07"
|
|
starts = [F.eapol_start(client)] * 3
|
|
v, ev = self.run_case(starts)
|
|
self.assertEqual(v, "isolated", ev)
|
|
self.assertTrue(any("3 client EAPOL frames ignored" in e
|
|
for e in ev), ev)
|
|
v, ev = self.run_case([F.eapol(client, code=2)])
|
|
self.assertEqual(v, "isolated", ev)
|
|
v, ev = self.run_case(starts + [F.eapol(client, code=2)])
|
|
self.assertEqual(v, "isolated", ev)
|
|
# a switch port asking for identity is still decisive
|
|
v, ev = self.run_case(starts + [F.eapol(SW, code=1)])
|
|
self.assertEqual(v, "ge", ev)
|
|
self.assertIn("802.1X EAPOL seen (port authentication)", ev)
|
|
|
|
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()
|