Overview: network status pill for the two NICs

A 'Network' pill next to 'Services' lists each physical NIC with its role
(imaging / GE network, from /etc/pxe-server/nic.conf or dnsmasq's
interface= pin), link, speed, addresses, and whether dnsmasq serves
DHCP/PXE on it. It reads pxe-nic-check's verdict file
(/run/pxe-nic-status.json) when present, and shows a red banner when the
imaging NIC is on the GE network or a network with another DHCP server,
or when dnsmasq answers DHCP on a non-imaging NIC. Without the file it
shows link and addresses only. Read-only.
This commit is contained in:
cproudlock
2026-10-14 16:49:32 -04:00
parent 63fafbfc73
commit 0d69222f85
4 changed files with 243 additions and 0 deletions

View File

@@ -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],

View File

@@ -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

View File

@@ -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}

View File

@@ -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')} %}
<details class="more">
<summary class="fbtn" style="background: var(--surface); min-height: 36px">
<span class="dot" style="background: {{ nic_dot }}"></span>
{{ 'Network OK' if nic.state == 'ok' else ('Network: check' if nic.state == 'warn' else 'Network: DANGER') }}
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="menu" style="width: min(460px, calc(100vw - 32px)); padding: 12px 14px; gap: 10px; font-size: 13px">
{% for i in nic.interfaces %}
{% set bad_here = (i.role == 'imaging' and i.verdict in ('ge', 'foreign-dhcp')) or (i.role != 'imaging' and i.dnsmasq_bound) %}
<div style="display: flex; flex-direction: column; gap: 4px">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<span class="dot" style="background: {{ 'var(--fail)' if bad_here else ('var(--ok)' if i.link == 'up' else 'var(--act)') }}"></span>
<strong>{{ role_name.get(i.role, i.role) }}</strong>
<span class="mono muted">{{ i.iface }}</span>
{% if i.verdict and i.verdict in verdict_chip %}{% set vc = verdict_chip[i.verdict] %}{{ ui.chip(vc[0], 'err' if bad_here else vc[1]) }}{% endif %}
</div>
<div class="muted" style="padding-left: 18px; white-space: normal">
{{ 'Up' if i.link == 'up' else 'No link' }}{% if i.speed_mbps %}, {% if i.speed_mbps >= 1000 %}{{ (i.speed_mbps // 1000) if i.speed_mbps % 1000 == 0 else (i.speed_mbps / 1000) | round(1) }} Gb/s{% else %}{{ i.speed_mbps }} Mb/s{% endif %}{% endif %}
{% if i.addrs %}<span class="mono">{{ i.addrs | join(', ') }}</span>{% endif %}
{% if i.dnsmasq_bound is not none %}<br>DHCP/PXE {{ 'served here' if i.dnsmasq_bound else 'not served here' }}{% endif %}
{% for e in i.evidence %}<br>{{ e }}{% endfor %}
</div>
</div>
{% else %}
<div class="muted">No network interfaces found.</div>
{% endfor %}
<div class="sub" style="white-space: normal; border-top: 1px solid var(--line); padding-top: 8px">
{% if nic.source == 'check' and not nic.stale %}Checked {{ nic.checked_at | local_dt }} by pxe-nic-check.
{% elif nic.source == 'check' %}The network check is stale (last {{ nic.checked_at | local_dt }}): showing link and addresses only.
{% else %}Link and addresses only; pxe-nic-check (the GE-network detector) is not installed yet.{% endif %}
</div>
</div>
</details>
{% endif %}
<details class="more">
<summary class="fbtn" style="background: var(--surface); min-height: 36px">
<span class="dot" style="background: {{ 'var(--ok)' if services_up == services|length else 'var(--fail)' }}"></span>
@@ -20,6 +59,11 @@
{% endcall %}
<div class="ui-stack">
{% if nic and nic.problems %}
<div class="notice{{ ' bad' if nic.state == 'bad' else '' }}" role="{{ 'alert' if nic.state == 'bad' else 'status' }}">
<span>{% if nic.state == 'bad' %}<strong>Network danger.</strong> {% endif %}{% for level, text in nic.problems %}{{ text }}{% if not loop.last %} {% endif %}{% endfor %}</span>
</div>
{% endif %}
{% if blancco_todo.total %}
{% set parts = [] %}
{% if blancco_todo.servicenow %}{% set _ = parts.append(blancco_todo.servicenow ~ ' to tick in ServiceNow') %}{% endif %}