diff --git a/webapp/app.py b/webapp/app.py index 0d9f79f..e79cbba 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -56,6 +56,7 @@ from services import ( images, imaging_status, mcl_ppkg, + nic_status, system, unattend, wim, @@ -113,6 +114,11 @@ def dashboard(): img["os_label"], img["driver_count"] = "", 0 img["lint"] = image_pages._lint_counts(img["image_type"]) services = [system.service_status(s) for s in ("dnsmasq", "apache2", "smbd")] + try: + nic = nic_status.status() + except Exception: # the page must render even if this breaks + app.logger.exception("network status failed") + nic = None sessions, _hidden = imaging_status.hide_stale(imaging_status.list_sessions()) floor = imaging_status.floor_order(sessions) real = [x for x in floor if x["floor_state"] != "logs"] @@ -121,6 +127,7 @@ def dashboard(): "dashboard.html", images=image_list, services=services, + nic=nic, image_types=config.IMAGE_TYPES, friendly_names=config.FRIENDLY_NAMES, sessions=real[:6], diff --git a/webapp/config.py b/webapp/config.py index 7640bcf..af2b9b3 100644 --- a/webapp/config.py +++ b/webapp/config.py @@ -39,6 +39,11 @@ BLANCCO_SERVER = os.environ.get("BLANCCO_SERVER", "172.16.9.1") UPDATES_DIR = os.environ.get("UPDATES_DIR", "/var/lib/pxe-webapp/updates") TFTP_ROOT = os.environ.get("TFTP_ROOT", "/srv/tftp") DNSMASQ_CONF = os.environ.get("DNSMASQ_CONF", "/etc/dnsmasq.conf") +# Network interfaces (Overview): the pxe-nic-check verdict file, its config, +# and dnsmasq's NIC pin (the fallback for which NIC is the imaging one). +NIC_STATUS_FILE = os.environ.get("NIC_STATUS_FILE", "/run/pxe-nic-status.json") +NIC_CONF = os.environ.get("NIC_CONF", "/etc/pxe-server/nic.conf") +DNSMASQ_NIC_CONF = os.environ.get("DNSMASQ_NIC_CONF", "/etc/dnsmasq.d/pxe-nic-only.conf") # Log sources used by services/imaging_log_tail.py to infer client progress # when no /imaging/status push has arrived yet. Override per-host if any of diff --git a/webapp/services/nic_status.py b/webapp/services/nic_status.py new file mode 100644 index 0000000..171d8a5 --- /dev/null +++ b/webapp/services/nic_status.py @@ -0,0 +1,187 @@ +"""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 +SYS_NET = "/sys/class/net" + + +def _read(path): + try: + with open(path) as f: + return f.read().strip() + except OSError: + return "" + + +def _roles(): + """{iface or mac: role} from nic.conf, else from dnsmasq's NIC pin.""" + roles = {} + cp = configparser.ConfigParser() + try: + cp.read_string("[nic]\n" + open(config.NIC_CONF).read()) + sec = cp["nic"] + for role in ("imaging", "ge"): + for key in (f"{role}_iface", f"{role}_mac"): + if sec.get(key): + roles[sec.get(key).strip().lower()] = role + except (OSError, configparser.Error): + pass + if not roles: + for line in _read(config.DNSMASQ_NIC_CONF).splitlines(): + key, _, val = line.split("#", 1)[0].strip().partition("=") + if key == "interface" and val: + roles[val.strip()] = "imaging" + elif key == "except-interface" and val: + roles.setdefault(val.strip(), "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: + res[link.get("ifname", "")] = [ + f"{a['local']}/{a['prefixlen']}" for a in link.get("addr_info", []) + if a.get("family") == "inet" or (a.get("family") == "inet6" and a.get("scope") == "global")] + return res + + +def _dhcp_bound(): + """Interfaces dnsmasq's DHCP socket (UDP 67) is bound to; None if unknown.""" + try: + out = subprocess.run(["ss", "-H", "-uln", "sport", "= :67"], capture_output=True, text=True, + timeout=5).stdout + except (OSError, subprocess.SubprocessError): + return None + bound = set(re.findall(r"%([\w.-]+):67\b", out)) + if re.search(r"(^|\s)(0\.0\.0\.0|\*|\[::\]):67\b", out): + bound.add("*") # bound on every interface + return bound + + +def _kernel_view(): + """Interfaces as the kernel sees them (physical NICs only).""" + roles, addrs, bound = _roles(), _addrs(), _dhcp_bound() + out = [] + try: + names = sorted(os.listdir(SYS_NET)) + except OSError: + names = [] + for name in names: + base = os.path.join(SYS_NET, name) + if name == "lo" or not os.path.exists(os.path.join(base, "device")): + continue # skip loopback, bridges, veths, tunnels + mac = _read(os.path.join(base, "address")).lower() + 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 + out.append({ + "iface": name, "mac": mac, + "role": roles.get(name) or roles.get(mac) or "other", + "link": "up" if oper == "up" else "down", + "speed_mbps": speed if oper == "up" else None, + "addrs": addrs.get(name, []), + "verdict": None, "evidence": [], + "dnsmasq_bound": (None if bound is None else (name in bound or "*" in bound)), + }) + return out + + +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: + return None + + +def _problems(ifaces, guard): + """[(level, text)]: 'bad' is a DHCP-on-the-wrong-network risk.""" + out = [] + if (guard or {}).get("state") == "blocked": + out.append(("bad", "DHCP/PXE is blocked: " + (guard.get("reason") or "the imaging NIC is not isolated"))) + 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"): + 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'.""" + 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 + source = "check" + live = {i["iface"]: i for i in _kernel_view()} + if source == "check": + ifaces = [] + for i in data["interfaces"]: + i = dict(i) + k = live.get(i.get("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"]) + if k["dnsmasq_bound"] is not None: + i["dnsmasq_bound"] = k["dnsmasq_bound"] + if stale: + i["verdict"] = None + ifaces.append(i) + guard = data.get("dhcp_guard") or {} + else: + ifaces, guard = list(live.values()), {} + ifaces.sort(key=lambda i: {"imaging": 0, "ge": 1}.get(i.get("role"), 2)) + problems = _problems(ifaces, guard) + 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": (data or {}).get("checked_at") if source == "check" else None, + "guard": guard, "problems": problems, "state": state} diff --git a/webapp/templates/dashboard.html b/webapp/templates/dashboard.html index e0cc0cc..1c049bd 100644 --- a/webapp/templates/dashboard.html +++ b/webapp/templates/dashboard.html @@ -5,6 +5,45 @@ {% block content %} {% set services_up = services | selectattr('active') | list | length %} {% call ui.head('Overview', 'West Jefferson') %} + {% if nic %} + {% set nic_dot = {'ok': 'var(--ok)', 'warn': 'var(--act)', 'bad': 'var(--fail)'}[nic.state] %} + {% set role_name = {'imaging': 'Imaging', 'ge': 'GE network', 'other': 'Other'} %} + {% set verdict_chip = {'isolated': ('Isolated', 'ok'), 'ge': ('GE network', 'neutral'), 'foreign-dhcp': ('Other DHCP server', 'err'), + 'no-link': ('No link', 'off'), 'error': ('Check failed', 'warn')} %} +
+ + + {{ 'Network OK' if nic.state == 'ok' else ('Network: check' if nic.state == 'warn' else 'Network: DANGER') }} + + + +
+ {% endif %}
@@ -20,6 +59,11 @@ {% endcall %}
+{% if nic and nic.problems %} +
+ {% if nic.state == 'bad' %}Network danger. {% endif %}{% for level, text in nic.problems %}{{ text }}{% if not loop.last %} {% endif %}{% endfor %} +
+{% endif %} {% if blancco_todo.total %} {% set parts = [] %} {% if blancco_todo.servicenow %}{% set _ = parts.append(blancco_todo.servicenow ~ ' to tick in ServiceNow') %}{% endif %}