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:
@@ -56,6 +56,7 @@ from services import (
|
|||||||
images,
|
images,
|
||||||
imaging_status,
|
imaging_status,
|
||||||
mcl_ppkg,
|
mcl_ppkg,
|
||||||
|
nic_status,
|
||||||
system,
|
system,
|
||||||
unattend,
|
unattend,
|
||||||
wim,
|
wim,
|
||||||
@@ -113,6 +114,11 @@ def dashboard():
|
|||||||
img["os_label"], img["driver_count"] = "", 0
|
img["os_label"], img["driver_count"] = "", 0
|
||||||
img["lint"] = image_pages._lint_counts(img["image_type"])
|
img["lint"] = image_pages._lint_counts(img["image_type"])
|
||||||
services = [system.service_status(s) for s in ("dnsmasq", "apache2", "smbd")]
|
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())
|
sessions, _hidden = imaging_status.hide_stale(imaging_status.list_sessions())
|
||||||
floor = imaging_status.floor_order(sessions)
|
floor = imaging_status.floor_order(sessions)
|
||||||
real = [x for x in floor if x["floor_state"] != "logs"]
|
real = [x for x in floor if x["floor_state"] != "logs"]
|
||||||
@@ -121,6 +127,7 @@ def dashboard():
|
|||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
images=image_list,
|
images=image_list,
|
||||||
services=services,
|
services=services,
|
||||||
|
nic=nic,
|
||||||
image_types=config.IMAGE_TYPES,
|
image_types=config.IMAGE_TYPES,
|
||||||
friendly_names=config.FRIENDLY_NAMES,
|
friendly_names=config.FRIENDLY_NAMES,
|
||||||
sessions=real[:6],
|
sessions=real[:6],
|
||||||
|
|||||||
@@ -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")
|
UPDATES_DIR = os.environ.get("UPDATES_DIR", "/var/lib/pxe-webapp/updates")
|
||||||
TFTP_ROOT = os.environ.get("TFTP_ROOT", "/srv/tftp")
|
TFTP_ROOT = os.environ.get("TFTP_ROOT", "/srv/tftp")
|
||||||
DNSMASQ_CONF = os.environ.get("DNSMASQ_CONF", "/etc/dnsmasq.conf")
|
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
|
# 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
|
# when no /imaging/status push has arrived yet. Override per-host if any of
|
||||||
|
|||||||
187
webapp/services/nic_status.py
Normal file
187
webapp/services/nic_status.py
Normal 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}
|
||||||
@@ -5,6 +5,45 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% set services_up = services | selectattr('active') | list | length %}
|
{% set services_up = services | selectattr('active') | list | length %}
|
||||||
{% call ui.head('Overview', 'West Jefferson') %}
|
{% 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">▾</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">
|
<details class="more">
|
||||||
<summary class="fbtn" style="background: var(--surface); min-height: 36px">
|
<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>
|
<span class="dot" style="background: {{ 'var(--ok)' if services_up == services|length else 'var(--fail)' }}"></span>
|
||||||
@@ -20,6 +59,11 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
<div class="ui-stack">
|
<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 %}
|
{% if blancco_todo.total %}
|
||||||
{% set parts = [] %}
|
{% set parts = [] %}
|
||||||
{% if blancco_todo.servicenow %}{% set _ = parts.append(blancco_todo.servicenow ~ ' to tick in ServiceNow') %}{% endif %}
|
{% if blancco_todo.servicenow %}{% set _ = parts.append(blancco_todo.servicenow ~ ' to tick in ServiceNow') %}{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user