Development
This commit is contained in:
parent
b99ea35f79
commit
5149e5a035
10 changed files with 197 additions and 213 deletions
|
|
@ -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'])
|
||||||
|
|
@ -14,24 +14,17 @@ DDNS_FILE = '/configs/ddns.json'
|
||||||
def add_ddns_provider():
|
def add_ddns_provider():
|
||||||
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 = sanitize.domainlist(request.form.get('hostnames', '').splitlines())
|
hostnames = sanitize.domainlist(request.form.get('hostnames', '').splitlines())
|
||||||
|
|
||||||
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:
|
flash(f'DDNS provider "{description}" added.', 'success')
|
||||||
json.dump(data, f, indent=2)
|
return redirect(VIEW)
|
||||||
flash(f'DDNS provider "{description}" added.', 'success')
|
|
||||||
except Exception as ex:
|
|
||||||
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)
|
||||||
|
flash('DDNS provider updated.', 'success')
|
||||||
try:
|
return redirect(VIEW)
|
||||||
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')
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
|
flash('DDNS provider deleted.', 'success')
|
||||||
try:
|
return redirect(VIEW)
|
||||||
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')
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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+" },
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
147
routlin/ddns.py
147
routlin/ddns.py
|
|
@ -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("--apply", action="store_true", help="Run update once (used by timer)")
|
||||||
parser.add_argument("--disable", action="store_true", help="Stop updates and remove systemd timer")
|
parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP")
|
||||||
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
|
parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
|
||||||
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")
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue