From 5149e5a03501d9cc5d6c86e95c09b697716d861e Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Sat, 23 May 2026 04:14:58 -0400 Subject: [PATCH] Development --- .../app/action_apply_ddns_providers.py | 90 ++++------- .../routlin-dash/app/action_dnsblocklists.py | 2 +- .../app/action_networkinterfaces.py | 2 +- docker/routlin-dash/app/config_utils.py | 17 +- docker/routlin-dash/app/view_page.py | 2 +- docker/routlin-dash/data/navbar_content.json | 2 +- routlin/core.json | 60 ++++++- routlin/core.py | 81 +++++++++- routlin/ddns.py | 147 ++---------------- routlin/status.py | 7 + 10 files changed, 197 insertions(+), 213 deletions(-) diff --git a/docker/routlin-dash/app/action_apply_ddns_providers.py b/docker/routlin-dash/app/action_apply_ddns_providers.py index e2f43bc..e2650b6 100644 --- a/docker/routlin-dash/app/action_apply_ddns_providers.py +++ b/docker/routlin-dash/app/action_apply_ddns_providers.py @@ -1,12 +1,12 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -import json +from config_utils import load_core, save_core import sanitize import validation as validate bp = Blueprint('action_apply_ddns_providers', __name__) -DDNS_FILE = '/configs/ddns.json' +VIEW = '/view/view_ddns' @bp.route('/action/add_ddns_provider', methods=['POST']) @@ -14,24 +14,17 @@ DDNS_FILE = '/configs/ddns.json' def add_ddns_provider(): provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS) description = sanitize.description(request.form.get('description', '')) - hostnames = sanitize.domainlist(request.form.get('hostnames', '').splitlines()) + hostnames = sanitize.domainlist(request.form.get('hostnames', '').splitlines()) if not description: flash('Description is required.', 'error') - return redirect('/view/view_ddns') + return redirect(VIEW) if not hostnames: flash('At least one hostname is required.', 'error') - return redirect('/view/view_ddns') + return redirect(VIEW) if not provider_type: flash('Unknown provider type.', 'error') - return redirect('/view/view_ddns') - - try: - with open(DDNS_FILE) as f: - data = json.load(f) - except Exception as ex: - flash(f'Could not read config: {ex}', 'error') - return redirect('/view/view_ddns') + return redirect(VIEW) entry = { 'description': description, @@ -45,15 +38,11 @@ def add_ddns_provider(): else: entry['api_token'] = request.form.get('api_token', '').strip() - data.setdefault('providers', []).append(entry) - - try: - with open(DDNS_FILE, 'w') as f: - json.dump(data, f, indent=2) - flash(f'DDNS provider "{description}" added.', 'success') - except Exception as ex: - flash(f'Could not save config: {ex}', 'error') - return redirect('/view/view_ddns') + core = load_core() + core.setdefault('ddns', {}).setdefault('providers', []).append(entry) + save_core(core) + flash(f'DDNS provider "{description}" added.', 'success') + return redirect(VIEW) @bp.route('/action/edit_ddns_provider', methods=['POST']) @@ -63,29 +52,22 @@ def edit_ddns_provider(): row_index = int(request.form.get('row_index', -1)) except (TypeError, ValueError): flash('Invalid row index.', 'error') - return redirect('/view/view_ddns') + return redirect(VIEW) provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS) description = sanitize.description(request.form.get('description', '')) - hostnames_raw = request.form.get('hostnames', '') + hostnames = [h.strip() for h in request.form.get('hostnames', '').splitlines() if h.strip()] enabled = request.form.get('enabled') == 'on' - hostnames = [h.strip() for h in hostnames_raw.splitlines() if h.strip()] if not provider_type: flash('Unknown provider type.', 'error') - return redirect('/view/view_ddns') + return redirect(VIEW) - try: - with open(DDNS_FILE) as f: - data = json.load(f) - except Exception as ex: - flash(f'Could not read config: {ex}', 'error') - return redirect('/view/view_ddns') - - providers = data.get('providers', []) + core = load_core() + providers = core.setdefault('ddns', {}).setdefault('providers', []) if row_index < 0 or row_index >= len(providers): flash('Invalid provider index.', 'error') - return redirect('/view/view_ddns') + return redirect(VIEW) entry = { 'description': description, @@ -100,15 +82,9 @@ def edit_ddns_provider(): entry['api_token'] = request.form.get('api_token', '').strip() providers[row_index] = entry - data['providers'] = providers - - try: - with open(DDNS_FILE, 'w') as f: - json.dump(data, f, indent=2) - flash('DDNS provider updated.', 'success') - except Exception as ex: - flash(f'Could not save config: {ex}', 'error') - return redirect('/view/view_ddns') + save_core(core) + flash('DDNS provider updated.', 'success') + return redirect(VIEW) @bp.route('/action/delete_ddns_provider', methods=['POST']) @@ -118,27 +94,15 @@ def delete_ddns_provider(): row_index = int(request.form.get('row_index', -1)) except (TypeError, ValueError): flash('Invalid row index.', 'error') - return redirect('/view/view_ddns') + return redirect(VIEW) - try: - with open(DDNS_FILE) as f: - data = json.load(f) - except Exception as ex: - flash(f'Could not read config: {ex}', 'error') - return redirect('/view/view_ddns') - - providers = data.get('providers', []) + core = load_core() + providers = core.setdefault('ddns', {}).setdefault('providers', []) if row_index < 0 or row_index >= len(providers): flash('Invalid provider index.', 'error') - return redirect('/view/view_ddns') + return redirect(VIEW) del providers[row_index] - data['providers'] = providers - - try: - with open(DDNS_FILE, 'w') as f: - json.dump(data, f, indent=2) - flash('DDNS provider deleted.', 'success') - except Exception as ex: - flash(f'Could not save config: {ex}', 'error') - return redirect('/view/view_ddns') + save_core(core) + flash('DDNS provider deleted.', 'success') + return redirect(VIEW) diff --git a/docker/routlin-dash/app/action_dnsblocklists.py b/docker/routlin-dash/app/action_dnsblocklists.py index fc9b967..03117cf 100644 --- a/docker/routlin-dash/app/action_dnsblocklists.py +++ b/docker/routlin-dash/app/action_dnsblocklists.py @@ -176,5 +176,5 @@ def dnsblocklists_cardblocklistrefresh_save(): @bp.route('/action/dnsblocklists_cardblocklistrefresh_refresh', methods=['POST']) @require_level('administrator') def dnsblocklists_cardblocklistrefresh_refresh(): - flash(queued_msg('core update-blocklists'), 'success') + flash(queued_msg('core update-blocklists', action_label='Blocklist refresh queued'), 'success') return redirect(VIEW) diff --git a/docker/routlin-dash/app/action_networkinterfaces.py b/docker/routlin-dash/app/action_networkinterfaces.py index c82e237..6aab51f 100644 --- a/docker/routlin-dash/app/action_networkinterfaces.py +++ b/docker/routlin-dash/app/action_networkinterfaces.py @@ -118,5 +118,5 @@ def networkinterfaces_cardinterfaceconfiguration_apply(): flash('No changes detected.', 'info') return redirect(_VIEW) - flash(queued_msg(), 'success') + flash(queued_msg(action_label='Changes queued'), 'success') return redirect(_VIEW) diff --git a/docker/routlin-dash/app/config_utils.py b/docker/routlin-dash/app/config_utils.py index cc6a928..1293f17 100644 --- a/docker/routlin-dash/app/config_utils.py +++ b/docker/routlin-dash/app/config_utils.py @@ -232,29 +232,30 @@ def queue_command(cmd, description=''): return _queue_command(cmd, description) -def queued_msg(cmd=None, description=''): +def queued_msg(cmd=None, description='', action_label='Configuration saved'): """Queue cmd if given, then return a timing message. - Without cmd, just returns timing (for commands already queued by the caller).""" + Without cmd, just returns timing (for commands already queued by the caller). + action_label replaces the 'Configuration saved' prefix for non-save actions.""" entry_ts = None if cmd is not None: _entry_uuid, entry_ts = queue_command(cmd, description) if not _apply_on_save(): - return 'Configuration saved. Click Apply Now on the Configuration Changes card to apply.' + return f'{action_label}. Click Apply Now on the Configuration Changes card to apply.' if _is_locked(): mtime = _lock_mtime() if entry_ts is not None and mtime and entry_ts < mtime: - return 'Configuration saved. Changes are being applied now.' - return 'Configuration saved. Changes will be applied on the next run.' + return f'{action_label}. Changes are being applied now.' + return f'{action_label}. Changes will be applied on the next run.' timing = _format_timing(_seconds_until_next_run()) if timing: - return f'Configuration saved. Changes will be applied {timing}.' + return f'{action_label}. Changes will be applied {timing}.' if cmd is None: - return 'Changes queued. The processing service is not running.' + return f'{action_label}. The processing service is not running.' parts = cmd.split() cli_cmd = f'sudo python3 {parts[0]}.py --{parts[1]}' if len(parts) == 2 else cmd install_cmd = f'sudo python3 install.py' from markupsafe import Markup - return Markup(f'Configuration saved. The command processing service is not installed. ' + return Markup(f'{action_label}. The command processing service is not installed. ' f'Run {install_cmd} to enable it, ' f'or {cli_cmd} to apply manually.') diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index c43ab7b..49669c9 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -48,7 +48,7 @@ def _load_json(path): return {} def _load_core(): return _load_json(f'{CONFIGS_DIR}/core.json') -def _load_ddns(): return _load_json(f'{CONFIGS_DIR}/ddns.json') +def _load_ddns(): return _load_core().get('ddns', {}) def _load_accounts(): return _load_json(f'{DATA_DIR}/authorized_accounts.json') def _load_css(): diff --git a/docker/routlin-dash/data/navbar_content.json b/docker/routlin-dash/data/navbar_content.json index dac490f..f8eaa07 100644 --- a/docker/routlin-dash/data/navbar_content.json +++ b/docker/routlin-dash/data/navbar_content.json @@ -14,9 +14,9 @@ { "type": "nav_item", "label": "General", "map_to": "view_general", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Network Interfaces", "map_to": "view_network_interfaces", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Upstream DNS", "map_to": "view_upstream_dns", "client_requirement": "client_is_administrator+" }, + { "type": "nav_item", "label": "DNS Blocklists", "map_to": "view_dns_blocklists", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "VLANs", "map_to": "view_vlans", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Inter-VLAN Exceptions", "map_to": "view_inter_vlan", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "DNS Blocklists", "map_to": "view_dns_blocklists", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Port Forwarding", "map_to": "view_port_forwarding", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "DHCP", "map_to": "view_dhcp" }, { "type": "nav_item", "label": "Host Overrides", "map_to": "view_host_overrides", "client_requirement": "client_is_administrator+" }, diff --git a/routlin/core.json b/routlin/core.json index 0fdb403..627164c 100644 --- a/routlin/core.json +++ b/routlin/core.json @@ -682,5 +682,61 @@ ], "is_vpn": true } - ] -} + ], + "ddns": { + "general": { + "log_max_kb": 1024, + "log_errors_only": false, + "ip_check_services": [ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + "https://api4.my-ip.io/ip", + "https://ipv4.icanhazip.com", + "https://checkip.amazonaws.com", + "https://1.1.1.1/cdn-cgi/trace", + "cf-dns:myip.cloudflare", + "https://ipinfo.io/ip", + "https://ipecho.net/plain", + "https://ident.me", + "https://myip.dnsomatic.com", + "https://wtfismyip.com/text" + ], + "timer_interval": "10m" + }, + "providers": [ + { + "description": "No-IP Account", + "provider": "noip", + "enabled": true, + "username": "your-username", + "password": "your-password", + "hostnames": [ + "yoursubdomain.ddns.net", + "yourothersubdomain.ddns.net" + ] + }, + { + "description": "Cloudflare Account", + "provider": "cloudflare", + "enabled": true, + "api_token": "your-cloudflare-api-token", + "hostnames": [ + "yourdomain.com", + "yoursubdomain.yourdomain.com", + "yourothersubdomain.yourdomain.com" + ] + }, + { + "description": "DuckDNS Account", + "provider": "duckdns", + "enabled": false, + "api_token": "your-duckdns-api-token", + "hostnames": [ + "yoursubdomain.duckdns.org", + "yourothersubdomain.duckdns.org" + ] + } + ] + } +} \ No newline at end of file diff --git a/routlin/core.py b/routlin/core.py index 1078601..fff94f1 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -120,6 +120,9 @@ 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" +DDNS_TIMER_NAME = f"{PRODUCT_NAME}-ddns-update" +DDNS_TIMER_FILE = SYSTEMD_DIR / f"{DDNS_TIMER_NAME}.timer" +DDNS_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DDNS_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" @@ -1300,6 +1303,69 @@ def _remove_timers(names, timer_files, svc_files, daemon_reload=False): if daemon_reload: subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + +def _parse_ddns_interval(interval_str): + """Convert interval string (e.g. 5m, 2h, 1d) to systemd OnUnitActiveSec value.""" + s = interval_str.strip() + if s.endswith("m"): return f"{s[:-1]}min" + if s.endswith("h"): return f"{s[:-1]}h" + if s.endswith("d"): return f"{s[:-1]}day" + raise ValueError(f"Invalid timer_interval format: '{s}'. Use e.g. 5m, 2h, 1d.") + + +def install_ddns_timer(data): + ddns = data.get("ddns", {}) + interval = ddns.get("general", {}).get("timer_interval", "10m") + script_path = SCRIPT_DIR / "ddns.py" + try: + systemd_unit = _parse_ddns_interval(interval) + except ValueError as e: + print(f"DDNS timer: {e}") + return + + service_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + "Description=DDNS IP update", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=oneshot", + f"ExecStart=/usr/bin/python3 {script_path} --apply", + "", + ]) + timer_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + "Description=DDNS IP update timer", + "", + "[Timer]", + f"OnActiveSec={systemd_unit}", + f"OnUnitActiveSec={systemd_unit}", + "OnBootSec=1min", + "AccuracySec=10s", + "", + "[Install]", + "WantedBy=timers.target", + "", + ]) + for path, content in ((DDNS_TIMER_SVC_FILE, service_content), (DDNS_TIMER_FILE, timer_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) + active = subprocess.run( + ["systemctl", "is-active", f"{DDNS_TIMER_NAME}.timer"], + capture_output=True, text=True + ).stdout.strip() == "active" + verb = "restart" if active else "enable --now" + subprocess.run(["systemctl"] + verb.split() + [f"{DDNS_TIMER_NAME}.timer"], + capture_output=True, text=True) + print(f"Timer {DDNS_TIMER_NAME}.timer enabled (runs every {interval}).") + # =================================================================== # banned_ips expansion # =================================================================== @@ -2484,9 +2550,9 @@ def show_metrics(data): def stop_instances(data): """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], + names=[BLIST_TIMER_NAME, DASHB_TIMER_NAME, STATUS_TIMER_NAME, DDNS_TIMER_NAME], + timer_files=[BLIST_TIMER_FILE, DASHB_TIMER_FILE, STATUS_TIMER_FILE, DDNS_TIMER_FILE], + svc_files=[BLIST_TIMER_SVC_FILE, DASHB_TIMER_SVC_FILE, STATUS_TIMER_SVC_FILE, DDNS_TIMER_SVC_FILE], daemon_reload=True, ) print() @@ -3097,6 +3163,15 @@ def cmd_apply(data, dry_run=False): _install_interval_timers(t_names, t_files, s_files, t_descs, t_execs, t_intervals) print() + print("DDNS timer ==========================================================") + enabled_ddns = [p for p in data.get("ddns", {}).get("providers", []) if p.get("enabled")] + if enabled_ddns: + install_ddns_timer(data) + else: + _remove_timers([DDNS_TIMER_NAME], [DDNS_TIMER_FILE], [DDNS_TIMER_SVC_FILE]) + print("No enabled DDNS providers — timer not installed.") + print() + print("Boot service ========================================================") install_nat_service() print() diff --git a/routlin/ddns.py b/routlin/ddns.py index aa09e3b..c399a9c 100644 --- a/routlin/ddns.py +++ b/routlin/ddns.py @@ -2,10 +2,10 @@ """ ddns.py -- Update DDNS provider(s) with current public IP. -Reads ddns.json, fetches the current public IP, and updates -each enabled provider block only if the IP has changed since the -last successful update for that provider. -Designed to be run on a systemd timer. +Reads the ddns block from core.json, fetches the current public IP, +and updates each enabled provider block only if the IP has changed +since the last successful update for that provider. +Designed to be run on a systemd timer managed by core.py --apply. IP check services are rotated each run using .ddns-last-service so no single provider is spammed. If the selected service fails, the @@ -16,11 +16,8 @@ Logs to ddns.log in the same directory as this script. Log is cleared when it exceeds general.log_max_kb from config. Usage: - sudo python3 ddns.py --start Run update and install systemd timer - sudo python3 ddns.py --disable Stop updates and remove systemd timer python3 ddns.py --apply Run update once (used by timer) python3 ddns.py --force Force update regardless of cached IP - python3 ddns.py --status Show timer/service status python3 ddns.py --getip Print current public IP and exit """ @@ -35,12 +32,9 @@ import logging from pathlib import Path SCRIPT_DIR = Path(__file__).parent -CONFIG_FILE = SCRIPT_DIR / "ddns.json" +CONFIG_FILE = SCRIPT_DIR / "core.json" CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service" LOG_FILE = SCRIPT_DIR / "ddns.log" -TIMER_NAME = "ddns-update" -SERVICE_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.service") -TIMER_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.timer") # log is assigned in setup_logging() after config is loaded log = None @@ -54,21 +48,21 @@ def load_config(): print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr) sys.exit(1) with open(CONFIG_FILE) as f: - data = json.load(f) + data = json.load(f).get("ddns", {}) # Validate general block required_general = {"log_max_kb", "log_errors_only", "ip_check_services"} missing = required_general - set(data.get("general", {}).keys()) if missing: - print(f"ERROR: Missing keys in general block: {missing}", file=sys.stderr) + print(f"ERROR: Missing keys in ddns.general block: {missing}", file=sys.stderr) sys.exit(1) if not data["general"]["ip_check_services"]: - print("ERROR: ip_check_services list is empty.", file=sys.stderr) + print("ERROR: ddns.ip_check_services list is empty.", file=sys.stderr) sys.exit(1) # Validate providers block if not data.get("providers"): - print("ERROR: No providers defined in config.", file=sys.stderr) + print("ERROR: No DDNS providers defined in config.", file=sys.stderr) sys.exit(1) for p in data["providers"]: base_required = {"description", "provider", "enabled"} @@ -473,98 +467,12 @@ def process_provider(provider, current_ip, force=False): save_cached_ip(description, current_ip) -# =================================================================== -# Timer management -# =================================================================== - -def parse_interval(interval_str): - """ - Convert interval string (e.g. 5m, 2h, 1d) to systemd OnUnitActiveSec value. - Supported units: m (minutes), h (hours), d (days). - """ - interval_str = interval_str.strip() - if interval_str.endswith("m"): - return f"{interval_str[:-1]}min" - elif interval_str.endswith("h"): - return f"{interval_str[:-1]}h" - elif interval_str.endswith("d"): - return f"{interval_str[:-1]}day" - else: - print(f"ERROR: Invalid timer_interval format: '{interval_str}'. Use e.g. 5m, 2h, 1d.") - sys.exit(1) - -def get_current_timer_interval(): - """Read the current OnUnitActiveSec value from the timer file, or None if not present.""" - if not TIMER_FILE.exists(): - return None - for line in TIMER_FILE.read_text().splitlines(): - if line.strip().startswith("OnUnitActiveSec="): - return line.strip().split("=", 1)[1] - return None - -def install_timer(cfg): - interval = cfg["general"].get("timer_interval", "5m") - systemd_unit = parse_interval(interval) - script_path = Path(__file__).resolve() - current_interval = get_current_timer_interval() - - if current_interval == systemd_unit: - log.info(f"Timer already set to {interval}, no update needed.") - return - - service_content = f"""[Unit] -Description=DDNS update service -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -ExecStart=/usr/bin/python3 {script_path} --apply -""" - - timer_content = f"""[Unit] -Description=DDNS update timer - -[Timer] -OnUnitActiveSec={systemd_unit} -OnBootSec=1min - -[Install] -WantedBy=timers.target -""" - - SERVICE_FILE.write_text(service_content) - TIMER_FILE.write_text(timer_content) - - subprocess.run(["systemctl", "daemon-reload"], check=True) - subprocess.run(["systemctl", "enable", "--now", f"{TIMER_NAME}.timer"], check=True) - - if current_interval is None: - log.info(f"Timer installed: runs every {interval}.") - else: - log.info(f"Timer updated: was {current_interval}, now runs every {interval}.") - -def remove_timer(): - if TIMER_FILE.exists() or SERVICE_FILE.exists(): - subprocess.run( - ["systemctl", "disable", "--now", f"{TIMER_NAME}.timer"], - capture_output=True - ) - for f in (TIMER_FILE, SERVICE_FILE): - if f.exists(): - f.unlink() - print(f"Removed: {f}") - subprocess.run(["systemctl", "daemon-reload"], capture_output=True) - print("Timer removed.") - else: - print("No timer found, nothing to remove.") - # =================================================================== # Main # =================================================================== def run_update(cfg, force=False, getip_only=False): - """Perform a single DDNS update pass. Called by both timer and --start. + """Perform a single DDNS update pass. If force=True, bypasses the cached IP check and always updates. If getip_only=True, prints the detected public IP and returns without updating providers.""" general = cfg["general"] @@ -583,14 +491,6 @@ def run_update(cfg, force=False, getip_only=False): for provider in enabled: process_provider(provider, current_ip, force=force) -def show_status(): - """Show status of managed timer.""" - result = subprocess.run( - ["systemctl", "status", f"{TIMER_NAME}.timer", "--no-pager"], - capture_output=True, text=True - ) - print(result.stdout) - def main(): @@ -600,31 +500,21 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "examples:\n" - " sudo python3 ddns.py --start Run update and install systemd timer\n" - " sudo python3 ddns.py --disable Stop updates and remove systemd timer\n" " python3 ddns.py --apply Run update once (used by timer)\n" " python3 ddns.py --force Force update regardless of cached IP\n" - " python3 ddns.py --status Show timer/service status\n" " python3 ddns.py --getip Print current public IP and exit\n" ) ) - parser.add_argument("--start", action="store_true", help="Run update and install systemd timer") - parser.add_argument("--disable", action="store_true", help="Stop updates and remove systemd timer") - parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)") - parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP") - parser.add_argument("--status", action="store_true", help="Show timer/service status") - parser.add_argument("--getip", action="store_true", help="Print current public IP and exit") + parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)") + parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP") + parser.add_argument("--getip", action="store_true", help="Print current public IP and exit") args = parser.parse_args() - if not any([args.start, args.disable, args.apply, args.force, args.status, args.getip]): + if not any([args.apply, args.force, args.getip]): parser.print_help() return - if args.status: - show_status() - return - if args.getip: global log log = logging.getLogger("ddns_quiet") @@ -638,15 +528,6 @@ def main(): general = cfg["general"] setup_logging(general["log_max_kb"], general["log_errors_only"]) - if args.disable: - remove_timer() - return - - if args.start: - run_update(cfg) - install_timer(cfg) - return - if args.apply or args.force: run_update(cfg, force=args.force) diff --git a/routlin/status.py b/routlin/status.py index 11dbd18..3091738 100644 --- a/routlin/status.py +++ b/routlin/status.py @@ -45,6 +45,7 @@ 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" STATUS_TIMER_NAME = f"{PRODUCT_NAME}-status-check" +DDNS_TIMER_NAME = f"{PRODUCT_NAME}-ddns-update" DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue" NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat" BLOCKLIST_STALE_SECS = 36 * 3600 @@ -170,6 +171,12 @@ def check_services(data): "name": f"{DASHB_TIMER_NAME}.timer", "expected_active": "active", "expected_enabled": "enabled"}) + enabled_ddns = [p for p in data.get("ddns", {}).get("providers", []) if p.get("enabled")] + if enabled_ddns: + units.append({"id": f"{DDNS_TIMER_NAME}.timer", + "name": f"{DDNS_TIMER_NAME}.timer", + "expected_active": "active", "expected_enabled": "enabled"}) + 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",