diff --git a/routlin/USAGE.md b/routlin/USAGE.md index 47fc8cb..695827c 100644 --- a/routlin/USAGE.md +++ b/routlin/USAGE.md @@ -24,6 +24,7 @@ All configuration lives in two JSON files. Edit these to match your network befo | `.dashboard-last-run` | Epoch timestamp of the last timer execution. | | `.dashboard-lock` | PID lock file preventing concurrent timer runs. | | `.dashboard-pending` | Changes held back when Apply on Save is disabled; flushed to `.dashboard-queue` when Apply Now is clicked. | +| `.status` | JSON health check results written by `core.py --apply`, `core.py --status`, and the dashboard timer. Read by the dashboard to display problem alerts. | | `.dns-metrics` | Cumulative lifetime DNS metrics across all VLAN instances. Created and updated each time `--view-metrics` is run. | | `.ddns-last-ip-*` | Cached public IP per DDNS provider. Managed by `ddns.py`. | | `.ddns-last-service` | Tracks IP-check service rotation. Managed by `ddns.py`. | diff --git a/routlin/core.py b/routlin/core.py index ec2e3e5..1078601 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -120,11 +120,16 @@ SYSTEMD_DIR = Path("/etc/systemd/system") BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-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 = f"{PRODUCT_NAME}-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_TIMER_NAME = f"{PRODUCT_NAME}-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" +STATUS_TIMER_NAME = f"{PRODUCT_NAME}-status-check" +STATUS_TIMER_FILE = SYSTEMD_DIR / f"{STATUS_TIMER_NAME}.timer" +STATUS_TIMER_SVC_FILE = SYSTEMD_DIR / f"{STATUS_TIMER_NAME}.service" +STATUS_TIMER_INTERVAL_SEC = 300 +STATUS_FILE = SCRIPT_DIR / ".status" DASHB_DONE_FILE = SCRIPT_DIR / ".dashboard-done" DASHB_LAST_RUN_FILE = SCRIPT_DIR / ".dashboard-last-run" DASHB_LOCK_FILE = SCRIPT_DIR / ".dashboard-lock" @@ -1237,73 +1242,63 @@ def install_timer(data): capture_output=True, text=True) 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}") +def _install_interval_timers(names, timer_files, svc_files, descriptions, exec_starts, interval_secs): + for name, timer_file, svc_file, description, exec_start, interval_sec in zip( + names, timer_files, svc_files, descriptions, exec_starts, interval_secs): + timer_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + f"Description={description}", + "", + "[Timer]", + f"OnActiveSec={interval_sec}s", + f"OnUnitActiveSec={interval_sec}s", + "AccuracySec=10s", + "", + "[Install]", + "WantedBy=timers.target", + "", + ]) + service_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + f"Description={description}", + "", + "[Service]", + "Type=oneshot", + f"ExecStart={exec_start}", + "", + ]) + for path, content in ((timer_file, timer_content), (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).") + for name, interval_sec in zip(names, interval_secs): + subprocess.run(["systemctl", "enable", f"{name}.timer"], capture_output=True, text=True) + active = subprocess.run( + ["systemctl", "is-active", f"{name}.timer"], + capture_output=True, text=True + ).stdout.strip() == "active" + verb = "restart" if active else "start" + subprocess.run(["systemctl", verb, f"{name}.timer"], capture_output=True, text=True) + print(f"Timer {name}.timer enabled (runs every {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"{BLIST_TIMER_NAME}.timer"], - capture_output=True, text=True) - for f in (BLIST_TIMER_FILE, BLIST_TIMER_SVC_FILE): - if f.exists(): - f.unlink() - print(f"Removed: {f}") - else: - print(f"Not found, skipping: {f}") - subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) +def _remove_timers(names, timer_files, svc_files, daemon_reload=False): + for name, timer_file, svc_file in zip(names, timer_files, svc_files): + subprocess.run(["systemctl", "disable", "--now", f"{name}.timer"], + capture_output=True, text=True) + for f in (timer_file, svc_file): + if f.exists(): + f.unlink() + print(f"Removed: {f}") + else: + print(f"Not found, skipping: {f}") + if daemon_reload: + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) # =================================================================== # banned_ips expansion @@ -2487,9 +2482,13 @@ def show_metrics(data): # =================================================================== def stop_instances(data): - """Remove timer and stop all per-VLAN instances (config files preserved).""" - remove_timer() - remove_dashboard_timer() + """Remove timers and stop all per-VLAN instances (config files preserved).""" + _remove_timers( + names=[BLIST_TIMER_NAME, DASHB_TIMER_NAME, STATUS_TIMER_NAME], + timer_files=[BLIST_TIMER_FILE, DASHB_TIMER_FILE, STATUS_TIMER_FILE], + svc_files=[BLIST_TIMER_SVC_FILE, DASHB_TIMER_SVC_FILE, STATUS_TIMER_SVC_FILE], + daemon_reload=True, + ) print() for vlan in data["vlans"]: svc = vlan_service_name(vlan, derive_interface(vlan, data)) @@ -3080,10 +3079,23 @@ def cmd_apply(data, dry_run=False): install_timer(data) print() + print("Interval timers =====================================================") + # build parallel lists; dashboard timer only installed when queue file exists + t_names = [STATUS_TIMER_NAME] + t_files = [STATUS_TIMER_FILE] + s_files = [STATUS_TIMER_SVC_FILE] + t_descs = ["Router status health check"] + t_execs = [f"/usr/bin/python3 {SCRIPT_DIR / 'status.py'}"] + t_intervals = [STATUS_TIMER_INTERVAL_SEC] if DASHB_QUEUE_FILE.exists(): - print("Dashboard timer =====================================================") - install_dashboard_timer() - print() + t_names += [DASHB_TIMER_NAME] + t_files += [DASHB_TIMER_FILE] + s_files += [DASHB_TIMER_SVC_FILE] + t_descs += ["Router dashboard pending-update processor"] + t_execs += [f"/bin/bash {DASHB_SCRIPT_FILE}"] + t_intervals += [DASHB_TIMER_INTERVAL_SEC] + _install_interval_timers(t_names, t_files, s_files, t_descs, t_execs, t_intervals) + print() print("Boot service ========================================================") install_nat_service() diff --git a/routlin/install.py b/routlin/install.py index 05a9412..d191423 100644 --- a/routlin/install.py +++ b/routlin/install.py @@ -30,6 +30,7 @@ DASHB_DONE_FILE = SCRIPT_DIR / ".dashboard-done" DASHB_LAST_RUN_FILE = SCRIPT_DIR / ".dashboard-last-run" DASHB_LOCK_FILE = SCRIPT_DIR / ".dashboard-lock" DASHB_PENDING_FILE = SCRIPT_DIR / ".dashboard-pending" +STATUS_FILE = SCRIPT_DIR / ".status" # =================================================================== @@ -304,7 +305,7 @@ def setup_docker_compose(): def create_dotfiles(): - for f in (DASHB_QUEUE_FILE, DASHB_DONE_FILE, DASHB_LAST_RUN_FILE, DASHB_LOCK_FILE, DASHB_PENDING_FILE): + for f in (DASHB_QUEUE_FILE, DASHB_DONE_FILE, DASHB_LAST_RUN_FILE, DASHB_LOCK_FILE, DASHB_PENDING_FILE, STATUS_FILE): if not f.exists(): f.touch() # chown to the routlin dir owner so the timer can write