Development

This commit is contained in:
Matthew Grotke 2026-05-24 01:13:45 -04:00
parent e72676eac5
commit 80390334fb
2 changed files with 75 additions and 45 deletions

View file

@ -1495,12 +1495,25 @@ def render_layout(view_id, content_html, tokens):
grouped.setdefault(sev, []).append(text) grouped.setdefault(sev, []).append(text)
for item in st.get('services', []): for item in st.get('services', []):
if item.get('status') == 'problem': if item.get('status') == 'problem':
parts = [] name = item.get('name', '')
utype = 'timer' if name.endswith('.timer') else 'service' if name.endswith('.service') else 'unit'
exp_parts, act_parts, fix_parts = [], [], []
if not item.get('active_ok'): if not item.get('active_ok'):
parts.append(f"active: {item.get('active')} (expected {item.get('expected_active')})") exp_parts.append(item.get('expected_active', 'active'))
act_parts.append(item.get('active', 'unknown'))
fix_parts.append('activate')
if not item.get('enabled_ok'): if not item.get('enabled_ok'):
parts.append(f"enabled: {item.get('enabled')} (expected {item.get('expected_enabled')})") exp_parts.append(item.get('expected_enabled', 'enabled'))
grouped['error'].append(e(f"{item.get('name')}: {', '.join(parts)}")) act_parts.append(item.get('enabled', 'unknown'))
fix_parts.append('enable')
detail = (f"The {utype} `{name}` is expected to be "
f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}.")
tip = f"Run `sudo python3 core.py --apply` to {' and '.join(reversed(fix_parts))} it."
sev = item.get('severity', 'error')
text = e(detail)
if tip:
text += f' <span style="opacity:0.75">{e(tip)}</span>'
grouped.setdefault(sev, []).append(text)
for sev, items in grouped.items(): for sev, items in grouped.items():
if not items: if not items:
continue continue

View file

@ -152,49 +152,59 @@ def check_services(data):
iface = derive_interface(vlan, data) iface = derive_interface(vlan, data)
name = _vlan_service_name(vlan, iface) name = _vlan_service_name(vlan, iface)
units.append({"id": name, "name": name, units.append({"id": name, "name": name,
"expected_active": "active", "expected_enabled": "enabled"}) "expected_active": "active", "expected_enabled": "enabled",
"severity": "error"})
units.append({"id": f"{BLIST_TIMER_NAME}.timer", units.append({"id": f"{BLIST_TIMER_NAME}.timer",
"name": f"{BLIST_TIMER_NAME}.timer", "name": f"{BLIST_TIMER_NAME}.timer",
"expected_active": "active", "expected_enabled": "enabled"}) "expected_active": "active", "expected_enabled": "enabled",
"severity": "warning"})
units.append({"id": NAT_SERVICE_NAME, units.append({"id": NAT_SERVICE_NAME,
"name": NAT_SERVICE_NAME, "name": NAT_SERVICE_NAME,
"expected_active": "inactive", "expected_active": "inactive",
"expected_enabled": "enabled"}) "expected_enabled": "enabled",
"severity": "error"})
units.append({"id": f"{STATUS_TIMER_NAME}.timer", units.append({"id": f"{STATUS_TIMER_NAME}.timer",
"name": f"{STATUS_TIMER_NAME}.timer", "name": f"{STATUS_TIMER_NAME}.timer",
"expected_active": "active", "expected_enabled": "enabled"}) "expected_active": "active", "expected_enabled": "enabled",
"severity": "warning"})
if DASHB_QUEUE_FILE.exists(): if DASHB_QUEUE_FILE.exists():
units.append({"id": f"{DASHB_TIMER_NAME}.timer", units.append({"id": f"{DASHB_TIMER_NAME}.timer",
"name": f"{DASHB_TIMER_NAME}.timer", "name": f"{DASHB_TIMER_NAME}.timer",
"expected_active": "active", "expected_enabled": "enabled"}) "expected_active": "active", "expected_enabled": "enabled",
"severity": "error"})
has_ddns = any(p.get("enabled") for p in data.get("ddns", {}).get("providers", [])) has_ddns = any(p.get("enabled") for p in data.get("ddns", {}).get("providers", []))
exp_ddns_active = "active" if has_ddns else "inactive" exp_ddns_active = "active" if has_ddns else "inactive"
exp_ddns_enabled = "enabled" if has_ddns else "not-found" exp_ddns_enabled = "enabled" if has_ddns else "not-found"
units.append({"id": f"{DDNS_TIMER_NAME}.timer", units.append({"id": f"{DDNS_TIMER_NAME}.timer",
"name": f"{DDNS_TIMER_NAME}.timer", "name": f"{DDNS_TIMER_NAME}.timer",
"expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled}) "expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled,
"severity": "warning"})
exp_fr_active = "active" if _radius_enabled(data) else "inactive" exp_fr_active = "active" if _radius_enabled(data) else "inactive"
exp_fr_enabled = "enabled" if _radius_enabled(data) else "disabled" exp_fr_enabled = "enabled" if _radius_enabled(data) else "disabled"
units.append({"id": "freeradius", "name": "freeradius", units.append({"id": "freeradius", "name": "freeradius",
"expected_active": exp_fr_active, "expected_active": exp_fr_active,
"expected_enabled": exp_fr_enabled}) "expected_enabled": exp_fr_enabled,
"severity": "error"})
exp_av_active = "active" if _avahi_enabled(data) else "inactive" exp_av_active = "active" if _avahi_enabled(data) else "inactive"
exp_av_enabled = "enabled" if _avahi_enabled(data) else "disabled" exp_av_enabled = "enabled" if _avahi_enabled(data) else "disabled"
units.append({"id": "avahi-daemon", "name": "avahi-daemon", units.append({"id": "avahi-daemon", "name": "avahi-daemon",
"expected_active": exp_av_active, "expected_active": exp_av_active,
"expected_enabled": exp_av_enabled}) "expected_enabled": exp_av_enabled,
"severity": "warning"})
units.append({"id": "chrony", "name": "chrony", units.append({"id": "chrony", "name": "chrony",
"expected_active": "active", "expected_enabled": "enabled"}) "expected_active": "active", "expected_enabled": "enabled",
"severity": "warning"})
units.append({"id": "systemd-networkd", "name": "systemd-networkd", units.append({"id": "systemd-networkd", "name": "systemd-networkd",
"expected_active": "active", "expected_enabled": "enabled"}) "expected_active": "active", "expected_enabled": "enabled",
"severity": "error"})
for u in units: for u in units:
active, enabled = _sysctl_query(u["id"]) active, enabled = _sysctl_query(u["id"])
@ -212,6 +222,7 @@ def check_services(data):
"expected_enabled": exp_enabled, "expected_enabled": exp_enabled,
"active_ok": active_ok, "active_ok": active_ok,
"enabled_ok": enabled_ok, "enabled_ok": enabled_ok,
"severity": u.get("severity", "error"),
"status": status, "status": status,
}) })
@ -235,7 +246,7 @@ def check_configurations(data):
if not exists: if not exists:
return _problem(id_, name, severity, return _problem(id_, name, severity,
f"{path} does not exist.", f"{path} does not exist.",
suggestion or f"Run sudo python3 core.py --apply to create it.") suggestion or f"Run `sudo python3 core.py --apply` to create it.")
return _ok(id_, name) return _ok(id_, name)
# --- nftables tables --- # --- nftables tables ---
@ -253,7 +264,7 @@ def check_configurations(data):
f"nftables table {tbl}", f"nftables table {tbl}",
"error", "error",
f"nftables table '{tbl}' is missing.", f"nftables table '{tbl}' is missing.",
"Run sudo python3 core.py --apply to rebuild firewall rules.")) "Run `sudo python3 core.py --apply` to rebuild firewall rules."))
except Exception: except Exception:
results.append(_problem("nft_tables", "nftables tables", "error", results.append(_problem("nft_tables", "nftables tables", "error",
"Could not query nftables (nft not available or failed).")) "Could not query nftables (nft not available or failed)."))
@ -275,7 +286,7 @@ def check_configurations(data):
results.append(_problem( results.append(_problem(
"nft_docker_bridges", "nftables Docker bridge rules", "warning", "nft_docker_bridges", "nftables Docker bridge rules", "warning",
f"Container bridge(s) {', '.join(missing)} have no nftables forward rules.", f"Container bridge(s) {', '.join(missing)} have no nftables forward rules.",
"Run sudo python3 core.py --apply to add the missing rules.")) "Run `sudo python3 core.py --apply` to add the missing rules."))
else: else:
results.append(_ok("nft_docker_bridges", "nftables Docker bridge rules")) results.append(_ok("nft_docker_bridges", "nftables Docker bridge rules"))
except Exception: except Exception:
@ -291,11 +302,11 @@ def check_configurations(data):
if state is None: if state is None:
results.append(_problem(id_, name, "error", results.append(_problem(id_, name, "error",
f"Interface {iface} does not exist in /sys/class/net/.", f"Interface {iface} does not exist in /sys/class/net/.",
"Run sudo python3 core.py --apply to configure network interfaces.")) "Run `sudo python3 core.py --apply` to configure network interfaces."))
elif state != "up": elif state != "up":
results.append(_problem(id_, name, "error", results.append(_problem(id_, name, "error",
f"Interface {iface} operstate is '{state}' (expected 'up').", f"Interface {iface} operstate is '{state}' (expected 'up').",
"Check systemd-networkd: sudo systemctl status systemd-networkd")) "Check systemd-networkd: `sudo systemctl status systemd-networkd`"))
else: else:
results.append(_ok(id_, name)) results.append(_ok(id_, name))
@ -308,7 +319,7 @@ def check_configurations(data):
if state is None: if state is None:
results.append(_problem(id_, name, "error", results.append(_problem(id_, name, "error",
f"WireGuard interface {iface} does not exist.", f"WireGuard interface {iface} does not exist.",
"Run sudo python3 core.py --apply to bring up WireGuard.")) "Run `sudo python3 core.py --apply` to bring up WireGuard."))
elif state in ("up", "unknown"): # WireGuard interfaces normally report 'unknown' elif state in ("up", "unknown"): # WireGuard interfaces normally report 'unknown'
results.append(_ok(id_, name)) results.append(_ok(id_, name))
else: else:
@ -399,7 +410,7 @@ def check_configurations(data):
"radius_secret_match", "FreeRADIUS shared secret", "error", "radius_secret_match", "FreeRADIUS shared secret", "error",
"clients.conf secret does not match .radius-secret. " "clients.conf secret does not match .radius-secret. "
"Access points will reject all authentication requests.", "Access points will reject all authentication requests.",
"Restore .radius-secret from backup, or run sudo python3 core.py --apply " "Restore .radius-secret from backup, or run `sudo python3 core.py --apply` "
"then update the shared secret in your AP controller.")) "then update the shared secret in your AP controller."))
except OSError: except OSError:
pass # already caught above by file_ok pass # already caught above by file_ok
@ -435,7 +446,7 @@ def check_configurations(data):
"avahi_ifaces", "avahi-daemon interface list", "warning", "avahi_ifaces", "avahi-daemon interface list", "warning",
f"avahi-daemon.conf interface list does not match config " f"avahi-daemon.conf interface list does not match config "
f"(missing: {missing or 'none'}, extra: {extra or 'none'}).", f"(missing: {missing or 'none'}, extra: {extra or 'none'}).",
"Run sudo python3 core.py --apply to update.")) "Run `sudo python3 core.py --apply` to update."))
else: else:
results.append(_ok("avahi_ifaces", results.append(_ok("avahi_ifaces",
"avahi-daemon interface list")) "avahi-daemon interface list"))
@ -458,11 +469,11 @@ def check_configurations(data):
"resolv_conf", "/etc/resolv.conf", "warning", "resolv_conf", "/etc/resolv.conf", "warning",
f"/etc/resolv.conf nameserver(s) {ns_ips} do not include any VLAN gateway. " f"/etc/resolv.conf nameserver(s) {ns_ips} do not include any VLAN gateway. "
f"Expected one of: {gateway_ips}.", f"Expected one of: {gateway_ips}.",
"Run sudo python3 core.py --apply to update /etc/resolv.conf.")) "Run `sudo python3 core.py --apply` to update /etc/resolv.conf."))
except OSError: except OSError:
results.append(_problem("resolv_conf", "/etc/resolv.conf", "warning", results.append(_problem("resolv_conf", "/etc/resolv.conf", "warning",
"/etc/resolv.conf is not readable.", "/etc/resolv.conf is not readable.",
"Run sudo python3 core.py --apply.")) "Run `sudo python3 core.py --apply`."))
# --- chrony.conf --- # --- chrony.conf ---
if CHRONY_CONF_FILE.exists(): if CHRONY_CONF_FILE.exists():
@ -482,7 +493,7 @@ def check_configurations(data):
results.append(_problem( results.append(_problem(
"chrony_conf", "/etc/chrony/chrony.conf", "warning", "chrony_conf", "/etc/chrony/chrony.conf", "warning",
f"chrony.conf is missing allow directives for: {', '.join(missing_subnets)}.", f"chrony.conf is missing allow directives for: {', '.join(missing_subnets)}.",
"Run sudo python3 core.py --apply to update chrony.conf.")) "Run `sudo python3 core.py --apply` to update chrony.conf."))
else: else:
results.append(_ok("chrony_conf", "/etc/chrony/chrony.conf")) results.append(_ok("chrony_conf", "/etc/chrony/chrony.conf"))
except OSError: except OSError:
@ -522,7 +533,7 @@ def check_configurations(data):
f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full " f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full "
f"({len(leases)}/{pool_size} leases).", f"({len(leases)}/{pool_size} leases).",
"Expand the pool range in core.json or clean up stale leases " "Expand the pool range in core.json or clean up stale leases "
"with: sudo python3 core.py --reset-leases " + vlan['name'])) f"with: `sudo python3 core.py --reset-leases {vlan['name']}`"))
else: else:
results.append(_ok(f"dhcp_pool_{vlan['name']}", results.append(_ok(f"dhcp_pool_{vlan['name']}",
f"DHCP pool ({vlan['name']})", f"DHCP pool ({vlan['name']})",
@ -546,13 +557,13 @@ def check_configurations(data):
results.append(_problem( results.append(_problem(
f"blocklist_{h}", f"blocklist ({label})", "warning", f"blocklist_{h}", f"blocklist ({label})", "warning",
f"Merged blocklist file for '{label}' does not exist.", f"Merged blocklist file for '{label}' does not exist.",
"Run sudo python3 core.py --update-blocklists to download blocklists.")) "Run `sudo python3 core.py --update-blocklists` to download blocklists."))
elif now - path.stat().st_mtime > BLOCKLIST_STALE_SECS: elif now - path.stat().st_mtime > BLOCKLIST_STALE_SECS:
age_h = int((now - path.stat().st_mtime) / 3600) age_h = int((now - path.stat().st_mtime) / 3600)
results.append(_problem( results.append(_problem(
f"blocklist_{h}", f"blocklist ({label})", "warning", f"blocklist_{h}", f"blocklist ({label})", "warning",
f"Merged blocklist for '{label}' is {age_h}h old (threshold 36h).", f"Merged blocklist for '{label}' is {age_h}h old (threshold 36h).",
"Run sudo python3 core.py --update-blocklists to refresh.")) "Run `sudo python3 core.py --update-blocklists` to refresh."))
else: else:
results.append(_ok(f"blocklist_{h}", f"blocklist ({label})")) results.append(_ok(f"blocklist_{h}", f"blocklist ({label})"))
@ -634,7 +645,7 @@ def check_logs(data):
"FreeRADIUS auth failures", "error", "FreeRADIUS auth failures", "error",
f"FreeRADIUS is rejecting requests from {ap_str} with " f"FreeRADIUS is rejecting requests from {ap_str} with "
f"'Shared secret is incorrect' ({len(failures)} failures in the last hour).", f"'Shared secret is incorrect' ({len(failures)} failures in the last hour).",
"Restore .radius-secret from backup and run sudo python3 core.py --apply, " "Restore .radius-secret from backup and run `sudo python3 core.py --apply`, "
"or update the shared secret in your AP controller to match .radius-secret.")) "or update the shared secret in your AP controller to match .radius-secret."))
else: else:
results.append(_ok("freeradius_auth_failures", results.append(_ok("freeradius_auth_failures",
@ -668,7 +679,7 @@ def check_logs(data):
"dnsmasq_errors", "dnsmasq errors", "error", "dnsmasq_errors", "dnsmasq errors", "error",
f"{len(err_lines)} dnsmasq error(s) in the last hour: " f"{len(err_lines)} dnsmasq error(s) in the last hour: "
f"{err_lines[0][:120]}{'...' if len(err_lines) > 1 else ''}", f"{err_lines[0][:120]}{'...' if len(err_lines) > 1 else ''}",
"Check dnsmasq logs: sudo journalctl -u 'dnsmasq-routlin-*' --since -1h")) "Check dnsmasq logs: `sudo journalctl -u 'dnsmasq-routlin-*' --since -1h`"))
else: else:
results.append(_ok("dnsmasq_errors", "dnsmasq errors")) results.append(_ok("dnsmasq_errors", "dnsmasq errors"))
except Exception: except Exception:
@ -742,15 +753,21 @@ def print_table(status):
svc_problems = [] svc_problems = []
for svc in status.get("services", []): for svc in status.get("services", []):
if svc.get("status") == "problem": if svc.get("status") == "problem":
parts = [] name = svc["name"]
utype = "timer" if name.endswith(".timer") else "service" if name.endswith(".service") else "unit"
exp_parts, act_parts, fix_parts = [], [], []
if not svc.get("active_ok"): if not svc.get("active_ok"):
parts.append(f"active: {svc.get('active')} (expected {svc.get('expected_active')})") exp_parts.append(svc.get("expected_active", "active"))
act_parts.append(svc.get("active", "unknown"))
fix_parts.append("activate")
if not svc.get("enabled_ok"): if not svc.get("enabled_ok"):
parts.append(f"enabled: {svc.get('enabled')} (expected {svc.get('expected_enabled')})") exp_parts.append(svc.get("expected_enabled", "enabled"))
svc_problems.append({ act_parts.append(svc.get("enabled", "unknown"))
"severity": "error", fix_parts.append("enable")
"detail": f"{svc['name']}: {', '.join(parts)}", detail = (f"The {utype} `{name}` is expected to be "
}) f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}.")
suggestion = f"Run `sudo python3 core.py --apply` to {' and '.join(reversed(fix_parts))} it."
svc_problems.append({"severity": svc.get("severity", "error"), "detail": detail, "suggestion": suggestion})
problems = svc_problems + [ problems = svc_problems + [
item item
for section in ("configurations", "logs") for section in ("configurations", "logs")