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",