UI improvements and input validations

This commit is contained in:
Matthew Grotke 2026-05-20 04:06:50 -04:00
parent b8c4914a52
commit 270856b391
22 changed files with 1548 additions and 302 deletions

View file

@ -111,9 +111,18 @@ DNSMASQ_CONF_DIR = Path("/etc/dnsmasq-router")
LEASES_DIR = Path("/var/lib/misc")
NETWORKD_DIR = Path("/etc/systemd/network")
SYSTEMD_DIR = Path("/etc/systemd/system")
TIMER_NAME = "dns-blocklists-update"
TIMER_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.timer"
TIMER_SVC_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.service"
BLIST_TIMER_NAME = "dns-blocklists-update"
BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer"
BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"
DASHB_TIMER_NAME = "router-dashboard-queue"
DASHB_TIMER_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.timer"
DASHB_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.service"
DASHB_TIMER_INTERVAL_SEC = 60
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
DASHB_DONE_FILE = SCRIPT_DIR / ".dashboard-done"
DASHB_LAST_RUN_FILE = SCRIPT_DIR / ".dashboard-last-run"
DASHB_LOCK_FILE = SCRIPT_DIR / ".dashboard-lock"
DASHB_SCRIPT_FILE = SCRIPT_DIR / "do_dashboard_queue.sh"
RESOLV_CONF = Path("/etc/resolv.conf")
NAT_SERVICE_NAME = "core-nat"
NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service"
@ -1729,20 +1738,77 @@ def install_timer(data):
"",
])
for path, content in ((TIMER_FILE, timer_content), (TIMER_SVC_FILE, service_content)):
for path, content in ((BLIST_TIMER_FILE, timer_content), (BLIST_TIMER_SVC_FILE, service_content)):
if not path.exists() or path.read_text() != content:
path.write_text(content)
print(f"Written: {path}")
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
subprocess.run(["systemctl", "enable", "--now", f"{TIMER_NAME}.timer"],
subprocess.run(["systemctl", "enable", "--now", f"{BLIST_TIMER_NAME}.timer"],
capture_output=True, text=True)
print(f"Timer {TIMER_NAME}.timer enabled (runs daily at {execute_time}).")
print(f"Timer {BLIST_TIMER_NAME}.timer enabled (runs daily at {execute_time}).")
def install_dashboard_timer():
"""Install the 1-minute dashboard-queue timer that processes .dashboard-queue."""
timer_content = "\n".join([
"# Generated by core.py -- do not edit manually.",
"",
"[Unit]",
"Description=Router dashboard pending-update processor",
"",
"[Timer]",
f"OnActiveSec={DASHB_TIMER_INTERVAL_SEC}s",
f"OnUnitActiveSec={DASHB_TIMER_INTERVAL_SEC}s",
"AccuracySec=10s",
"",
"[Install]",
"WantedBy=timers.target",
"",
])
service_content = "\n".join([
"# Generated by core.py -- do not edit manually.",
"",
"[Unit]",
"Description=Router dashboard update processor",
"",
"[Service]",
"Type=oneshot",
f"ExecStart=/bin/bash {DASHB_SCRIPT_FILE}",
"",
])
for path, content in ((DASHB_TIMER_FILE, timer_content), (DASHB_TIMER_SVC_FILE, service_content)):
if not path.exists() or path.read_text() != content:
path.write_text(content)
print(f"Written: {path}")
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
subprocess.run(["systemctl", "enable", f"{DASHB_TIMER_NAME}.timer"],
capture_output=True, text=True)
active = subprocess.run(
["systemctl", "is-active", f"{DASHB_TIMER_NAME}.timer"],
capture_output=True, text=True
).stdout.strip() == "active"
verb = "restart" if active else "start"
subprocess.run(["systemctl", verb, f"{DASHB_TIMER_NAME}.timer"],
capture_output=True, text=True)
print(f"Timer {DASHB_TIMER_NAME}.timer enabled (runs every {DASHB_TIMER_INTERVAL_SEC}s).")
def remove_dashboard_timer():
subprocess.run(["systemctl", "disable", "--now", f"{DASHB_TIMER_NAME}.timer"],
capture_output=True, text=True)
for f in (DASHB_TIMER_FILE, DASHB_TIMER_SVC_FILE):
if f.exists():
f.unlink()
print(f"Removed: {f}")
else:
print(f"Not found, skipping: {f}")
def remove_timer():
subprocess.run(["systemctl", "disable", "--now", f"{TIMER_NAME}.timer"],
subprocess.run(["systemctl", "disable", "--now", f"{BLIST_TIMER_NAME}.timer"],
capture_output=True, text=True)
for f in (TIMER_FILE, TIMER_SVC_FILE):
for f in (BLIST_TIMER_FILE, BLIST_TIMER_SVC_FILE):
if f.exists():
f.unlink()
print(f"Removed: {f}")
@ -2252,12 +2318,12 @@ def apply_nftables(data, dry_run=False):
dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "")
try:
# Single IP -- check if it's in an active subnet
addr = _ipaddress.IPv4Address(dst)
addr = ipaddress.IPv4Address(dst)
return any(addr in net for net in active_subnets)
except ValueError:
try:
# Subnet -- check if it overlaps with any active subnet
net = _ipaddress.IPv4Network(dst, strict=False)
net = ipaddress.IPv4Network(dst, strict=False)
return any(net.overlaps(s) for s in active_subnets)
except ValueError:
return True
@ -2636,7 +2702,7 @@ def show_status(data):
units.append((vlan_service_name(vlan), "(wg0 not up)", "active"))
else:
units.append((vlan_service_name(vlan), None, "active"))
units.append((f"{TIMER_NAME}.timer", None, "active"))
units.append((f"{BLIST_TIMER_NAME}.timer", None, "active"))
units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot - exits after running
units.append(("freeradius", None, "active"))
units.append(("avahi-daemon", None, "active"))
@ -2652,12 +2718,12 @@ def show_status(data):
# Timer next trigger
r = subprocess.run(
["systemctl", "show", f"{TIMER_NAME}.timer", "--property=NextElapseUSecRealtime,NextElapseUSecMonotonic"],
["systemctl", "show", f"{BLIST_TIMER_NAME}.timer", "--property=NextElapseUSecRealtime,NextElapseUSecMonotonic"],
capture_output=True, text=True
)
# Fall back to human-readable 'Trigger' field from status output
r2 = subprocess.run(
["systemctl", "status", f"{TIMER_NAME}.timer", "--no-pager"],
["systemctl", "status", f"{BLIST_TIMER_NAME}.timer", "--no-pager"],
capture_output=True, text=True
)
for line in r2.stdout.splitlines():
@ -2983,6 +3049,7 @@ def show_metrics(data):
def stop_instances(data):
"""Remove timer and stop all per-VLAN instances (config files preserved)."""
remove_timer()
remove_dashboard_timer()
print()
for vlan in data["vlans"]:
svc = vlan_service_name(vlan)
@ -3193,7 +3260,7 @@ def _dry_run_timer(data):
print("-- Timer (dry-run) ---------------------------------------------------")
general = data.get("general", {})
execute_time = general.get("daily_execute_time_24hr_local", "02:30")
for path, label in [(TIMER_FILE, "timer unit"), (TIMER_SVC_FILE, "service unit")]:
for path, label in [(BLIST_TIMER_FILE, "timer unit"), (BLIST_TIMER_SVC_FILE, "service unit")]:
action = "update" if path.exists() else "create and enable"
print(f" Would {action}: {path}")
print(f" Schedule: daily at {execute_time} local time (Persistent=true - catches up if missed)")
@ -3213,7 +3280,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
print()
print("-- Stopping router services (dry-run) --------------------------------")
print(f" Would disable and stop: {TIMER_NAME}.timer")
print(f" Would disable and stop: {BLIST_TIMER_NAME}.timer")
for vlan in data["vlans"]:
svc = vlan_service_name(vlan)
conf = vlan_conf_file(vlan)
@ -3484,6 +3551,13 @@ def cmd_install(data):
check_root()
check_dependencies()
print("All required packages are installed.")
install_dashboard_timer()
# Create blank dotfiles for dashboard updates
for dotfile in (DASHB_QUEUE_FILE, DASHB_DONE_FILE, DASHB_LAST_RUN_FILE, DASHB_LOCK_FILE):
if not dotfile.exists():
dotfile.touch()
chown_to_script_dir_owner(dotfile)
print(f"Created: {dotfile}")
def cmd_apply(data, dry_run=False):
@ -3580,6 +3654,10 @@ def cmd_apply(data, dry_run=False):
install_timer(data)
print()
print("-- Dashboard timer ---------------------------------------------------")
install_dashboard_timer()
print()
print("-- Boot service ------------------------------------------------------")
install_nat_service()
print()