From 8eec61a1dfe6d1f6a67809839dd95694b4272b9e Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Mon, 1 Jun 2026 22:12:11 -0400 Subject: [PATCH] Development --- .../app/pages/dhcpleases/content.json | 9 + .../routlin-dash/app/pages/radius/action.py | 14 +- .../app/pages/radius/content.json | 33 +- docker/routlin-dash/app/view_page.py | 83 ++- routlin/config.json | 2 +- routlin/core.py | 34 +- routlin/health.py | 6 +- routlin/maintenance.py | 572 ++++++++++++++++++ 8 files changed, 697 insertions(+), 56 deletions(-) create mode 100644 routlin/maintenance.py diff --git a/docker/routlin-dash/app/pages/dhcpleases/content.json b/docker/routlin-dash/app/pages/dhcpleases/content.json index 8b19e4d..fe5525b 100644 --- a/docker/routlin-dash/app/pages/dhcpleases/content.json +++ b/docker/routlin-dash/app/pages/dhcpleases/content.json @@ -48,9 +48,18 @@ "label": "VLAN", "field": "vlan_name" }, + { + "label": "Obtained", + "field": "obtained" + }, { "label": "Expires", "field": "expires" + }, + { + "label": "Recent", + "field": "recent", + "render": "badge_yes_no" } ] } diff --git a/docker/routlin-dash/app/pages/radius/action.py b/docker/routlin-dash/app/pages/radius/action.py index 93ac008..4b1d6e6 100644 --- a/docker/routlin-dash/app/pages/radius/action.py +++ b/docker/routlin-dash/app/pages/radius/action.py @@ -45,12 +45,12 @@ def options_save(): return redirect(f'/{_PAGE}') cfg = load_config() - before = copy.deepcopy(cfg.get('free_radius', {}).get('options', {})) + before = copy.deepcopy(cfg.get('radius', {}).get('options', {})) after = {'mac_format': mac_format, 'apply_to': apply_to} - cfg.setdefault('free_radius', {})['options'] = after + cfg.setdefault('radius', {})['options'] = after changes = diff_fields(before, after) - flash(record_group(cfg, 'free_radius.options', 'setting', 'free_radius', changes, 'core apply'), 'success') + flash(record_group(cfg, 'radius.options', 'setting', 'radius', changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -64,12 +64,12 @@ def logging_save(): logging = 'logging' in request.form cfg = load_config() - before = copy.deepcopy(cfg.get('free_radius', {}).get('general', {})) + before = copy.deepcopy(cfg.get('radius', {}).get('general', {})) after = {'logging': logging, 'log_max_kb': log_max_kb} - cfg.setdefault('free_radius', {})['general'] = after + cfg.setdefault('radius', {})['general'] = after changes = diff_fields(before, after) - flash(record_group(cfg, 'free_radius.general', 'setting', 'free_radius', changes, 'core apply'), 'success') + flash(record_group(cfg, 'radius.general', 'setting', 'radius', changes, 'core apply'), 'success') return redirect(f'/{_PAGE}') @@ -97,7 +97,7 @@ def logging_download(): def api_log_tail(): try: cfg = load_config() - log_max_kb = cfg.get('free_radius', {}).get('general', {}).get('log_max_kb', 1024) + log_max_kb = cfg.get('radius', {}).get('general', {}).get('log_max_kb', 1024) size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024 with open(RADIUS_LOG_FILE) as f: lines = f.readlines() diff --git a/docker/routlin-dash/app/pages/radius/content.json b/docker/routlin-dash/app/pages/radius/content.json index 2bf621d..1abb899 100644 --- a/docker/routlin-dash/app/pages/radius/content.json +++ b/docker/routlin-dash/app/pages/radius/content.json @@ -128,6 +128,22 @@ "type": "raw_html", "html": "%RADIUS_LOG_SUMMARY%" }, + { + "type": "button_row", + "justify": "space-between", + "items": [ + { + "type": "button_ghost", + "action": "/action/radius/logging_download", + "text": "Download Log" + }, + { + "type": "button_danger", + "formaction": "/action/radius/logging_clear", + "text": "Clear Log" + } + ] + }, { "type": "hr" }, @@ -155,23 +171,6 @@ ] } ] - }, - { - "type": "button_row", - "justify": "space-between", - "items": [ - { - "type": "button_ghost", - "action": "/action/radius/logging_download", - "text": "Download Log" - }, - { - "type": "button_danger", - "action": "/action/radius/logging_clear", - "method": "post", - "text": "Clear Log" - } - ] } ] } diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index dfeb0e7..b72e904 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -151,21 +151,64 @@ def resolve_iface(vlan, cfg): # Live data loaders ================================================= +def _parse_lease_secs(s): + s = str(s).strip().lower() + try: + if s.endswith('h'): return int(s[:-1]) * 3600 + if s.endswith('m'): return int(s[:-1]) * 60 + if s.endswith('d'): return int(s[:-1]) * 86400 + except ValueError: + pass + return None + +def _dnsmasq_start_time(vlan_name): + """Return epoch timestamp when the dnsmasq instance for this VLAN last started.""" + try: + pid = int(open(f'/run/dnsmasq-routlin-{vlan_name}.pid').read().strip()) + start_ticks = int(open(f'/proc/{pid}/stat').read().split()[21]) + clk_tck = os.sysconf('SC_CLK_TCK') + boot_time = next( + int(line.split()[1]) for line in open('/proc/stat') if line.startswith('btime ') + ) + return boot_time + start_ticks / clk_tck + except Exception: + return None + def live_dhcp_leases(): rows = [] - for leases_file in glob.glob('/var/lib/misc/*.leases'): + now = int(datetime.now(tz=timezone.utc).timestamp()) + vlans = load_config().get('vlans', []) + vlan_lease_secs = { + v['name']: _parse_lease_secs(v.get('dhcp', {}).get('lease_time', '')) + for v in vlans if v.get('name') + } + for leases_file in glob.glob('/var/lib/misc/dnsmasq-routlin-*.leases'): + stem = os.path.basename(leases_file) + vlan_name = stem[len('dnsmasq-routlin-'):-len('.leases')] + lease_secs = vlan_lease_secs.get(vlan_name) + restart_time = _dnsmasq_start_time(vlan_name) try: with open(leases_file) as f: for line in f: parts = line.strip().split() - if len(parts) >= 4: - rows.append({ - 'hostname': parts[3] if parts[3] != '*' else '-', - 'ip_address': parts[2], - 'mac_address': parts[1], - 'vlan_name': _vlan_name_for_ip(parts[2]), - 'expires': fmt_timestamp(int(parts[0])), - }) + if len(parts) < 4: + continue + expiry = int(parts[0]) + if expiry < now: + continue + obtained_ts = (expiry - lease_secs) if lease_secs else None + obtained = relative_time(obtained_ts) if obtained_ts else '-' + recent = (obtained_ts is not None and restart_time is not None + and obtained_ts >= restart_time) + rows.append({ + 'hostname': parts[3] if parts[3] != '*' else '-', + 'ip_address': parts[2], + 'mac_address': parts[1], + 'vlan_name': vlan_name, + 'obtained': obtained, + 'expires': relative_time_future(expiry), + 'recent': recent, + }) except Exception: pass return rows @@ -210,6 +253,24 @@ def relative_time(ts): except Exception: return '' +def relative_time_future(ts): + try: + diff = int(ts) - int(datetime.now(tz=timezone.utc).timestamp()) + if diff <= 0: + return 'expired' + if diff < 60: + return f'in {diff} second{"s" if diff != 1 else ""}' + m = diff // 60 + if m < 60: + return f'in {m} minute{"s" if m != 1 else ""}' + h, rem_m = divmod(m, 60) + if h < 24: + return f'in {h}h {rem_m}m' if rem_m else f'in {h} hour{"s" if h != 1 else ""}' + d = h // 24 + return f'in {d} day{"s" if d != 1 else ""}' + except Exception: + return '' + def live_vpn_sessions(): rows = [] out = run('wg show all dump 2>/dev/null') @@ -509,7 +570,7 @@ RADIUS_LOG_FILE = '/var/log/freeradius/radius.log' def _radius_log_tail(): try: cfg = load_config() - log_max_kb = cfg.get('free_radius', {}).get('general', {}).get('log_max_kb', 1024) + log_max_kb = cfg.get('radius', {}).get('general', {}).get('log_max_kb', 1024) size_kb = os.path.getsize(RADIUS_LOG_FILE) / 1024 with open(RADIUS_LOG_FILE) as f: lines = f.readlines() @@ -848,7 +909,7 @@ def collect_tokens(): tokens['RADIUS_SECRET'] = open(f'{CONFIGS_DIR}/.radius-secret').read().strip() except OSError: tokens['RADIUS_SECRET'] = '(Generation is pending - visit Actions to apply generation command)' - _fr = cfg.get('free_radius', {}) + _fr = cfg.get('radius', {}) _fr_opts = _fr.get('options', {}) _fr_gen = _fr.get('general', {}) tokens['RADIUS_MAC_FORMAT'] = _fr_opts.get('mac_format', 'aabbccddeeff') diff --git a/routlin/config.json b/routlin/config.json index bf26cc6..3911b81 100644 --- a/routlin/config.json +++ b/routlin/config.json @@ -829,7 +829,7 @@ "vlan": "vpn" } ], - "free_radius": { + "radius": { "general": { "logging": false, "log_max_kb": 1024 diff --git a/routlin/core.py b/routlin/core.py index 5676380..bad8abb 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -114,9 +114,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" +MAINT_TIMER_NAME = f"{PRODUCT_NAME}-maintenance" +MAINT_TIMER_FILE = SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.timer" +MAINT_TIMER_SVC_FILE = SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.service" HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check" HEALTH_TIMER_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.timer" HEALTH_TIMER_SVC_FILE = SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.service" @@ -1143,10 +1143,10 @@ def _parse_ddns_interval(interval_str): raise ValueError(f"Invalid timer_interval format: '{s}'. Use e.g. 5m, 2h, 1d.") -def install_ddns_timer(data): +def install_maint_timer(data): ddns = data.get("ddns", {}) interval = ddns.get("general", {}).get("timer_interval", "10m") - script_path = SCRIPT_DIR / "ddns.py" + script_path = SCRIPT_DIR / "maintenance.py" try: systemd_unit = _parse_ddns_interval(interval) except ValueError as e: @@ -1182,19 +1182,19 @@ def install_ddns_timer(data): "WantedBy=timers.target", "", ]) - for path, content in ((DDNS_TIMER_SVC_FILE, service_content), (DDNS_TIMER_FILE, timer_content)): + for path, content in ((MAINT_TIMER_SVC_FILE, service_content), (MAINT_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"], + ["systemctl", "is-active", f"{MAINT_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"], + subprocess.run(["systemctl"] + verb.split() + [f"{MAINT_TIMER_NAME}.timer"], capture_output=True, text=True) - print(f"Timer {DDNS_TIMER_NAME}.timer enabled (runs every {interval}).") + print(f"Timer {MAINT_TIMER_NAME}.timer enabled (runs every {interval}).") # =================================================================== # banned_ips expansion @@ -1898,7 +1898,7 @@ def build_radius_users(data): Generate freeradius users file. Each MAC reservation across all VLANs gets an entry mapping it to its VLAN ID. Unknown MACs fall through to DEFAULT which returns the radius_default VLAN. - MAC format and DEFAULT rule scope are read from free_radius.options in config. + MAC format and DEFAULT rule scope are read from radius.options in config. """ default_vlan = next( (v for v in data["vlans"] if v.get("radius_default") is True), None @@ -1906,7 +1906,7 @@ def build_radius_users(data): if default_vlan is None: die("No VLAN has radius_default: true. Cannot generate RADIUS users file.") - fr_opts = data.get('free_radius', {}).get('options', {}) + fr_opts = data.get('radius', {}).get('options', {}) mac_fmt = fr_opts.get('mac_format', 'aabbccddeeff') apply_to = fr_opts.get('apply_to', 'all') @@ -1979,7 +1979,7 @@ def apply_radius(data): clients_content = build_radius_clients_conf(data, secret) users_content = build_radius_users(data) - logging = data.get('free_radius', {}).get('general', {}).get('logging', False) + logging = data.get('radius', {}).get('general', {}).get('logging', False) changed = _set_freeradius_log(logging) for path, content in [(RADIUS_CLIENTS_CONF, clients_content), @@ -2449,9 +2449,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, HEALTH_TIMER_NAME, DDNS_TIMER_NAME], - timer_files=[BLIST_TIMER_FILE, HEALTH_TIMER_FILE, DDNS_TIMER_FILE], - svc_files=[BLIST_TIMER_SVC_FILE, HEALTH_TIMER_SVC_FILE, DDNS_TIMER_SVC_FILE], + names=[BLIST_TIMER_NAME, HEALTH_TIMER_NAME, MAINT_TIMER_NAME], + timer_files=[BLIST_TIMER_FILE, HEALTH_TIMER_FILE, MAINT_TIMER_FILE], + svc_files=[BLIST_TIMER_SVC_FILE, HEALTH_TIMER_SVC_FILE, MAINT_TIMER_SVC_FILE], daemon_reload=True, ) print() @@ -3051,9 +3051,9 @@ def cmd_apply(data, dry_run=False): 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) + install_maint_timer(data) else: - _remove_timers([DDNS_TIMER_NAME], [DDNS_TIMER_FILE], [DDNS_TIMER_SVC_FILE]) + _remove_timers([MAINT_TIMER_NAME], [MAINT_TIMER_FILE], [MAINT_TIMER_SVC_FILE]) print("No enabled DDNS providers — timer not installed.") print() diff --git a/routlin/health.py b/routlin/health.py index 77e96eb..2430385 100644 --- a/routlin/health.py +++ b/routlin/health.py @@ -45,7 +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" HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check" -DDNS_TIMER_NAME = f"{PRODUCT_NAME}-ddns-update" +MAINT_TIMER_NAME = f"{PRODUCT_NAME}-maintenance" DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue" NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat" BLOCKLIST_STALE_SECS = 36 * 3600 @@ -179,8 +179,8 @@ def check_services(data): 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_enabled = "enabled" if has_ddns else "not-found" - units.append({"id": f"{DDNS_TIMER_NAME}.timer", - "name": f"{DDNS_TIMER_NAME}.timer", + units.append({"id": f"{MAINT_TIMER_NAME}.timer", + "name": f"{MAINT_TIMER_NAME}.timer", "expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled, "severity": "warning"}) diff --git a/routlin/maintenance.py b/routlin/maintenance.py new file mode 100644 index 0000000..04bfde4 --- /dev/null +++ b/routlin/maintenance.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +""" +maintenance.py -- Periodic maintenance tasks run by the routlin-maintenance systemd timer. + +Tasks performed on each run: + 1. DDNS: fetch current public IP and update enabled provider(s) if changed. + 2. FreeRADIUS log rotation: truncate radius.log if it exceeds radius.general.log_max_kb. + +Reads config.json in the same directory. Designed to be invoked by core.py --apply +via the routlin-maintenance.timer systemd timer. + +IP check services are rotated each run using .ddns-last-service so +no single provider is spammed. If the selected service fails, the +script falls back through the remaining services in order. + +Per-provider cache files are named .ddns-last-ip-. +DDNS activity is logged to ddns.log in the same directory as this script. +DDNS log is cleared when it exceeds ddns.general.log_max_kb from config. + +Usage: + python3 maintenance.py --update Run all tasks once (used by timer) + python3 maintenance.py --force Force DDNS update regardless of cached IP + python3 maintenance.py --getip Print current public IP and exit +""" + +import json +import os +import subprocess +import re +import urllib.request +import urllib.error +import sys +import logging +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +CONFIG_FILE = SCRIPT_DIR / "config.json" +CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service" +LOG_FILE = SCRIPT_DIR / "ddns.log" +RADIUS_LOG_FILE = Path("/var/log/freeradius/radius.log") + +# log is assigned in setup_logging() after config is loaded +log = None + +# =================================================================== +# Load config +# =================================================================== + +def load_config(): + if not CONFIG_FILE.exists(): + print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr) + sys.exit(1) + with open(CONFIG_FILE) as f: + full = json.load(f) + data = full.get("ddns", {}) + + # Validate general block + required_general = {"log_max_kb", "log_errors_only"} + missing = required_general - set(data.get("general", {}).keys()) + if missing: + print(f"ERROR: Missing keys in ddns.general block: {missing}", file=sys.stderr) + sys.exit(1) + services = data.get("ip_check_services", []) + if not services: + print("ERROR: ddns.general.ip_check_services is empty.", file=sys.stderr) + sys.exit(1) + for svc in services: + if not isinstance(svc, dict) or "type" not in svc: + print(f"ERROR: ip_check_services entry missing 'type': {svc}", file=sys.stderr) + sys.exit(1) + if svc["type"] == "http" and "url" not in svc: + print(f"ERROR: ip_check_services 'http' entry missing 'url': {svc}", file=sys.stderr) + sys.exit(1) + if svc["type"] == "dig" and "url" not in svc: + print(f"ERROR: ip_check_services 'dig' entry missing 'url': {svc}", file=sys.stderr) + sys.exit(1) + + # Validate providers block + if not data.get("providers"): + print("ERROR: No DDNS providers defined in config.", file=sys.stderr) + sys.exit(1) + for p in data["providers"]: + base_required = {"description", "provider", "enabled"} + missing = base_required - set(p.keys()) + if missing: + print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}", file=sys.stderr) + sys.exit(1) + ptype = p.get("provider", "").lower() + if ptype == "noip": + extra = {"username", "password", "hostnames"} + elif ptype == "duckdns": + extra = {"api_token", "hostnames"} + elif ptype == "cloudflare": + extra = {"api_token", "hostnames"} + else: + print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'", file=sys.stderr) + sys.exit(1) + missing = extra - set(p.keys()) + if missing: + print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}", file=sys.stderr) + sys.exit(1) + + data['_radius'] = full.get("radius", {}) + return data + +# =================================================================== +# Helpers +# =================================================================== + +def chown_to_script_dir_owner(path): + """Chown a file to the owner of the script directory. + This works correctly whether invoked via sudo, directly as root (e.g. systemd timer), + or as a normal user - the script directory owner is always the right target. + """ + try: + stat = SCRIPT_DIR.stat() + os.chown(path, stat.st_uid, stat.st_gid) + except OSError: + pass # non-fatal + +# =================================================================== +# Logging +# =================================================================== + +def setup_logging(max_kb, errors_only): + """Clear log if oversized, then initialise logger. Must be called before log is used.""" + global log + max_bytes = int(max_kb * 1024) + try: + if LOG_FILE.exists() and LOG_FILE.stat().st_size > max_bytes: + LOG_FILE.write_text("") + if not LOG_FILE.exists(): + LOG_FILE.touch() + chown_to_script_dir_owner(LOG_FILE) + file_handler = logging.FileHandler(LOG_FILE) + except PermissionError: + print(f"WARNING: Cannot write to {LOG_FILE} (permission denied). " + f"Run with sudo or fix ownership: sudo chown $USER {LOG_FILE}") + file_handler = None + level = logging.ERROR if errors_only else logging.INFO + handlers = [logging.StreamHandler(sys.stdout)] + if file_handler: + handlers.insert(0, file_handler) + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=handlers, + ) + log = logging.getLogger("ddns") + +# =================================================================== +# Per-provider IP cache +# =================================================================== + +def cache_file_for(description): + """Return the cache file path for a given provider description.""" + safe_name = description.replace(" ", "-") + return SCRIPT_DIR / f".ddns-last-ip-{safe_name}" + +def get_cached_ip(description): + f = cache_file_for(description) + if f.exists(): + return f.read_text().strip() + return None + +def save_cached_ip(description, ip): + f = cache_file_for(description) + f.write_text(ip) + chown_to_script_dir_owner(f) + +# =================================================================== +# Service rotation +# =================================================================== + +def get_next_service_index(total): + """Read last used index, increment, wrap around, return next index.""" + if CACHE_SERVICE_FILE.exists(): + try: + last = int(CACHE_SERVICE_FILE.read_text().strip()) + except ValueError: + last = -1 + else: + last = -1 + return (last + 1) % total + +def save_service_index(index): + CACHE_SERVICE_FILE.write_text(str(index)) + chown_to_script_dir_owner(CACHE_SERVICE_FILE) + +# =================================================================== +# Public IP detection +# =================================================================== + +def _extract_ip(body): + """Extract an IPv4 address from an HTTP response body. + Handles plain text, key=value (e.g. Cloudflare /cdn-cgi/trace), and HTML. + """ + for line in body.splitlines(): + if line.startswith("ip="): + candidate = line[3:].strip() + if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', candidate): + return candidate + plain = body.strip() + if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', plain): + return plain + match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', body) + return match.group(1) if match else None + + +def _get_ip_via_http(spec): + """Fetch public IP from an HTTP endpoint. spec: {"type": "http", "url": "..."}""" + req = urllib.request.Request(spec["url"], headers={"User-Agent": "ddns-update/1.0"}) + with urllib.request.urlopen(req, timeout=10) as r: + return _extract_ip(r.read().decode().strip()) + + +_SAFE_DIG_RE = re.compile(r'^[a-zA-Z0-9.\-_@+:\s]+$') + +def _get_ip_via_dig(spec): + """Query public IP via dig. spec: {"type": "dig", "url": ""} + Requires the 'dig' utility to be installed. + """ + url = spec["url"] + if not _SAFE_DIG_RE.match(url): + log.warning(f"Skipping dig service with disallowed characters: {url!r}") + return None + cmd = ["dig", "+short"] + url.split() + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if result.returncode != 0: + return None + match = re.search(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b', result.stdout) + if match: + return match.group(1) + except FileNotFoundError: + log.warning("'dig' not found; cannot use dig IP check service.") + except Exception: + pass + return None + + +# =================================================================== + +def get_public_ip(services): + """ + Start at the next service in rotation. If it fails, fall through + the remaining services in order. Saves the index of the service + that succeeded so the next run starts with the following one. + """ + total = len(services) + start = get_next_service_index(total) + ordered = [services[(start + i) % total] for i in range(total)] + + for i, spec in enumerate(ordered): + stype = spec.get("type", "http") + label = spec.get("url", "?") + try: + if stype == "dig": + ip = _get_ip_via_dig(spec) + else: + ip = _get_ip_via_http(spec) + if ip: + save_service_index((start + i) % total) + log.info(f"Public IP retrieved from {label}: {ip}") + return ip + except Exception as ex: + log.warning(f"IP check failed for {label}: {ex}") + continue + + log.error("Could not determine public IP from any configured service.") + sys.exit(1) + +# =================================================================== +# No-IP update +# =================================================================== + +def update_noip(provider, ip): + """ + No-IP HTTP update API. + Docs: https://www.noip.com/integrate/request + Uses HTTP Basic Auth. Supports comma-separated list of hostnames. + """ + username = provider["username"] + password = provider["password"] + hostnames = ",".join(provider["hostnames"]) + + url = f"https://dynupdate.no-ip.com/nic/update?hostname={hostnames}&myip={ip}" + + password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm() + password_mgr.add_password(None, url, username, password) + handler = urllib.request.HTTPBasicAuthHandler(password_mgr) + opener = urllib.request.build_opener(handler) + + req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"}) + + try: + with opener.open(req, timeout=10) as r: + return r.read().decode().strip() + except urllib.error.URLError as e: + log.error(f"Network error contacting No-IP: {e}") + return None + +def interpret_noip_response(response, hostnames, ip): + """ + No-IP response codes: + good -- update successful + nochg -- IP already set to this value (no change needed) + nohost -- hostname not found in account + badauth -- invalid credentials + badagent -- client blocked + !donator -- feature requires paid account + abuse -- account blocked for abuse + 911 -- server-side error, retry later + """ + if response is None: + return False + if response.startswith("good"): + log.info(f"No-IP updated successfully: {hostnames} -> {ip}") + return True + elif response.startswith("nochg"): + log.info(f"No-IP: no change needed ({hostnames} already set to {ip})") + return True + elif response == "nohost": + log.error(f"No-IP: hostname '{hostnames}' not found in account.") + elif response == "badauth": + log.error(f"No-IP: authentication failed for '{hostnames}'. Check username and password.") + elif response == "badagent": + log.error("No-IP: client blocked by No-IP.") + elif response == "!donator": + log.error("No-IP: this feature requires a paid account.") + elif response == "abuse": + log.error("No-IP: account blocked for abuse.") + elif response == "911": + log.error("No-IP: server error. Will retry on next run.") + else: + log.error(f"No-IP: unexpected response: {response}") + return False + + +# =================================================================== +# DuckDNS update +# =================================================================== + +def update_duckdns(provider, ip): + """ + DuckDNS HTTP update API. + Docs: https://www.duckdns.org/spec.jsp + Token-based, no username/password. Subdomains are the short name only + (e.g. "myhome", not "myhome.duckdns.org"). Supports multiple subdomains + as a comma-separated list. + Returns True on success, False on failure. + """ + token = provider["api_token"] + subdomains = ",".join(h.replace(".duckdns.org", "") for h in provider["hostnames"]) + description = provider["description"] + + url = f"https://www.duckdns.org/update?domains={subdomains}&token={token}&ip={ip}" + + try: + req = urllib.request.Request(url, headers={"User-Agent": "ddns-update/1.0"}) + with urllib.request.urlopen(req, timeout=10) as r: + response = r.read().decode().strip() + if response == "OK": + log.info(f"DuckDNS updated successfully: {subdomains} -> {ip}") + return True + else: + log.error(f"DuckDNS update failed for '{description}': response was '{response}'") + return False + except urllib.error.URLError as e: + log.error(f"Network error contacting DuckDNS: {e}") + return False + +# =================================================================== +# Cloudflare DNS update +# =================================================================== + +def _cf_api_get(url, headers): + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read().decode()) + except Exception as e: + log.error(f"Cloudflare API GET error ({url}): {e}") + return None + +def _cf_get_zone_id(zone_name, headers): + data = _cf_api_get( + f"https://api.cloudflare.com/client/v4/zones?name={zone_name}", headers + ) + if data and data.get("success") and data["result"]: + return data["result"][0]["id"] + return None + +def _cf_get_record_id(zone_id, hostname, headers): + data = _cf_api_get( + f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?name={hostname}&type=A", + headers, + ) + if data and data.get("success") and data["result"]: + return data["result"][0]["id"] + return None + +def update_cloudflare(provider, ip): + """ + Cloudflare DNS update API. + Docs: https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/edit/ + Bearer-token auth. Looks up zone and record IDs dynamically, then PATCHes each A record. + """ + token = provider["api_token"] + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "ddns-update/1.0", + } + success = True + for hostname in provider["hostnames"]: + zone_name = ".".join(hostname.split(".")[-2:]) + zone_id = _cf_get_zone_id(zone_name, headers) + if not zone_id: + log.error(f"Cloudflare: zone '{zone_name}' not found in account.") + success = False + continue + record_id = _cf_get_record_id(zone_id, hostname, headers) + if not record_id: + log.error(f"Cloudflare: A record for '{hostname}' not found in zone '{zone_name}'.") + success = False + continue + url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" + payload = json.dumps({"content": ip}).encode() + req = urllib.request.Request(url, data=payload, headers=headers, method="PATCH") + try: + with urllib.request.urlopen(req, timeout=10) as r: + data = json.loads(r.read().decode()) + if data.get("success"): + log.info(f"Cloudflare updated successfully: {hostname} -> {ip}") + else: + log.error(f"Cloudflare update failed for '{hostname}': {data.get('errors')}") + success = False + except Exception as e: + log.error(f"Cloudflare API PATCH error for '{hostname}': {e}") + success = False + return success + +# =================================================================== +# Process a single provider block +# =================================================================== + +def process_provider(provider, current_ip, force=False): + description = provider["description"] + + if not provider.get("enabled") is True: + log.info(f"Provider '{description}' is disabled, skipping.") + return + + cached_ip = get_cached_ip(description) + + if not force and current_ip == cached_ip: + log.info(f"[{description}] IP unchanged ({current_ip}), skipping update.") + return + + if force: + log.info(f"[{description}] Force update requested. Updating with {current_ip}...") + elif cached_ip: + log.info(f"[{description}] IP changed: {cached_ip} -> {current_ip}. Updating...") + else: + log.info(f"[{description}] No cached IP found. Updating with {current_ip}...") + + ptype = provider["provider"].lower() + + if ptype == "noip": + hostnames = ",".join(provider["hostnames"]) + response = update_noip(provider, current_ip) + success = interpret_noip_response(response, hostnames, current_ip) + elif ptype == "duckdns": + success = update_duckdns(provider, current_ip) + elif ptype == "cloudflare": + success = update_cloudflare(provider, current_ip) + else: + log.error(f"[{description}] Unknown provider type: '{ptype}'") + return + + if success: + save_cached_ip(description, current_ip) + + +# =================================================================== +# FreeRADIUS log rotation +# =================================================================== + +def rotate_radius_log(radius_cfg): + """Truncate the FreeRADIUS log if it exceeds radius.general.log_max_kb.""" + max_kb = radius_cfg.get("general", {}).get("log_max_kb", 1024) + max_bytes = int(max_kb * 1024) + if not RADIUS_LOG_FILE.exists(): + return + try: + if RADIUS_LOG_FILE.stat().st_size > max_bytes: + RADIUS_LOG_FILE.write_text("") + print(f"FreeRADIUS log cleared (exceeded {max_kb} KB).") + except PermissionError: + print(f"WARNING: Cannot write to {RADIUS_LOG_FILE} (permission denied).") + except OSError as e: + print(f"WARNING: Error checking FreeRADIUS log: {e}") + + +# =================================================================== +# Main +# =================================================================== + +def run_update(cfg, force=False, getip_only=False): + """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.""" + current_ip = get_public_ip(cfg["ip_check_services"]) + + if getip_only: + print(current_ip) + return + + enabled = [p for p in cfg["providers"] if p.get("enabled") is True] + + if not enabled: + log.error("No enabled providers found in config.") + sys.exit(1) + + for provider in enabled: + process_provider(provider, current_ip, force=force) + + +def main(): + import argparse + parser = argparse.ArgumentParser( + description="Routlin periodic maintenance (DDNS update + log rotation)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "examples:\n" + " python3 maintenance.py --update Run all tasks once (used by timer)\n" + " python3 maintenance.py --force Force DDNS update regardless of cached IP\n" + " python3 maintenance.py --getip Print current public IP and exit\n" + ) + ) + parser.add_argument("--update", action="store_true", help="Run all tasks once (used by timer)") + parser.add_argument("--force", action="store_true", help="Force DDNS 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.update, args.force, args.getip]): + parser.print_help() + return + + if args.getip: + global log + log = logging.getLogger("ddns_quiet") + log.addHandler(logging.NullHandler()) + log.propagate = False + cfg = load_config() + run_update(cfg, getip_only=True) + return + + cfg = load_config() + general = cfg["general"] + setup_logging(general["log_max_kb"], general["log_errors_only"]) + + if args.update or args.force: + run_update(cfg, force=args.force) + + rotate_radius_log(cfg.get("_radius", {})) + +if __name__ == "__main__": + main()