"""Network interfaces for the Overview: link, addresses, role and verdict. The server has two NICs: the imaging NIC (static 172.16.9.1, the only one dnsmasq may serve DHCP/PXE on) and the GE NIC (corporate DHCP client). The classifier pxe-nic-check (playbook/pxe-server-helpers) writes its verdicts to config.NIC_STATUS_FILE; when that file is missing or stale this module falls back to what the kernel says (link, speed, addresses) and which interface dnsmasq is bound to, without a verdict. Read-only: never changes interfaces or services. """ from __future__ import annotations import configparser import json import os import re import subprocess from datetime import datetime, timezone import config STALE_SECONDS = 20 * 60 # the watch timer runs every 5 min # A checked_at this far in the future is clock skew or a bad file: stale. FUTURE_SKEW_SECONDS = 300 SYS_NET = "/sys/class/net" # cdc_ncm (the generic USB NIC driver, e.g. a WisdPI RTL8157 5G adapter on a # kernel whose r8152 does not know the chip) keeps the link speed as a 32-bit # bits-per-second value, so multi-gig links wrap: 5 Gb/s reads 705 Mb/s and # 10 Gb/s reads 1410 Mb/s. CDC_NCM_WRAPPED = {705: 5000, 1410: 10000} def _read(path): try: with open(path) as f: return f.read().strip() except OSError: return "" def _norm_mac(v): return str(v or "").strip().lower().replace("-", ":") def _nic_conf(): """{key: value} from nic.conf, loaded like pxe-nic-check does: as-is (the playbook writes a [nic] header), else with a [nic] header added.""" try: with open(config.NIC_CONF) as f: text = f.read() except (OSError, UnicodeDecodeError): return {} cp = configparser.ConfigParser(inline_comment_prefixes=("#", ";")) try: try: cp.read_string(text) except configparser.MissingSectionHeaderError: cp = configparser.ConfigParser(inline_comment_prefixes=("#", ";")) cp.read_string("[nic]\n" + text) out = {} for sect in cp.sections(): for k, v in cp.items(sect): if v and v.strip(): out[k] = v.strip() return out except configparser.Error: return {} def _dnsmasq_rules(): """dnsmasq's NIC pin: {interface, except, no_dhcp, listen} sets, from DNSMASQ_CONF (and what it includes) plus DNSMASQ_NIC_CONF. None when neither could be read (the pin is then unknown).""" from services import ipxe_update # its include-aware dnsmasq.conf reader seen = set() lines = ipxe_update._conf_lines(config.DNSMASQ_CONF, seen=seen) lines += ipxe_update._conf_lines(config.DNSMASQ_NIC_CONF, seen=seen) if not lines: return None rules = {"interface": set(), "except": set(), "no_dhcp": set(), "listen": set()} keymap = {"interface": "interface", "except-interface": "except", "no-dhcp-interface": "no_dhcp", "listen-address": "listen"} for _src, line in lines: key, _, val = line.partition("=") key = key.strip() if key in keymap: for item in val.split(","): if item.strip(): rules[keymap[key]].add(item.strip()) return rules def _roles(rules=None): """{iface or mac: role} from nic.conf, else from dnsmasq's NIC pin.""" roles = {} conf = _nic_conf() for role in ("imaging", "ge"): if conf.get(f"{role}_iface"): roles[conf[f"{role}_iface"].lower()] = role if conf.get(f"{role}_mac"): roles[_norm_mac(conf[f"{role}_mac"])] = role if not roles: rules = _dnsmasq_rules() if rules is None else rules for name in sorted((rules or {}).get("interface", ())): roles[name] = "imaging" for name in sorted((rules or {}).get("except", ())): roles.setdefault(name, "ge") return roles def _addrs(): """{iface: ["a.b.c.d/nn", ...]} (IPv4 and global IPv6) from `ip -j addr`.""" try: out = subprocess.run(["ip", "-j", "addr", "show"], capture_output=True, text=True, timeout=5).stdout data = json.loads(out or "[]") except (OSError, subprocess.SubprocessError, ValueError): return {} res = {} for link in data if isinstance(data, list) else []: if not isinstance(link, dict): continue res[str(link.get("ifname", ""))] = [ f"{a.get('local')}/{a.get('prefixlen')}" for a in link.get("addr_info", []) or [] if isinstance(a, dict) and a.get("local") and (a.get("family") == "inet" or (a.get("family") == "inet6" and a.get("scope") == "global"))] return res def _dhcp_sockets(): """[(addr, dev)] of the UDP 67 sockets; dev is None for an unbound one. None if ss cannot be run.""" try: r = subprocess.run(["ss", "-H", "-uln", "sport", "= :67"], capture_output=True, text=True, timeout=5) except (OSError, subprocess.SubprocessError): return None if r.returncode != 0: return None out = [] for line in r.stdout.splitlines(): fields = line.split() if len(fields) < 4: continue host = fields[3].rsplit(":", 1)[0] host, _, dev = host.partition("%") out.append((host.strip("[]"), dev or None)) return out def _bound(name, ips, socks, rules): """Does dnsmasq answer DHCP on `name`? True/False, or None if unknown. Mirrors pxe-nic-check: a wildcard socket serves only what dnsmasq's interface pin allows, so it never marks an excluded NIC as served.""" if socks is None: return None if not socks: return False for addr, dev in socks: if dev == name or (dev is None and addr in ips): return True if not any(dev is None and addr in ("0.0.0.0", "*", "", "::") for addr, dev in socks): return False if rules is None: return None # wildcard socket and the pin is unknown if name in rules["no_dhcp"] or name in rules["except"]: return False if name in rules["interface"] or any(ip in rules["listen"] for ip in ips): return True if not rules["interface"] and not rules["listen"]: return bool(ips) return False def _kernel_view(): """Interfaces as the kernel sees them: physical NICs, plus bridges that carry a role (the imaging NIC is often a bridge over the port). Returns (interfaces, ok) where ok is False if /sys could not be read.""" rules = _dnsmasq_rules() roles, addrs, socks = _roles(rules), _addrs(), _dhcp_sockets() try: names = sorted(os.listdir(SYS_NET)) except OSError: return [], False macs = {n: _read(os.path.join(SYS_NET, n, "address")).lower() for n in names} bridges, port_of = {}, {} for name in names: brif = os.path.join(SYS_NET, name, "brif") if os.path.isdir(os.path.join(SYS_NET, name, "bridge")): try: ports = sorted(os.listdir(brif)) except OSError: ports = [] role = roles.get(name) or roles.get(macs.get(name, "")) role = role or next((roles.get(p) or roles.get(macs.get(p, "")) for p in ports if roles.get(p) or roles.get(macs.get(p, ""))), None) if role: bridges[name] = role for p in ports: port_of[p] = name out = [] for name in names: base = os.path.join(SYS_NET, name) physical = os.path.exists(os.path.join(base, "device")) if name == "lo" or not (physical or name in bridges): continue # skip loopback, plain bridges, veths, tunnels mac = macs.get(name, "") oper = _read(os.path.join(base, "operstate")) try: speed = int(_read(os.path.join(base, "speed"))) speed = speed if speed > 0 else None except ValueError: speed = None notes = [] driver = os.path.basename(os.path.realpath(os.path.join(base, "device", "driver"))) if physical else "" if driver == "cdc_ncm" and speed in CDC_NCM_WRAPPED: notes.append(f"Link {CDC_NCM_WRAPPED[speed] // 1000} Gb/s (the cdc_ncm driver reports " f"{speed} Mb/s: its 32-bit speed counter wraps above 4.29 Gb/s)") speed = CDC_NCM_WRAPPED[speed] if name in bridges: role = bridges[name] notes.append("bridge") elif name in port_of: role = "other" # the bridge carries the role and the address notes.append(f"port of bridge {port_of[name]}") else: role = roles.get(name) or roles.get(mac) or "other" up = oper == "up" or (name in bridges and oper == "unknown") ips = [a.split("/")[0] for a in addrs.get(name, [])] out.append({ "iface": name, "mac": mac, "role": role, "link": "up" if up else "down", "speed_mbps": speed if up else None, "addrs": addrs.get(name, []), "verdict": None, "evidence": [], "notes": notes, "dnsmasq_bound": None if name in port_of else _bound(name, ips, socks, rules), }) return out, True def _age_seconds(stamp): try: t = datetime.fromisoformat(str(stamp).replace("Z", "+00:00")) if t.tzinfo is None: t = t.replace(tzinfo=timezone.utc) return (datetime.now(timezone.utc) - t).total_seconds() except (ValueError, TypeError, OverflowError): return None def _str(v): return v.strip() if isinstance(v, str) else "" def _str_list(v): if isinstance(v, str): v = [v] if not isinstance(v, (list, tuple)): return [] return [x if isinstance(x, str) else str(x) for x in v if isinstance(x, (str, int, float)) and not isinstance(x, bool)] def _speed(v): if isinstance(v, bool): return None try: n = int(v) except (TypeError, ValueError, OverflowError): return None return n if 0 < n < 10_000_000 else None def _norm_iface(i): """One interface entry of the check file with every field the page uses in the type it expects; None for something that is not an entry.""" if not isinstance(i, dict): return None bound = i.get("dnsmasq_bound") return { "iface": _str(i.get("iface")) or "?", "mac": _norm_mac(_str(i.get("mac"))), "role": _str(i.get("role")) or "other", "link": "up" if _str(i.get("link")) == "up" else "down", "speed_mbps": _speed(i.get("speed_mbps")), "addrs": _str_list(i.get("addrs")), "verdict": _str(i.get("verdict")) or None, "evidence": _str_list(i.get("evidence")), "notes": _str_list(i.get("notes")), "dnsmasq_bound": bound if isinstance(bound, bool) else None, } def _when(stamp): """checked_at for a sentence: 'YYYY-MM-DD HH:MM'.""" return str(stamp or "?").replace("T", " ")[:16] def _problems(ifaces, guard, stale=False, checked_at=None): """[(level, text)]: 'bad' is a DHCP-on-the-wrong-network risk.""" out = [] if (guard or {}).get("state") == "blocked": reason = guard.get("reason") or "the imaging NIC is not isolated" if stale: out.append(("warn", f"The last network check ({_when(checked_at)}) said DHCP/PXE is blocked: {reason}")) else: out.append(("bad", "DHCP/PXE is blocked: " + reason)) imaging = [i for i in ifaces if i["role"] == "imaging"] for i in ifaces: if i["role"] == "imaging" and i.get("verdict") in ("ge", "foreign-dhcp"): what = "the GE network" if i["verdict"] == "ge" else "a network with another DHCP server" out.append(("bad", f"Imaging NIC {i['iface']} is on {what}. Do not serve DHCP/PXE here.")) if i["role"] != "imaging" and i.get("dnsmasq_bound") is True: out.append(("bad", f"dnsmasq is answering DHCP on {i['iface']} ({i['role']} NIC), not only on the imaging NIC.")) if not imaging: out.append(("warn", "No imaging NIC configured (/etc/pxe-server/nic.conf or dnsmasq's interface= pin).")) for i in imaging: if i["link"] != "up": out.append(("warn", f"Imaging NIC {i['iface']} has no link: no PC can network-boot.")) elif i.get("dnsmasq_bound") is False: out.append(("warn", f"dnsmasq is not listening for DHCP on the imaging NIC {i['iface']}.")) for i in ifaces: if i["role"] == "ge" and i["link"] != "up": out.append(("warn", f"GE NIC {i['iface']} has no link (no updates or remote admin).")) return out def status(): """{interfaces, source, checked_at, stale, guard, problems, state}. state: 'ok' | 'warn' | 'bad'. Every interface entry is normalised (see _norm_iface), whatever the check file holds.""" data, source = None, "kernel" try: with open(config.NIC_STATUS_FILE) as f: data = json.load(f) except (OSError, ValueError): data = None stale = True if isinstance(data, dict) and isinstance(data.get("interfaces"), list): age = _age_seconds(data.get("checked_at")) stale = age is None or age > STALE_SECONDS or age < -FUTURE_SKEW_SECONDS source = "check" kernel, kernel_ok = _kernel_view() live = {i["iface"]: i for i in kernel} checked_at = None if source == "check": checked_at = data.get("checked_at") if isinstance(data.get("checked_at"), str) else None ifaces = [] for raw in data["interfaces"]: i = _norm_iface(raw) if i is None: continue k = live.get(i["iface"]) if k: # link and addresses are cheap to read now; keep the check's verdict i.update(link=k["link"], speed_mbps=k["speed_mbps"], addrs=k["addrs"], notes=k["notes"] + [n for n in i["notes"] if n not in k["notes"]]) # The check knows the dnsmasq config and process; the # kernel fallback is only used once the check is stale. if stale or i["dnsmasq_bound"] is None: i["dnsmasq_bound"] = k["dnsmasq_bound"] elif kernel_ok: i.update(link="down", speed_mbps=None, addrs=[], dnsmasq_bound=False, notes=i["notes"] + ["not present (unplugged?)"]) if stale: i["verdict"] = None i["evidence"] = [] ifaces.append(i) g = data.get("dhcp_guard") guard = {"state": _str(g.get("state")), "reason": _str(g.get("reason"))} if isinstance(g, dict) else {} else: ifaces, guard = kernel, {} ifaces.sort(key=lambda i: {"imaging": 0, "ge": 1}.get(i.get("role"), 2)) problems = _problems(ifaces, guard, stale and source == "check", checked_at) state = "bad" if any(l == "bad" for l, _ in problems) else "warn" if problems else "ok" return {"interfaces": ifaces, "source": source, "stale": stale and source == "check", "checked_at": checked_at, "guard": guard, "problems": problems, "state": state} def unknown(error=""): """What the Overview shows when status() itself failed.""" return {"interfaces": [], "source": "error", "stale": False, "checked_at": None, "guard": {}, "problems": [], "state": "unknown", "error": str(error)[:200]}