Development

This commit is contained in:
Matthew Grotke 2026-06-05 01:48:27 -04:00
parent 205d6889df
commit 58ab569e42
27 changed files with 2894 additions and 2605 deletions

View file

@ -11,7 +11,6 @@ Public API:
import hashlib
import ipaddress
import json
import os
import re
import shutil
import socket
@ -20,66 +19,57 @@ import sys
from datetime import datetime, timezone
from pathlib import Path
from validation import derive_interface, is_wg
import mod_shared as shared
import mod_validation as validation
# ===================================================================
# Constants (mirror core.py - no import to avoid circular dependency)
# Constants
# ===================================================================
PRODUCT_NAME = "routlin"
SCRIPT_DIR = Path(__file__).parent
HEALTH_FILE = SCRIPT_DIR / ".health"
CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}")
LEASES_DIR = Path("/var/lib/misc")
HEALTH_FILE = shared.SCRIPT_DIR / ".health"
CONFIG_FILE = shared.SCRIPT_DIR / "config.json"
BLOCKLIST_DIR = shared.SCRIPT_DIR / "blocklists"
NETWORKD_DIR = Path("/etc/systemd/network")
SYSTEMD_DIR = Path("/etc/systemd/system")
WG_DIR = Path("/etc/wireguard")
RESOLV_CONF = Path("/etc/resolv.conf")
AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf")
CHRONY_CONF_FILE = Path("/etc/chrony/chrony.conf")
RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret"
RADIUS_SECRET_FILE = shared.SCRIPT_DIR / ".radius-secret"
RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf")
RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users")
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue"
HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check"
MAINT_TIMER_NAME = f"{PRODUCT_NAME}-maintenance"
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat"
BLIST_TIMER_NAME = f"{shared.PRODUCT_NAME}-dns-blocklist-update"
DASHB_TIMER_NAME = f"{shared.PRODUCT_NAME}-dashboard-queue"
HEALTH_TIMER_NAME = f"{shared.PRODUCT_NAME}-health-check"
MAINT_TIMER_NAME = f"{shared.PRODUCT_NAME}-maintenance"
DASHB_QUEUE_FILE = shared.SCRIPT_DIR / ".dashboard-queue"
NAT_SERVICE_NAME = f"{shared.PRODUCT_NAME}-nat"
BLOCKLIST_STALE_SECS = 36 * 3600
DISK_WARN_PCT = 90
DHCP_WARN_PCT = 90
DNS_TIMEOUT_SECS = 2
# ===================================================================
# Small helpers replicated from core.py (no import)
# Small helpers
# ===================================================================
def _vlan_service_name(vlan, iface):
if is_wg(vlan):
return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}-{iface}"
return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}"
def _radius_enabled(data):
def radius_enabled(data):
return any(
r.get("radius_client") is True
for r in data.get("dhcp_reservations", [])
)
def _avahi_enabled(data):
def avahi_enabled(data):
return any(
v.get("mdns_reflection") is True
for v in data.get("vlans", [])
if not is_wg(v)
if not validation.is_wg(v)
)
def _avahi_interfaces(data):
return [
derive_interface(v, data)
validation.derive_interface(v, data)
for v in data.get("vlans", [])
if v.get("mdns_reflection") is True and not is_wg(v)
if v.get("mdns_reflection") is True and not validation.is_wg(v)
]
def _combo_hash(names):
@ -89,25 +79,16 @@ def _combo_hash(names):
def _merged_path(h):
return BLOCKLIST_DIR / f"merged-{h}.conf"
def _lowest_quartet_ip(vlan):
ips = []
for s in vlan.get("server_identities", []):
try:
ips.append(ipaddress.IPv4Address(s["ip"]))
except (KeyError, ValueError):
pass
return str(min(ips, key=lambda ip: ip.packed[-1])) if ips else None
def _gateway_ips(data):
"""Return set of all gateway IPs across all VLANs."""
gws = set()
for vlan in data.get("vlans", []):
ip = _lowest_quartet_ip(vlan)
ip = shared.lowest_quartet_ip(vlan)
if ip:
gws.add(ip)
return gws
def _iface_operstate(iface):
def iface_operstate(iface):
"""Read operstate from sysfs. Returns 'up', 'down', 'unknown', or None."""
try:
return Path(f"/sys/class/net/{iface}/operstate").read_text().strip()
@ -125,13 +106,13 @@ def _sysctl_query(unit):
# Result builders
# ===================================================================
def _ok(id_, name, detail=""):
def ok(id_, name, detail=""):
r = {"id": id_, "name": name, "status": "ok"}
if detail:
r["detail"] = detail
return r
def _problem(id_, name, severity, detail, suggestion=""):
def problem(id_, name, severity, detail, suggestion=""):
r = {"id": id_, "name": name, "status": "problem",
"severity": severity, "detail": detail}
if suggestion:
@ -148,8 +129,8 @@ def check_services(data):
units = []
for vlan in vlans:
iface = derive_interface(vlan, data)
name = _vlan_service_name(vlan, iface)
iface = validation.derive_interface(vlan, data)
name = shared.vlan_service_name(vlan, iface)
units.append({"id": name, "name": name,
"expected_active": "active", "expected_enabled": "enabled",
"severity": "error"})
@ -184,15 +165,15 @@ def check_services(data):
"expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled,
"severity": "warning"})
exp_fr_active = "active" if _radius_enabled(data) else "inactive"
exp_fr_enabled = "enabled" if _radius_enabled(data) else "disabled"
exp_fr_active = "active" if radius_enabled(data) else "inactive"
exp_fr_enabled = "enabled" if radius_enabled(data) else "disabled"
units.append({"id": "freeradius", "name": "freeradius",
"expected_active": exp_fr_active,
"expected_enabled": exp_fr_enabled,
"severity": "error"})
exp_av_active = "active" if _avahi_enabled(data) else "inactive"
exp_av_enabled = "enabled" if _avahi_enabled(data) else "disabled"
exp_av_active = "active" if avahi_enabled(data) else "inactive"
exp_av_enabled = "enabled" if avahi_enabled(data) else "disabled"
units.append({"id": "avahi-daemon", "name": "avahi-daemon",
"expected_active": exp_av_active,
"expected_enabled": exp_av_enabled,
@ -211,7 +192,7 @@ def check_services(data):
exp_enabled = u["expected_enabled"]
active_ok = active == exp_active
enabled_ok = enabled == exp_enabled
status = "ok" if (active_ok and enabled_ok) else "problem"
svc_status = "ok" if (active_ok and enabled_ok) else "problem"
results.append({
"id": u["id"],
"name": u["name"],
@ -222,7 +203,7 @@ def check_services(data):
"active_ok": active_ok,
"enabled_ok": enabled_ok,
"severity": u.get("severity", "error"),
"status": status,
"status": svc_status,
})
return results
@ -234,19 +215,19 @@ def check_services(data):
def check_configurations(data):
results = []
vlans = data.get("vlans", [])
non_wg = [v for v in vlans if not is_wg(v)]
wg_vlans = [v for v in vlans if is_wg(v)]
non_wg = [v for v in vlans if not validation.is_wg(v)]
wg_vlans = [v for v in vlans if validation.is_wg(v)]
def file_ok(id_, name, path, severity="error", suggestion=""):
try:
exists = path.exists()
except PermissionError:
return _problem(id_, name, "warning",
f"{path}: permission denied run with sudo for accurate status.")
return problem(id_, name, "warning",
f"{path}: permission denied - run with sudo for accurate status.")
if not exists:
return _problem(id_, name, severity,
f"{path} does not exist.",
suggestion or f"Run `sudo python3 core.py --apply` to create it.")
return _ok(id_, name)
return problem(id_, name, severity,
f"{path} does not exist.",
suggestion or f"Run `sudo python3 core.py --apply` to create it.")
return ok(id_, name)
# --- nftables tables ---
try:
@ -255,25 +236,25 @@ def check_configurations(data):
).stdout
for tbl in ("ip routlin-nat", "ip routlin-filter"):
if tbl in tables_out:
results.append(_ok(f"nft_{tbl.replace(' ', '_')}",
f"nftables table {tbl}"))
results.append(ok(f"nft_{tbl.replace(' ', '_')}",
f"nftables table {tbl}"))
else:
results.append(_problem(
results.append(problem(
f"nft_{tbl.replace(' ', '_')}",
f"nftables table {tbl}",
"error",
f"nftables table '{tbl}' is missing.",
"Run `sudo python3 core.py --apply` to rebuild firewall rules."))
except Exception:
results.append(_problem("nft_tables", "nftables tables", "error",
"Could not query nftables (nft not available or failed)."))
results.append(problem("nft_tables", "nftables tables", "error",
"Could not query nftables (nft not available or failed)."))
# --- Docker bridge rules ---
try:
bridges = [
p.parent.name
for p in Path("/sys/class/net").glob("*/bridge")
if _iface_operstate(p.parent.name) == "up"
if iface_operstate(p.parent.name) == "up"
]
if bridges:
fwd_out = subprocess.run(
@ -282,49 +263,48 @@ def check_configurations(data):
).stdout
missing = [b for b in bridges if b not in fwd_out]
if missing:
results.append(_problem(
results.append(problem(
"nft_docker_bridges", "nftables Docker bridge rules", "warning",
f"Container bridge(s) {', '.join(missing)} have no nftables forward rules.",
"Run `sudo python3 core.py --apply` to add the missing rules."))
else:
results.append(_ok("nft_docker_bridges", "nftables Docker bridge rules"))
results.append(ok("nft_docker_bridges", "nftables Docker bridge rules"))
except Exception:
pass
# --- VLAN sub-interfaces ---
for vlan in non_wg:
iface = derive_interface(vlan, data)
vid = vlan.get("vlan_id")
state = _iface_operstate(iface)
iface = validation.derive_interface(vlan, data)
state = iface_operstate(iface)
id_ = f"iface_{vlan['name']}"
name = f"interface {iface}"
if state is None:
results.append(_problem(id_, name, "error",
f"Interface {iface} does not exist in /sys/class/net/.",
"Run `sudo python3 core.py --apply` to configure network interfaces."))
results.append(problem(id_, name, "error",
f"Interface {iface} does not exist in /sys/class/net/.",
"Run `sudo python3 core.py --apply` to configure network interfaces."))
elif state != "up":
results.append(_problem(id_, name, "error",
f"Interface {iface} operstate is '{state}' (expected 'up').",
"Check systemd-networkd: `sudo systemctl status systemd-networkd`"))
results.append(problem(id_, name, "error",
f"Interface {iface} operstate is '{state}' (expected 'up').",
"Check systemd-networkd: `sudo systemctl status systemd-networkd`"))
else:
results.append(_ok(id_, name))
results.append(ok(id_, name))
# --- WireGuard interfaces ---
for vlan in wg_vlans:
iface = derive_interface(vlan, data)
state = _iface_operstate(iface)
iface = validation.derive_interface(vlan, data)
state = iface_operstate(iface)
id_ = f"iface_wg_{vlan['name']}"
name = f"WireGuard interface {iface}"
if state is None:
results.append(_problem(id_, name, "error",
f"WireGuard interface {iface} does not exist.",
"Run `sudo python3 core.py --apply` to bring up WireGuard."))
results.append(problem(id_, name, "error",
f"WireGuard interface {iface} does not exist.",
"Run `sudo python3 core.py --apply` to bring up WireGuard."))
elif state in ("up", "unknown"): # WireGuard interfaces normally report 'unknown'
results.append(_ok(id_, name))
results.append(ok(id_, name))
else:
results.append(_problem(id_, name, "error",
f"WireGuard interface {iface} operstate is '{state}'.",
f"Try: sudo wg-quick up {iface}"))
results.append(problem(id_, name, "error",
f"WireGuard interface {iface} operstate is '{state}'.",
f"Try: sudo wg-quick up {iface}"))
# --- Stale WG interfaces when no WG VLANs configured ---
if not wg_vlans:
@ -333,41 +313,41 @@ def check_configurations(data):
if p.name.startswith("wg") and re.match(r"^wg\d+$", p.name)
]
if stale_wg:
results.append(_problem(
results.append(problem(
"stale_wg_ifaces", "Stale WireGuard interfaces", "warning",
f"WireGuard interface(s) {', '.join(stale_wg)} exist but no VPN VLANs are configured.",
f"Bring them down manually: sudo wg-quick down {stale_wg[0]}"))
# --- dnsmasq config files ---
for vlan in vlans:
path = DNSMASQ_CONF_DIR / f"{vlan['name']}.conf"
path = shared.DNSMASQ_CONF_DIR / f"{vlan['name']}.conf"
results.append(file_ok(f"dnsmasq_conf_{vlan['name']}",
f"dnsmasq config {path.name}", path))
# --- systemd-networkd files ---
for vlan in non_wg:
iface = derive_interface(vlan, data)
iface = validation.derive_interface(vlan, data)
vid = vlan.get("vlan_id")
net = NETWORKD_DIR / f"10-{PRODUCT_NAME}-{vlan['name']}.network"
net = NETWORKD_DIR / f"10-{shared.PRODUCT_NAME}-{vlan['name']}.network"
results.append(file_ok(f"networkd_net_{vlan['name']}",
f"networkd {net.name}", net))
if vid != 1: # non-physical VLANs have a .netdev too
netdev = NETWORKD_DIR / f"10-{PRODUCT_NAME}-{vlan['name']}.netdev"
netdev = NETWORKD_DIR / f"10-{shared.PRODUCT_NAME}-{vlan['name']}.netdev"
results.append(file_ok(f"networkd_netdev_{vlan['name']}",
f"networkd {netdev.name}", netdev))
# --- systemd unit files ---
for path in (SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service",
SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer",
SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"):
for path in (shared.SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service",
shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer",
shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"):
results.append(file_ok(f"unit_{path.stem}", f"systemd unit {path.name}", path))
# --- WireGuard config and key files ---
for vlan in wg_vlans:
iface = derive_interface(vlan, data)
iface = validation.derive_interface(vlan, data)
conf = WG_DIR / f"{iface}.conf"
key = WG_DIR / f"{iface}.key"
pub = SCRIPT_DIR / f".{iface}.pub"
pub = shared.SCRIPT_DIR / f".{iface}.pub"
results.append(file_ok(f"wg_conf_{iface}", f"WireGuard {conf.name}", conf))
results.append(file_ok(f"wg_key_{iface}", f"WireGuard {key.name}", key))
results.append(file_ok(f"wg_pubkey_{iface}", f"WireGuard {pub.name}", pub))
@ -379,13 +359,13 @@ def check_configurations(data):
if p.read_text().startswith("# Generated by")
]
if stale:
results.append(_problem(
results.append(problem(
"stale_wg_conf", "Stale WireGuard config files", "warning",
f"{', '.join(p.name for p in stale)} exist but no VPN VLANs are configured.",
"Remove with: sudo rm " + " ".join(str(p) for p in stale)))
# --- RADIUS files and secret check ---
if _radius_enabled(data):
if radius_enabled(data):
results.append(file_ok("radius_secret_file", ".radius-secret file",
RADIUS_SECRET_FILE, "error"))
results.append(file_ok("radius_clients_conf", "FreeRADIUS clients.conf",
@ -403,9 +383,9 @@ def check_configurations(data):
if "secret" in line and not line.strip().startswith("#")
)
if secret_ok:
results.append(_ok("radius_secret_match", "FreeRADIUS shared secret"))
results.append(ok("radius_secret_match", "FreeRADIUS shared secret"))
else:
results.append(_problem(
results.append(problem(
"radius_secret_match", "FreeRADIUS shared secret", "error",
"clients.conf secret does not match .radius-secret. "
"Access points will reject all authentication requests.",
@ -418,7 +398,7 @@ def check_configurations(data):
if RADIUS_CLIENTS_CONF.exists():
try:
if "# Generated by" in RADIUS_CLIENTS_CONF.read_text():
results.append(_problem(
results.append(problem(
"radius_conf_orphan", "FreeRADIUS config", "warning",
"FreeRADIUS clients.conf contains routlin-generated content "
"but RADIUS is not enabled.",
@ -428,7 +408,7 @@ def check_configurations(data):
pass
# --- Avahi config ---
if _avahi_enabled(data):
if avahi_enabled(data):
results.append(file_ok("avahi_conf", "avahi-daemon.conf",
AVAHI_CONF_FILE, "warning"))
if AVAHI_CONF_FILE.exists():
@ -441,14 +421,14 @@ def check_configurations(data):
missing = expected_ifaces - actual_ifaces
extra = actual_ifaces - expected_ifaces
if missing or extra:
results.append(_problem(
results.append(problem(
"avahi_ifaces", "avahi-daemon interface list", "warning",
f"avahi-daemon.conf interface list does not match config "
f"(missing: {missing or 'none'}, extra: {extra or 'none'}).",
"Run `sudo python3 core.py --apply` to update."))
else:
results.append(_ok("avahi_ifaces",
"avahi-daemon interface list"))
results.append(ok("avahi_ifaces",
"avahi-daemon interface list"))
except OSError:
pass
@ -462,17 +442,17 @@ def check_configurations(data):
if line.startswith("nameserver") and len(line.split()) >= 2
}
if ns_ips & gateway_ips:
results.append(_ok("resolv_conf", "/etc/resolv.conf"))
results.append(ok("resolv_conf", "/etc/resolv.conf"))
else:
results.append(_problem(
results.append(problem(
"resolv_conf", "/etc/resolv.conf", "warning",
f"/etc/resolv.conf nameserver(s) {ns_ips} do not include any VLAN gateway. "
f"Expected one of: {gateway_ips}.",
"Run `sudo python3 core.py --apply` to update /etc/resolv.conf."))
except OSError:
results.append(_problem("resolv_conf", "/etc/resolv.conf", "warning",
"/etc/resolv.conf is not readable.",
"Run `sudo python3 core.py --apply`."))
results.append(problem("resolv_conf", "/etc/resolv.conf", "warning",
"/etc/resolv.conf is not readable.",
"Run `sudo python3 core.py --apply`."))
# --- chrony.conf ---
if CHRONY_CONF_FILE.exists():
@ -489,21 +469,19 @@ def check_configurations(data):
except Exception:
pass
if missing_subnets:
results.append(_problem(
results.append(problem(
"chrony_conf", "/etc/chrony/chrony.conf", "warning",
f"chrony.conf is missing allow directives for: {', '.join(missing_subnets)}.",
"Run `sudo python3 core.py --apply` to update chrony.conf."))
else:
results.append(_ok("chrony_conf", "/etc/chrony/chrony.conf"))
results.append(ok("chrony_conf", "/etc/chrony/chrony.conf"))
except OSError:
results.append(_problem("chrony_conf", "/etc/chrony/chrony.conf", "warning",
"/etc/chrony/chrony.conf is not readable."))
results.append(problem("chrony_conf", "/etc/chrony/chrony.conf", "warning",
"/etc/chrony/chrony.conf is not readable."))
else:
results.append(_problem("chrony_conf", "/etc/chrony/chrony.conf", "warning",
"/etc/chrony/chrony.conf does not exist.",
"Install chrony: sudo apt-get install chrony"))
# --- Stale WG conf when no WG VLANs (already handled above) ---
results.append(problem("chrony_conf", "/etc/chrony/chrony.conf", "warning",
"/etc/chrony/chrony.conf does not exist.",
"Install chrony: sudo apt-get install chrony"))
# --- DHCP pool utilization ---
for vlan in non_wg:
@ -517,7 +495,7 @@ def check_configurations(data):
- int(ipaddress.IPv4Address(start)) + 1)
if pool_size <= 0:
continue
lease_file = LEASES_DIR / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.leases"
lease_file = shared.LEASES_DIR / f"dnsmasq-{shared.PRODUCT_NAME}-{vlan['name']}.leases"
if not lease_file.exists():
continue
leases = [
@ -526,7 +504,7 @@ def check_configurations(data):
]
pct = len(leases) * 100 // pool_size
if pct >= DHCP_WARN_PCT:
results.append(_problem(
results.append(problem(
f"dhcp_pool_{vlan['name']}",
f"DHCP pool ({vlan['name']})", "warning",
f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full "
@ -534,9 +512,9 @@ def check_configurations(data):
"Expand the pool range in config.json or clean up stale leases "
f"with: `sudo python3 core.py --reset-leases {vlan['name']}`"))
else:
results.append(_ok(f"dhcp_pool_{vlan['name']}",
f"DHCP pool ({vlan['name']})",
f"{pct}% used ({len(leases)}/{pool_size})"))
results.append(ok(f"dhcp_pool_{vlan['name']}",
f"DHCP pool ({vlan['name']})",
f"{pct}% used ({len(leases)}/{pool_size})"))
except Exception:
pass
@ -550,35 +528,35 @@ def check_configurations(data):
combos[_combo_hash(names)] = names
now = datetime.now(timezone.utc).timestamp()
for h, names in combos.items():
path = _merged_path(h)
path = _merged_path(h)
label = ", ".join(names)
if not path.exists():
results.append(_problem(
results.append(problem(
f"blocklist_{h}", f"blocklist ({label})", "warning",
f"Merged blocklist file for '{label}' does not exist.",
"Run `sudo python3 dns-blocklists.py` to download blocklists."))
elif now - path.stat().st_mtime > BLOCKLIST_STALE_SECS:
age_h = int((now - path.stat().st_mtime) / 3600)
results.append(_problem(
results.append(problem(
f"blocklist_{h}", f"blocklist ({label})", "warning",
f"Merged blocklist for '{label}' is {age_h}h old (threshold 36h).",
"Run `sudo python3 dns-blocklists.py` to refresh."))
else:
results.append(_ok(f"blocklist_{h}", f"blocklist ({label})"))
results.append(ok(f"blocklist_{h}", f"blocklist ({label})"))
# --- Disk space ---
try:
usage = shutil.disk_usage("/")
pct = usage.used * 100 // usage.total
if pct >= DISK_WARN_PCT:
results.append(_problem(
results.append(problem(
"disk_space", "Disk space", "warning",
f"Root filesystem is {pct}% full "
f"({usage.used // 1_073_741_824}G of {usage.total // 1_073_741_824}G used).",
"Free up disk space to avoid service disruption."))
else:
results.append(_ok("disk_space", "Disk space",
f"{pct}% used"))
results.append(ok("disk_space", "Disk space",
f"{pct}% used"))
except Exception:
pass
@ -592,12 +570,12 @@ def check_configurations(data):
except OSError:
unreachable.append(srv)
if unreachable:
results.append(_problem(
results.append(problem(
"upstream_dns", "Upstream DNS reachability", "warning",
f"Upstream DNS server(s) unreachable on port 53: {', '.join(unreachable)}.",
"Check WAN connectivity and upstream DNS server addresses in config.json."))
elif servers:
results.append(_ok("upstream_dns", "Upstream DNS reachability"))
results.append(ok("upstream_dns", "Upstream DNS reachability"))
return results
@ -639,7 +617,7 @@ def check_logs(data):
aps = sorted({m.group(1) for l in failures
for m in ap_re.finditer(l)})
ap_str = ", ".join(aps) if aps else f"{len(failures)} request(s)"
results.append(_problem(
results.append(problem(
"freeradius_auth_failures",
"FreeRADIUS auth failures", "error",
f"FreeRADIUS is rejecting requests from {ap_str} with "
@ -647,20 +625,20 @@ def check_logs(data):
"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."))
else:
results.append(_ok("freeradius_auth_failures",
"FreeRADIUS auth failures"))
results.append(ok("freeradius_auth_failures",
"FreeRADIUS auth failures"))
# High rejection rate (>50% of recent activity is failures)
if recent and len(failures) > len(recent) * 0.5 and not failures:
results.append(_problem(
results.append(problem(
"freeradius_high_reject_rate",
"FreeRADIUS rejection rate", "warning",
f"Over half of recent FreeRADIUS activity ({len(failures)}/{len(recent)}) "
f"are auth failures.",
"Investigate FreeRADIUS config and shared secrets."))
elif recent:
results.append(_ok("freeradius_high_reject_rate",
"FreeRADIUS rejection rate"))
results.append(ok("freeradius_high_reject_rate",
"FreeRADIUS rejection rate"))
except OSError:
pass
@ -668,19 +646,19 @@ def check_logs(data):
# --- dnsmasq errors ---
try:
r = subprocess.run(
["journalctl", f"-u", f"dnsmasq-{PRODUCT_NAME}-*",
["journalctl", f"-u", f"dnsmasq-{shared.PRODUCT_NAME}-*",
"--since", "-1h", "--priority=err", "--no-pager", "-q"],
capture_output=True, text=True, timeout=5
)
err_lines = [l for l in r.stdout.splitlines() if l.strip()]
if err_lines:
results.append(_problem(
results.append(problem(
"dnsmasq_errors", "dnsmasq errors", "error",
f"{len(err_lines)} dnsmasq error(s) in the last hour: "
f"{err_lines[0][:120]}{'...' if len(err_lines) > 1 else ''}",
"Check dnsmasq logs: `sudo journalctl -u 'dnsmasq-routlin-*' --since -1h`"))
else:
results.append(_ok("dnsmasq_errors", "dnsmasq errors"))
results.append(ok("dnsmasq_errors", "dnsmasq errors"))
except Exception:
pass
@ -691,6 +669,7 @@ def check_logs(data):
# ===================================================================
def _next_blocklist_update():
"""Return the next scheduled trigger time for the blocklist timer as a string, or None if unavailable."""
try:
r = subprocess.run(
["systemctl", "status", f"{BLIST_TIMER_NAME}.timer", "--no-pager"],
@ -781,8 +760,8 @@ def print_table(status):
if problems:
print(f"\n Problems {'=' * (col - 12)}")
for p in problems:
sev = p.get("severity", "error")
tag = f"[{sev}]"
sev = p.get("severity", "error")
tag = f"[{sev}]"
detail = p.get("detail", p.get("name", ""))
print(f" {tag:<10} {detail}")
tip = p.get("suggestion", "")