Development

This commit is contained in:
Matthew Grotke 2026-05-23 04:14:58 -04:00
parent b99ea35f79
commit 5149e5a035
10 changed files with 197 additions and 213 deletions

View file

@ -1,12 +1,12 @@
from flask import Blueprint, request, redirect, flash from flask import Blueprint, request, redirect, flash
from auth import require_level from auth import require_level
import json from config_utils import load_core, save_core
import sanitize import sanitize
import validation as validate import validation as validate
bp = Blueprint('action_apply_ddns_providers', __name__) bp = Blueprint('action_apply_ddns_providers', __name__)
DDNS_FILE = '/configs/ddns.json' VIEW = '/view/view_ddns'
@bp.route('/action/add_ddns_provider', methods=['POST']) @bp.route('/action/add_ddns_provider', methods=['POST'])
@ -18,20 +18,13 @@ def add_ddns_provider():
if not description: if not description:
flash('Description is required.', 'error') flash('Description is required.', 'error')
return redirect('/view/view_ddns') return redirect(VIEW)
if not hostnames: if not hostnames:
flash('At least one hostname is required.', 'error') flash('At least one hostname is required.', 'error')
return redirect('/view/view_ddns') return redirect(VIEW)
if not provider_type: if not provider_type:
flash('Unknown provider type.', 'error') 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')
entry = { entry = {
'description': description, 'description': description,
@ -45,15 +38,11 @@ def add_ddns_provider():
else: else:
entry['api_token'] = request.form.get('api_token', '').strip() entry['api_token'] = request.form.get('api_token', '').strip()
data.setdefault('providers', []).append(entry) core = load_core()
core.setdefault('ddns', {}).setdefault('providers', []).append(entry)
try: save_core(core)
with open(DDNS_FILE, 'w') as f:
json.dump(data, f, indent=2)
flash(f'DDNS provider "{description}" added.', 'success') flash(f'DDNS provider "{description}" added.', 'success')
except Exception as ex: return redirect(VIEW)
flash(f'Could not save config: {ex}', 'error')
return redirect('/view/view_ddns')
@bp.route('/action/edit_ddns_provider', methods=['POST']) @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)) row_index = int(request.form.get('row_index', -1))
except (TypeError, ValueError): except (TypeError, ValueError):
flash('Invalid row index.', 'error') 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) provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
description = sanitize.description(request.form.get('description', '')) 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' enabled = request.form.get('enabled') == 'on'
hostnames = [h.strip() for h in hostnames_raw.splitlines() if h.strip()]
if not provider_type: if not provider_type:
flash('Unknown provider type.', 'error') flash('Unknown provider type.', 'error')
return redirect('/view/view_ddns') return redirect(VIEW)
try: core = load_core()
with open(DDNS_FILE) as f: providers = core.setdefault('ddns', {}).setdefault('providers', [])
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', [])
if row_index < 0 or row_index >= len(providers): if row_index < 0 or row_index >= len(providers):
flash('Invalid provider index.', 'error') flash('Invalid provider index.', 'error')
return redirect('/view/view_ddns') return redirect(VIEW)
entry = { entry = {
'description': description, 'description': description,
@ -100,15 +82,9 @@ def edit_ddns_provider():
entry['api_token'] = request.form.get('api_token', '').strip() entry['api_token'] = request.form.get('api_token', '').strip()
providers[row_index] = entry providers[row_index] = entry
data['providers'] = providers save_core(core)
try:
with open(DDNS_FILE, 'w') as f:
json.dump(data, f, indent=2)
flash('DDNS provider updated.', 'success') flash('DDNS provider updated.', 'success')
except Exception as ex: return redirect(VIEW)
flash(f'Could not save config: {ex}', 'error')
return redirect('/view/view_ddns')
@bp.route('/action/delete_ddns_provider', methods=['POST']) @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)) row_index = int(request.form.get('row_index', -1))
except (TypeError, ValueError): except (TypeError, ValueError):
flash('Invalid row index.', 'error') flash('Invalid row index.', 'error')
return redirect('/view/view_ddns') return redirect(VIEW)
try: core = load_core()
with open(DDNS_FILE) as f: providers = core.setdefault('ddns', {}).setdefault('providers', [])
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', [])
if row_index < 0 or row_index >= len(providers): if row_index < 0 or row_index >= len(providers):
flash('Invalid provider index.', 'error') flash('Invalid provider index.', 'error')
return redirect('/view/view_ddns') return redirect(VIEW)
del providers[row_index] del providers[row_index]
data['providers'] = providers save_core(core)
try:
with open(DDNS_FILE, 'w') as f:
json.dump(data, f, indent=2)
flash('DDNS provider deleted.', 'success') flash('DDNS provider deleted.', 'success')
except Exception as ex: return redirect(VIEW)
flash(f'Could not save config: {ex}', 'error')
return redirect('/view/view_ddns')

View file

@ -176,5 +176,5 @@ def dnsblocklists_cardblocklistrefresh_save():
@bp.route('/action/dnsblocklists_cardblocklistrefresh_refresh', methods=['POST']) @bp.route('/action/dnsblocklists_cardblocklistrefresh_refresh', methods=['POST'])
@require_level('administrator') @require_level('administrator')
def dnsblocklists_cardblocklistrefresh_refresh(): 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) return redirect(VIEW)

View file

@ -118,5 +118,5 @@ def networkinterfaces_cardinterfaceconfiguration_apply():
flash('No changes detected.', 'info') flash('No changes detected.', 'info')
return redirect(_VIEW) return redirect(_VIEW)
flash(queued_msg(), 'success') flash(queued_msg(action_label='Changes queued'), 'success')
return redirect(_VIEW) return redirect(_VIEW)

View file

@ -232,29 +232,30 @@ def queue_command(cmd, description=''):
return _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. """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 entry_ts = None
if cmd is not None: if cmd is not None:
_entry_uuid, entry_ts = queue_command(cmd, description) _entry_uuid, entry_ts = queue_command(cmd, description)
if not _apply_on_save(): 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(): if _is_locked():
mtime = _lock_mtime() mtime = _lock_mtime()
if entry_ts is not None and mtime and entry_ts < mtime: if entry_ts is not None and mtime and entry_ts < mtime:
return 'Configuration saved. Changes are being applied now.' return f'{action_label}. Changes are being applied now.'
return 'Configuration saved. Changes will be applied on the next run.' return f'{action_label}. Changes will be applied on the next run.'
timing = _format_timing(_seconds_until_next_run()) timing = _format_timing(_seconds_until_next_run())
if timing: if timing:
return f'Configuration saved. Changes will be applied {timing}.' return f'{action_label}. Changes will be applied {timing}.'
if cmd is None: 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() parts = cmd.split()
cli_cmd = f'sudo python3 {parts[0]}.py --{parts[1]}' if len(parts) == 2 else cmd cli_cmd = f'sudo python3 {parts[0]}.py --{parts[1]}' if len(parts) == 2 else cmd
install_cmd = f'sudo python3 install.py' install_cmd = f'sudo python3 install.py'
from markupsafe import Markup 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 <strong>{install_cmd}</strong> to enable it, ' f'Run <strong>{install_cmd}</strong> to enable it, '
f'or <strong>{cli_cmd}</strong> to apply manually.') f'or <strong>{cli_cmd}</strong> to apply manually.')

View file

@ -48,7 +48,7 @@ def _load_json(path):
return {} return {}
def _load_core(): return _load_json(f'{CONFIGS_DIR}/core.json') 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_accounts(): return _load_json(f'{DATA_DIR}/authorized_accounts.json')
def _load_css(): def _load_css():

View file

@ -14,9 +14,9 @@
{ "type": "nav_item", "label": "General", "map_to": "view_general", "client_requirement": "client_is_administrator+" }, { "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": "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": "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": "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": "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": "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": "DHCP", "map_to": "view_dhcp" },
{ "type": "nav_item", "label": "Host Overrides", "map_to": "view_host_overrides", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Host Overrides", "map_to": "view_host_overrides", "client_requirement": "client_is_administrator+" },

View file

@ -682,5 +682,61 @@
], ],
"is_vpn": true "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"
]
}
]
}
} }

View file

@ -120,6 +120,9 @@ SYSTEMD_DIR = Path("/etc/systemd/system")
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update" BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer" BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer"
BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service" 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_NAME = f"{PRODUCT_NAME}-dashboard-queue"
DASHB_TIMER_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.timer" DASHB_TIMER_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.timer"
DASHB_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.service" 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: if daemon_reload:
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) 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 # banned_ips expansion
# =================================================================== # ===================================================================
@ -2484,9 +2550,9 @@ def show_metrics(data):
def stop_instances(data): def stop_instances(data):
"""Remove timers and stop all per-VLAN instances (config files preserved).""" """Remove timers and stop all per-VLAN instances (config files preserved)."""
_remove_timers( _remove_timers(
names=[BLIST_TIMER_NAME, DASHB_TIMER_NAME, STATUS_TIMER_NAME], names=[BLIST_TIMER_NAME, DASHB_TIMER_NAME, STATUS_TIMER_NAME, DDNS_TIMER_NAME],
timer_files=[BLIST_TIMER_FILE, DASHB_TIMER_FILE, STATUS_TIMER_FILE], 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], svc_files=[BLIST_TIMER_SVC_FILE, DASHB_TIMER_SVC_FILE, STATUS_TIMER_SVC_FILE, DDNS_TIMER_SVC_FILE],
daemon_reload=True, daemon_reload=True,
) )
print() 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) _install_interval_timers(t_names, t_files, s_files, t_descs, t_execs, t_intervals)
print() 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 ========================================================") print("Boot service ========================================================")
install_nat_service() install_nat_service()
print() print()

View file

@ -2,10 +2,10 @@
""" """
ddns.py -- Update DDNS provider(s) with current public IP. ddns.py -- Update DDNS provider(s) with current public IP.
Reads ddns.json, fetches the current public IP, and updates Reads the ddns block from core.json, fetches the current public IP,
each enabled provider block only if the IP has changed since the and updates each enabled provider block only if the IP has changed
last successful update for that provider. since the last successful update for that provider.
Designed to be run on a systemd timer. 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 IP check services are rotated each run using .ddns-last-service so
no single provider is spammed. If the selected service fails, the 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. Log is cleared when it exceeds general.log_max_kb from config.
Usage: 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 --apply Run update once (used by timer)
python3 ddns.py --force Force update regardless of cached IP 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 python3 ddns.py --getip Print current public IP and exit
""" """
@ -35,12 +32,9 @@ import logging
from pathlib import Path from pathlib import Path
SCRIPT_DIR = Path(__file__).parent 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" CACHE_SERVICE_FILE = SCRIPT_DIR / ".ddns-last-service"
LOG_FILE = SCRIPT_DIR / "ddns.log" 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 is assigned in setup_logging() after config is loaded
log = None log = None
@ -54,21 +48,21 @@ def load_config():
print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr) print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr)
sys.exit(1) sys.exit(1)
with open(CONFIG_FILE) as f: with open(CONFIG_FILE) as f:
data = json.load(f) data = json.load(f).get("ddns", {})
# Validate general block # Validate general block
required_general = {"log_max_kb", "log_errors_only", "ip_check_services"} required_general = {"log_max_kb", "log_errors_only", "ip_check_services"}
missing = required_general - set(data.get("general", {}).keys()) missing = required_general - set(data.get("general", {}).keys())
if missing: 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) sys.exit(1)
if not data["general"]["ip_check_services"]: 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) sys.exit(1)
# Validate providers block # Validate providers block
if not data.get("providers"): 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) sys.exit(1)
for p in data["providers"]: for p in data["providers"]:
base_required = {"description", "provider", "enabled"} base_required = {"description", "provider", "enabled"}
@ -473,98 +467,12 @@ def process_provider(provider, current_ip, force=False):
save_cached_ip(description, current_ip) 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 # Main
# =================================================================== # ===================================================================
def run_update(cfg, force=False, getip_only=False): 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 force=True, bypasses the cached IP check and always updates.
If getip_only=True, prints the detected public IP and returns without updating providers.""" If getip_only=True, prints the detected public IP and returns without updating providers."""
general = cfg["general"] general = cfg["general"]
@ -583,14 +491,6 @@ def run_update(cfg, force=False, getip_only=False):
for provider in enabled: for provider in enabled:
process_provider(provider, current_ip, force=force) 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(): def main():
@ -600,31 +500,21 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=( epilog=(
"examples:\n" "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 --apply Run update once (used by timer)\n"
" python3 ddns.py --force Force update regardless of cached IP\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" " 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("--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("--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("--getip", action="store_true", help="Print current public IP and exit")
args = parser.parse_args() 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() parser.print_help()
return return
if args.status:
show_status()
return
if args.getip: if args.getip:
global log global log
log = logging.getLogger("ddns_quiet") log = logging.getLogger("ddns_quiet")
@ -638,15 +528,6 @@ def main():
general = cfg["general"] general = cfg["general"]
setup_logging(general["log_max_kb"], general["log_errors_only"]) 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: if args.apply or args.force:
run_update(cfg, force=args.force) run_update(cfg, force=args.force)

View file

@ -45,6 +45,7 @@ RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users")
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update" BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue" DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue"
STATUS_TIMER_NAME = f"{PRODUCT_NAME}-status-check" STATUS_TIMER_NAME = f"{PRODUCT_NAME}-status-check"
DDNS_TIMER_NAME = f"{PRODUCT_NAME}-ddns-update"
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue" DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat" NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat"
BLOCKLIST_STALE_SECS = 36 * 3600 BLOCKLIST_STALE_SECS = 36 * 3600
@ -170,6 +171,12 @@ def check_services(data):
"name": f"{DASHB_TIMER_NAME}.timer", "name": f"{DASHB_TIMER_NAME}.timer",
"expected_active": "active", "expected_enabled": "enabled"}) "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_active = "active" if _radius_enabled(data) else "inactive"
exp_fr_enabled = "enabled" if _radius_enabled(data) else "disabled" exp_fr_enabled = "enabled" if _radius_enabled(data) else "disabled"
units.append({"id": "freeradius", "name": "freeradius", units.append({"id": "freeradius", "name": "freeradius",