diff --git a/docker/router-dash/app/action_add_account.py b/docker/router-dash/app/action_add_account.py
new file mode 100644
index 0000000..7756f8e
--- /dev/null
+++ b/docker/router-dash/app/action_add_account.py
@@ -0,0 +1,65 @@
+from flask import Blueprint, request, session, redirect, flash
+import json, re
+from datetime import datetime, timezone
+from auth import require_level
+import sanitize
+
+bp = Blueprint('action_add_account', __name__)
+
+DATA_DIR = '/data'
+ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
+
+VALID_LEVELS = {'viewer', 'administrator', 'manager'}
+
+
+def _load_accounts():
+ try:
+ with open(ACCOUNTS_FILE) as f:
+ return json.load(f)
+ except Exception:
+ return {'accounts': []}
+
+def _save_accounts(data):
+ with open(ACCOUNTS_FILE, 'w') as f:
+ json.dump(data, f, indent=2)
+
+
+@bp.route('/action/add_account', methods=['POST'])
+@require_level('manager')
+def add_account():
+ email = sanitize.email(request.form.get('email_address', ''))
+ access_level = request.form.get('access_level', '').strip()
+
+ if not email:
+ flash('Email address is required.', 'error')
+ return redirect('/view/view_manage_accounts')
+
+ if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email):
+ flash('Email address does not appear to be valid.', 'error')
+ return redirect('/view/view_manage_accounts')
+
+ if access_level not in VALID_LEVELS:
+ flash('Invalid access level.', 'error')
+ return redirect('/view/view_manage_accounts')
+
+ data = _load_accounts()
+ accounts = data.get('accounts', [])
+
+ if any(a.get('email_address', '').lower() == email for a in accounts):
+ flash('An account with that email address already exists.', 'error')
+ return redirect('/view/view_manage_accounts')
+
+ now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
+ accounts.append({
+ 'email_address': email,
+ 'access_level': access_level,
+ 'account_created_utc': now,
+ 'account_created_by': session.get('email_address', ''),
+ 'hashed_password': '',
+ 'timezone': '',
+ })
+ data['accounts'] = accounts
+ _save_accounts(data)
+
+ flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success')
+ return redirect('/view/view_manage_accounts')
diff --git a/docker/router-dash/app/action_apply_banned_ips.py b/docker/router-dash/app/action_apply_banned_ips.py
new file mode 100644
index 0000000..8881ca6
--- /dev/null
+++ b/docker/router-dash/app/action_apply_banned_ips.py
@@ -0,0 +1,137 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg
+import sanitize
+import validate
+
+bp = Blueprint('action_apply_banned_ips', __name__)
+
+VIEW = '/view/view_banned_ips'
+
+
+def _row_index():
+ try:
+ return int(request.form.get('row_index', ''))
+ except (ValueError, TypeError):
+ return None
+
+
+def _hash_ok():
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return False
+ return True
+
+
+def _parse_ip():
+ """Return validated IP string, or None after flashing an error."""
+ raw = request.form.get('ip', '').strip()
+ if not raw:
+ flash('The configuration has not been saved because an IP address, CIDR, or wildcard pattern is required.', 'error')
+ return None
+ ip = validate.banned_ip(raw)
+ if not ip:
+ flash(f'The configuration has not been saved because "{raw}" is not a valid IP address, CIDR, or wildcard pattern.', 'error')
+ return None
+ return ip
+
+
+@bp.route('/action/add_banned_ip', methods=['POST'])
+@require_level('administrator')
+def add_banned_ip():
+ description = sanitize.text(request.form.get('description', ''))
+ ip = _parse_ip()
+ if ip is None:
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ core.setdefault('banned_ips', []).append({
+ 'description': description,
+ 'ip': ip,
+ 'enabled': True,
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/toggle_banned_ip', methods=['POST'])
+@require_level('administrator')
+def toggle_banned_ip():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('banned_ips', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ items[idx]['enabled'] = not items[idx].get('enabled', True)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/edit_banned_ip', methods=['POST'])
+@require_level('administrator')
+def edit_banned_ip():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ description = sanitize.text(request.form.get('description', ''))
+ ip = _parse_ip()
+ if ip is None:
+ return redirect(VIEW)
+ enabled = request.form.get('enabled') == 'on'
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('banned_ips', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ items[idx].update({'description': description, 'ip': ip, 'enabled': enabled})
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/delete_banned_ip', methods=['POST'])
+@require_level('administrator')
+def delete_banned_ip():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('banned_ips', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ removed = items.pop(idx)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
diff --git a/docker/router-dash/app/action_apply_blocklists.py b/docker/router-dash/app/action_apply_blocklists.py
new file mode 100644
index 0000000..e9bd297
--- /dev/null
+++ b/docker/router-dash/app/action_apply_blocklists.py
@@ -0,0 +1,172 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, run_update_blocklists, apply_msg
+import re
+import sanitize
+import validate
+
+bp = Blueprint('action_apply_blocklists', __name__)
+
+VIEW = '/view/view_blocklists'
+
+_VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS))
+
+
+def _row_index():
+ try:
+ return int(request.form.get('row_index', ''))
+ except (ValueError, TypeError):
+ return None
+
+
+def _hash_ok():
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return False
+ return True
+
+
+def _save_as_from_name(name):
+ slug = re.sub(r'[^a-z0-9_-]', '_', name.lower()).strip('_')
+ return f'{slug}.conf'
+
+
+def _parse_fields():
+ """Parse and validate add/edit form fields. Returns (fields_dict, None) or (None, already_flashed)."""
+ name = sanitize.name(request.form.get('name', ''))
+ description = sanitize.text(request.form.get('description', ''))
+ fmt = request.form.get('format', '').strip()
+ url = sanitize.url(request.form.get('url', ''))
+
+ if not name:
+ flash('The configuration has not been saved because a name is required.', 'error')
+ return None, True
+ if not url:
+ flash('The configuration has not been saved because a URL is required.', 'error')
+ return None, True
+ if fmt not in validate.VALID_BLOCKLIST_FORMATS:
+ flash(f'The configuration has not been saved because "{fmt}" is not a valid format. '
+ f'Accepted formats: {_VALID_FORMATS_STR}.', 'error')
+ return None, True
+
+ return {'name': name, 'description': description, 'format': fmt, 'url': url}, None
+
+
+@bp.route('/action/add_blocklist', methods=['POST'])
+@require_level('administrator')
+def add_blocklist():
+ fields, err = _parse_fields()
+ if err:
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ blocklists = core.setdefault('blocklists', [])
+
+ if any(b.get('name', '').lower() == fields['name'].lower() for b in blocklists):
+ flash('The configuration has not been saved because a blocklist with that name already exists.', 'error')
+ return redirect(VIEW)
+
+ blocklists.append({
+ 'name': fields['name'],
+ 'description': fields['description'],
+ 'format': fields['format'],
+ 'url': fields['url'],
+ 'save_as': _save_as_from_name(fields['name']),
+ 'enabled': True,
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/toggle_blocklist', methods=['POST'])
+@require_level('administrator')
+def toggle_blocklist():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('blocklists', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ items[idx]['enabled'] = not items[idx].get('enabled', True)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/edit_blocklist', methods=['POST'])
+@require_level('administrator')
+def edit_blocklist():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ fields, err = _parse_fields()
+ if err:
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('blocklists', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ items[idx].update({
+ 'name': fields['name'],
+ 'description': fields['description'],
+ 'format': fields['format'],
+ 'url': fields['url'],
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/delete_blocklist', methods=['POST'])
+@require_level('administrator')
+def delete_blocklist():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('blocklists', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ removed = items.pop(idx)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/update_blocklists', methods=['POST'])
+@require_level('administrator')
+def update_blocklists():
+ run_update_blocklists()
+ flash('Blocklist refresh triggered.', 'success')
+ return redirect(VIEW)
diff --git a/docker/router-dash/app/action_apply_ddns_providers.py b/docker/router-dash/app/action_apply_ddns_providers.py
new file mode 100644
index 0000000..c3b2396
--- /dev/null
+++ b/docker/router-dash/app/action_apply_ddns_providers.py
@@ -0,0 +1,139 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+import json
+
+bp = Blueprint('action_apply_ddns_providers', __name__)
+
+DDNS_FILE = '/configs/ddns.json'
+
+
+@bp.route('/action/add_ddns_provider', methods=['POST'])
+@require_level('administrator')
+def add_ddns_provider():
+ provider_type = request.form.get('provider', '').strip().lower()
+ description = request.form.get('description', '').strip()
+ hostnames_raw = request.form.get('hostnames', '')
+ hostnames = [h.strip() for h in hostnames_raw.splitlines() if h.strip()]
+
+ if not description:
+ flash('Description is required.', 'error')
+ return redirect('/view/view_ddns')
+ if not hostnames:
+ flash('At least one hostname is required.', 'error')
+ return redirect('/view/view_ddns')
+ if provider_type not in ('noip', 'cloudflare', 'duckdns'):
+ 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')
+
+ entry = {
+ 'description': description,
+ 'provider': provider_type,
+ 'enabled': True,
+ 'hostnames': hostnames,
+ }
+ if provider_type == 'noip':
+ entry['username'] = request.form.get('username', '').strip()
+ entry['password'] = request.form.get('password', '').strip()
+ 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')
+
+
+@bp.route('/action/edit_ddns_provider', methods=['POST'])
+@require_level('administrator')
+def edit_ddns_provider():
+ try:
+ row_index = int(request.form.get('row_index', -1))
+ except (TypeError, ValueError):
+ flash('Invalid row index.', 'error')
+ return redirect('/view/view_ddns')
+
+ provider_type = request.form.get('provider', '').strip().lower()
+ description = request.form.get('description', '').strip()
+ hostnames_raw = request.form.get('hostnames', '')
+ enabled = request.form.get('enabled') == 'on'
+ hostnames = [h.strip() for h in hostnames_raw.splitlines() if h.strip()]
+
+ 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', [])
+ if row_index < 0 or row_index >= len(providers):
+ flash('Invalid provider index.', 'error')
+ return redirect('/view/view_ddns')
+
+ entry = {
+ 'description': description,
+ 'provider': provider_type,
+ 'enabled': enabled,
+ 'hostnames': hostnames,
+ }
+ if provider_type == 'noip':
+ entry['username'] = request.form.get('username', '').strip()
+ entry['password'] = request.form.get('password', '').strip()
+ else:
+ 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')
+
+
+@bp.route('/action/delete_ddns_provider', methods=['POST'])
+@require_level('administrator')
+def delete_ddns_provider():
+ try:
+ row_index = int(request.form.get('row_index', -1))
+ except (TypeError, ValueError):
+ flash('Invalid row index.', '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')
+
+ providers = data.get('providers', [])
+ if row_index < 0 or row_index >= len(providers):
+ flash('Invalid provider index.', 'error')
+ return redirect('/view/view_ddns')
+
+ 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')
diff --git a/docker/router-dash/app/action_apply_dhcp_reservations.py b/docker/router-dash/app/action_apply_dhcp_reservations.py
new file mode 100644
index 0000000..f6553bb
--- /dev/null
+++ b/docker/router-dash/app/action_apply_dhcp_reservations.py
@@ -0,0 +1,187 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg
+import sanitize
+import validate
+
+bp = Blueprint('action_apply_dhcp_reservations', __name__)
+
+VIEW = '/view/view_dhcp'
+
+
+def _row_index():
+ try:
+ return int(request.form.get('row_index', ''))
+ except (ValueError, TypeError):
+ return None
+
+
+def _hash_ok():
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return False
+ return True
+
+
+def _flat_index_to_vlan_res(vlans, flat_idx):
+ pos = 0
+ for vi, vlan in enumerate(vlans):
+ for ri in range(len(vlan.get('reservations', []))):
+ if pos == flat_idx:
+ return vi, ri
+ pos += 1
+ return None, None
+
+
+def _parse_ip():
+ """Return validated IP string, or None after flashing an error."""
+ raw = request.form.get('ip', '').strip()
+ if not raw:
+ flash('The configuration has not been saved because an IP address is required.', 'error')
+ return None
+ ip = validate.ip(raw)
+ if not ip:
+ flash(f'The configuration has not been saved because "{raw}" is not a valid IP address.', 'error')
+ return None
+ return ip
+
+
+@bp.route('/action/add_dhcp_reservation', methods=['POST'])
+@require_level('administrator')
+def add_dhcp_reservation():
+ vlan_name = sanitize.name(request.form.get('vlan_name', ''))
+ description = sanitize.text(request.form.get('description', ''))
+ hostname = sanitize.hostname(request.form.get('hostname', ''))
+ mac = sanitize.mac(request.form.get('mac', ''))
+ ip = _parse_ip()
+ radius_client = 'radius_client' in request.form
+
+ if ip is None:
+ return redirect(VIEW)
+
+ if not vlan_name:
+ flash('The configuration has not been saved because a VLAN is required.', 'error')
+ return redirect(VIEW)
+ if not mac:
+ flash('The configuration has not been saved because a MAC address is required.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ vlans = core.get('vlans', [])
+ vlan = next((v for v in vlans if v.get('name') == vlan_name), None)
+ if vlan is None:
+ flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error')
+ return redirect(VIEW)
+
+ vlan.setdefault('reservations', []).append({
+ 'description': description,
+ 'hostname': hostname,
+ 'mac': mac,
+ 'ip': ip,
+ 'radius_client': radius_client,
+ 'enabled': True,
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/toggle_dhcp_reservation', methods=['POST'])
+@require_level('administrator')
+def toggle_dhcp_reservation():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ vlans = core.get('vlans', [])
+ vi, ri = _flat_index_to_vlan_res(vlans, idx)
+ if vi is None:
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ res = vlans[vi]['reservations'][ri]
+ res['enabled'] = not res.get('enabled', True)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/edit_dhcp_reservation', methods=['POST'])
+@require_level('administrator')
+def edit_dhcp_reservation():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ description = sanitize.text(request.form.get('description', ''))
+ hostname = sanitize.hostname(request.form.get('hostname', ''))
+ mac = sanitize.mac(request.form.get('mac', ''))
+ ip = _parse_ip()
+ radius_client = 'radius_client' in request.form
+
+ if ip is None:
+ return redirect(VIEW)
+ if not mac:
+ flash('The configuration has not been saved because a MAC address is required.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ vlans = core.get('vlans', [])
+ vi, ri = _flat_index_to_vlan_res(vlans, idx)
+ if vi is None:
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ res = vlans[vi]['reservations'][ri]
+ enabled = res.get('enabled', True)
+ res.update({
+ 'description': description,
+ 'hostname': hostname,
+ 'mac': mac,
+ 'ip': ip,
+ 'radius_client': radius_client,
+ 'enabled': enabled,
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/delete_dhcp_reservation', methods=['POST'])
+@require_level('administrator')
+def delete_dhcp_reservation():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ vlans = core.get('vlans', [])
+ vi, ri = _flat_index_to_vlan_res(vlans, idx)
+ if vi is None:
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ removed = vlans[vi]['reservations'].pop(ri)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
diff --git a/docker/router-dash/app/action_apply_general.py b/docker/router-dash/app/action_apply_general.py
new file mode 100644
index 0000000..5a1a7e1
--- /dev/null
+++ b/docker/router-dash/app/action_apply_general.py
@@ -0,0 +1,46 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg
+import sanitize
+
+bp = Blueprint('action_apply_general', __name__)
+
+
+
+@bp.route('/action/apply_general', methods=['POST'])
+@require_level('administrator')
+def apply_general():
+ wan_interface = sanitize.interface_name(request.form.get('wan_interface', ''))
+ log_max_kb_raw = request.form.get('log_max_kb', '').strip()
+ log_errors_only = 'log_errors_only' in request.form
+ dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form
+ daily_execute_time = sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', ''))
+
+ if not wan_interface:
+ flash('WAN Interface is required.', 'error')
+ return redirect('/view/view_general')
+
+ try:
+ log_max_kb = int(log_max_kb_raw)
+ if log_max_kb < 64:
+ raise ValueError
+ except (ValueError, TypeError):
+ flash('Max Log Size must be a number >= 64.', 'error')
+ return redirect('/view/view_general')
+
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return redirect('/view/view_general')
+
+ core = load_core()
+ core.setdefault('general', {}).update({
+ 'wan_interface': wan_interface,
+ 'log_max_kb': log_max_kb,
+ 'log_errors_only': log_errors_only,
+ 'dnsmasq_log_queries': dnsmasq_log_queries,
+ 'daily_execute_time_24hr_local': daily_execute_time,
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect('/view/view_general')
diff --git a/docker/router-dash/app/action_apply_host_overrides.py b/docker/router-dash/app/action_apply_host_overrides.py
new file mode 100644
index 0000000..934b061
--- /dev/null
+++ b/docker/router-dash/app/action_apply_host_overrides.py
@@ -0,0 +1,129 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg
+import sanitize
+
+bp = Blueprint('action_apply_host_overrides', __name__)
+
+VIEW = '/view/view_host_overrides'
+
+
+def _row_index():
+ try:
+ return int(request.form.get('row_index', ''))
+ except (ValueError, TypeError):
+ return None
+
+
+def _hash_ok():
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return False
+ return True
+
+
+@bp.route('/action/add_host_override', methods=['POST'])
+@require_level('administrator')
+def add_host_override():
+ description = sanitize.text(request.form.get('description', ''))
+ host = sanitize.hostname(request.form.get('host', ''))
+ ip = sanitize.ip(request.form.get('ip', ''))
+
+ if not host or not ip:
+ flash('Hostname and IP address are required.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ core.setdefault('host_overrides', []).append({
+ 'description': description,
+ 'host': host,
+ 'ip': ip,
+ 'enabled': True,
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/toggle_host_override', methods=['POST'])
+@require_level('administrator')
+def toggle_host_override():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('host_overrides', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ items[idx]['enabled'] = not items[idx].get('enabled', True)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/edit_host_override', methods=['POST'])
+@require_level('administrator')
+def edit_host_override():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ description = sanitize.text(request.form.get('description', ''))
+ host = sanitize.hostname(request.form.get('host', ''))
+ ip = sanitize.ip(request.form.get('ip', ''))
+
+ if not host or not ip:
+ flash('Hostname and IP address are required.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('host_overrides', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ items[idx].update({'description': description, 'host': host, 'ip': ip})
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/delete_host_override', methods=['POST'])
+@require_level('administrator')
+def delete_host_override():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('host_overrides', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ removed = items.pop(idx)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
diff --git a/docker/router-dash/app/action_apply_inter_vlan.py b/docker/router-dash/app/action_apply_inter_vlan.py
new file mode 100644
index 0000000..f6961f7
--- /dev/null
+++ b/docker/router-dash/app/action_apply_inter_vlan.py
@@ -0,0 +1,167 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg
+import sanitize
+import validate
+
+bp = Blueprint('action_apply_inter_vlan', __name__)
+
+VIEW = '/view/view_inter_vlan'
+
+_VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))
+
+
+def _row_index():
+ try:
+ return int(request.form.get('row_index', ''))
+ except (ValueError, TypeError):
+ return None
+
+
+def _hash_ok():
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return False
+ return True
+
+
+def _parse_entry():
+ """Parse and validate form fields. Returns (entry_dict, None) or (None, already_flashed)."""
+ description = sanitize.text(request.form.get('description', ''))
+ protocol = request.form.get('protocol', '').strip()
+ src_raw = request.form.get('src_ip_or_subnet', '').strip()
+ dst_raw = request.form.get('dst_ip_or_subnet', '').strip()
+ dst_port_raw = request.form.get('dst_port', '').strip()
+
+ if protocol not in validate.VALID_PROTOCOLS:
+ flash(f'The configuration has not been saved because "{protocol}" is not a valid protocol. '
+ f'Accepted values: {_VALID_PROTOS_STR}.', 'error')
+ return None, True
+
+ if not src_raw:
+ flash('The configuration has not been saved because a source IP or subnet is required.', 'error')
+ return None, True
+ src = validate.ip_or_cidr(src_raw)
+ if not src:
+ flash(f'The configuration has not been saved because "{src_raw}" is not a valid IP address or subnet.', 'error')
+ return None, True
+
+ if not dst_raw:
+ flash('The configuration has not been saved because a destination IP or subnet is required.', 'error')
+ return None, True
+ dst = validate.ip_or_cidr(dst_raw)
+ if not dst:
+ flash(f'The configuration has not been saved because "{dst_raw}" is not a valid IP address or subnet.', 'error')
+ return None, True
+
+ dst_port = ''
+ if dst_port_raw:
+ dst_port = validate.port(dst_port_raw)
+ if not dst_port:
+ flash(f'The configuration has not been saved because "{dst_port_raw}" is not a valid port number (1-65535).', 'error')
+ return None, True
+
+ return {
+ 'description': description,
+ 'protocol': protocol,
+ 'src_ip_or_subnet': src,
+ 'dst_ip_or_subnet': dst,
+ 'dst_port': dst_port,
+ 'enabled': True,
+ }, None
+
+
+@bp.route('/action/add_inter_vlan', methods=['POST'])
+@require_level('administrator')
+def add_inter_vlan():
+ entry, err = _parse_entry()
+ if err:
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ core.setdefault('inter_vlan_exceptions', []).append(entry)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/toggle_inter_vlan', methods=['POST'])
+@require_level('administrator')
+def toggle_inter_vlan():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('inter_vlan_exceptions', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ items[idx]['enabled'] = not items[idx].get('enabled', True)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/edit_inter_vlan', methods=['POST'])
+@require_level('administrator')
+def edit_inter_vlan():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ entry, err = _parse_entry()
+ if err:
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('inter_vlan_exceptions', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ enabled = items[idx].get('enabled', True)
+ items[idx] = entry
+ items[idx]['enabled'] = enabled
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/delete_inter_vlan', methods=['POST'])
+@require_level('administrator')
+def delete_inter_vlan():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('inter_vlan_exceptions', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ items.pop(idx)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
diff --git a/docker/router-dash/app/action_apply_mdns.py b/docker/router-dash/app/action_apply_mdns.py
new file mode 100644
index 0000000..5008f5e
--- /dev/null
+++ b/docker/router-dash/app/action_apply_mdns.py
@@ -0,0 +1,28 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg
+import sanitize
+
+bp = Blueprint('action_apply_mdns', __name__)
+
+
+
+@bp.route('/action/apply_mdns', methods=['POST'])
+@require_level('administrator')
+def apply_mdns():
+ mdns_enabled = 'mdns_enabled' in request.form
+ mdns_reflect_vlans = [sanitize.name(v) for v in request.form.getlist('mdns_reflect_vlans') if v.strip()]
+
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return redirect('/view/view_mdns')
+
+ core = load_core()
+ core.setdefault('mdns_reflection', {}).update({
+ 'enabled': mdns_enabled,
+ 'reflect_vlans': mdns_reflect_vlans,
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect('/view/view_mdns')
diff --git a/docker/router-dash/app/action_apply_port_forwarding.py b/docker/router-dash/app/action_apply_port_forwarding.py
new file mode 100644
index 0000000..8ff5281
--- /dev/null
+++ b/docker/router-dash/app/action_apply_port_forwarding.py
@@ -0,0 +1,168 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg
+import sanitize
+import validate
+
+bp = Blueprint('action_apply_port_forwarding', __name__)
+
+VIEW = '/view/view_port_forwarding'
+
+_VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))
+
+
+def _row_index():
+ try:
+ return int(request.form.get('row_index', ''))
+ except (ValueError, TypeError):
+ return None
+
+
+def _hash_ok():
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return False
+ return True
+
+
+def _parse_entry():
+ """Parse and validate form fields. Returns (entry_dict, None) or (None, already_flashed)."""
+ description = sanitize.text(request.form.get('description', ''))
+ protocol = request.form.get('protocol', '').strip()
+ dest_port_raw = request.form.get('dest_port', '').strip()
+ nat_ip_raw = request.form.get('nat_ip', '').strip()
+ nat_port_raw = request.form.get('nat_port', '').strip()
+
+ if protocol not in validate.VALID_PROTOCOLS:
+ flash(f'The configuration has not been saved because "{protocol}" is not a valid protocol. '
+ f'Accepted values: {_VALID_PROTOS_STR}.', 'error')
+ return None, True
+
+ if not dest_port_raw:
+ flash('The configuration has not been saved because the external port is required.', 'error')
+ return None, True
+ dest_port = validate.port(dest_port_raw)
+ if not dest_port:
+ flash(f'The configuration has not been saved because "{dest_port_raw}" is not a valid port number (1-65535).', 'error')
+ return None, True
+
+ if not nat_ip_raw:
+ flash('The configuration has not been saved because the NAT IP address is required.', 'error')
+ return None, True
+ nat_ip = validate.ip(nat_ip_raw)
+ if not nat_ip:
+ flash(f'The configuration has not been saved because "{nat_ip_raw}" is not a valid IP address.', 'error')
+ return None, True
+
+ if not nat_port_raw:
+ flash('The configuration has not been saved because the NAT port is required.', 'error')
+ return None, True
+ nat_port = validate.port(nat_port_raw)
+ if not nat_port:
+ flash(f'The configuration has not been saved because "{nat_port_raw}" is not a valid port number (1-65535).', 'error')
+ return None, True
+
+ return {
+ 'description': description,
+ 'protocol': protocol,
+ 'dest_port': dest_port,
+ 'nat_ip': nat_ip,
+ 'nat_port': nat_port,
+ 'enabled': True,
+ }, None
+
+
+@bp.route('/action/add_port_forward', methods=['POST'])
+@require_level('administrator')
+def add_port_forward():
+ entry, err = _parse_entry()
+ if err:
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ core.setdefault('port_forwarding', []).append(entry)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/toggle_port_forward', methods=['POST'])
+@require_level('administrator')
+def toggle_port_forward():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('port_forwarding', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ items[idx]['enabled'] = not items[idx].get('enabled', True)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/edit_port_forward', methods=['POST'])
+@require_level('administrator')
+def edit_port_forward():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ entry, err = _parse_entry()
+ if err:
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('port_forwarding', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ enabled = items[idx].get('enabled', True)
+ items[idx] = entry
+ items[idx]['enabled'] = enabled
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/delete_port_forward', methods=['POST'])
+@require_level('administrator')
+def delete_port_forward():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ items = core.get('port_forwarding', [])
+ if idx < 0 or idx >= len(items):
+ flash('Entry not found.', 'error')
+ return redirect(VIEW)
+
+ removed = items.pop(idx)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
diff --git a/docker/router-dash/app/action_apply_upstream_dns.py b/docker/router-dash/app/action_apply_upstream_dns.py
new file mode 100644
index 0000000..fa98631
--- /dev/null
+++ b/docker/router-dash/app/action_apply_upstream_dns.py
@@ -0,0 +1,39 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg
+import sanitize
+
+bp = Blueprint('action_apply_upstream_dns', __name__)
+
+
+
+@bp.route('/action/apply_upstream_dns', methods=['POST'])
+@require_level('administrator')
+def apply_upstream_dns():
+ strict_order = 'strict_order' in request.form
+ cache_size_raw = request.form.get('cache_size', '').strip()
+ upstream_servers = [sanitize.ip(s) for s in request.form.getlist('upstream_servers') if s.strip()]
+ upstream_servers = [s for s in upstream_servers if s]
+
+ try:
+ cache_size = int(cache_size_raw)
+ if cache_size < 0:
+ raise ValueError
+ except (ValueError, TypeError):
+ flash('Cache Size must be a non-negative integer.', 'error')
+ return redirect('/view/view_upstream_dns')
+
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return redirect('/view/view_upstream_dns')
+
+ core = load_core()
+ core.setdefault('upstream_dns', {}).update({
+ 'strict_order': strict_order,
+ 'cache_size': cache_size,
+ 'upstream_servers': upstream_servers,
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect('/view/view_upstream_dns')
diff --git a/docker/router-dash/app/action_apply_vlans.py b/docker/router-dash/app/action_apply_vlans.py
new file mode 100644
index 0000000..c1b4e86
--- /dev/null
+++ b/docker/router-dash/app/action_apply_vlans.py
@@ -0,0 +1,129 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg
+import sanitize
+
+bp = Blueprint('action_apply_vlans', __name__)
+
+VIEW = '/view/view_vlans'
+
+
+def _row_index():
+ try:
+ return int(request.form.get('row_index', ''))
+ except (ValueError, TypeError):
+ return None
+
+
+def _hash_ok():
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return False
+ return True
+
+
+@bp.route('/action/add_vlan', methods=['POST'])
+@require_level('administrator')
+def add_vlan():
+ vlan_id_raw = request.form.get('vlan_id', '').strip()
+ name = sanitize.name(request.form.get('name', ''))
+ interface = sanitize.interface_name(request.form.get('interface', ''))
+ subnet = sanitize.ip_or_cidr(request.form.get('subnet', ''))
+ radius_default = 'radius_default' in request.form
+ mdns_reflection = 'mdns_reflection' in request.form
+
+ if not vlan_id_raw or not name or not interface:
+ flash('VLAN ID, name, and interface are required.', 'error')
+ return redirect(VIEW)
+
+ try:
+ vlan_id = int(vlan_id_raw)
+ if not (1 <= vlan_id <= 4094):
+ raise ValueError
+ except (ValueError, TypeError):
+ flash('VLAN ID must be between 1 and 4094.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ vlans = core.setdefault('vlans', [])
+
+ if any(v.get('vlan_id') == vlan_id for v in vlans):
+ flash(f'VLAN {vlan_id} already exists.', 'error')
+ return redirect(VIEW)
+
+ vlans.append({
+ 'vlan_id': vlan_id,
+ 'name': name,
+ 'interface': interface,
+ 'dhcp': {'subnet': subnet},
+ 'use_blocklists': [],
+ 'radius_default': radius_default,
+ 'mdns_reflection': mdns_reflection,
+ 'reservations': [],
+ })
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/edit_vlan', methods=['POST'])
+@require_level('administrator')
+def edit_vlan():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ name = sanitize.name(request.form.get('name', ''))
+ interface = sanitize.interface_name(request.form.get('interface', ''))
+ subnet = sanitize.ip_or_cidr(request.form.get('subnet', ''))
+ radius_default = 'radius_default' in request.form
+ mdns_reflection = 'mdns_reflection' in request.form
+
+ if not name or not interface:
+ flash('Name and interface are required.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ vlans = core.get('vlans', [])
+ if idx < 0 or idx >= len(vlans):
+ flash('VLAN not found.', 'error')
+ return redirect(VIEW)
+
+ vlans[idx].update({'name': name, 'interface': interface, 'radius_default': radius_default, 'mdns_reflection': mdns_reflection})
+ vlans[idx].setdefault('dhcp', {})['subnet'] = subnet
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
+
+
+@bp.route('/action/delete_vlan', methods=['POST'])
+@require_level('administrator')
+def delete_vlan():
+ idx = _row_index()
+ if idx is None:
+ flash('Invalid request.', 'error')
+ return redirect(VIEW)
+
+ if not _hash_ok():
+ return redirect(VIEW)
+
+ core = load_core()
+ vlans = core.get('vlans', [])
+ if idx < 0 or idx >= len(vlans):
+ flash('VLAN not found.', 'error')
+ return redirect(VIEW)
+
+ removed = vlans.pop(idx)
+ save_core(core)
+
+ flash(apply_msg(), 'success')
+ return redirect(VIEW)
diff --git a/docker/router-dash/app/action_apply_vpn.py b/docker/router-dash/app/action_apply_vpn.py
new file mode 100644
index 0000000..e019f2f
--- /dev/null
+++ b/docker/router-dash/app/action_apply_vpn.py
@@ -0,0 +1,93 @@
+from flask import Blueprint, request, redirect, flash
+from auth import require_level
+from config_utils import load_core, save_core, verify_core_hash, apply_msg, _APPLY_CMD_VPN
+import sanitize
+import validate
+
+bp = Blueprint('action_apply_vpn', __name__)
+
+_VIEW = '/view/view_vpn'
+_MTU_MIN = 576
+_MTU_MAX = 9000
+
+
+@bp.route('/action/apply_vpn', methods=['POST'])
+@require_level('administrator')
+def apply_vpn():
+ listen_port_raw = request.form.get('vpn_listen_port', '').strip()
+ gateway_raw = request.form.get('vpn_gateway', '').strip()
+ domain = sanitize.hostname(request.form.get('vpn_domain', ''))
+ dns_raw = request.form.get('vpn_dns_server', '').strip()
+ mtu_raw = request.form.get('vpn_mtu', '').strip()
+
+ # -- Listen port -----------------------------------------------------------
+ if not listen_port_raw:
+ flash('The configuration has not been saved because the listen port is required.', 'error')
+ return redirect(_VIEW)
+ try:
+ listen_port = int(listen_port_raw)
+ if not (1 <= listen_port <= 65535):
+ raise ValueError
+ except (ValueError, TypeError):
+ flash(f'The configuration has not been saved because "{listen_port_raw}" is not a valid port number (1-65535).', 'error')
+ return redirect(_VIEW)
+
+ # -- Gateway (required) ----------------------------------------------------
+ if not gateway_raw:
+ flash('The configuration has not been saved because a gateway IP address is required.', 'error')
+ return redirect(_VIEW)
+ gateway = validate.ip(gateway_raw)
+ if not gateway:
+ flash(f'The configuration has not been saved because "{gateway_raw}" is not a valid IP address.', 'error')
+ return redirect(_VIEW)
+
+ # -- DNS server (optional) -------------------------------------------------
+ dns_server = ''
+ if dns_raw:
+ dns_server = validate.ip(dns_raw)
+ if not dns_server:
+ flash(f'The configuration has not been saved because "{dns_raw}" is not a valid IP address for DNS server.', 'error')
+ return redirect(_VIEW)
+
+ # -- MTU (optional) --------------------------------------------------------
+ mtu = None
+ if mtu_raw:
+ try:
+ mtu = int(mtu_raw)
+ if not (_MTU_MIN <= mtu <= _MTU_MAX):
+ raise ValueError
+ except (ValueError, TypeError):
+ flash(f'The configuration has not been saved because "{mtu_raw}" is not a valid MTU '
+ f'(must be a number between {_MTU_MIN} and {_MTU_MAX}).', 'error')
+ return redirect(_VIEW)
+
+ # -- Hash check and save ---------------------------------------------------
+ if not verify_core_hash(request.form.get('config_hash', '')):
+ flash('Configuration was modified by another session. Please refresh and try again.', 'error')
+ return redirect(_VIEW)
+
+ core = load_core()
+ vpn_vlan = next((v for v in core.get('vlans', []) if 'vpn_information' in v), None)
+ if vpn_vlan is None:
+ flash('The configuration has not been saved because no VPN VLAN was found in the configuration.', 'error')
+ return redirect(_VIEW)
+
+ info = vpn_vlan.setdefault('vpn_information', {})
+ info['listen_port'] = listen_port
+ info['gateway'] = gateway
+ info['domain'] = domain
+
+ overrides = info.setdefault('explicit_overrides', {})
+ if dns_server:
+ overrides['dns_server'] = dns_server
+ else:
+ overrides.pop('dns_server', None)
+ if mtu is not None:
+ overrides['mtu'] = mtu
+ else:
+ overrides.pop('mtu', None)
+
+ save_core(core)
+
+ flash(apply_msg(_APPLY_CMD_VPN), 'success')
+ return redirect(_VIEW)
diff --git a/docker/router-dash/app/action_change_password.py b/docker/router-dash/app/action_change_password.py
new file mode 100644
index 0000000..a330f92
--- /dev/null
+++ b/docker/router-dash/app/action_change_password.py
@@ -0,0 +1,64 @@
+from flask import Blueprint, request, session, redirect, flash
+import json, bcrypt
+from auth import require_level
+
+bp = Blueprint('action_change_password', __name__)
+
+DATA_DIR = '/data'
+ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
+
+
+def _load_accounts():
+ try:
+ with open(ACCOUNTS_FILE) as f:
+ return json.load(f)
+ except Exception:
+ return {'accounts': []}
+
+def _save_accounts(data):
+ with open(ACCOUNTS_FILE, 'w') as f:
+ json.dump(data, f, indent=2)
+
+
+@bp.route('/action/change_password', methods=['POST'])
+@require_level('viewer')
+def change_password():
+ current_password = request.form.get('current_password', '')
+ new_password = request.form.get('new_password', '')
+ confirm_password = request.form.get('confirm_password', '')
+
+ if not current_password or not new_password or not confirm_password:
+ flash('All fields are required.', 'error')
+ return redirect('/view/view_preferences')
+
+ if new_password != confirm_password:
+ flash('New passwords do not match.', 'error')
+ return redirect('/view/view_preferences')
+
+ if len(new_password) < 8:
+ flash('New password must be at least 8 characters.', 'error')
+ return redirect('/view/view_preferences')
+
+ email = session.get('email_address', '').lower()
+ data = _load_accounts()
+ accounts = data.get('accounts', [])
+ account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
+
+ if account is None:
+ flash('Account not found. Please log in again.', 'error')
+ return redirect('/view/view_log_in')
+
+ stored_hash = account.get('hashed_password', '').encode('utf-8')
+ if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash):
+ flash('Current password is incorrect.', 'error')
+ return redirect('/view/view_preferences')
+
+ salt = bcrypt.gensalt()
+ hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt)
+
+ account['hashed_password'] = hashed.decode('utf-8')
+ account['salt'] = salt.decode('utf-8')
+ _save_accounts(data)
+
+ flash('Password changed successfully.', 'success')
+ return redirect('/view/view_preferences')
diff --git a/docker/router-dash/app/action_clear_ddns_log.py b/docker/router-dash/app/action_clear_ddns_log.py
new file mode 100644
index 0000000..7149df4
--- /dev/null
+++ b/docker/router-dash/app/action_clear_ddns_log.py
@@ -0,0 +1,17 @@
+from flask import Blueprint, redirect, flash
+from auth import require_level
+
+bp = Blueprint('action_clear_ddns_log', __name__)
+
+LOG_FILE = '/configs/ddns.log'
+
+
+@bp.route('/action/clear_ddns_log', methods=['POST'])
+@require_level('administrator')
+def clear_ddns_log():
+ try:
+ open(LOG_FILE, 'w').close()
+ flash('DDNS log cleared.', 'success')
+ except Exception as ex:
+ flash(f'Could not clear log: {ex}', 'error')
+ return redirect('/view/view_ddns')
diff --git a/docker/router-dash/app/action_create_account.py b/docker/router-dash/app/action_create_account.py
new file mode 100644
index 0000000..ae5af99
--- /dev/null
+++ b/docker/router-dash/app/action_create_account.py
@@ -0,0 +1,106 @@
+from flask import Blueprint, request, session, redirect, flash
+import json, os, bcrypt, secrets, smtplib
+from datetime import datetime, timezone, timedelta
+from email.message import EmailMessage
+from auth import require_level
+import sanitize
+
+bp = Blueprint('action_create_account', __name__)
+
+DATA_DIR = '/data'
+ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
+CODE_TTL_MIN = 15
+
+
+def _load_accounts():
+ try:
+ with open(ACCOUNTS_FILE) as f:
+ return json.load(f)
+ except Exception:
+ return {'accounts': []}
+
+
+def _send_verification_email(to_address, code):
+ host = os.environ.get('SMTP_HOST', '')
+ port = int(os.environ.get('SMTP_PORT', 587))
+ user = os.environ.get('SMTP_USER', '')
+ password = os.environ.get('SMTP_PASSWORD', '')
+ from_addr = os.environ.get('SMTP_FROM', user)
+
+ if not host:
+ raise RuntimeError('SMTP_HOST is not configured.')
+
+ msg = EmailMessage()
+ msg['Subject'] = 'Router Dashboard - Email Verification'
+ msg['From'] = from_addr
+ msg['To'] = to_address
+ msg.set_content(
+ f'Your verification code is: {code}\n\n'
+ f'This code expires in {CODE_TTL_MIN} minutes.\n\n'
+ f'If you did not request this, you can ignore this email.'
+ )
+
+ with smtplib.SMTP(host, port) as smtp:
+ smtp.ehlo()
+ if port != 465:
+ smtp.starttls()
+ if user and password:
+ smtp.login(user, password)
+ smtp.send_message(msg)
+
+
+@bp.route('/action/create_account', methods=['POST'])
+@require_level('nothing')
+def create_account():
+ # Abort if already logged in
+ if session.get('access_level', 'nothing') != 'nothing':
+ return redirect('/view/view_overview')
+
+ email = sanitize.email(request.form.get('email', ''))
+ password = request.form.get('password', '')
+ password_confirm = request.form.get('password_confirm', '')
+ tz = sanitize.timezone(request.form.get('timezone', '').strip())
+
+ if not email or not password or not password_confirm or not tz:
+ flash('All fields are required.', 'error')
+ return redirect('/view/view_create_account')
+
+ if password != password_confirm:
+ flash('Passwords do not match.', 'error')
+ return redirect('/view/view_create_account')
+
+ if len(password) < 8:
+ flash('Password must be at least 8 characters.', 'error')
+ return redirect('/view/view_create_account')
+
+ accounts = _load_accounts().get('accounts', [])
+ account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
+
+ if account is None:
+ flash('Email address not recognised. Contact your manager.', 'error')
+ return redirect('/view/view_create_account')
+
+ if account.get('hashed_password'):
+ flash('This account is already set up. Please log in instead.', 'error')
+ return redirect('/view/view_create_account')
+
+ salt = bcrypt.gensalt()
+ hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
+ code = f'{secrets.randbelow(1000000):06d}'
+ expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat()
+
+ try:
+ _send_verification_email(account['email_address'], code)
+ except Exception as exc:
+ flash(f'Could not send verification email: {exc}', 'error')
+ return redirect('/view/view_create_account')
+
+ session['pending_create_account'] = {
+ 'email': account['email_address'],
+ 'hashed_password': hashed.decode('utf-8'),
+ 'timezone': tz,
+ 'code': code,
+ 'expires': expires,
+ }
+
+ return redirect('/view/view_verify_email')
diff --git a/docker/router-dash/app/action_delete_account.py b/docker/router-dash/app/action_delete_account.py
new file mode 100644
index 0000000..dfb9593
--- /dev/null
+++ b/docker/router-dash/app/action_delete_account.py
@@ -0,0 +1,51 @@
+from flask import Blueprint, request, session, redirect, flash
+import json
+from auth import require_level
+
+bp = Blueprint('action_delete_account', __name__)
+
+DATA_DIR = '/data'
+ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
+
+
+def _load_accounts():
+ try:
+ with open(ACCOUNTS_FILE) as f:
+ return json.load(f)
+ except Exception:
+ return {'accounts': []}
+
+def _save_accounts(data):
+ with open(ACCOUNTS_FILE, 'w') as f:
+ json.dump(data, f, indent=2)
+
+
+@bp.route('/action/delete_account', methods=['POST'])
+@require_level('manager')
+def delete_account():
+ try:
+ row_index = int(request.form.get('row_index', ''))
+ except (ValueError, TypeError):
+ flash('Invalid request.', 'error')
+ return redirect('/view/view_manage_accounts')
+
+ data = _load_accounts()
+ accounts = data.get('accounts', [])
+
+ if row_index < 0 or row_index >= len(accounts):
+ flash('Account not found.', 'error')
+ return redirect('/view/view_manage_accounts')
+
+ target = accounts[row_index]
+
+ if target.get('email_address', '').lower() == session.get('email_address', '').lower():
+ flash('You cannot remove your own account.', 'error')
+ return redirect('/view/view_manage_accounts')
+
+ removed_email = target.get('email_address', '')
+ accounts.pop(row_index)
+ data['accounts'] = accounts
+ _save_accounts(data)
+
+ flash(f'Account for {removed_email} has been removed.', 'success')
+ return redirect('/view/view_manage_accounts')
diff --git a/docker/router-dash/app/action_log_in.py b/docker/router-dash/app/action_log_in.py
new file mode 100644
index 0000000..957fd31
--- /dev/null
+++ b/docker/router-dash/app/action_log_in.py
@@ -0,0 +1,55 @@
+from flask import Blueprint, request, session, redirect, flash
+import json, bcrypt
+from auth import require_level
+import sanitize
+
+bp = Blueprint('action_log_in', __name__)
+
+DATA_DIR = '/data'
+
+
+def _load_accounts():
+ try:
+ with open(f'{DATA_DIR}/authorized_accounts.json') as f:
+ return json.load(f)
+ except Exception:
+ return {'accounts': []}
+
+
+@bp.route('/action/log_in', methods=['POST'])
+@require_level('nothing')
+def log_in():
+ # Abort if already logged in
+ if session.get('access_level', 'nothing') != 'nothing':
+ return redirect('/view/view_overview')
+
+ email = sanitize.email(request.form.get('email', ''))
+ password = request.form.get('password', '')
+
+ if not email or not password:
+ flash('Email address and password are required.', 'error')
+ return redirect('/view/view_log_in')
+
+ accounts = _load_accounts().get('accounts', [])
+ account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
+
+ if account is None:
+ flash('Email address not recognised.', 'error')
+ return redirect('/view/view_log_in')
+
+ if not account.get('hashed_password'):
+ flash('Account setup is not complete. Please use Create Account to set your password first.', 'error')
+ return redirect('/view/view_log_in')
+
+ stored_hash = account['hashed_password'].encode('utf-8')
+ if not bcrypt.checkpw(password.encode('utf-8'), stored_hash):
+ flash('Invalid email address or password.', 'error')
+ return redirect('/view/view_log_in')
+
+ session.clear()
+ session['email_address'] = account['email_address']
+ session['access_level'] = account.get('access_level', 'viewer')
+ session['timezone'] = account.get('timezone', '')
+ session.permanent = True
+
+ return redirect('/view/view_overview')
diff --git a/docker/router-dash/app/action_log_out.py b/docker/router-dash/app/action_log_out.py
new file mode 100644
index 0000000..4afd7d9
--- /dev/null
+++ b/docker/router-dash/app/action_log_out.py
@@ -0,0 +1,11 @@
+from flask import Blueprint, session, redirect
+from auth import require_level
+
+bp = Blueprint('action_log_out', __name__)
+
+
+@bp.route('/action/log_out', methods=['POST'])
+@require_level('viewer')
+def log_out():
+ session.clear()
+ return redirect('/view/view_overview')
diff --git a/docker/router-dash/app/action_save_preferences.py b/docker/router-dash/app/action_save_preferences.py
new file mode 100644
index 0000000..8b32d90
--- /dev/null
+++ b/docker/router-dash/app/action_save_preferences.py
@@ -0,0 +1,48 @@
+from flask import Blueprint, request, session, redirect, flash
+import json
+from auth import require_level
+import sanitize
+
+bp = Blueprint('action_save_preferences', __name__)
+
+DATA_DIR = '/data'
+ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
+
+
+def _load_accounts():
+ try:
+ with open(ACCOUNTS_FILE) as f:
+ return json.load(f)
+ except Exception:
+ return {'accounts': []}
+
+def _save_accounts(data):
+ with open(ACCOUNTS_FILE, 'w') as f:
+ json.dump(data, f, indent=2)
+
+
+@bp.route('/action/save_preferences', methods=['POST'])
+@require_level('viewer')
+def save_preferences():
+ tz = sanitize.timezone(request.form.get('timezone', '').strip())
+
+ if not tz:
+ flash('Timezone is required.', 'error')
+ return redirect('/view/view_preferences')
+
+ email = session.get('email_address', '').lower()
+ data = _load_accounts()
+ accounts = data.get('accounts', [])
+ account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
+
+ if account is None:
+ flash('Account not found. Please log in again.', 'error')
+ return redirect('/view/view_log_in')
+
+ account['timezone'] = tz
+ _save_accounts(data)
+
+ session['timezone'] = tz
+
+ flash('Preferences saved.', 'success')
+ return redirect('/view/view_preferences')
diff --git a/docker/router-dash/app/action_verify_email.py b/docker/router-dash/app/action_verify_email.py
new file mode 100644
index 0000000..4e84979
--- /dev/null
+++ b/docker/router-dash/app/action_verify_email.py
@@ -0,0 +1,113 @@
+from flask import Blueprint, request, session, redirect, flash
+import json, os, secrets
+from datetime import datetime, timezone, timedelta
+from auth import require_level
+
+bp = Blueprint('action_verify_email', __name__)
+
+DATA_DIR = '/data'
+ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
+
+
+def _load_accounts():
+ try:
+ with open(ACCOUNTS_FILE) as f:
+ return json.load(f)
+ except Exception:
+ return {'accounts': []}
+
+def _save_accounts(data):
+ with open(ACCOUNTS_FILE, 'w') as f:
+ json.dump(data, f, indent=2)
+
+
+@bp.route('/action/verify_email', methods=['POST'])
+@require_level('nothing')
+def verify_email():
+ # Abort if already logged in
+ if session.get('access_level', 'nothing') != 'nothing':
+ return redirect('/view/view_overview')
+
+ pending = session.get('pending_create_account')
+
+ if not pending:
+ flash('No pending account creation found. Please start over.', 'error')
+ return redirect('/view/view_create_account')
+
+ expires = datetime.fromisoformat(pending['expires'])
+ if datetime.now(tz=timezone.utc) > expires:
+ session.pop('pending_create_account', None)
+ flash('Verification code has expired. Please start over.', 'error')
+ return redirect('/view/view_create_account')
+
+ submitted = request.form.get('code', '').strip()
+ if submitted != pending['code']:
+ flash('Incorrect verification code.', 'error')
+ return redirect('/view/view_verify_email')
+
+ data = _load_accounts()
+ accounts = data.get('accounts', [])
+ account = next(
+ (a for a in accounts if a.get('email_address', '').lower() == pending['email'].lower()),
+ None
+ )
+
+ if account is None:
+ session.pop('pending_create_account', None)
+ flash('Account no longer exists. Contact your manager.', 'error')
+ return redirect('/view/view_create_account')
+
+ if account.get('hashed_password'):
+ session.pop('pending_create_account', None)
+ flash('This account is already set up. Please log in.', 'error')
+ return redirect('/view/view_log_in')
+
+ now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
+ account['hashed_password'] = pending['hashed_password']
+ account['timezone'] = pending['timezone']
+ if not account.get('account_created_utc'):
+ account['account_created_utc'] = now
+ if not account.get('account_created_by'):
+ account['account_created_by'] = 'self'
+
+ _save_accounts(data)
+ session.pop('pending_create_account', None)
+
+ session['email_address'] = account['email_address']
+ session['access_level'] = account.get('access_level', 'viewer')
+ session['timezone'] = pending['timezone']
+ session.permanent = True
+
+ return redirect('/view/view_overview')
+
+
+@bp.route('/action/resend_verification')
+@require_level('nothing')
+def resend_verification():
+ # Abort if already logged in
+ if session.get('access_level', 'nothing') != 'nothing':
+ return redirect('/view/view_overview')
+
+ from action_create_account import _send_verification_email, CODE_TTL_MIN
+
+ pending = session.get('pending_create_account')
+
+ if not pending:
+ flash('No pending account creation found. Please start over.', 'error')
+ return redirect('/view/view_create_account')
+
+ code = f'{secrets.randbelow(1000000):06d}'
+ expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat()
+
+ try:
+ _send_verification_email(pending['email'], code)
+ except Exception as exc:
+ flash(f'Could not resend verification email: {exc}', 'error')
+ return redirect('/view/view_verify_email')
+
+ pending['code'] = code
+ pending['expires'] = expires
+ session['pending_create_account'] = pending
+
+ flash('A new verification code has been sent.', 'success')
+ return redirect('/view/view_verify_email')
diff --git a/docker/router-dash/app/auth.py b/docker/router-dash/app/auth.py
new file mode 100644
index 0000000..3f59a44
--- /dev/null
+++ b/docker/router-dash/app/auth.py
@@ -0,0 +1,21 @@
+from flask import session, redirect, flash
+from functools import wraps
+
+LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
+
+
+def require_level(minimum):
+ """Decorator that enforces a minimum access level on an action route."""
+ def decorator(f):
+ @wraps(f)
+ def wrapper(*args, **kwargs):
+ current = session.get('access_level', 'nothing')
+ if LEVEL_RANK.get(current, 0) < LEVEL_RANK.get(minimum, 0):
+ if current == 'nothing':
+ flash('Please log in to continue.', 'error')
+ return redirect('/view/view_log_in')
+ flash('You do not have permission to perform this action.', 'error')
+ return redirect('/view/view_overview')
+ return f(*args, **kwargs)
+ return wrapper
+ return decorator
diff --git a/docker/router-dash/app/config_utils.py b/docker/router-dash/app/config_utils.py
new file mode 100644
index 0000000..092bf1d
--- /dev/null
+++ b/docker/router-dash/app/config_utils.py
@@ -0,0 +1,74 @@
+import json, subprocess, hashlib
+from markupsafe import Markup
+
+_APPLY_CMD = 'sudo python3 ~/router/core.py --apply'
+_APPLY_CMD_VPN = 'sudo python3 ~/router/vpn.py --apply'
+
+
+def apply_msg(cmd=None):
+ """Return a Markup flash message for the apply reminder."""
+ command = cmd if cmd is not None else _APPLY_CMD
+ return Markup(
+ f'Configuration updated. To apply changes, run: '
+ f'{command} '
+ )
+
+CONFIGS_DIR = '/configs'
+CORE_FILE = f'{CONFIGS_DIR}/core.json'
+
+
+def load_core():
+ try:
+ with open(CORE_FILE) as f:
+ return json.load(f)
+ except Exception:
+ return {}
+
+
+def save_core(data):
+ with open(CORE_FILE, 'w') as f:
+ json.dump(data, f, indent=2)
+
+
+def core_hash():
+ try:
+ with open(CORE_FILE, 'rb') as f:
+ return hashlib.md5(f.read()).hexdigest()
+ except Exception:
+ return ''
+
+
+def verify_core_hash(submitted):
+ if not submitted:
+ return True
+ return submitted == core_hash()
+
+
+def run_apply():
+ try:
+ subprocess.run(
+ ['python3', f'{CONFIGS_DIR}/core.py', '--apply'],
+ capture_output=True, timeout=30
+ )
+ except Exception:
+ pass
+
+
+def run_apply_vpn():
+ try:
+ subprocess.run(
+ ['python3', f'{CONFIGS_DIR}/vpn.py', '--apply'],
+ capture_output=True, timeout=30
+ )
+ except Exception:
+ pass
+
+
+def run_update_blocklists():
+ try:
+ subprocess.run(
+ ['python3', f'{CONFIGS_DIR}/core.py', '--update-blocklists'],
+ capture_output=True, timeout=120
+ )
+ except Exception:
+ pass
diff --git a/docker/router-dash/app/main.py b/docker/router-dash/app/main.py
index c212c79..12d2ba7 100644
--- a/docker/router-dash/app/main.py
+++ b/docker/router-dash/app/main.py
@@ -1,12 +1,84 @@
+import os, json, sys
from flask import Flask
-from page_dashboard import bp as dashboard_bp
-from page_signup import bp as signup_bp
-from page_signin import bp as signin_bp
+from view_page import bp as view_page_bp
+from action_apply_general import bp as action_apply_general_bp
+from action_apply_upstream_dns import bp as action_apply_upstream_dns_bp
+from action_apply_mdns import bp as action_apply_mdns_bp
+from action_apply_vpn import bp as action_apply_vpn_bp
+from action_apply_banned_ips import bp as action_apply_banned_ips_bp
+from action_apply_host_overrides import bp as action_apply_host_overrides_bp
+from action_apply_blocklists import bp as action_apply_blocklists_bp
+from action_apply_vlans import bp as action_apply_vlans_bp
+from action_apply_inter_vlan import bp as action_apply_inter_vlan_bp
+from action_apply_port_forwarding import bp as action_apply_port_forwarding_bp
+from action_apply_dhcp_reservations import bp as action_apply_dhcp_reservations_bp
+from action_create_account import bp as action_create_account_bp
+from action_log_in import bp as action_log_in_bp
+from action_log_out import bp as action_log_out_bp
+from action_verify_email import bp as action_verify_email_bp
+from action_add_account import bp as action_add_account_bp
+from action_delete_account import bp as action_delete_account_bp
+from action_save_preferences import bp as action_save_preferences_bp
+from action_change_password import bp as action_change_password_bp
+from action_clear_ddns_log import bp as action_clear_ddns_log_bp
+from action_apply_ddns_providers import bp as action_apply_ddns_providers_bp
app = Flask(__name__)
-app.register_blueprint(dashboard_bp)
-app.register_blueprint(signup_bp)
-app.register_blueprint(signin_bp)
+app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
+app.register_blueprint(view_page_bp)
+app.register_blueprint(action_apply_general_bp)
+app.register_blueprint(action_apply_upstream_dns_bp)
+app.register_blueprint(action_apply_mdns_bp)
+app.register_blueprint(action_apply_vpn_bp)
+app.register_blueprint(action_apply_banned_ips_bp)
+app.register_blueprint(action_apply_host_overrides_bp)
+app.register_blueprint(action_apply_blocklists_bp)
+app.register_blueprint(action_apply_vlans_bp)
+app.register_blueprint(action_apply_inter_vlan_bp)
+app.register_blueprint(action_apply_port_forwarding_bp)
+app.register_blueprint(action_apply_dhcp_reservations_bp)
+app.register_blueprint(action_create_account_bp)
+app.register_blueprint(action_log_in_bp)
+app.register_blueprint(action_log_out_bp)
+app.register_blueprint(action_verify_email_bp)
+app.register_blueprint(action_add_account_bp)
+app.register_blueprint(action_delete_account_bp)
+app.register_blueprint(action_save_preferences_bp)
+app.register_blueprint(action_change_password_bp)
+app.register_blueprint(action_clear_ddns_log_bp)
+app.register_blueprint(action_apply_ddns_providers_bp)
+
+def _seed_initial_account():
+ email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
+ if not email:
+ try:
+ with open(accounts_file) as f:
+ data = json.load(f)
+ except Exception:
+ data = {'accounts': []}
+ if not data.get('accounts'):
+ print('[main] WARNING: No accounts exist and INITIAL_MANAGER_EMAIL is not set. '
+ 'Set it in docker-compose.yml to seed the initial manager account.', file=sys.stderr)
+ return
+ accounts_file = '/data/authorized_accounts.json'
+ try:
+ with open(accounts_file) as f:
+ data = json.load(f)
+ except Exception:
+ data = {'accounts': []}
+ if data.get('accounts'):
+ return
+ data['accounts'] = [{
+ 'email_address': email,
+ 'access_level': 'manager',
+ 'hashed_password': '',
+ 'timezone': '',
+ }]
+ with open(accounts_file, 'w') as f:
+ json.dump(data, f, indent=2)
+ print(f'[main] Seeded initial manager account: {email}', file=sys.stderr)
+
+_seed_initial_account()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=25327)
diff --git a/docker/router-dash/app/sanitize.py b/docker/router-dash/app/sanitize.py
new file mode 100644
index 0000000..85c6a90
--- /dev/null
+++ b/docker/router-dash/app/sanitize.py
@@ -0,0 +1,156 @@
+import re
+
+# Curated IANA timezone list for the dropdown. Validation accepts any entry from this set.
+VALID_TIMEZONES = [
+ 'UTC',
+ # Americas
+ 'America/New_York',
+ 'America/Detroit',
+ 'America/Indiana/Indianapolis',
+ 'America/Chicago',
+ 'America/Denver',
+ 'America/Phoenix',
+ 'America/Los_Angeles',
+ 'America/Anchorage',
+ 'America/Adak',
+ 'Pacific/Honolulu',
+ 'America/Toronto',
+ 'America/Vancouver',
+ 'America/Winnipeg',
+ 'America/Halifax',
+ 'America/St_Johns',
+ 'America/Mexico_City',
+ 'America/Bogota',
+ 'America/Lima',
+ 'America/Santiago',
+ 'America/Caracas',
+ 'America/Sao_Paulo',
+ 'America/Argentina/Buenos_Aires',
+ 'America/Montevideo',
+ # Europe
+ 'Europe/London',
+ 'Europe/Dublin',
+ 'Europe/Lisbon',
+ 'Europe/Paris',
+ 'Europe/Berlin',
+ 'Europe/Amsterdam',
+ 'Europe/Brussels',
+ 'Europe/Madrid',
+ 'Europe/Rome',
+ 'Europe/Zurich',
+ 'Europe/Vienna',
+ 'Europe/Stockholm',
+ 'Europe/Oslo',
+ 'Europe/Copenhagen',
+ 'Europe/Helsinki',
+ 'Europe/Warsaw',
+ 'Europe/Prague',
+ 'Europe/Budapest',
+ 'Europe/Bucharest',
+ 'Europe/Athens',
+ 'Europe/Istanbul',
+ 'Europe/Moscow',
+ 'Europe/Kyiv',
+ # Africa
+ 'Africa/Casablanca',
+ 'Africa/Lagos',
+ 'Africa/Cairo',
+ 'Africa/Nairobi',
+ 'Africa/Johannesburg',
+ # Asia
+ 'Asia/Dubai',
+ 'Asia/Tbilisi',
+ 'Asia/Tehran',
+ 'Asia/Karachi',
+ 'Asia/Kolkata',
+ 'Asia/Colombo',
+ 'Asia/Dhaka',
+ 'Asia/Yangon',
+ 'Asia/Bangkok',
+ 'Asia/Ho_Chi_Minh',
+ 'Asia/Singapore',
+ 'Asia/Kuala_Lumpur',
+ 'Asia/Jakarta',
+ 'Asia/Shanghai',
+ 'Asia/Hong_Kong',
+ 'Asia/Taipei',
+ 'Asia/Manila',
+ 'Asia/Seoul',
+ 'Asia/Tokyo',
+ 'Asia/Yakutsk',
+ 'Asia/Vladivostok',
+ # Australia / Pacific
+ 'Australia/Perth',
+ 'Australia/Darwin',
+ 'Australia/Adelaide',
+ 'Australia/Brisbane',
+ 'Australia/Sydney',
+ 'Australia/Melbourne',
+ 'Australia/Hobart',
+ 'Pacific/Auckland',
+ 'Pacific/Fiji',
+ 'Pacific/Guam',
+ 'Pacific/Honolulu',
+]
+
+_TIMEZONE_SET = set(VALID_TIMEZONES)
+
+
+def _strip(value, pattern, max_len):
+ return re.sub(pattern, '', str(value).strip())[:max_len]
+
+
+def text(value, max_len=200):
+ """General description: letters, digits, spaces, basic punctuation. No quotes/braces/brackets/slashes."""
+ return _strip(value, r'''["'{}\[\]\\/<>;`^~]''', max_len)
+
+def name(value, max_len=64):
+ """Label/name: letters, digits, spaces, hyphens, underscores, dots."""
+ return _strip(value, r'[^A-Za-z0-9 \-_.]', max_len)
+
+def hostname(value, max_len=253):
+ """Hostname or domain: letters, digits, hyphens, dots. Lowercased."""
+ return _strip(value.lower(), r'[^a-z0-9\-.]', max_len)
+
+def ip(value, max_len=45):
+ """IPv4 or IPv6 address: digits, dots, colons, hex letters."""
+ return _strip(value, r'[^0-9a-fA-F.:]', max_len)
+
+def ip_or_cidr(value, max_len=49):
+ """IP address or CIDR subnet: adds forward slash."""
+ return _strip(value, r'[^0-9a-fA-F.:/]', max_len)
+
+def mac(value, max_len=17):
+ """MAC address: hex digits and colons."""
+ return _strip(value.upper(), r'[^0-9A-F:]', max_len)
+
+def url(value, max_len=500):
+ """URL: printable ASCII except quotes, braces, brackets, backslash, spaces."""
+ return _strip(value, r'''["'{}\[\]\\ ]''', max_len)
+
+def interface_name(value, max_len=32):
+ """Network interface name: letters, digits, hyphens, underscores, dots."""
+ return _strip(value, r'[^A-Za-z0-9\-_.]', max_len)
+
+def port(value):
+ """Port number string, validated 1-65535. Returns '' if invalid."""
+ digits = re.sub(r'[^0-9]', '', str(value))
+ try:
+ n = int(digits)
+ if 1 <= n <= 65535:
+ return str(n)
+ except (ValueError, TypeError):
+ pass
+ return ''
+
+def time_24h(value, max_len=5):
+ """24-hour time HH:MM: digits and colon only."""
+ return _strip(value, r'[^0-9:]', max_len)
+
+def email(value, max_len=254):
+ """Email address: letters, digits, @, dot, hyphen, underscore, plus. Lowercased."""
+ return _strip(value.lower(), r'[^a-z0-9@.\-_+]', max_len)
+
+def timezone(value):
+ """Timezone string: must be in VALID_TIMEZONES list. Returns '' if not found."""
+ return value if value in _TIMEZONE_SET else ''
diff --git a/docker/router-dash/app/validate.py b/docker/router-dash/app/validate.py
new file mode 100644
index 0000000..0389999
--- /dev/null
+++ b/docker/router-dash/app/validate.py
@@ -0,0 +1,25 @@
+"""
+validate.py -- Flask app re-export of the shared validation module.
+
+~/router/validation.py is volume-mounted to /app/validation.py by
+docker-compose, making it directly importable. Action scripts import
+this module (validate.*) so they are insulated from the shared file's
+location and name.
+"""
+from validation import (
+ VALID_PROTOCOLS,
+ VALID_BLOCKLIST_FORMATS,
+ ip,
+ ip_or_cidr,
+ port,
+ banned_ip,
+)
+
+__all__ = [
+ 'VALID_PROTOCOLS',
+ 'VALID_BLOCKLIST_FORMATS',
+ 'ip',
+ 'ip_or_cidr',
+ 'port',
+ 'banned_ip',
+]
diff --git a/docker/router-dash/app/view_page.py b/docker/router-dash/app/view_page.py
new file mode 100644
index 0000000..f6162a9
--- /dev/null
+++ b/docker/router-dash/app/view_page.py
@@ -0,0 +1,1210 @@
+from flask import Blueprint, session, redirect, get_flashed_messages
+from markupsafe import Markup
+import json, re, subprocess, os, sys, html as html_mod
+import sanitize
+from datetime import datetime, timezone
+from config_utils import core_hash
+
+bp = Blueprint('view_page', __name__)
+
+DATA_DIR = '/data'
+CONFIGS_DIR = '/configs'
+
+LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
+
+
+# -- Access level --------------------------------------------------------------
+
+def _client_level():
+ return LEVEL_RANK.get(session.get('access_level', 'nothing'), 0)
+
+def _passes(req, level):
+ if not req:
+ return False
+ for suffix, check in (('+', lambda n, l: l >= n),
+ ('-', lambda n, l: l <= n),
+ ('=', lambda n, l: l == n)):
+ if req.endswith(suffix):
+ role = req[:-1].replace('client_is_', '', 1)
+ needed = LEVEL_RANK.get(role)
+ if needed is None:
+ print(f'[view_page] WARNING: unknown role "{role}" in client_requirement "{req}"', file=sys.stderr)
+ return False
+ return check(needed, level)
+ print(f'[view_page] WARNING: client_requirement "{req}" has no valid suffix (+, -, =)', file=sys.stderr)
+ return False
+
+
+# -- File loaders --------------------------------------------------------------
+
+def _load_json(path):
+ try:
+ with open(path) as f:
+ return json.load(f)
+ except Exception as ex:
+ print(f'[view_page] ERROR loading {path}: {ex}', file=sys.stderr)
+ 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_accounts(): return _load_json(f'{DATA_DIR}/authorized_accounts.json')
+
+def _load_css():
+ try:
+ with open(f'{DATA_DIR}/page_styles.css') as f:
+ return f.read()
+ except Exception:
+ return ''
+
+
+# -- Shell helper --------------------------------------------------------------
+
+def _run(cmd):
+ try:
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
+ return r.stdout.strip()
+ except Exception:
+ return ''
+
+
+# -- Live data loaders ---------------------------------------------------------
+
+def _live_dhcp_leases():
+ rows = []
+ leases_file = '/var/lib/misc/dnsmasq.leases'
+ try:
+ with open(leases_file) as f:
+ for line in f:
+ parts = line.strip().split()
+ if len(parts) >= 4:
+ rows.append({
+ 'hostname': parts[3] if parts[3] != '*' else '-',
+ 'ip_address': parts[2],
+ 'mac_address': parts[1],
+ 'vlan_name': _vlan_name_for_ip(parts[2]),
+ 'expires': _fmt_timestamp(int(parts[0])),
+ })
+ except Exception:
+ pass
+ return rows
+
+def _vlan_name_for_ip(ip):
+ import ipaddress
+ for vlan in _load_core().get('vlans', []):
+ subnet = vlan.get('dhcp', {}).get('subnet', '')
+ if not subnet:
+ continue
+ try:
+ if ipaddress.ip_address(ip) in ipaddress.ip_network(subnet + '/24', strict=False):
+ return vlan.get('name', '-')
+ except Exception:
+ pass
+ return '-'
+
+def _fmt_timestamp(ts):
+ try:
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
+ except Exception:
+ return '-'
+
+def _live_vpn_sessions():
+ rows = []
+ out = _run('wg show all dump 2>/dev/null')
+ for line in out.splitlines():
+ parts = line.split('\t')
+ if len(parts) == 9:
+ interface, _pubkey, _psk, endpoint, allowed_ips, last_hs, rx, tx, _ka = parts
+ rows.append({
+ 'peer_name': _pubkey[:16] + '...',
+ 'interface': interface,
+ 'tunnel_ip': allowed_ips.split(',')[0].split('/')[0] if allowed_ips else '-',
+ 'endpoint': endpoint if endpoint != '(none)' else '-',
+ 'last_handshake': _fmt_timestamp(int(last_hs)) if last_hs.isdigit() and last_hs != '0' else 'Never',
+ 'rx_bytes': _fmt_bytes(int(rx)) if rx.isdigit() else '-',
+ 'tx_bytes': _fmt_bytes(int(tx)) if tx.isdigit() else '-',
+ })
+ return rows
+
+def _fmt_bytes(n):
+ for unit in ('B', 'KB', 'MB', 'GB'):
+ if n < 1024:
+ return f'{n:.1f} {unit}'
+ n /= 1024
+ return f'{n:.1f} TB'
+
+
+# -- Config data loaders -------------------------------------------------------
+
+def _config_datasource(name):
+ core = _load_core()
+ vlans = core.get('vlans', [])
+
+ if name == 'banned_ips':
+ return core.get('banned_ips', [])
+
+ if name == 'host_overrides':
+ return core.get('host_overrides', [])
+
+ if name == 'blocklists':
+ rows = []
+ for bl in core.get('blocklists', []):
+ row = dict(bl)
+ bl_path = f'{CONFIGS_DIR}/blocklists/{bl.get("save_as", "")}'
+ try:
+ with open(bl_path) as f:
+ row['domain_count'] = str(sum(1 for _ in f))
+ row['last_updated'] = _fmt_timestamp(int(os.path.getmtime(bl_path)))
+ except Exception:
+ row['domain_count'] = '-'
+ row['last_updated'] = '-'
+ rows.append(row)
+ return rows
+
+ if name == 'vlans':
+ rows = []
+ for v in vlans:
+ row = {k: v.get(k) for k in ('vlan_id', 'name', 'interface', 'dhcp', 'radius_default', 'mdns_reflection')}
+ row['subnet'] = v.get('dhcp', {}).get('subnet', '')
+ row['use_blocklists'] = json.dumps(v.get('use_blocklists', []))
+ rows.append(row)
+ return rows
+
+ if name == 'inter_vlan_exceptions':
+ return core.get('inter_vlan_exceptions', [])
+
+ if name == 'port_forwarding':
+ return core.get('port_forwarding', [])
+
+ if name == 'dhcp_reservations':
+ rows = []
+ for vlan in vlans:
+ for res in vlan.get('reservations', []):
+ row = dict(res)
+ row['vlan_name'] = vlan.get('name', '-')
+ rows.append(row)
+ return rows
+
+ if name == 'ddns_providers':
+ ddns = _load_ddns()
+ rows = []
+ for p in ddns.get('providers', []):
+ row = dict(p)
+ ptype = p.get('provider', '').lower()
+ if ptype == 'noip':
+ row['credentials'] = f"U: {p.get('username', '-')}"
+ elif ptype in ('cloudflare', 'duckdns'):
+ row['credentials'] = '(token set)' if p.get('api_token') else '(not set)'
+ else:
+ row['credentials'] = '-'
+ row['hostnames'] = json.dumps(p.get('hostnames', p.get('subdomains', [])))
+ rows.append(row)
+ return rows
+
+ if name == 'accounts':
+ rows = []
+ for acct in _load_accounts().get('accounts', []):
+ row = dict(acct)
+ row['account_status'] = 'active' if acct.get('hashed_password') else 'pending'
+ rows.append(row)
+ return rows
+
+ return []
+
+
+# -- Live stat helpers ---------------------------------------------------------
+
+def _get_dnsmasq_stats():
+ stats = {'queries': '-', 'hits': '-', 'hit_rate': '-',
+ 'forwarded': '-', 'auth': '-', 'tcp_peak': '-'}
+ out = _run('journalctl -u dnsmasq -n 200 --no-pager 2>/dev/null')
+ for line in reversed(out.splitlines()):
+ if 'queries forwarded' in line:
+ m = re.search(r'queries forwarded (\d+)', line)
+ if m: stats['forwarded'] = m.group(1)
+ m = re.search(r'queries answered locally (\d+)', line)
+ if m: stats['hits'] = m.group(1)
+ fwd = int(stats['forwarded']) if stats['forwarded'] != '-' else 0
+ hit = int(stats['hits']) if stats['hits'] != '-' else 0
+ total = fwd + hit
+ stats['queries'] = str(total) if total else '-'
+ if total > 0:
+ stats['hit_rate'] = f'{hit / total * 100:.0f}%'
+ break
+ if 'auth answered' in line:
+ m = re.search(r'auth answered (\d+)', line)
+ if m and stats['auth'] == '-':
+ stats['auth'] = m.group(1)
+ if 'max TCP connections' in line:
+ m = re.search(r'max TCP connections (\d+)', line)
+ if m and stats['tcp_peak'] == '-':
+ stats['tcp_peak'] = m.group(1)
+ return stats
+
+def _count_blocked_today():
+ out = _run("journalctl -u dnsmasq --since today --no-pager 2>/dev/null | grep -c 'is NXDOMAIN'")
+ return out or '0'
+
+def _count_blocked_domains():
+ bl_dir = f'{CONFIGS_DIR}/blocklists'
+ try:
+ total = sum(
+ int(_run(f'wc -l < "{bl_dir}/{f}"') or 0)
+ for f in os.listdir(bl_dir) if f.endswith('.conf')
+ )
+ return str(total)
+ except Exception:
+ return '-'
+
+def _bl_last_update():
+ bl_dir = f'{CONFIGS_DIR}/blocklists'
+ try:
+ mtime = max(
+ os.path.getmtime(f'{bl_dir}/{f}')
+ for f in os.listdir(bl_dir) if f.endswith('.conf')
+ )
+ return _fmt_timestamp(int(mtime))
+ except Exception:
+ return '-'
+
+def _ddns_log_tail(n=50):
+ log_path = f'{CONFIGS_DIR}/ddns.log'
+ try:
+ with open(log_path) as f:
+ lines = f.readlines()
+ return ''.join(lines[-n:]).strip() or '(log is empty)'
+ except FileNotFoundError:
+ return '(log file not found)'
+ except Exception:
+ return '(error reading log)'
+
+def _fmt_seconds(secs):
+ secs = int(secs)
+ if secs < 60:
+ return f'{secs}s'
+ m, s = divmod(secs, 60)
+ if m < 60:
+ return f'{m}m {s}s' if s else f'{m}m'
+ h, m = divmod(m, 60)
+ return f'{h}h {m}m' if m else f'{h}h'
+
+def _parse_interval_to_seconds(s):
+ m = re.match(r'^(\d+)([mhd])$', str(s).strip())
+ if not m:
+ return None
+ val, unit = int(m.group(1)), m.group(2)
+ return val * {'m': 60, 'h': 3600, 'd': 86400}[unit]
+
+def _parse_time_remaining(text):
+ for line in text.splitlines():
+ if 'Trigger:' in line:
+ total, found = 0, False
+ for amt, unit in re.findall(r'(\d+)\s*(day|h|min|s)\b', line):
+ total += int(amt) * {'day': 86400, 'h': 3600, 'min': 60, 's': 1}[unit]
+ found = True
+ if found:
+ return total
+ return None
+
+def _read_cached_ip():
+ try:
+ best_ip, best_mtime = '', 0
+ for fname in os.listdir(CONFIGS_DIR):
+ if fname.startswith('.ddns-last-ip-'):
+ path = f'{CONFIGS_DIR}/{fname}'
+ mtime = os.path.getmtime(path)
+ if mtime > best_mtime:
+ ip = open(path).read().strip()
+ if ip:
+ best_ip, best_mtime = ip, mtime
+ return best_ip
+ except Exception:
+ return ''
+
+def _public_ip_info(ddns_cfg):
+ """Return (ip_str, domains_sub, next_interval_str) for stat cards."""
+ script = f'{CONFIGS_DIR}/ddns.py'
+ enabled_p = [p for p in ddns_cfg.get('providers', []) if p.get('enabled', True)]
+ all_hosts = []
+ for p in enabled_p:
+ all_hosts.extend(p.get('hostnames', p.get('subdomains', [])))
+ domains_sub = ', '.join(all_hosts)
+ interval_secs = _parse_interval_to_seconds(ddns_cfg.get('general', {}).get('timer_interval', ''))
+ next_interval = '-'
+
+ # Path 1: timer healthy and within interval -> use cached IP
+ if interval_secs and enabled_p:
+ status = _run(f'python3 {script} --status 2>/dev/null')
+ if status:
+ is_enabled = '; enabled' in status
+ is_active = 'active (waiting)' in status or 'active (running)' in status
+ remaining = _parse_time_remaining(status)
+ if remaining is not None:
+ next_interval = _fmt_seconds(remaining)
+ if is_enabled and is_active and remaining is not None and remaining < interval_secs:
+ ip = _read_cached_ip()
+ if ip:
+ return ip, domains_sub, next_interval
+
+ # Path 2: live fetch
+ ip = _run(f'python3 {script} --getip 2>/dev/null')
+ if ip and re.match(r'^\d{1,3}(\.\d{1,3}){3}$', ip):
+ return ip, domains_sub, next_interval
+
+ # Path 3: offline
+ return 'DDNS Offline', domains_sub, next_interval
+
+def _vpn_info():
+ for vlan in _load_core().get('vlans', []):
+ if 'vpn_information' in vlan:
+ return vlan['vpn_information']
+ return {}
+
+
+# -- Token collection ----------------------------------------------------------
+
+def collect_tokens():
+ tokens = {}
+ core = _load_core()
+ gen = core.get('general', {})
+ dns = core.get('upstream_dns', {})
+ vlans = core.get('vlans', [])
+ tokens['GENERAL_WAN_INTERFACE'] = str(gen.get('wan_interface', '-'))
+ tokens['GENERAL_LOG_MAX_KB'] = str(gen.get('log_max_kb', '-'))
+ tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if gen.get('log_errors_only') else 'false'
+ tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if gen.get('dnsmasq_log_queries') else 'false'
+ tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(gen.get('daily_execute_time_24hr_local', '-'))
+
+ servers = dns.get('upstream_servers', [])
+ tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false'
+ tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-'))
+ tokens['DNS_UPSTREAM_SERVERS_JSON'] = json.dumps(servers)
+ tokens['OVERVIEW_UPSTREAM_SERVERS'] = ', '.join(servers) or '-'
+
+ non_vpn_vlans = [v for v in vlans if 'dhcp' in v]
+ vlan_names = [v.get('name', '') for v in vlans]
+ tokens['OVERVIEW_VLAN_NAMES'] = ', '.join(vlan_names) or '-'
+ tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans))
+ tokens['STAT_LEASE_COUNT'] = str(len(_live_dhcp_leases()))
+
+ filter_opts = 'All VLANs ' + ''.join(
+ f'{e(n)} ' for n in vlan_names
+ )
+ tokens['VLAN_FILTER_OPTIONS'] = filter_opts
+ tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names])
+
+ tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
+ tokens['STAT_BLOCKLIST_COUNT'] = str(sum(1 for b in core.get('blocklists', []) if b.get('enabled', True)))
+
+ ddns = _load_ddns()
+ tokens['DDNS_TIMER_INTERVAL'] = ddns.get('general', {}).get('timer_interval', '-')
+ enabled_p = [p for p in ddns.get('providers', []) if p.get('enabled', True)]
+ tokens['STAT_DDNS_PROVIDER_COUNT'] = str(len(enabled_p))
+ tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([
+ {'value': 'noip', 'label': 'No-IP'},
+ {'value': 'cloudflare', 'label': 'Cloudflare'},
+ {'value': 'duckdns', 'label': 'DuckDNS'},
+ ])
+
+ vpn = _vpn_info()
+ overrides = vpn.get('explicit_overrides', {})
+ tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
+ tokens['VPN_GATEWAY'] = str(vpn.get('gateway', ''))
+ tokens['VPN_DOMAIN'] = str(vpn.get('domain', ''))
+ tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_server', ''))
+ tokens['VPN_MTU'] = str(overrides.get('mtu', ''))
+
+ ip_str, sub_str, next_interval = _public_ip_info(ddns)
+ tokens['STAT_PUBLIC_IP'] = ip_str
+ tokens['STAT_DDNS_HOSTNAME'] = sub_str
+ tokens['STAT_DDNS_NEXT_INTERVAL'] = next_interval
+ tokens['DDNS_LOG_TAIL'] = _ddns_log_tail()
+ tokens['STAT_UPTIME'] = _run('uptime -p') or '-'
+ tokens['STAT_NFTABLES_STATUS'] = 'Active' if _run('nft list tables 2>/dev/null').strip() else 'Inactive'
+
+ dns_stats = _get_dnsmasq_stats()
+ tokens['DNS_STAT_QUERIES'] = dns_stats['queries']
+ tokens['DNS_STAT_HITS'] = dns_stats['hits']
+ tokens['DNS_STAT_HIT_RATE'] = dns_stats['hit_rate']
+ tokens['DNS_STAT_FORWARDED'] = dns_stats['forwarded']
+ tokens['DNS_STAT_AUTH'] = dns_stats['auth']
+ tokens['DNS_STAT_TCP_PEAK'] = dns_stats['tcp_peak']
+
+ tokens['STAT_BLOCKED_TODAY'] = _count_blocked_today()
+ tokens['STAT_BLOCKED_DOMAINS'] = _count_blocked_domains()
+ tokens['STAT_BL_LAST_UPDATE'] = _bl_last_update()
+
+ tokens['PREF_EMAIL'] = session.get('email_address', '')
+ tokens['PREF_TIMEZONE'] = session.get('timezone', '')
+
+ blank = [{'value': '', 'label': '-- Select timezone --'}]
+ tokens['TIMEZONE_OPTIONS'] = json.dumps(
+ blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]
+ )
+
+ return tokens
+
+
+# -- HTML helpers --------------------------------------------------------------
+
+def e(text):
+ return html_mod.escape(str(text))
+
+def apply_tokens(text, tokens):
+ """Substitute %TOKEN% placeholders. Values are NOT auto-escaped - callers
+ that use results in HTML attribute or text context should call e() around
+ the expanded value (or around individual fields) as appropriate."""
+ return re.sub(r'%([A-Z_]+)%', lambda m: str(tokens.get(m.group(1), m.group(0))), text)
+
+
+def _expand_fields(obj, tokens):
+ """Recursively apply token substitution to a field-definition object.
+ String values that resolve to a JSON array or object are parsed back into
+ Python structures so they serialize correctly into data-fields JSON."""
+ if isinstance(obj, list):
+ return [_expand_fields(item, tokens) for item in obj]
+ if isinstance(obj, dict):
+ out = {}
+ for k, v in obj.items():
+ if isinstance(v, str):
+ s = apply_tokens(v, tokens)
+ if s != v and s[:1] in ('[', '{'):
+ try:
+ out[k] = json.loads(s)
+ continue
+ except Exception:
+ pass
+ out[k] = s
+ else:
+ out[k] = _expand_fields(v, tokens)
+ return out
+ return obj
+
+
+# -- Content item renderers ----------------------------------------------------
+
+def render_items(items, tokens, inherited_req=None):
+ level = _client_level()
+ parts = []
+ for item in items:
+ req = item.get('client_requirement', inherited_req)
+ if not _passes(req, level):
+ continue
+ parts.append(_render_item(item, tokens, req))
+ return ''.join(parts)
+
+def _render_item(item, tokens, inherited_req=None):
+ t = item.get('type', '')
+ req = item.get('client_requirement', inherited_req)
+
+ if t == 'h1':
+ return f'
{e(apply_tokens(item.get("text", ""), tokens))} '
+
+ if t == 'p':
+ text = e(apply_tokens(item.get('text', ''), tokens))
+ link = item.get('link')
+ if link:
+ href = e(apply_tokens(link.get('action', '#'), tokens))
+ ltext = e(apply_tokens(link.get('text', ''), tokens))
+ return f'{text} {ltext}
'
+ return f'{text}
'
+
+ if t == 'spacer':
+ return '
'
+
+ if t in ('button_primary', 'button_secondary', 'button_danger', 'button_ghost'):
+ cls_map = {
+ 'button_primary': 'btn-primary',
+ 'button_secondary': 'btn-secondary',
+ 'button_danger': 'btn-danger',
+ 'button_ghost': 'btn-ghost',
+ }
+ cls = cls_map[t]
+ extra = item.get('class', '')
+ if extra:
+ cls = f'{cls} {extra}'
+ text = e(apply_tokens(item.get('text', ''), tokens))
+ action = e(apply_tokens(item.get('action', '#'), tokens))
+ if item.get('method', '').lower() == 'post':
+ return (f'')
+ return f'{text} '
+
+ if t == 'button_cancel':
+ text = e(apply_tokens(item.get('text', 'Cancel'), tokens))
+ return f'{text} '
+
+ if t == 'page_header':
+ return f''
+
+ if t in ('section', 'auth_wrapper'):
+ tag = 'div'
+ cls = 'auth-wrapper' if t == 'auth_wrapper' else 'section'
+ return f'<{tag} class="{cls}">{render_items(item.get("items", []), tokens, req)}{tag}>'
+
+ if t == 'auth_card':
+ return f'{render_items(item.get("items", []), tokens, req)}
'
+
+ if t == 'stat_card_grid':
+ return f'{render_items(item.get("items", []), tokens, req)}
'
+
+ if t == 'stat_card':
+ label = e(apply_tokens(item.get('label', ''), tokens))
+ value = e(apply_tokens(item.get('value', ''), tokens))
+ sub = e(apply_tokens(item.get('sub', ''), tokens))
+ variant = item.get('variant', '')
+ cls = f'stat-card{(" stat-card-" + variant) if variant else ""}'
+ return (f''
+ f'
{label}
'
+ f'
{value}
'
+ f'
{sub}
'
+ f'
')
+
+ if t == 'card':
+ label = item.get('label', '')
+ id_attr = f' id="{e(item["id"])}"' if item.get('id') else ''
+ style = ' style="display:none"' if item.get('hidden') else ''
+ header = f'' if label else ''
+ body = render_items(item.get('items', []), tokens, req)
+ return f''
+
+ if t == 'info_bar':
+ variant = item.get('variant', 'info')
+ text = e(apply_tokens(item.get('text', ''), tokens))
+ return f'{text}
'
+
+ if t == 'pre_block':
+ text = e(apply_tokens(item.get('text', ''), tokens))
+ return f'{text} '
+
+ if t == 'credential_fields':
+ psel = e(item.get('provider_select', 'provider'))
+ return (
+ f''
+ f'
'
+ f'
API Token '
+ f'
'
+ f'
'
+ f'
'
+ f'
'
+ )
+
+ if t == 'grid':
+ rows_html = ''
+ for row in item.get('rows', []):
+ cells = ''.join(_render_item(c, tokens, req) for c in row.get('cells', []))
+ rows_html += f'{cells}
'
+ return f'{rows_html}
'
+
+ if t == 'grid_label':
+ return f'{e(apply_tokens(item.get("text", ""), tokens))}
'
+
+ if t == 'grid_value':
+ return f'{e(apply_tokens(item.get("text", ""), tokens))}
'
+
+ if t == 'form':
+ action = e(apply_tokens(item.get('action', ''), tokens))
+ method = e(item.get('method', 'post'))
+ inner = render_items(item.get('items', []), tokens, req)
+ hash_field = f' '
+ return f''
+
+ if t == 'field':
+ return _render_field(item, tokens)
+
+ if t == 'editable_list':
+ return _render_editable_list(item, tokens)
+
+ if t == 'select':
+ name = e(item.get('name', ''))
+ options = apply_tokens(item.get('options', ''), tokens)
+ return f'{options} '
+
+ if t == 'button_row':
+ inner = render_items(item.get('items', []), tokens, req)
+ return f'{inner}
'
+
+ if t == 'table':
+ return _render_table(item, tokens, req)
+
+ return ''
+
+
+def _render_field(item, tokens):
+ label = e(item.get('label', ''))
+ name = e(item.get('name', ''))
+ input_type = item.get('input_type', 'text')
+ value = apply_tokens(item.get('value', ''), tokens)
+ placeholder = e(apply_tokens(item.get('placeholder', ''), tokens))
+ hint = e(apply_tokens(item.get('hint', ''), tokens))
+ hint_html = f'{hint}
' if hint else ''
+
+ if input_type == 'hidden':
+ return f' '
+
+ if input_type == 'checkbox':
+ checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
+ return (f''
+ f''
+ f' {label}'
+ f' {hint_html}
')
+
+ if input_type == 'checkbox_group':
+ try:
+ opts = json.loads(apply_tokens(item.get('options', '[]'), tokens))
+ selected = json.loads(value) if value else []
+ except Exception:
+ opts, selected = [], []
+ boxes = ''.join(
+ f''
+ f' {e(o.get("label",""))}'
+ f' '
+ for o in opts
+ )
+ return (f'')
+
+ if input_type == 'select':
+ options = item.get('options', [])
+ if isinstance(options, str):
+ try:
+ options = json.loads(apply_tokens(options, tokens))
+ except Exception:
+ options = []
+ current = apply_tokens(item.get('value', ''), tokens)
+ opts_html = ''.join(
+ f'{e(o["label"])} '
+ for o in options
+ )
+ return (f'{label} '
+ f'{opts_html} '
+ f'{hint_html}
')
+
+ if input_type == 'number':
+ min_attr = f' min="{item["min"]}"' if 'min' in item else ''
+ max_attr = f' max="{item["max"]}"' if 'max' in item else ''
+ return (f'{label} '
+ f' '
+ f'{hint_html}
')
+
+ if input_type == 'textarea':
+ rows = item.get('rows', 4)
+ return (f'{label} '
+ f''
+ f'{hint_html}
')
+
+ return (f'{label} '
+ f' {hint_html}
')
+
+
+def _render_editable_list(item, tokens):
+ label = e(item.get('label', ''))
+ name = e(item.get('name', ''))
+ ph = e(apply_tokens(item.get('item_placeholder', ''), tokens))
+ add_lbl = e(apply_tokens(item.get('add_label', 'Add'), tokens))
+ hint = e(apply_tokens(item.get('hint', ''), tokens))
+ hint_html = f'{hint}
' if hint else ''
+
+ try:
+ items_list = json.loads(apply_tokens(item.get('items', '[]'), tokens))
+ except Exception:
+ items_list = []
+
+ rows = ''.join(
+ f''
+ f' '
+ f'Remove '
+ f'
'
+ for v in items_list
+ )
+ return (f'')
+
+
+def _render_table(item, tokens, inherited_req=None):
+ level = _client_level()
+ columns = item.get('columns', [])
+ rows = _load_datasource(item.get('datasource', ''))
+ empty = e(item.get('empty_message', 'No data.'))
+ row_actions = item.get('row_actions', [])
+ hash_val = core_hash()
+
+ toolbar_html = ''
+ toolbar = item.get('toolbar')
+ if toolbar:
+ req = toolbar.get('client_requirement', inherited_req)
+ if _passes(req, level):
+ t_inner = render_items(toolbar.get('items', []), tokens, req)
+ toolbar_html = f'{t_inner}
'
+
+ thead = ''.join(f'{e(c.get("label",""))} ' for c in columns)
+ if row_actions:
+ thead += ' '
+
+ if not rows:
+ colspan = len(columns) + (1 if row_actions else 0)
+ tbody = f'{empty} '
+ else:
+ tbody = ''
+ for idx, row in enumerate(rows):
+ cells = ''
+ for col in columns:
+ val = row
+ for part in col.get('field', '').split('.'):
+ val = val.get(part, '') if isinstance(val, dict) else ''
+ col_req = col.get('client_requirement', inherited_req)
+ toggle_allowed = _passes(col_req, level) if col_req else True
+ cells += _render_table_cell(
+ str(val) if val != '' else '-',
+ col.get('render', ''),
+ col.get('class', ''),
+ field=col.get('field', ''),
+ row_idx=idx,
+ toggle_action=col.get('toggle_action'),
+ toggle_allowed=toggle_allowed,
+ )
+ if row_actions:
+ btns = ''
+ for ra in row_actions:
+ req = ra.get('client_requirement', inherited_req)
+ if not _passes(req, level):
+ continue
+ text = e(ra.get('text', ''))
+ cls = e(ra.get('class', 'btn-ghost btn-sm'))
+ action = e(apply_tokens(ra.get('action', '#'), tokens))
+ method = ra.get('method', 'post').lower()
+ if method == 'post':
+ btns += (f'')
+ elif method == 'js_edit':
+ target = e(ra.get('target', 'edit-form'))
+ row_json = e(json.dumps(row))
+ btns += (f'{text} ')
+ elif method == 'inline_edit':
+ fields_json = e(json.dumps(_expand_fields(ra.get('fields', []), tokens)))
+ row_json = e(json.dumps(row))
+ btns += (f'{text} ')
+ else:
+ btns += f'{text} '
+ cells += f'{btns} '
+ tbody += f'{cells} '
+
+ return (f'{toolbar_html}'
+ f''
+ f'
'
+ f'{thead} '
+ f'{tbody} '
+ f'
')
+
+
+def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None,
+ toggle_action=None, toggle_allowed=True):
+ parts = []
+ if col_class:
+ parts.append(f'class="{e(col_class)}"')
+ if field:
+ parts.append(f'data-field="{e(field)}"')
+ td_open = f'' if parts else ' '
+
+ if not render_fn:
+ return f'{td_open}{e(value)} '
+
+ if render_fn == 'badge_enabled_disabled':
+ if str(value).lower() in ('true', '1', 'yes', 'enabled'):
+ inner = 'Enabled '
+ else:
+ inner = 'Disabled '
+ return f'{td_open}{inner}'
+
+ if render_fn == 'badge_toggle':
+ if str(value).lower() in ('true', '1', 'yes', 'enabled'):
+ label = 'Enabled'; badge_cls = 'badge-enabled'
+ else:
+ label = 'Disabled'; badge_cls = 'badge-disabled'
+ if toggle_action and row_idx is not None and toggle_allowed:
+ inner = (f'')
+ else:
+ inner = f'{label} '
+ return f'{td_open}{inner}'
+
+ if render_fn == 'badge_active_inactive':
+ badges = {'active': 'badge-enabled', 'pending': 'badge-warning'}
+ cls = badges.get(value.lower(), 'badge-disabled')
+ return f'{td_open}{e(value.title())} '
+
+ if render_fn == 'tag_list':
+ try:
+ items = json.loads(value) if value.startswith('[') else [s.strip() for s in value.split(',')]
+ except Exception:
+ items = [value]
+ tags = ''.join(f'{e(str(t))} ' for t in items if str(t).strip())
+ return f'{td_open}{tags}
'
+
+ return f'{td_open}{e(value)}'
+
+
+def _load_datasource(spec):
+ if spec.startswith('live:'):
+ name = spec[5:]
+ if name == 'dhcp_leases': return _live_dhcp_leases()
+ if name == 'vpn_sessions': return _live_vpn_sessions()
+ return []
+ if spec.startswith('config:'):
+ return _config_datasource(spec[7:])
+ return []
+
+
+# -- Layout renderer -----------------------------------------------------------
+
+def render_layout(view_id, content_html, tokens):
+ css = _load_css()
+ level = _client_level()
+ titlebar_html = 'Router Dashboard
'
+ navbar_html = _render_navbar(view_id, level, tokens)
+ footer_html = ''
+
+ page_hash = core_hash()
+ return (f'\n\n\n'
+ f' \n'
+ f' \n'
+ f' Router Dashboard \n'
+ f' \n'
+ f'\n\n'
+ f'{titlebar_html}\n'
+ f'{navbar_html}\n'
+ f'\n{content_html}\n \n'
+ f'{footer_html}\n'
+ f'\n'
+ f'\n'
+ f'\n')
+
+
+def _render_navbar(active_view, level, tokens):
+ navbar_data = _load_json(f'{DATA_DIR}/navbar_content.json')
+ left, right = [], []
+ for item in navbar_data.get('items', []):
+ req = item.get('client_requirement')
+ align = item.get('align', 'left')
+ if not _passes(req, level):
+ continue
+ frag = _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=req)
+ (right if align == 'right' else left).append(frag)
+
+ return (f''
+ f'{"".join(left)}
'
+ f'{"".join(right)}
'
+ f' ')
+
+
+def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=None):
+ req = item.get('client_requirement', inherited_req)
+ t = item.get('type', '')
+
+ if t in ('nav_item', 'nav_action'):
+ label = e(item.get('label', ''))
+ map_to = item.get('map_to', '')
+ action = item.get('action', '')
+ is_active = ' active' if map_to and map_to == active_view else ''
+ cls = f'dropdown-item{is_active}' if in_dropdown else f'nav-item{is_active}'
+ if action:
+ return (f'')
+ if map_to:
+ return f'{label} '
+ return f'{label} '
+
+ if t == 'nav_menu':
+ raw_label = item.get('label', '')
+ if raw_label == '%MENU_LABEL%':
+ raw_label = 'Configure' if level >= LEVEL_RANK['administrator'] else 'View'
+ label = e(raw_label)
+ children = ''
+ for child in item.get('items', []):
+ child_req = child.get('client_requirement', req)
+ if not _passes(child_req, level):
+ continue
+ children += _render_nav_item(child, active_view, level, in_dropdown=True, inherited_req=req)
+ if not children:
+ return ''
+ return (f'')
+ return ''
+
+
+# -- Inline JavaScript ---------------------------------------------------------
+
+def _inline_js():
+ return r"""
+document.querySelectorAll('.row-edit-btn').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ var row = JSON.parse(this.dataset.row);
+ var idx = this.dataset.rowIndex;
+ var target = document.getElementById(this.dataset.target);
+ if (!target) return;
+ var idxField = target.querySelector('[name="row_index"]');
+ if (idxField) idxField.value = idx;
+ Object.keys(row).forEach(function(key) {
+ var field = target.querySelector('[name="' + key + '"]');
+ if (!field) return;
+ if (field.type === 'checkbox') {
+ field.checked = row[key] === true || row[key] === 'true' || row[key] === 1;
+ } else {
+ field.value = row[key] != null ? String(row[key]) : '';
+ }
+ });
+ target.style.display = '';
+ target.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+ });
+});
+
+document.addEventListener('click', function(e) {
+ var btn = e.target.closest('.row-inline-edit-btn');
+ if (!btn) return;
+ var rowData = JSON.parse(btn.dataset.row);
+ var idx = btn.dataset.rowIndex;
+ var action = btn.dataset.action;
+ var fields = JSON.parse(btn.dataset.fields);
+ var tr = btn.closest('tr');
+ var fieldMap = {};
+ fields.forEach(function(f) { fieldMap[f.col] = f; });
+
+ function esc(s) {
+ return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(//g,'>');
+ }
+
+ function buildCredentialsHtml(provider, data) {
+ if (provider === 'noip') {
+ return 'U: ' +
+ '
' +
+ 'P: ' +
+ '
';
+ } else {
+ return ' ';
+ }
+ }
+
+ tr.querySelectorAll('td[data-field]').forEach(function(td) {
+ var field = td.dataset.field;
+ td.dataset.orig = td.innerHTML;
+ var fDef = fieldMap[field];
+ if (fDef === undefined) return;
+ var inputType = fDef.input_type || 'text';
+ var val = rowData[field] != null ? rowData[field] : '';
+
+ if (inputType === 'checkbox') {
+ var checked = (val === true || val === 'true' || val === 1 || val === '1');
+ td.innerHTML = ' ';
+ } else if (inputType === 'select') {
+ var opts = fDef.options || [];
+ var selHtml = '';
+ opts.forEach(function(o) {
+ selHtml += '' + esc(o.label) + ' ';
+ });
+ selHtml += ' ';
+ td.innerHTML = selHtml;
+ } else if (inputType === 'textarea') {
+ var textVal;
+ try { var arr = JSON.parse(val); textVal = Array.isArray(arr) ? arr.join('\n') : String(val||''); }
+ catch(ex) { textVal = String(val||''); }
+ td.innerHTML = '';
+ } else if (inputType === 'credentials') {
+ td.innerHTML = buildCredentialsHtml(rowData.provider || 'noip', rowData);
+ } else {
+ td.innerHTML = ' ';
+ }
+ });
+
+ var providerTd = tr.querySelector('td[data-field="provider"]');
+ var credsTd = tr.querySelector('td[data-field="credentials"]');
+ if (providerTd && credsTd) {
+ var provSel = providerTd.querySelector('select');
+ if (provSel) {
+ provSel.addEventListener('change', function() {
+ credsTd.innerHTML = buildCredentialsHtml(this.value, rowData);
+ });
+ }
+ }
+
+ var actTd = tr.querySelector('.col-actions');
+ if (actTd) {
+ actTd.dataset.origActions = actTd.innerHTML;
+ actTd.innerHTML =
+ 'Save ' +
+ ' Cancel ';
+
+ actTd.querySelector('.inline-save-btn').addEventListener('click', function() {
+ var f = document.createElement('form');
+ f.method = 'post';
+ f.action = this.dataset.action;
+ f.style.display = 'none';
+ var addHidden = function(name, value) {
+ var inp = document.createElement('input');
+ inp.type = 'hidden'; inp.name = name; inp.value = value;
+ f.appendChild(inp);
+ };
+ addHidden('row_index', this.dataset.rowIndex);
+ addHidden('config_hash', typeof CONFIG_HASH !== 'undefined' ? CONFIG_HASH : '');
+ tr.querySelectorAll('td[data-field] input[name], td[data-field] textarea[name], td[data-field] select[name]').forEach(function(inp) {
+ if (inp.type === 'checkbox') {
+ if (inp.checked) addHidden(inp.name, 'on');
+ } else {
+ addHidden(inp.name, inp.value);
+ }
+ });
+ document.body.appendChild(f);
+ f.submit();
+ });
+
+ actTd.querySelector('.inline-cancel-btn').addEventListener('click', function() {
+ tr.querySelectorAll('td[data-field]').forEach(function(td) {
+ if (td.dataset.orig !== undefined) td.innerHTML = td.dataset.orig;
+ });
+ actTd.innerHTML = actTd.dataset.origActions;
+ });
+ }
+});
+
+document.querySelectorAll('.js-hide-card').forEach(function(btn) {
+ btn.addEventListener('click', function(e) {
+ e.preventDefault();
+ var card = this.closest('.card');
+ if (card) card.style.display = 'none';
+ });
+});
+
+(function() {
+ document.querySelectorAll('form').forEach(function(form) {
+ var cancelBtn = form.querySelector('.btn-cancel');
+ if (!cancelBtn) return;
+ var origValues = {};
+ form.querySelectorAll('input, textarea, select').forEach(function(el) {
+ if (el.name) origValues[el.name] = el.type === 'checkbox' ? el.checked : el.value;
+ });
+ function checkChanged() {
+ var changed = false;
+ form.querySelectorAll('input, textarea, select').forEach(function(el) {
+ if (!el.name) return;
+ var cur = el.type === 'checkbox' ? el.checked : el.value;
+ if (cur !== origValues[el.name]) changed = true;
+ });
+ cancelBtn.disabled = !changed;
+ }
+ form.addEventListener('input', checkChanged);
+ form.addEventListener('change', checkChanged);
+ cancelBtn.addEventListener('click', function() {
+ form.querySelectorAll('input, textarea, select').forEach(function(el) {
+ if (!el.name) return;
+ if (el.type === 'checkbox') {
+ el.checked = origValues[el.name];
+ } else {
+ el.value = origValues[el.name];
+ }
+ });
+ cancelBtn.disabled = true;
+ });
+ });
+})();
+
+document.querySelectorAll('.editable-list').forEach(function(list) {
+ var name = list.dataset.name;
+ var ph = list.dataset.placeholder;
+ function attachRemove(row) {
+ row.querySelector('.editable-list-remove').addEventListener('click', function() {
+ row.remove();
+ });
+ }
+ list.querySelectorAll('.editable-list-item').forEach(attachRemove);
+ list.querySelector('.editable-list-add').addEventListener('click', function() {
+ var row = document.createElement('div');
+ row.className = 'editable-list-item';
+ row.innerHTML = 'Remove ';
+ list.insertBefore(row, this);
+ attachRemove(row);
+ });
+});
+
+(function() {
+ function updateCredFields(container, provider) {
+ var tokenGrp = container.querySelector('.cred-group-token');
+ var noipGrp = container.querySelector('.cred-group-noip');
+ if (!tokenGrp || !noipGrp) return;
+ tokenGrp.style.display = (provider === 'noip') ? 'none' : '';
+ noipGrp.style.display = (provider === 'noip') ? '' : 'none';
+ }
+ document.querySelectorAll('.credential-fields').forEach(function(container) {
+ var selName = container.dataset.providerSelect;
+ var form = container.closest('form');
+ if (!form || !selName) return;
+ var sel = form.querySelector('[name="' + selName + '"]');
+ if (!sel) return;
+ updateCredFields(container, sel.value);
+ sel.addEventListener('change', function() { updateCredFields(container, this.value); });
+ });
+})();
+"""
+
+
+# -- Routes --------------------------------------------------------------------
+
+@bp.route('/')
+def index():
+ return _serve_view('view_overview')
+
+@bp.route('/view/')
+def view(view_id):
+ return _serve_view(view_id)
+
+def _serve_view(view_id):
+ content_data = _load_json(f'{DATA_DIR}/page_content.json')
+ view_def = next((v for v in content_data.get('views', []) if v.get('id') == view_id), None)
+
+ if view_def is None:
+ from flask import abort
+ abort(404)
+
+ view_req = view_def.get('client_requirement')
+ level = _client_level()
+ if not _passes(view_req, level):
+ return redirect('/view/view_overview' if level > 0 else '/view/view_log_in')
+
+ tokens = collect_tokens()
+
+ flash_html = ''
+ for category, message in get_flashed_messages(with_categories=True):
+ variant = {'error': 'danger', 'warning': 'warning', 'success': 'success'}.get(category, 'info')
+ msg_html = message if isinstance(message, Markup) else e(message)
+ flash_html += f'{msg_html}
'
+
+ content_html = flash_html + render_items(view_def.get('items', []), tokens, view_req)
+ return render_layout(view_id, content_html, tokens)
diff --git a/docker/router-dash/data/authorized_accounts.json b/docker/router-dash/data/authorized_accounts.json
index 0967ef4..5f1a638 100644
--- a/docker/router-dash/data/authorized_accounts.json
+++ b/docker/router-dash/data/authorized_accounts.json
@@ -1 +1 @@
-{}
+{"accounts": []}
diff --git a/docker/router-dash/data/dashboard_content.json b/docker/router-dash/data/dashboard_content.json
deleted file mode 100644
index 0967ef4..0000000
--- a/docker/router-dash/data/dashboard_content.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/docker/router-dash/data/navbar_content.json b/docker/router-dash/data/navbar_content.json
new file mode 100644
index 0000000..37b7f0b
--- /dev/null
+++ b/docker/router-dash/data/navbar_content.json
@@ -0,0 +1,53 @@
+{
+ "items": [
+ {
+ "type": "nav_item",
+ "label": "Overview",
+ "map_to": "view_overview",
+ "client_requirement": "client_is_nothing+"
+ },
+ {
+ "type": "nav_menu",
+ "label": "%MENU_LABEL%",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ { "type": "nav_item", "label": "General", "map_to": "view_general", "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": "Upstream DNS", "map_to": "view_upstream_dns", "client_requirement": "client_is_administrator+" },
+ { "type": "nav_item", "label": "DNS Blocklists", "map_to": "view_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+" },
+ { "type": "nav_item", "label": "DDNS", "map_to": "view_ddns" },
+ { "type": "nav_item", "label": "VPN", "map_to": "view_vpn" },
+ { "type": "nav_item", "label": "Banned IPs", "map_to": "view_banned_ips", "client_requirement": "client_is_administrator+" }
+ ]
+ },
+ {
+ "type": "nav_menu",
+ "label": "Profile",
+ "align": "right",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ { "type": "nav_item", "label": "Preferences", "map_to": "view_preferences" },
+ { "type": "nav_item", "label": "Manage Accounts", "map_to": "view_manage_accounts", "client_requirement": "client_is_manager+" },
+ { "type": "nav_action", "label": "Log Out", "action": "log_out" }
+ ]
+ },
+ {
+ "type": "nav_item",
+ "label": "Log In",
+ "map_to": "view_log_in",
+ "align": "right",
+ "client_requirement": "client_is_nothing="
+ },
+ {
+ "type": "nav_item",
+ "label": "Create Account",
+ "map_to": "view_create_account",
+ "align": "right",
+ "client_requirement": "client_is_nothing="
+ }
+ ]
+}
diff --git a/docker/router-dash/data/page_content.json b/docker/router-dash/data/page_content.json
new file mode 100644
index 0000000..99ecce4
--- /dev/null
+++ b/docker/router-dash/data/page_content.json
@@ -0,0 +1,2661 @@
+{
+ "views": [
+ {
+ "id": "view_overview",
+ "client_requirement": "client_is_nothing+",
+ "items": [
+ {
+ "type": "auth_wrapper",
+ "client_requirement": "client_is_nothing=",
+ "items": [
+ {
+ "type": "auth_card",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Router Dashboard"
+ },
+ {
+ "type": "p",
+ "text": "Log in to monitor and manage your home network."
+ },
+ {
+ "type": "spacer"
+ },
+ {
+ "type": "button_primary",
+ "text": "Log In",
+ "action": "/view/view_log_in"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "page_header",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Overview"
+ },
+ {
+ "type": "p",
+ "text": "Current network status at a glance."
+ }
+ ]
+ },
+ {
+ "type": "stat_card_grid",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "stat_card",
+ "label": "DHCP Leases",
+ "value": "%STAT_LEASE_COUNT%",
+ "sub": "active leases",
+ "variant": "accent"
+ },
+ {
+ "type": "stat_card",
+ "label": "Queries Blocked",
+ "value": "%STAT_BLOCKED_TODAY%",
+ "sub": "since midnight",
+ "variant": "warning"
+ },
+ {
+ "type": "stat_card",
+ "label": "Public IP",
+ "value": "%STAT_PUBLIC_IP%",
+ "sub": "%STAT_DDNS_HOSTNAME%"
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "client_requirement": "client_is_viewer+",
+ "label": "Network",
+ "items": [
+ {
+ "type": "grid",
+ "rows": [
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "WAN Interface"
+ },
+ {
+ "type": "grid_value",
+ "text": "%GENERAL_WAN_INTERFACE%"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "VLANs"
+ },
+ {
+ "type": "grid_value",
+ "text": "%OVERVIEW_VLAN_NAMES%"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Firewall"
+ },
+ {
+ "type": "grid_value",
+ "text": "%STAT_NFTABLES_STATUS%"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "System Uptime"
+ },
+ {
+ "type": "grid_value",
+ "text": "%STAT_UPTIME%"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "client_requirement": "client_is_viewer+",
+ "label": "DNS Blocking",
+ "items": [
+ {
+ "type": "grid",
+ "rows": [
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Blocked Domains"
+ },
+ {
+ "type": "grid_value",
+ "text": "%STAT_BLOCKED_DOMAINS%"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Active Blocklists"
+ },
+ {
+ "type": "grid_value",
+ "text": "%STAT_BLOCKLIST_COUNT% lists"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Last Refreshed"
+ },
+ {
+ "type": "grid_value",
+ "text": "%STAT_BL_LAST_UPDATE%"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Active IP Bans"
+ },
+ {
+ "type": "grid_value",
+ "text": "%STAT_BANNED_IP_COUNT% rules"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "client_requirement": "client_is_viewer+",
+ "label": "DNS Caching",
+ "items": [
+ {
+ "type": "grid",
+ "rows": [
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Total Queries"
+ },
+ {
+ "type": "grid_value",
+ "text": "%DNS_STAT_QUERIES%"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Cache Hits"
+ },
+ {
+ "type": "grid_value",
+ "text": "%DNS_STAT_HITS% (%DNS_STAT_HIT_RATE% hit rate)"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Forwarded"
+ },
+ {
+ "type": "grid_value",
+ "text": "%DNS_STAT_FORWARDED%"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Cache Capacity"
+ },
+ {
+ "type": "grid_value",
+ "text": "%DNS_CACHE_SIZE% entries"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Authoritative Answers"
+ },
+ {
+ "type": "grid_value",
+ "text": "%DNS_STAT_AUTH%"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "TCP Connections Peak"
+ },
+ {
+ "type": "grid_value",
+ "text": "%DNS_STAT_TCP_PEAK%"
+ }
+ ]
+ },
+ {
+ "cells": [
+ {
+ "type": "grid_label",
+ "text": "Upstream Servers"
+ },
+ {
+ "type": "grid_value",
+ "text": "%OVERVIEW_UPSTREAM_SERVERS%"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_ddns",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "DDNS"
+ },
+ {
+ "type": "p",
+ "text": "Dynamic DNS provider status and last known IP update."
+ }
+ ]
+ },
+ {
+ "type": "stat_card_grid",
+ "items": [
+ {
+ "type": "stat_card",
+ "label": "Current Public IP",
+ "value": "%STAT_PUBLIC_IP%",
+ "sub": ""
+ },
+ {
+ "type": "stat_card",
+ "label": "Check Interval",
+ "value": "%DDNS_TIMER_INTERVAL%",
+ "sub": "next in %STAT_DDNS_NEXT_INTERVAL%"
+ },
+ {
+ "type": "stat_card",
+ "label": "Providers",
+ "value": "%STAT_DDNS_PROVIDER_COUNT%",
+ "sub": "configured"
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "datasource": "config:ddns_providers",
+ "empty_message": "No DDNS providers configured.",
+ "columns": [
+ {
+ "label": "Description",
+ "field": "description"
+ },
+ {
+ "label": "Provider",
+ "field": "provider"
+ },
+ {
+ "label": "Hostname(s)",
+ "field": "hostnames",
+ "render": "tag_list"
+ },
+ {
+ "label": "Status",
+ "field": "enabled",
+ "render": "badge_enabled_disabled"
+ },
+ {
+ "label": "Credentials",
+ "field": "credentials"
+ }
+ ],
+ "row_actions": [
+ {
+ "text": "Edit",
+ "class": "btn-ghost btn-sm",
+ "action": "/action/edit_ddns_provider",
+ "method": "inline_edit",
+ "client_requirement": "client_is_administrator+",
+ "fields": [
+ {
+ "col": "description",
+ "input_type": "text"
+ },
+ {
+ "col": "provider",
+ "input_type": "select",
+ "options": "%DDNS_PROVIDER_OPTIONS%"
+ },
+ {
+ "col": "hostnames",
+ "input_type": "textarea"
+ },
+ {
+ "col": "enabled",
+ "input_type": "checkbox"
+ },
+ {
+ "col": "credentials",
+ "input_type": "credentials"
+ }
+ ]
+ },
+ {
+ "text": "Delete",
+ "class": "btn-danger btn-sm",
+ "action": "/action/delete_ddns_provider",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "label": "Add DDNS Account",
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/add_ddns_provider",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text",
+ "placeholder": "e.g. My DuckDNS Account"
+ },
+ {
+ "type": "field",
+ "label": "Provider",
+ "name": "provider",
+ "input_type": "select",
+ "options": "%DDNS_PROVIDER_OPTIONS%"
+ },
+ {
+ "type": "field",
+ "label": "Hostnames (one per line)",
+ "name": "hostnames",
+ "input_type": "textarea",
+ "placeholder": "e.g. myhome.duckdns.org"
+ },
+ {
+ "type": "credential_fields",
+ "provider_select": "provider"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Add Provider",
+ "action": "/action/add_ddns_provider",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "label": "DDNS Log",
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_danger",
+ "text": "Clear Log",
+ "action": "/action/clear_ddns_log",
+ "method": "post"
+ }
+ ]
+ },
+ {
+ "type": "pre_block",
+ "text": "%DDNS_LOG_TAIL%"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_general",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "General Settings"
+ },
+ {
+ "type": "p",
+ "text": "Core network and logging configuration from core.json."
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "label": "General",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/apply_general",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "WAN Interface",
+ "name": "wan_interface",
+ "input_type": "text",
+ "value": "%GENERAL_WAN_INTERFACE%",
+ "placeholder": "e.g. eno2",
+ "hint": "The network interface facing your ISP modem or ONT."
+ },
+ {
+ "type": "field",
+ "label": "Max Log Size (KB)",
+ "name": "log_max_kb",
+ "input_type": "number",
+ "value": "%GENERAL_LOG_MAX_KB%",
+ "min": 64,
+ "hint": "Log is cleared and restarted when it exceeds this size."
+ },
+ {
+ "type": "field",
+ "label": "Errors Only",
+ "name": "log_errors_only",
+ "input_type": "checkbox",
+ "value": "%GENERAL_LOG_ERRORS_ONLY%",
+ "hint": "Only write error-level messages to the log."
+ },
+ {
+ "type": "field",
+ "label": "Log DNS Queries",
+ "name": "dnsmasq_log_queries",
+ "input_type": "checkbox",
+ "value": "%GENERAL_DNSMASQ_LOG_QUERIES%",
+ "hint": "Log every DNS query. High volume \u2014 enable for debugging only."
+ },
+ {
+ "type": "field",
+ "label": "Daily Task Time",
+ "name": "daily_execute_time_24hr_local",
+ "input_type": "text",
+ "value": "%GENERAL_DAILY_EXECUTE_TIME%",
+ "placeholder": "e.g. 02:30",
+ "hint": "24-hour local time for the daily blocklist refresh timer."
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Save",
+ "action": "/action/apply_general",
+ "method": "post"
+ },
+ {
+ "type": "button_cancel",
+ "text": "Cancel"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "id": "view_upstream_dns",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Upstream DNS"
+ },
+ {
+ "type": "p",
+ "text": "Upstream resolvers and caching behaviour for dnsmasq."
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "label": "Upstream DNS Settings",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/apply_upstream_dns",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Strict Order",
+ "name": "strict_order",
+ "input_type": "checkbox",
+ "value": "%DNS_STRICT_ORDER%",
+ "hint": "Query upstream servers in list order rather than in parallel."
+ },
+ {
+ "type": "field",
+ "label": "Cache Size",
+ "name": "cache_size",
+ "input_type": "number",
+ "value": "%DNS_CACHE_SIZE%",
+ "min": 0,
+ "hint": "Max DNS responses to cache per instance. Set to 0 to disable caching."
+ },
+ {
+ "type": "editable_list",
+ "label": "Upstream Servers",
+ "name": "upstream_servers",
+ "items": "%DNS_UPSTREAM_SERVERS_JSON%",
+ "item_placeholder": "e.g. 1.1.1.1",
+ "add_label": "Add Server",
+ "hint": "DNS resolvers queried for external hostnames. Supports IPv4 and IPv6."
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Save",
+ "action": "/action/apply_upstream_dns",
+ "method": "post"
+ },
+ {
+ "type": "button_secondary",
+ "text": "Cancel",
+ "action": "/view/view_upstream_dns"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "id": "view_banned_ips",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Banned IPs"
+ },
+ {
+ "type": "p",
+ "text": "IPs and ranges blocked in both directions at the nftables firewall."
+ }
+ ]
+ },
+ {
+ "type": "info_bar",
+ "variant": "info",
+ "text": "Supports single IPs, CIDR (94.130.0.0/16), wildcards (94.130.*.*), and ranges (94.130.52.1-20). IPv4 and IPv6 are both supported."
+ },
+ {
+ "type": "table",
+ "datasource": "config:banned_ips",
+ "empty_message": "No IP bans configured.",
+ "columns": [
+ {
+ "label": "Description",
+ "field": "description"
+ },
+ {
+ "label": "IP / Range",
+ "field": "ip",
+ "class": "col-mono"
+ },
+ {
+ "label": "Status",
+ "field": "enabled",
+ "render": "badge_enabled_disabled"
+ }
+ ],
+ "row_actions": [
+ {
+ "text": "Edit",
+ "class": "btn-ghost btn-sm",
+ "action": "/action/edit_banned_ip",
+ "method": "inline_edit",
+ "fields": [
+ {
+ "col": "description",
+ "input_type": "text"
+ },
+ {
+ "col": "ip",
+ "input_type": "text"
+ },
+ {
+ "col": "enabled",
+ "input_type": "checkbox"
+ }
+ ],
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Delete",
+ "class": "btn-danger btn-sm",
+ "action": "/action/delete_banned_ip",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "id": "add-form",
+ "type": "card",
+ "label": "Add Banned IP",
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/add_banned_ip",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text",
+ "placeholder": "e.g. Bad actor",
+ "hint": "Optional label for this entry."
+ },
+ {
+ "type": "field",
+ "label": "IP / Range",
+ "name": "ip",
+ "input_type": "text",
+ "placeholder": "e.g. 1.2.3.4 or 1.2.3.0/24"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Add Banned IP",
+ "action": "/action/add_banned_ip",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_host_overrides",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Host Overrides"
+ },
+ {
+ "type": "p",
+ "text": "Force a hostname to resolve to a specific internal IP."
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "datasource": "config:host_overrides",
+ "empty_message": "No host overrides configured.",
+ "columns": [
+ {
+ "label": "Description",
+ "field": "description"
+ },
+ {
+ "label": "Hostname",
+ "field": "host",
+ "class": "col-mono"
+ },
+ {
+ "label": "Resolves To",
+ "field": "ip",
+ "class": "col-mono"
+ },
+ {
+ "label": "Status",
+ "field": "enabled",
+ "render": "badge_enabled_disabled"
+ }
+ ],
+ "row_actions": [
+ {
+ "text": "Toggle",
+ "class": "btn-ghost btn-sm",
+ "action": "/action/toggle_host_override",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Edit",
+ "class": "btn-ghost btn-sm",
+ "action": "#",
+ "method": "js_edit",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Delete",
+ "class": "btn-danger btn-sm",
+ "action": "/action/delete_host_override",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "id": "add-form",
+ "type": "card",
+ "label": "Add Host Override",
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/add_host_override",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text",
+ "placeholder": "e.g. Local server"
+ },
+ {
+ "type": "field",
+ "label": "Hostname",
+ "name": "host",
+ "input_type": "text",
+ "placeholder": "e.g. server.home.local"
+ },
+ {
+ "type": "field",
+ "label": "Resolves To",
+ "name": "ip",
+ "input_type": "text",
+ "placeholder": "e.g. 192.168.1.100"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Add Host Override",
+ "action": "/action/add_host_override",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "edit-form",
+ "type": "card",
+ "label": "Edit Host Override",
+ "hidden": true,
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/edit_host_override",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "name": "row_index",
+ "input_type": "hidden",
+ "value": ""
+ },
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "Hostname",
+ "name": "host",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "Resolves To",
+ "name": "ip",
+ "input_type": "text"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Save Changes",
+ "action": "/action/edit_host_override",
+ "method": "post"
+ },
+ {
+ "type": "button_secondary",
+ "text": "Cancel",
+ "action": "#",
+ "class": "js-hide-card"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_blocklists",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "DNS Blocklists"
+ },
+ {
+ "type": "p",
+ "text": "Upstream blocklist sources downloaded and merged by the daily systemd timer."
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "datasource": "config:blocklists",
+ "empty_message": "No blocklists configured.",
+ "columns": [
+ {
+ "label": "Name",
+ "field": "name"
+ },
+ {
+ "label": "Description",
+ "field": "description"
+ },
+ {
+ "label": "Format",
+ "field": "format",
+ "class": "col-mono"
+ },
+ {
+ "label": "Source URL",
+ "field": "url",
+ "class": "col-mono"
+ },
+ {
+ "label": "Status",
+ "field": "enabled",
+ "render": "badge_enabled_disabled"
+ }
+ ],
+ "toolbar": {
+ "items": [
+ {
+ "type": "button_secondary",
+ "text": "Refresh All Now",
+ "action": "/action/update_blocklists",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ "row_actions": [
+ {
+ "text": "Toggle",
+ "class": "btn-ghost btn-sm",
+ "action": "/action/toggle_blocklist",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Edit",
+ "class": "btn-ghost btn-sm",
+ "action": "#",
+ "method": "js_edit",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Delete",
+ "class": "btn-danger btn-sm",
+ "action": "/action/delete_blocklist",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "id": "add-form",
+ "type": "card",
+ "label": "Add Blocklist",
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/add_blocklist",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Name",
+ "name": "name",
+ "input_type": "text",
+ "placeholder": "e.g. StevenBlack"
+ },
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text",
+ "placeholder": "e.g. Unified ad/malware hosts"
+ },
+ {
+ "type": "field",
+ "label": "Format",
+ "name": "format",
+ "input_type": "select",
+ "options": [
+ {
+ "value": "hosts",
+ "label": "hosts \u2014 /etc/hosts format"
+ },
+ {
+ "value": "dnsmasq",
+ "label": "dnsmasq \u2014 local=/ syntax"
+ }
+ ]
+ },
+ {
+ "type": "field",
+ "label": "Source URL",
+ "name": "url",
+ "input_type": "text",
+ "placeholder": "https://..."
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Add Blocklist",
+ "action": "/action/add_blocklist",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "edit-form",
+ "type": "card",
+ "label": "Edit Blocklist",
+ "hidden": true,
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/edit_blocklist",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "name": "row_index",
+ "input_type": "hidden",
+ "value": ""
+ },
+ {
+ "type": "field",
+ "label": "Name",
+ "name": "name",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "Format",
+ "name": "format",
+ "input_type": "select",
+ "options": [
+ {
+ "value": "hosts",
+ "label": "hosts \u2014 /etc/hosts format"
+ },
+ {
+ "value": "dnsmasq",
+ "label": "dnsmasq \u2014 local=/ syntax"
+ }
+ ]
+ },
+ {
+ "type": "field",
+ "label": "Source URL",
+ "name": "url",
+ "input_type": "text"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Save Changes",
+ "action": "/action/edit_blocklist",
+ "method": "post"
+ },
+ {
+ "type": "button_secondary",
+ "text": "Cancel",
+ "action": "#",
+ "class": "js-hide-card"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_vlans",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "VLANs"
+ },
+ {
+ "type": "p",
+ "text": "Network segments managed by systemd-networkd, dnsmasq, nftables, and freeradius."
+ }
+ ]
+ },
+ {
+ "type": "info_bar",
+ "variant": "info",
+ "text": "For a basic flat network with no VLAN segmentation, only use VLAN 1 and delete the others."
+ },
+ {
+ "type": "table",
+ "datasource": "config:vlans",
+ "empty_message": "No VLANs configured.",
+ "columns": [
+ {
+ "label": "VLAN ID",
+ "field": "vlan_id",
+ "class": "col-mono"
+ },
+ {
+ "label": "Name",
+ "field": "name"
+ },
+ {
+ "label": "Interface",
+ "field": "interface",
+ "class": "col-mono"
+ },
+ {
+ "label": "Subnet",
+ "field": "subnet",
+ "class": "col-mono"
+ },
+ {
+ "label": "Blocklists",
+ "field": "use_blocklists",
+ "render": "tag_list"
+ },
+ {
+ "label": "RADIUS Default",
+ "field": "radius_default",
+ "render": "badge_enabled_disabled"
+ },
+ {
+ "label": "mDNS Reflection",
+ "field": "mdns_reflection",
+ "render": "badge_enabled_disabled"
+ }
+ ],
+ "row_actions": [
+ {
+ "text": "Edit",
+ "class": "btn-ghost btn-sm",
+ "action": "/action/edit_vlan",
+ "method": "inline_edit",
+ "client_requirement": "client_is_administrator+",
+ "fields": [
+ {
+ "col": "name",
+ "input_type": "text"
+ },
+ {
+ "col": "interface",
+ "input_type": "text"
+ },
+ {
+ "col": "subnet",
+ "input_type": "text"
+ },
+ {
+ "col": "radius_default",
+ "input_type": "checkbox"
+ },
+ {
+ "col": "mdns_reflection",
+ "input_type": "checkbox"
+ }
+ ]
+ },
+ {
+ "text": "Delete",
+ "class": "btn-danger btn-sm",
+ "action": "/action/delete_vlan",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "id": "add-form",
+ "type": "card",
+ "label": "Add VLAN",
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/add_vlan",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "VLAN ID",
+ "name": "vlan_id",
+ "input_type": "number",
+ "min": 1,
+ "max": 4094,
+ "placeholder": "e.g. 10"
+ },
+ {
+ "type": "field",
+ "label": "Name",
+ "name": "name",
+ "input_type": "text",
+ "placeholder": "e.g. IoT"
+ },
+ {
+ "type": "field",
+ "label": "Interface",
+ "name": "interface",
+ "input_type": "text",
+ "placeholder": "e.g. eth0.10"
+ },
+ {
+ "type": "field",
+ "label": "Subnet",
+ "name": "subnet",
+ "input_type": "text",
+ "placeholder": "e.g. 192.168.10.0/24",
+ "hint": "DHCP subnet for this VLAN."
+ },
+ {
+ "type": "field",
+ "label": "RADIUS Default",
+ "name": "radius_default",
+ "input_type": "checkbox",
+ "hint": "Wireless devices without a DHCP reservation will be placed into this VLAN. (Note: wired devices are not placed via RADIUS but rather by layer 3 switch policy.)"
+ },
+ {
+ "type": "field",
+ "label": "mDNS Reflection",
+ "name": "mdns_reflection",
+ "input_type": "checkbox",
+ "hint": "Reflect mDNS traffic to/from this VLAN via avahi-daemon. Not supported on WireGuard interfaces."
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Add VLAN",
+ "action": "/action/add_vlan",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_inter_vlan",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Inter-VLAN Exceptions"
+ },
+ {
+ "type": "p",
+ "text": "Firewall rules that permit specific traffic to cross VLAN boundaries."
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "datasource": "config:inter_vlan_exceptions",
+ "empty_message": "No inter-VLAN exceptions configured. All cross-VLAN traffic is blocked by default.",
+ "columns": [
+ {
+ "label": "Description",
+ "field": "description"
+ },
+ {
+ "label": "Protocol",
+ "field": "protocol",
+ "class": "col-mono"
+ },
+ {
+ "label": "Source",
+ "field": "src_ip_or_subnet",
+ "class": "col-mono"
+ },
+ {
+ "label": "Destination",
+ "field": "dst_ip_or_subnet",
+ "class": "col-mono"
+ },
+ {
+ "label": "Dest Port",
+ "field": "dst_port",
+ "class": "col-mono"
+ },
+ {
+ "label": "Status",
+ "field": "enabled",
+ "render": "badge_enabled_disabled"
+ }
+ ],
+ "row_actions": [
+ {
+ "text": "Toggle",
+ "class": "btn-ghost btn-sm",
+ "action": "/action/toggle_inter_vlan",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Edit",
+ "class": "btn-ghost btn-sm",
+ "action": "#",
+ "method": "js_edit",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Delete",
+ "class": "btn-danger btn-sm",
+ "action": "/action/delete_inter_vlan",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "id": "add-form",
+ "type": "card",
+ "label": "Add Exception",
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/add_inter_vlan",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text",
+ "placeholder": "e.g. Allow Chromecast"
+ },
+ {
+ "type": "field",
+ "label": "Protocol",
+ "name": "protocol",
+ "input_type": "select",
+ "options": [
+ {
+ "value": "tcp",
+ "label": "TCP"
+ },
+ {
+ "value": "udp",
+ "label": "UDP"
+ },
+ {
+ "value": "both",
+ "label": "TCP/UDP"
+ }
+ ]
+ },
+ {
+ "type": "field",
+ "label": "Source",
+ "name": "src_ip_or_subnet",
+ "input_type": "text",
+ "placeholder": "e.g. 192.168.20.0/24"
+ },
+ {
+ "type": "field",
+ "label": "Destination",
+ "name": "dst_ip_or_subnet",
+ "input_type": "text",
+ "placeholder": "e.g. 192.168.10.100"
+ },
+ {
+ "type": "field",
+ "label": "Dest Port",
+ "name": "dst_port",
+ "input_type": "text",
+ "placeholder": "e.g. 8009"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Add Exception",
+ "action": "/action/add_inter_vlan",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "edit-form",
+ "type": "card",
+ "label": "Edit Exception",
+ "hidden": true,
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/edit_inter_vlan",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "name": "row_index",
+ "input_type": "hidden",
+ "value": ""
+ },
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "Protocol",
+ "name": "protocol",
+ "input_type": "select",
+ "options": [
+ {
+ "value": "tcp",
+ "label": "TCP"
+ },
+ {
+ "value": "udp",
+ "label": "UDP"
+ },
+ {
+ "value": "both",
+ "label": "TCP/UDP"
+ }
+ ]
+ },
+ {
+ "type": "field",
+ "label": "Source",
+ "name": "src_ip_or_subnet",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "Destination",
+ "name": "dst_ip_or_subnet",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "Dest Port",
+ "name": "dst_port",
+ "input_type": "text"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Save Changes",
+ "action": "/action/edit_inter_vlan",
+ "method": "post"
+ },
+ {
+ "type": "button_secondary",
+ "text": "Cancel",
+ "action": "#",
+ "class": "js-hide-card"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_port_forwarding",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Port Forwarding"
+ },
+ {
+ "type": "p",
+ "text": "DNAT rules that forward inbound WAN traffic to internal hosts."
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "datasource": "config:port_forwarding",
+ "empty_message": "No port forwarding rules configured.",
+ "columns": [
+ {
+ "label": "Description",
+ "field": "description"
+ },
+ {
+ "label": "Protocol",
+ "field": "protocol",
+ "class": "col-mono"
+ },
+ {
+ "label": "Ext Port",
+ "field": "dest_port",
+ "class": "col-mono"
+ },
+ {
+ "label": "NAT IP",
+ "field": "nat_ip",
+ "class": "col-mono"
+ },
+ {
+ "label": "NAT Port",
+ "field": "nat_port",
+ "class": "col-mono"
+ },
+ {
+ "label": "Status",
+ "field": "enabled",
+ "render": "badge_enabled_disabled"
+ }
+ ],
+ "row_actions": [
+ {
+ "text": "Toggle",
+ "class": "btn-ghost btn-sm",
+ "action": "/action/toggle_port_forward",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Edit",
+ "class": "btn-ghost btn-sm",
+ "action": "#",
+ "method": "js_edit",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Delete",
+ "class": "btn-danger btn-sm",
+ "action": "/action/delete_port_forward",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "id": "add-form",
+ "type": "card",
+ "label": "Add Rule",
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/add_port_forward",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text",
+ "placeholder": "e.g. Minecraft server"
+ },
+ {
+ "type": "field",
+ "label": "Protocol",
+ "name": "protocol",
+ "input_type": "select",
+ "options": [
+ {
+ "value": "tcp",
+ "label": "TCP"
+ },
+ {
+ "value": "udp",
+ "label": "UDP"
+ },
+ {
+ "value": "both",
+ "label": "TCP/UDP"
+ }
+ ]
+ },
+ {
+ "type": "field",
+ "label": "Ext Port",
+ "name": "dest_port",
+ "input_type": "text",
+ "placeholder": "e.g. 25565"
+ },
+ {
+ "type": "field",
+ "label": "NAT IP",
+ "name": "nat_ip",
+ "input_type": "text",
+ "placeholder": "e.g. 192.168.1.50"
+ },
+ {
+ "type": "field",
+ "label": "NAT Port",
+ "name": "nat_port",
+ "input_type": "text",
+ "placeholder": "e.g. 25565"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Add Rule",
+ "action": "/action/add_port_forward",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "edit-form",
+ "type": "card",
+ "label": "Edit Rule",
+ "hidden": true,
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/edit_port_forward",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "name": "row_index",
+ "input_type": "hidden",
+ "value": ""
+ },
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "Protocol",
+ "name": "protocol",
+ "input_type": "select",
+ "options": [
+ {
+ "value": "tcp",
+ "label": "TCP"
+ },
+ {
+ "value": "udp",
+ "label": "UDP"
+ },
+ {
+ "value": "both",
+ "label": "TCP/UDP"
+ }
+ ]
+ },
+ {
+ "type": "field",
+ "label": "Ext Port",
+ "name": "dest_port",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "NAT IP",
+ "name": "nat_ip",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "NAT Port",
+ "name": "nat_port",
+ "input_type": "text"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Save Changes",
+ "action": "/action/edit_port_forward",
+ "method": "post"
+ },
+ {
+ "type": "button_secondary",
+ "text": "Cancel",
+ "action": "#",
+ "class": "js-hide-card"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_dhcp",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "DHCP"
+ },
+ {
+ "type": "p",
+ "text": "Active leases and static reservations across all VLANs."
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "datasource": "live:dhcp_leases",
+ "empty_message": "No active DHCP leases found.",
+ "columns": [
+ {
+ "label": "Hostname",
+ "field": "hostname"
+ },
+ {
+ "label": "IP Address",
+ "field": "ip_address",
+ "class": "col-mono"
+ },
+ {
+ "label": "MAC Address",
+ "field": "mac_address",
+ "class": "col-mono"
+ },
+ {
+ "label": "VLAN",
+ "field": "vlan_name"
+ },
+ {
+ "label": "Expires",
+ "field": "expires"
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "datasource": "config:dhcp_reservations",
+ "empty_message": "No DHCP reservations configured.",
+ "columns": [
+ {
+ "label": "Description",
+ "field": "description"
+ },
+ {
+ "label": "Hostname",
+ "field": "hostname",
+ "class": "col-mono"
+ },
+ {
+ "label": "MAC",
+ "field": "mac",
+ "class": "col-mono"
+ },
+ {
+ "label": "IP",
+ "field": "ip",
+ "class": "col-mono"
+ },
+ {
+ "label": "VLAN",
+ "field": "vlan_name"
+ },
+ {
+ "label": "RADIUS",
+ "field": "radius_client",
+ "render": "badge_enabled_disabled"
+ },
+ {
+ "label": "Status",
+ "field": "enabled",
+ "render": "badge_enabled_disabled"
+ }
+ ],
+ "toolbar": {
+ "items": [
+ {
+ "type": "select",
+ "name": "vlan_filter",
+ "value": "all",
+ "options": "%VLAN_FILTER_OPTIONS%"
+ }
+ ]
+ },
+ "row_actions": [
+ {
+ "text": "Toggle",
+ "class": "btn-ghost btn-sm",
+ "action": "/action/toggle_dhcp_reservation",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Edit",
+ "class": "btn-ghost btn-sm",
+ "action": "#",
+ "method": "js_edit",
+ "client_requirement": "client_is_administrator+"
+ },
+ {
+ "text": "Delete",
+ "class": "btn-danger btn-sm",
+ "action": "/action/delete_dhcp_reservation",
+ "method": "post",
+ "client_requirement": "client_is_administrator+"
+ }
+ ]
+ },
+ {
+ "id": "add-form",
+ "type": "card",
+ "label": "Add Reservation",
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/add_dhcp_reservation",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "VLAN",
+ "name": "vlan_name",
+ "input_type": "select",
+ "options": "%VLAN_NAMES_AS_OPTIONS%",
+ "hint": "VLAN this reservation belongs to."
+ },
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text",
+ "placeholder": "e.g. NAS"
+ },
+ {
+ "type": "field",
+ "label": "Hostname",
+ "name": "hostname",
+ "input_type": "text",
+ "placeholder": "e.g. nas"
+ },
+ {
+ "type": "field",
+ "label": "MAC Address",
+ "name": "mac",
+ "input_type": "text",
+ "placeholder": "e.g. aa:bb:cc:dd:ee:ff"
+ },
+ {
+ "type": "field",
+ "label": "IP Address",
+ "name": "ip",
+ "input_type": "text",
+ "placeholder": "e.g. 192.168.10.50"
+ },
+ {
+ "type": "field",
+ "label": "RADIUS Client",
+ "name": "radius_client",
+ "input_type": "checkbox",
+ "hint": "Authenticate this device via RADIUS."
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Add Reservation",
+ "action": "/action/add_dhcp_reservation",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "edit-form",
+ "type": "card",
+ "label": "Edit Reservation",
+ "hidden": true,
+ "client_requirement": "client_is_administrator+",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/edit_dhcp_reservation",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "name": "row_index",
+ "input_type": "hidden",
+ "value": ""
+ },
+ {
+ "type": "field",
+ "label": "Description",
+ "name": "description",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "Hostname",
+ "name": "hostname",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "MAC Address",
+ "name": "mac",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "IP Address",
+ "name": "ip",
+ "input_type": "text"
+ },
+ {
+ "type": "field",
+ "label": "RADIUS Client",
+ "name": "radius_client",
+ "input_type": "checkbox"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Save Changes",
+ "action": "/action/edit_dhcp_reservation",
+ "method": "post"
+ },
+ {
+ "type": "button_secondary",
+ "text": "Cancel",
+ "action": "#",
+ "class": "js-hide-card"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_vpn",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "VPN"
+ },
+ {
+ "type": "p",
+ "text": "Active WireGuard peer connections and server interface configuration."
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "datasource": "live:vpn_sessions",
+ "empty_message": "No active VPN sessions.",
+ "columns": [
+ {
+ "label": "Peer",
+ "field": "peer_name"
+ },
+ {
+ "label": "Interface",
+ "field": "interface",
+ "class": "col-mono"
+ },
+ {
+ "label": "Tunnel IP",
+ "field": "tunnel_ip",
+ "class": "col-mono"
+ },
+ {
+ "label": "Endpoint",
+ "field": "endpoint",
+ "class": "col-mono"
+ },
+ {
+ "label": "Last Handshake",
+ "field": "last_handshake"
+ },
+ {
+ "label": "Received",
+ "field": "rx_bytes",
+ "class": "col-mono"
+ },
+ {
+ "label": "Sent",
+ "field": "tx_bytes",
+ "class": "col-mono"
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "client_requirement": "client_is_administrator+",
+ "label": "WireGuard Interface",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/apply_vpn",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Listen Port",
+ "name": "vpn_listen_port",
+ "input_type": "number",
+ "value": "%VPN_LISTEN_PORT%",
+ "min": 1024,
+ "max": 65535,
+ "hint": "UDP port WireGuard listens on. Must match your port forwarding rule."
+ },
+ {
+ "type": "field",
+ "label": "Gateway IP",
+ "name": "vpn_gateway",
+ "input_type": "text",
+ "value": "%VPN_GATEWAY%",
+ "placeholder": "e.g. 192.168.40.1",
+ "hint": "Router IP on the VPN subnet, assigned to the WireGuard interface."
+ },
+ {
+ "type": "field",
+ "label": "Domain",
+ "name": "vpn_domain",
+ "input_type": "text",
+ "value": "%VPN_DOMAIN%",
+ "placeholder": "e.g. local",
+ "hint": "DNS search domain pushed to VPN clients."
+ },
+ {
+ "type": "field",
+ "label": "DNS Override",
+ "name": "vpn_dns_server",
+ "input_type": "text",
+ "value": "%VPN_DNS_SERVER%",
+ "placeholder": "Leave blank to use gateway IP",
+ "hint": "Explicit DNS server pushed to peers. Defaults to the gateway IP."
+ },
+ {
+ "type": "field",
+ "label": "MTU Override",
+ "name": "vpn_mtu",
+ "input_type": "number",
+ "value": "%VPN_MTU%",
+ "placeholder": "Leave blank for default",
+ "hint": "Override tunnel MTU. Leave blank for the system default."
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Save",
+ "action": "/action/apply_vpn",
+ "method": "post"
+ },
+ {
+ "type": "button_cancel",
+ "text": "Cancel"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_log_in",
+ "client_requirement": "client_is_nothing=",
+ "items": [
+ {
+ "type": "auth_wrapper",
+ "client_requirement": "client_is_nothing=",
+ "items": [
+ {
+ "type": "auth_card",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Log In"
+ },
+ {
+ "type": "p",
+ "text": "Enter your credentials to access the dashboard."
+ },
+ {
+ "type": "form",
+ "action": "/action/log_in",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Email Address",
+ "name": "email",
+ "input_type": "text",
+ "placeholder": "you@example.com"
+ },
+ {
+ "type": "field",
+ "label": "Password",
+ "name": "password",
+ "input_type": "password",
+ "placeholder": "Password"
+ },
+ {
+ "type": "button_primary",
+ "text": "Log In",
+ "action": "/action/log_in",
+ "method": "post",
+ "class": "btn-full"
+ }
+ ]
+ },
+ {
+ "type": "p",
+ "text": "Need to complete your account?",
+ "link": {
+ "text": "Create Account",
+ "action": "/view/view_create_account"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "section",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Already logged in."
+ },
+ {
+ "type": "p",
+ "text": "You are already authenticated."
+ },
+ {
+ "type": "spacer"
+ },
+ {
+ "type": "button_primary",
+ "text": "Go to Overview",
+ "action": "/view/overview"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_create_account",
+ "client_requirement": "client_is_nothing=",
+ "items": [
+ {
+ "type": "auth_wrapper",
+ "client_requirement": "client_is_nothing=",
+ "items": [
+ {
+ "type": "auth_card",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Complete Your Account"
+ },
+ {
+ "type": "p",
+ "text": "Your email has been pre-registered by a manager. Enter it below to verify access and set a password."
+ },
+ {
+ "type": "form",
+ "action": "/action/create_account",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Email Address",
+ "name": "email",
+ "input_type": "text",
+ "placeholder": "you@example.com",
+ "hint": "Must match your pre-registered email address."
+ },
+ {
+ "type": "field",
+ "label": "New Password",
+ "name": "password",
+ "input_type": "password",
+ "placeholder": "Choose a strong password"
+ },
+ {
+ "type": "field",
+ "label": "Confirm Password",
+ "name": "password_confirm",
+ "input_type": "password",
+ "placeholder": "Repeat your password"
+ },
+ {
+ "type": "field",
+ "label": "Timezone",
+ "name": "timezone",
+ "input_type": "select",
+ "value": "",
+ "options": "%TIMEZONE_OPTIONS%",
+ "hint": "Used to display timestamps in your local time."
+ },
+ {
+ "type": "button_primary",
+ "text": "Create Account",
+ "action": "/action/create_account",
+ "method": "post",
+ "class": "btn-full"
+ }
+ ]
+ },
+ {
+ "type": "p",
+ "text": "Already have an account?",
+ "link": {
+ "text": "Log In",
+ "action": "/view/view_log_in"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "section",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Already logged in."
+ },
+ {
+ "type": "p",
+ "text": "Your account is already active."
+ },
+ {
+ "type": "spacer"
+ },
+ {
+ "type": "button_primary",
+ "text": "Go to Overview",
+ "action": "/view/overview"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_verify_email",
+ "client_requirement": "client_is_nothing=",
+ "items": [
+ {
+ "type": "auth_wrapper",
+ "client_requirement": "client_is_nothing=",
+ "items": [
+ {
+ "type": "auth_card",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Verify Your Email"
+ },
+ {
+ "type": "p",
+ "text": "A 6-digit code was sent to your email address. Enter it below to complete your account."
+ },
+ {
+ "type": "form",
+ "action": "/action/verify_email",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Verification Code",
+ "name": "code",
+ "input_type": "text",
+ "placeholder": "000000",
+ "hint": "The code expires in 15 minutes."
+ },
+ {
+ "type": "button_primary",
+ "text": "Verify",
+ "action": "/action/verify_email",
+ "method": "post",
+ "class": "btn-full"
+ }
+ ]
+ },
+ {
+ "type": "p",
+ "text": "Didn't receive it?",
+ "link": {
+ "text": "Resend code",
+ "action": "/action/resend_verification"
+ }
+ },
+ {
+ "type": "p",
+ "text": "Wrong email?",
+ "link": {
+ "text": "Start over",
+ "action": "/view/view_create_account"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "section",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Already logged in."
+ },
+ {
+ "type": "p",
+ "text": "Your account is already active."
+ },
+ {
+ "type": "spacer"
+ },
+ {
+ "type": "button_primary",
+ "text": "Go to Overview",
+ "action": "/view/view_overview"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_preferences",
+ "client_requirement": "client_is_viewer+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Preferences"
+ },
+ {
+ "type": "p",
+ "text": "Your personal account settings."
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "label": "Account Details",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/save_preferences",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Email Address",
+ "name": "email",
+ "input_type": "text",
+ "value": "%PREF_EMAIL%",
+ "hint": "Contact your manager to change your email address."
+ },
+ {
+ "type": "field",
+ "label": "Timezone",
+ "name": "timezone",
+ "input_type": "select",
+ "value": "%PREF_TIMEZONE%",
+ "options": "%TIMEZONE_OPTIONS%",
+ "hint": "All timestamps will be displayed in this timezone."
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Save Preferences",
+ "action": "/action/save_preferences",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "label": "Change Password",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/change_password",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Current Password",
+ "name": "current_password",
+ "input_type": "password",
+ "placeholder": "Current password"
+ },
+ {
+ "type": "field",
+ "label": "New Password",
+ "name": "new_password",
+ "input_type": "password",
+ "placeholder": "New password"
+ },
+ {
+ "type": "field",
+ "label": "Confirm Password",
+ "name": "confirm_password",
+ "input_type": "password",
+ "placeholder": "Repeat new password"
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Change Password",
+ "action": "/action/change_password",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "view_manage_accounts",
+ "client_requirement": "client_is_manager+",
+ "items": [
+ {
+ "type": "page_header",
+ "items": [
+ {
+ "type": "h1",
+ "text": "Manage Accounts"
+ }
+ ]
+ },
+ {
+ "type": "table",
+ "datasource": "config:accounts",
+ "empty_message": "No accounts configured.",
+ "columns": [
+ {
+ "label": "Email Address",
+ "field": "email_address"
+ },
+ {
+ "label": "Access Level",
+ "field": "access_level"
+ },
+ {
+ "label": "Added By",
+ "field": "account_created_by"
+ },
+ {
+ "label": "Added",
+ "field": "account_created_utc"
+ },
+ {
+ "label": "Status",
+ "field": "account_status",
+ "render": "badge_active_inactive"
+ }
+ ],
+ "row_actions": [
+ {
+ "text": "Remove",
+ "class": "btn-danger btn-sm",
+ "action": "/action/delete_account",
+ "method": "post"
+ }
+ ]
+ },
+ {
+ "type": "card",
+ "label": "Authorize New Account",
+ "items": [
+ {
+ "type": "form",
+ "action": "/action/add_account",
+ "method": "post",
+ "items": [
+ {
+ "type": "field",
+ "label": "Email Address",
+ "name": "email_address",
+ "input_type": "text",
+ "placeholder": "user@example.com",
+ "hint": "The user will verify ownership of this address during account setup."
+ },
+ {
+ "type": "field",
+ "label": "Access Level",
+ "name": "access_level",
+ "input_type": "select",
+ "options": [
+ {
+ "value": "viewer",
+ "label": "Viewer \u2014 read-only access to live data"
+ },
+ {
+ "value": "administrator",
+ "label": "Administrator \u2014 can modify configuration"
+ },
+ {
+ "value": "manager",
+ "label": "Manager \u2014 full access including account management"
+ }
+ ]
+ },
+ {
+ "type": "button_row",
+ "items": [
+ {
+ "type": "button_primary",
+ "text": "Authorize",
+ "action": "/action/add_account",
+ "method": "post"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/router/README.md b/router/README.md
index cccf24a..33f9a39 100644
--- a/router/README.md
+++ b/router/README.md
@@ -333,6 +333,7 @@ sudo python3 ddns.py --disable # Stop updates and remove systemd ti
python3 ddns.py --apply # Run one immediate DDNS update (used by timer)
python3 ddns.py --force # Force update regardless of cached IP
python3 ddns.py --status # Timer/service status
+python3 ddns.py --getip # Print current public IP and exit
```
---
diff --git a/router/core.json b/router/core.json
index 74e992d..4d81e79 100644
--- a/router/core.json
+++ b/router/core.json
@@ -100,6 +100,7 @@
"name": "trusted",
"interface": "enp6s0",
"radius_default": false,
+ "mdns_reflection": false,
"use_blocklists": ["oisd-big", "hagezi-light"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.1.1" },
@@ -134,6 +135,7 @@
"name": "iot",
"interface": "enp6s0.10",
"radius_default": false,
+ "mdns_reflection": true,
"use_blocklists": ["oisd-big", "hagezi-light"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.10.1" }
@@ -168,6 +170,7 @@
"name": "guest",
"interface": "enp6s0.20",
"radius_default": true,
+ "mdns_reflection": true,
"use_blocklists": ["oisd-big", "hagezi-light"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.20.1" }
@@ -196,6 +199,7 @@
"name": "kids",
"interface": "enp6s0.30",
"radius_default": false,
+ "mdns_reflection": true,
"use_blocklists": ["oisd-big", "hagezi-light", "hagezi-pro-plus"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.30.1" }
@@ -226,6 +230,7 @@
"name": "vpn",
"interface": "wg0",
"radius_default": false,
+ "mdns_reflection": false,
"use_blocklists": ["oisd-big", "hagezi-light"],
"vpn_information": {
"listen_port": 51820,
@@ -240,11 +245,6 @@
]
}
- ],
-
- "mdns_reflection": {
- "enabled": true,
- "reflect_vlans": ["iot", "guest", "kids"]
- }
+ ]
}
diff --git a/router/core.py b/router/core.py
index 946032d..73c5d03 100644
--- a/router/core.py
+++ b/router/core.py
@@ -100,6 +100,7 @@ import urllib.error
import argparse
from datetime import datetime
from pathlib import Path
+from validation import VALID_PROTOCOLS, VALID_BLOCKLIST_FORMATS
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
@@ -119,14 +120,14 @@ NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service"
log = None
-# ------------------------------------------------------------------------------
+# ===================================================================
# Logging
-# ------------------------------------------------------------------------------
+# ===================================================================
def chown_to_script_dir_owner(path):
"""Chown a file to the owner of the script directory.
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
- or as a normal user — the script directory owner is always the right target.
+ or as a normal user - the script directory owner is always the right target.
"""
try:
stat = SCRIPT_DIR.stat()
@@ -159,9 +160,9 @@ def setup_logging(max_kb, errors_only):
)
log = logging.getLogger("dns-dhcp")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Helpers
-# ------------------------------------------------------------------------------
+# ===================================================================
def service_warning(action, svc, stderr):
"""Print a service start/restart warning, adding --install hint if unit not found."""
@@ -172,7 +173,7 @@ def service_warning(action, svc, stderr):
def die(msg):
- print(f"ERROR: {msg}")
+ print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def check_root():
@@ -279,9 +280,9 @@ def expand_protocols(rule):
return [("tcp", rule, " (tcp)"), ("udp", rule, " (udp)")]
return [(proto, rule, "")]
-# ------------------------------------------------------------------------------
+# ===================================================================
# Load
-# ------------------------------------------------------------------------------
+# ===================================================================
def load_config():
if not CONFIG_FILE.exists():
@@ -292,9 +293,9 @@ def load_config():
die("No vlans defined in core.json.")
return data
-# ------------------------------------------------------------------------------
+# ===================================================================
# Validate
-# ------------------------------------------------------------------------------
+# ===================================================================
def validate_config(data):
errors = []
@@ -330,8 +331,8 @@ def validate_config(data):
for field in ("name", "description", "save_as", "url", "format"):
if not bl.get(field):
errors.append(f"{label}: missing or empty field '{field}'.")
- if bl.get("format") and bl["format"] not in ("dnsmasq", "hosts"):
- errors.append(f"{label}: format must be 'dnsmasq' or 'hosts'.")
+ if bl.get("format") and bl["format"] not in VALID_BLOCKLIST_FORMATS:
+ errors.append(f"{label}: format must be one of: {', '.join(sorted(VALID_BLOCKLIST_FORMATS))}.")
if name:
if name in blocklists_by_name:
errors.append(f"{label}: duplicate blocklist name '{name}'.")
@@ -365,6 +366,9 @@ def validate_config(data):
else:
seen_interfaces[iface] = name
+ if vlan.get("mdns_reflection") is True and is_wg(vlan):
+ errors.append(f"{label}: mdns_reflection must be false for WireGuard interfaces.")
+
if is_wg(vlan):
vpi = vlan.get("vpn_information")
if not isinstance(vpi, dict):
@@ -538,7 +542,7 @@ def validate_config(data):
errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.")
# -- NAT / firewall validation ---------------------------------------------
- valid_protos = {"tcp", "udp", "both"}
+ valid_protos = VALID_PROTOCOLS
known_interfaces = set(seen_interfaces.keys())
def nat_check_port(label, port):
@@ -621,25 +625,11 @@ def validate_config(data):
if r.get("dst_port") is not None:
nat_check_port(f"{label} dst_port", r.get("dst_port"))
- # -- mdns_reflection validation --------------------------------------------
- mdns = data.get("mdns_reflection", {})
- if mdns.get("enabled") is True:
- known_vlan_names = {v["name"] for v in data["vlans"]}
- reflect_vlans = mdns.get("reflect_vlans", [])
- for vname in reflect_vlans:
- if vname not in known_vlan_names:
- errors.append(f"mdns_reflection.reflect_vlans: '{vname}' is not a known VLAN name.")
- else:
- vlan = next(v for v in data["vlans"] if v["name"] == vname)
- if is_wg(vlan):
- errors.append(f"mdns_reflection.reflect_vlans: '{vname}' is a WireGuard VLAN "
- f"and cannot participate in mDNS reflection.")
- if not reflect_vlans:
- errors.append("mdns_reflection.reflect_vlans is empty. "
- "Add at least two VLAN names or set enabled: false.")
- elif len(reflect_vlans) < 2:
- errors.append("mdns_reflection.reflect_vlans must contain at least two VLANs — "
- "reflecting mDNS on a single VLAN has no effect.")
+ # -- radius_default uniqueness check ---------------------------------------
+ defaults = [v["name"] for v in data["vlans"] if v.get("radius_default") is True]
+ if len(defaults) > 1:
+ errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). "
+ f"Only one VLAN may be the RADIUS default.")
# -- banned_ips validation -------------------------------------------------
for idx, entry in enumerate(data.get("banned_ips", [])):
@@ -654,14 +644,14 @@ def validate_config(data):
errors.append(f"{lbl}: {e}")
if errors:
- print("Validation failed:")
+ print("Validation failed:", file=sys.stderr)
for e in errors:
- print(f" - {e}")
+ print(f" - {e}", file=sys.stderr)
sys.exit(1)
-# ------------------------------------------------------------------------------
+# ===================================================================
# Build systemd-networkd files
-# ------------------------------------------------------------------------------
+# ===================================================================
def build_netdev(vlan):
return "\n".join([
@@ -787,9 +777,9 @@ def apply_networkd(data, dry_run=False, only_if_changed=False):
print("systemd-networkd: no changes. Good.")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Blocklist management
-# ------------------------------------------------------------------------------
+# ===================================================================
def combo_hash(names):
"""Return a stable 8-char hex hash for a list/set of blocklist names."""
@@ -934,9 +924,9 @@ def update_blocklists(data):
any_failed = any(content is None for content, _ in downloaded.values())
return not any_failed
-# ------------------------------------------------------------------------------
+# ===================================================================
# Build per-VLAN dnsmasq config
-# ------------------------------------------------------------------------------
+# ===================================================================
def _wan_has_ipv6(iface):
"""Return True if the WAN interface has a non-link-local IPv6 address."""
@@ -1087,9 +1077,9 @@ def build_vlan_dnsmasq_conf(vlan, data):
return "\n".join(L)
-# ------------------------------------------------------------------------------
+# ===================================================================
# Build per-VLAN systemd service unit
-# ------------------------------------------------------------------------------
+# ===================================================================
def build_vlan_service(vlan):
name = vlan["name"]
@@ -1133,9 +1123,9 @@ def build_vlan_service(vlan):
return "\n".join(lines)
-# ------------------------------------------------------------------------------
+# ===================================================================
# System dnsmasq / resolv.conf
-# ------------------------------------------------------------------------------
+# ===================================================================
def ensure_resolv_conf(data):
"""Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1)."""
@@ -1297,9 +1287,9 @@ def restore_ntp():
else:
print("systemd-timesyncd is not available on this system.")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Apply dnsmasq instances
-# ------------------------------------------------------------------------------
+# ===================================================================
def wg_interface_up(iface):
"""Return True if the WireGuard interface exists and is up."""
@@ -1452,9 +1442,9 @@ def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True):
else:
print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Timer management
-# ------------------------------------------------------------------------------
+# ===================================================================
def parse_time_to_calendar(time_str):
parts = time_str.strip().split(":")
@@ -1519,9 +1509,9 @@ def remove_timer():
print(f"Not found, skipping: {f}")
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
-# ------------------------------------------------------------------------------
+# ===================================================================
# banned_ips expansion
-# ------------------------------------------------------------------------------
+# ===================================================================
def _expand_banned_ipv4(ip_str):
"""Convert an IPv4 pattern (CIDR, wildcard, range) to nftables set elements."""
@@ -1531,7 +1521,7 @@ def _expand_banned_ipv4(ip_str):
parts = ip_str.split('.')
if len(parts) != 4:
- raise ValueError(f"Invalid IPv4 pattern: {ip_str!r} — expected 4 octets")
+ raise ValueError(f"Invalid IPv4 pattern: {ip_str!r} - expected 4 octets")
def parse_octet(s, pos):
if s == '*':
@@ -1587,7 +1577,7 @@ def _expand_banned_ipv4(ip_str):
_enum_cidr(idx + 1, chosen + [v])
_enum_cidr(0, [])
else:
- # No trailing wildcards — enumerate outer 3 octets, express last as range
+ # No trailing wildcards - enumerate outer 3 octets, express last as range
outer_ranges = ranges[:3]
lo4, hi4 = ranges[3]
@@ -1682,9 +1672,9 @@ def banned_ip_sets(data):
return v4, v6
-# ------------------------------------------------------------------------------
+# ===================================================================
# nftables config generation
-# ------------------------------------------------------------------------------
+# ===================================================================
def build_nft_config(data, dry_run=False):
wan = data["general"]["wan_interface"]
@@ -1946,9 +1936,9 @@ def build_nft_config(data, dry_run=False):
return "\n".join(L)
-# ------------------------------------------------------------------------------
+# ===================================================================
# nftables apply / disable / status
-# ------------------------------------------------------------------------------
+# ===================================================================
def table_exists(family, name):
result = subprocess.run(
@@ -1977,8 +1967,8 @@ def apply_nft_config(config_text):
capture_output=True, text=True
)
if result.returncode != 0:
- print("ERROR: nft rejected the ruleset:")
- print(result.stderr)
+ print("ERROR: nft rejected the ruleset:", file=sys.stderr)
+ print(result.stderr, file=sys.stderr)
sys.exit(1)
def apply_nftables(data, dry_run=False):
@@ -2075,9 +2065,9 @@ def show_rules():
else:
print(result.stdout)
-# ------------------------------------------------------------------------------
+# ===================================================================
# nftables boot service
-# ------------------------------------------------------------------------------
+# ===================================================================
def install_nat_service():
script_path = Path(__file__).resolve()
@@ -2121,13 +2111,13 @@ def remove_nat_service():
else:
print(f"Boot service not found, skipping: {NAT_SERVICE_NAME}.service")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Status
-# ------------------------------------------------------------------------------
+# ===================================================================
-# ------------------------------------------------------------------------------
+# ===================================================================
# RADIUS
-# ------------------------------------------------------------------------------
+# ===================================================================
RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret"
RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf")
@@ -2275,25 +2265,19 @@ def apply_radius(data):
service_warning("start", "freeradius", result.stderr)
-# ------------------------------------------------------------------------------
+# ===================================================================
# Avahi mDNS Reflector
-# ------------------------------------------------------------------------------
+# ===================================================================
AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf")
def avahi_enabled(data):
- """Return True if mdns_reflection is enabled with at least two VLANs configured."""
- mdns = data.get("mdns_reflection", {})
- return mdns.get("enabled") is True
+ """Return True if at least one non-WireGuard VLAN has mdns_reflection enabled."""
+ return any(v.get("mdns_reflection") is True for v in data.get("vlans", []) if not is_wg(v))
def avahi_interfaces(data):
- """Return list of interface names for mDNS reflection based on reflect_vlans."""
- reflect = data.get("mdns_reflection", {}).get("reflect_vlans", [])
- ifaces = []
- for vlan in data["vlans"]:
- if vlan["name"] in reflect and not is_wg(vlan):
- ifaces.append(vlan["interface"])
- return ifaces
+ """Return list of interface names for VLANs with mdns_reflection enabled."""
+ return [v["interface"] for v in data.get("vlans", []) if v.get("mdns_reflection") is True and not is_wg(v)]
def build_avahi_conf(data):
"""Patch avahi-daemon.conf directives needed for cross-VLAN mDNS reflection.
@@ -2317,7 +2301,7 @@ def build_avahi_conf(data):
replacement = f"{directive}={value}"
if pattern.search(text):
return pattern.sub(replacement, text)
- # Not present at all — this shouldn't happen with a standard avahi install
+ # Not present at all - this shouldn't happen with a standard avahi install
# but append it to the relevant section if needed
return text + f"\n{replacement}\n"
@@ -2403,8 +2387,8 @@ def show_status(data):
r_enabled = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True)
active = r_active.stdout.strip()
enabled = r_enabled.stdout.strip()
- active_sym = "✓" if active == "active" else "✗"
- enabled_sym = "✓" if enabled == "enabled" else "✗"
+ active_sym = "+" if active == "active" else "x"
+ enabled_sym = "+" if enabled == "enabled" else "x"
active_ok = "(OK) " if active == expected_active else "(BAD)"
enabled_ok = "(OK) " if enabled == "enabled" else "(BAD)"
return active_sym, active, active_ok, enabled_sym, enabled, enabled_ok
@@ -2416,7 +2400,7 @@ def show_status(data):
else:
units.append((vlan_service_name(vlan), None, "active"))
units.append((f"{TIMER_NAME}.timer", None, "active"))
- units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot — exits after running
+ units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot - exits after running
units.append(("freeradius", None, "active"))
units.append(("avahi-daemon", None, "active"))
@@ -2456,9 +2440,9 @@ def show_configs(data):
else:
print(f"No config found at {cf} (not yet applied).")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Leases
-# ------------------------------------------------------------------------------
+# ===================================================================
def reset_leases(data, vlan_name=None):
"""Stop dnsmasq instances, delete lease files, restart instances.
@@ -2572,9 +2556,9 @@ def show_leases(data):
if not any_leases:
print("No active leases found.")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Metrics
-# ------------------------------------------------------------------------------
+# ===================================================================
def collect_metrics(data):
"""
@@ -2755,9 +2739,9 @@ def show_metrics(data):
print(f" NXDOMAIN : {s['nxdomain']:,}")
print(f" Latency : {s['avg_latency_ms']}ms (last recorded)")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Stop / disable
-# ------------------------------------------------------------------------------
+# ===================================================================
def stop_instances(data):
"""Remove timer and stop all per-VLAN instances (config files preserved)."""
@@ -2867,19 +2851,19 @@ def _suggest_static_ip(physical_vlan):
chosen = max(non_gateway, key=lambda ip: ip.packed[-1])
return f"{chosen}/{prefix}"
- # All identities end in .1 — pick a random unused host in the subnet
+ # All identities end in .1 - pick a random unused host in the subnet
hosts = list(network.hosts())
candidates = [h for h in hosts if h not in known_ips and h.packed[-1] != 1]
if candidates:
chosen = random.choice(candidates)
return f"{chosen}/{prefix}"
- # Degenerate fallback — extremely small subnet
+ # Degenerate fallback - extremely small subnet
return f"{list(network.hosts())[0]}/{prefix}"
-# ------------------------------------------------------------------------------
+# ===================================================================
# Dry-run helpers
-# ------------------------------------------------------------------------------
+# ===================================================================
def _svc_state(unit):
"""Return 'active', 'inactive', or 'unknown' for a systemd unit."""
@@ -2900,12 +2884,12 @@ def _dry_run_conflicting_services(data):
if state == "active":
print(f" Would stop and disable: {label} (currently: active)")
else:
- print(f" {label}: not active — no action needed")
+ print(f" {label}: not active - no action needed")
chrony_ok = subprocess.run(["systemctl", "cat", "chrony"],
capture_output=True, text=True).returncode == 0
if not chrony_ok:
- print(" chrony: not installed — dependency check would have prompted to install it")
+ print(" chrony: not installed - dependency check would have prompted to install it")
else:
chrony_conf = Path("/etc/chrony/chrony.conf")
if chrony_conf.exists():
@@ -2922,7 +2906,7 @@ def _dry_run_conflicting_services(data):
if missing:
print(f" Would add chrony allow directives for: {', '.join(missing)}")
else:
- print(" chrony.conf already has required allow directives — no change needed")
+ print(" chrony.conf already has required allow directives - no change needed")
print(f" Would enable and restart: chrony")
if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode == 0:
@@ -2930,20 +2914,20 @@ def _dry_run_conflicting_services(data):
if "Status: active" in status.stdout:
print(" Would disable: ufw (currently: active)")
else:
- print(" ufw: not active — no rule action needed")
+ print(" ufw: not active - no rule action needed")
if _svc_enabled("ufw"):
print(" Would disable: ufw.service (currently: enabled at boot)")
else:
- print(" ufw.service: not enabled at boot — no action needed")
+ print(" ufw.service: not enabled at boot - no action needed")
else:
- print(" ufw: not installed — no action needed")
+ print(" ufw: not installed - no action needed")
r = subprocess.run(["systemctl", "is-enabled", "dnsmasq"],
capture_output=True, text=True)
if r.stdout.strip() in ("enabled", "enabled-runtime"):
print(f" Would stop and disable: system dnsmasq.service (currently: enabled)")
else:
- print(" system dnsmasq.service: not enabled — no action needed")
+ print(" system dnsmasq.service: not enabled - no action needed")
physical = next((v for v in data["vlans"] if is_physical(v)), None)
if physical:
@@ -2956,7 +2940,7 @@ def _dry_run_conflicting_services(data):
if wanted not in current:
print(f" Would update /etc/resolv.conf: nameserver {gw}")
else:
- print(f" /etc/resolv.conf already points to {gw} — no change needed")
+ print(f" /etc/resolv.conf already points to {gw} - no change needed")
def _dry_run_blocklists(data):
print("-- Blocklists (dry-run) ----------------------------------------------")
@@ -2982,7 +2966,7 @@ def _dry_run_timer(data):
for path, label in [(TIMER_FILE, "timer unit"), (TIMER_SVC_FILE, "service unit")]:
action = "update" if path.exists() else "create and enable"
print(f" Would {action}: {path}")
- print(f" Schedule: daily at {execute_time} local time (Persistent=true — catches up if missed)")
+ print(f" Schedule: daily at {execute_time} local time (Persistent=true - catches up if missed)")
def _dry_run_boot_service():
print("-- Boot service (dry-run) --------------------------------------------")
@@ -3016,11 +3000,11 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
if r.returncode == 0:
print(f" Would flush nftables table: {table}")
else:
- print(f" nftables table {table}: not present — no action needed")
+ print(f" nftables table {table}: not present - no action needed")
if NAT_SERVICE_FILE.exists():
print(f" Would stop, disable, and remove: {NAT_SERVICE_NAME}.service")
else:
- print(f" {NAT_SERVICE_NAME}.service: not installed — no action needed")
+ print(f" {NAT_SERVICE_NAME}.service: not installed - no action needed")
print()
print("-- Restoring NTP client (dry-run) ------------------------------------")
@@ -3028,7 +3012,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
if state == "active":
print(f" Would stop and disable: chrony (currently: active)")
else:
- print(f" chrony: not active — no action needed")
+ print(f" chrony: not active - no action needed")
r = subprocess.run(["systemctl", "cat", "systemd-timesyncd"],
capture_output=True, text=True)
if r.returncode == 0:
@@ -3063,9 +3047,9 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
print(f" nameserver {static_nameserver}")
print()
-# ------------------------------------------------------------------------------
+# ===================================================================
# Disable wizard
-# ------------------------------------------------------------------------------
+# ===================================================================
def cmd_disable(data, dry_run=False):
"""Interactive wizard to revert the machine from router to plain network client."""
@@ -3085,7 +3069,7 @@ def cmd_disable(data, dry_run=False):
print()
# ------------------------------------------------------------------
- # Step 1 — Confirmation
+ # Step 1 - Confirmation
# ------------------------------------------------------------------
while True:
print(" [1] Proceed with reversion")
@@ -3100,7 +3084,7 @@ def cmd_disable(data, dry_run=False):
print()
# ------------------------------------------------------------------
- # Step 2 — IP configuration
+ # Step 2 - IP configuration
# ------------------------------------------------------------------
physical = next((v for v in data["vlans"] if is_physical(v)), None)
if physical is None:
@@ -3110,7 +3094,7 @@ def cmd_disable(data, dry_run=False):
print(" How should this machine obtain its IP address after reversion?")
print()
- print(" [1] Obtain IP via DHCP (recommended — let the new router assign one)")
+ print(" [1] Obtain IP via DHCP (recommended - let the new router assign one)")
print(" [2] Use a static IP")
print()
@@ -3156,7 +3140,7 @@ def cmd_disable(data, dry_run=False):
print()
# ------------------------------------------------------------------
- # Step 3 — DNS resolver
+ # Step 3 - DNS resolver
# ------------------------------------------------------------------
# If resolv.conf is already a plain file with no router gateway IPs, leave it alone.
@@ -3187,7 +3171,7 @@ def cmd_disable(data, dry_run=False):
print()
if resolved_available:
- print(" [1] Re-enable systemd-resolved (recommended — adapts to any network)")
+ print(" [1] Re-enable systemd-resolved (recommended - adapts to any network)")
print(" [2] Enter a static nameserver IP")
while True:
choice = input(" Choice [1/2]: ").strip()
@@ -3219,7 +3203,7 @@ def cmd_disable(data, dry_run=False):
print()
# ------------------------------------------------------------------
- # Step 4 — Execute (or dry-run summary)
+ # Step 4 - Execute (or dry-run summary)
# ------------------------------------------------------------------
if dry_run:
_dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, static_nameserver)
@@ -3260,9 +3244,9 @@ def cmd_disable(data, dry_run=False):
else:
print(f" Interface {iface} will use static IP: {static_cidr}")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Main
-# ------------------------------------------------------------------------------
+# ===================================================================
def cmd_install(data):
@@ -3455,7 +3439,7 @@ def main():
sys.exit(0)
if args.dry_run and not any([args.apply, args.disable]):
- print("ERROR: --dry-run must be combined with --apply or --disable.")
+ print("ERROR: --dry-run must be combined with --apply or --disable.", file=sys.stderr)
sys.exit(1)
data = load_config()
diff --git a/router/ddns.json b/router/ddns.json
index e18dda3..c2f3946 100644
--- a/router/ddns.json
+++ b/router/ddns.json
@@ -27,7 +27,8 @@
"username": "your-username",
"password": "your-password",
"hostnames": [
- "grotke.ddns.net"
+ "yoursubdomain.ddns.net",
+ "yourothersubdomain.ddns.net"
]
},
{
@@ -36,16 +37,19 @@
"enabled": true,
"api_token": "your-cloudflare-api-token",
"hostnames": [
- "yourdomain.com"
+ "yourdomain.com",
+ "yoursubdomain.yourdomain.com",
+ "yourothersubdomain.yourdomain.com"
]
},
{
"description": "DuckDNS Account",
"provider": "duckdns",
"enabled": false,
- "token": "your-duckdns-token",
- "subdomains": [
- "yoursubdomain"
+ "api_token": "your-duckdns-api-token",
+ "hostnames": [
+ "yoursubdomain.duckdns.org",
+ "yourothersubdomain.duckdns.org"
]
}
]
diff --git a/router/ddns.py b/router/ddns.py
index 0bfe94a..aa09e3b 100644
--- a/router/ddns.py
+++ b/router/ddns.py
@@ -18,9 +18,10 @@ 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
- sudo python3 ddns.py --apply Run update once (used by timer)
- sudo python3 ddns.py --force Force update regardless of cached IP
- sudo python3 ddns.py --status Show timer/service status
+ 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
"""
import json
@@ -44,13 +45,13 @@ TIMER_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.timer")
# log is assigned in setup_logging() after config is loaded
log = None
-# ------------------------------------------------------------------------------
+# ===================================================================
# Load config
-# ------------------------------------------------------------------------------
+# ===================================================================
def load_config():
if not CONFIG_FILE.exists():
- print(f"ERROR: Config file not found: {CONFIG_FILE}")
+ 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)
@@ -59,47 +60,47 @@ def load_config():
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}")
+ print(f"ERROR: Missing keys in general block: {missing}", file=sys.stderr)
sys.exit(1)
if not data["general"]["ip_check_services"]:
- print("ERROR: ip_check_services list is empty.")
+ print("ERROR: 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.")
+ print("ERROR: No providers defined in config.", file=sys.stderr)
sys.exit(1)
for p in data["providers"]:
base_required = {"description", "provider", "enabled"}
missing = base_required - set(p.keys())
if missing:
- print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}")
+ print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}", file=sys.stderr)
sys.exit(1)
ptype = p.get("provider", "").lower()
if ptype == "noip":
extra = {"username", "password", "hostnames"}
elif ptype == "duckdns":
- extra = {"token", "subdomains"}
+ extra = {"api_token", "hostnames"}
elif ptype == "cloudflare":
extra = {"api_token", "hostnames"}
else:
- print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'")
+ print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'", file=sys.stderr)
sys.exit(1)
missing = extra - set(p.keys())
if missing:
- print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}")
+ print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}", file=sys.stderr)
sys.exit(1)
return data
-# ------------------------------------------------------------------------------
+# ===================================================================
# Helpers
-# ------------------------------------------------------------------------------
+# ===================================================================
def chown_to_script_dir_owner(path):
"""Chown a file to the owner of the script directory.
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
- or as a normal user — the script directory owner is always the right target.
+ or as a normal user - the script directory owner is always the right target.
"""
try:
stat = SCRIPT_DIR.stat()
@@ -107,9 +108,9 @@ def chown_to_script_dir_owner(path):
except OSError:
pass # non-fatal
-# ------------------------------------------------------------------------------
+# ===================================================================
# Logging
-# ------------------------------------------------------------------------------
+# ===================================================================
def setup_logging(max_kb, errors_only):
"""Clear log if oversized, then initialise logger. Must be called before log is used."""
@@ -138,9 +139,9 @@ def setup_logging(max_kb, errors_only):
)
log = logging.getLogger("ddns")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Per-provider IP cache
-# ------------------------------------------------------------------------------
+# ===================================================================
def cache_file_for(description):
"""Return the cache file path for a given provider description."""
@@ -158,9 +159,9 @@ def save_cached_ip(description, ip):
f.write_text(ip)
chown_to_script_dir_owner(f)
-# ------------------------------------------------------------------------------
+# ===================================================================
# Service rotation
-# ------------------------------------------------------------------------------
+# ===================================================================
def get_next_service_index(total):
"""Read last used index, increment, wrap around, return next index."""
@@ -177,8 +178,9 @@ def save_service_index(index):
CACHE_SERVICE_FILE.write_text(str(index))
chown_to_script_dir_owner(CACHE_SERVICE_FILE)
-# ------------------------------------------------------------------------------
+# ===================================================================
# Public IP detection
+# ===================================================================
def extract_ip(body):
"""
@@ -225,7 +227,7 @@ def _get_ip_via_cf_dns(spec):
return None
-# ------------------------------------------------------------------------------
+# ===================================================================
def get_public_ip(services):
"""
@@ -258,9 +260,9 @@ def get_public_ip(services):
log.error("Could not determine public IP from any configured service.")
sys.exit(1)
-# ------------------------------------------------------------------------------
+# ===================================================================
# No-IP update
-# ------------------------------------------------------------------------------
+# ===================================================================
def update_noip(provider, ip):
"""
@@ -325,9 +327,9 @@ def interpret_noip_response(response, hostnames, ip):
return False
-# ------------------------------------------------------------------------------
+# ===================================================================
# DuckDNS update
-# ------------------------------------------------------------------------------
+# ===================================================================
def update_duckdns(provider, ip):
"""
@@ -338,8 +340,8 @@ def update_duckdns(provider, ip):
as a comma-separated list.
Returns True on success, False on failure.
"""
- token = provider["token"]
- subdomains = ",".join(provider["subdomains"])
+ token = provider["api_token"]
+ subdomains = ",".join(h.replace(".duckdns.org", "") for h in provider["hostnames"])
description = provider["description"]
url = f"https://www.duckdns.org/update?domains={subdomains}&token={token}&ip={ip}"
@@ -358,9 +360,9 @@ def update_duckdns(provider, ip):
log.error(f"Network error contacting DuckDNS: {e}")
return False
-# ------------------------------------------------------------------------------
+# ===================================================================
# Cloudflare DNS update
-# ------------------------------------------------------------------------------
+# ===================================================================
def _cf_api_get(url, headers):
req = urllib.request.Request(url, headers=headers)
@@ -429,9 +431,9 @@ def update_cloudflare(provider, ip):
success = False
return success
-# ------------------------------------------------------------------------------
+# ===================================================================
# Process a single provider block
-# ------------------------------------------------------------------------------
+# ===================================================================
def process_provider(provider, current_ip, force=False):
description = provider["description"]
@@ -471,9 +473,9 @@ def process_provider(provider, current_ip, force=False):
save_cached_ip(description, current_ip)
-# ------------------------------------------------------------------------------
+# ===================================================================
# Timer management
-# ------------------------------------------------------------------------------
+# ===================================================================
def parse_interval(interval_str):
"""
@@ -557,16 +559,22 @@ def remove_timer():
else:
print("No timer found, nothing to remove.")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Main
-# ------------------------------------------------------------------------------
+# ===================================================================
-def run_update(cfg, force=False):
+def run_update(cfg, force=False, getip_only=False):
"""Perform a single DDNS update pass. Called by both timer and --start.
- 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."""
general = cfg["general"]
current_ip = get_public_ip(general["ip_check_services"])
- enabled = [p for p in cfg["providers"] if p.get("enabled") is True]
+
+ if getip_only:
+ print(current_ip)
+ return
+
+ enabled = [p for p in cfg["providers"] if p.get("enabled") is True]
if not enabled:
log.error("No enabled providers found in config.")
@@ -594,20 +602,22 @@ def main():
"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"
- " sudo python3 ddns.py --apply Run update once (used by timer)\n"
- " sudo python3 ddns.py --force Force update regardless of cached IP\n"
- " sudo python3 ddns.py --status Show timer/service status\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("--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("--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")
args = parser.parse_args()
- if not any([args.start, args.disable, args.apply, args.force, args.status]):
+ if not any([args.start, args.disable, args.apply, args.force, args.status, args.getip]):
parser.print_help()
return
@@ -615,6 +625,15 @@ def main():
show_status()
return
+ if args.getip:
+ global log
+ log = logging.getLogger("ddns_quiet")
+ log.addHandler(logging.NullHandler())
+ log.propagate = False
+ cfg = load_config()
+ run_update(cfg, getip_only=True)
+ return
+
cfg = load_config()
general = cfg["general"]
setup_logging(general["log_max_kb"], general["log_errors_only"])
diff --git a/router/validation.py b/router/validation.py
new file mode 100644
index 0000000..81b4e85
--- /dev/null
+++ b/router/validation.py
@@ -0,0 +1,164 @@
+"""
+validation.py -- Shared structural validators for core.json fields.
+
+Lives alongside core.py in ~/router/ and is volume-mounted into the
+router-dash container at /configs/validation.py. Importable by both
+core.py (router host) and the Flask app (via validate.py which adds
+/configs to sys.path).
+
+Convention: each function accepts a raw string and returns the
+normalised valid value, or '' if the input is invalid.
+"""
+import ipaddress
+import re
+
+VALID_PROTOCOLS = {'tcp', 'udp', 'both'}
+VALID_BLOCKLIST_FORMATS = {'dnsmasq', 'hosts'}
+
+
+# ===================================================================
+# IP / CIDR
+# ===================================================================
+
+def ip(value):
+ """Return value if it is a valid IPv4 or IPv6 address, else ''."""
+ if not value:
+ return ''
+ v = str(value).strip()
+ try:
+ ipaddress.ip_address(v)
+ return v
+ except ValueError:
+ return ''
+
+
+def ip_or_cidr(value):
+ """Return value if it is a valid IPv4/IPv6 address or CIDR network, else ''."""
+ if not value:
+ return ''
+ v = str(value).strip()
+ try:
+ ipaddress.ip_address(v)
+ return v
+ except ValueError:
+ pass
+ try:
+ ipaddress.ip_network(v, strict=False)
+ return v
+ except ValueError:
+ return ''
+
+
+# ===================================================================
+# Port
+# ===================================================================
+
+def port(value):
+ """Return port as string if valid 1-65535, else ''."""
+ try:
+ p = int(re.sub(r'[^0-9]', '', str(value)))
+ if 1 <= p <= 65535:
+ return str(p)
+ except (ValueError, TypeError):
+ pass
+ return ''
+
+
+# ===================================================================
+# Banned-IP pattern
+# ===================================================================
+
+def banned_ip(value):
+ """
+ Return value if it is a valid banned_ip pattern, else ''.
+
+ Accepted formats (mirrors core.py expand_banned_ip):
+ IPv4:
+ Single address 192.0.2.1
+ CIDR 192.0.2.0/24
+ Wildcard octet 192.0.2.*
+ Octet range 192.0.2.10-20
+ (combinations that expand to <=1024 entries are accepted)
+ IPv6:
+ Single address 2001:db8::1
+ CIDR 2001:db8::/32
+ Trailing wildcard 2001:db8:c17:*
+ """
+ if not value:
+ return ''
+ v = str(value).strip()
+ try:
+ _check_banned_ip(v)
+ return v
+ except (ValueError, TypeError):
+ return ''
+
+
+def _check_banned_ip(ip_str):
+ if ':' in ip_str:
+ _check_banned_ipv6(ip_str)
+ else:
+ _check_banned_ipv4(ip_str)
+
+
+def _check_banned_ipv4(ip_str):
+ if '/' in ip_str:
+ ipaddress.IPv4Network(ip_str, strict=False)
+ return
+
+ parts = ip_str.split('.')
+ if len(parts) != 4:
+ raise ValueError(f"Expected 4 octets: {ip_str!r}")
+
+ def parse_octet(s):
+ if s == '*':
+ return (0, 255)
+ if '-' in s:
+ a, b = s.split('-', 1)
+ lo, hi = int(a), int(b)
+ if not (0 <= lo <= hi <= 255):
+ raise ValueError(f"Invalid octet range {s!r}")
+ return (lo, hi)
+ v = int(s)
+ if not 0 <= v <= 255:
+ raise ValueError(f"Octet {v} out of 0-255")
+ return (v, v)
+
+ ranges = [parse_octet(p) for p in parts]
+
+ trailing = 0
+ for lo, hi in reversed(ranges):
+ if lo == 0 and hi == 255:
+ trailing += 1
+ else:
+ break
+
+ total = 1
+ for lo, hi in ranges[:4 - trailing]:
+ total *= (hi - lo + 1)
+ if total > 1024:
+ raise ValueError(f"Pattern expands to {total} entries (limit 1024); use CIDR")
+
+
+def _check_banned_ipv6(ip_str):
+ if '/' in ip_str:
+ ipaddress.IPv6Network(ip_str, strict=False)
+ return
+ if '*' not in ip_str:
+ ipaddress.IPv6Address(ip_str)
+ return
+ if not ip_str.endswith(':*'):
+ raise ValueError(f"Unsupported IPv6 wildcard: {ip_str!r}; use 'prefix:*' or CIDR")
+ prefix_part = ip_str[:-2]
+ if '::' in prefix_part:
+ left, right = prefix_part.split('::', 1)
+ lg = [g for g in left.split(':') if g] if left else []
+ rg = [g for g in right.split(':') if g] if right else []
+ zeros = 8 - len(lg) - len(rg) - 1
+ if zeros < 0:
+ raise ValueError(f"Too many groups in {ip_str!r}")
+ groups = lg + ['0000'] * zeros + rg
+ else:
+ groups = [g for g in prefix_part.split(':') if g]
+ if not (1 <= len(groups) <= 7):
+ raise ValueError(f"IPv6 wildcard must have 1-7 prefix groups: {ip_str!r}")
diff --git a/router/vpn.py b/router/vpn.py
index 9b9f595..0a02bba 100644
--- a/router/vpn.py
+++ b/router/vpn.py
@@ -41,6 +41,7 @@ import sys
import argparse
from pathlib import Path
from datetime import datetime, timezone
+from validation import ip as validate_ip
SCRIPT_DIR = Path(__file__).parent
DHCP_CONFIG_FILE = SCRIPT_DIR / "core.json"
@@ -48,12 +49,12 @@ DDNS_CONFIG_FILE = SCRIPT_DIR / "ddns.json"
WG_DIR = Path("/etc/wireguard")
KEEPALIVE = 25
-# ------------------------------------------------------------------------------
+# ===================================================================
# Helpers
-# ------------------------------------------------------------------------------
+# ===================================================================
def die(msg):
- print(f"ERROR: {msg}")
+ print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def check_root():
@@ -63,7 +64,7 @@ def check_root():
def chown_to_script_dir_owner(path):
"""Chown a file to the owner of the script directory.
Keeps SCRIPT_DIR files user-owned even when running as root.
- /etc/wireguard files are intentionally excluded — they stay root-owned.
+ /etc/wireguard files are intentionally excluded - they stay root-owned.
"""
try:
stat = SCRIPT_DIR.stat()
@@ -124,9 +125,9 @@ def _fmt_bytes(n):
else:
return f"{n / 1024**3:.2f} GB"
-# ------------------------------------------------------------------------------
+# ===================================================================
# Load core.json / dotfiles
-# ------------------------------------------------------------------------------
+# ===================================================================
def load_dhcp():
if not DHCP_CONFIG_FILE.exists():
@@ -169,9 +170,9 @@ def save_peers(iface, peers):
path.chmod(0o600)
chown_to_script_dir_owner(path)
-# ------------------------------------------------------------------------------
+# ===================================================================
# IP allocation
-# ------------------------------------------------------------------------------
+# ===================================================================
def next_available_ip(vlan, peers):
"""
@@ -197,9 +198,9 @@ def next_available_ip(vlan, peers):
die(f"No available IPs in VPN subnet {network} (all .2-.254 allocated).")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Key management
-# ------------------------------------------------------------------------------
+# ===================================================================
def generate_server_key(iface):
"""Generate server private key and store at WG_DIR/.key (600)."""
@@ -228,9 +229,9 @@ def generate_peer_keypair():
).stdout.strip()
return private, public
-# ------------------------------------------------------------------------------
+# ===================================================================
# Endpoint resolution
-# ------------------------------------------------------------------------------
+# ===================================================================
def resolve_endpoint(listen_port):
"""
@@ -294,9 +295,9 @@ def resolve_endpoint(listen_port):
entry = f"{entry}:{listen_port}"
return entry
-# ------------------------------------------------------------------------------
+# ===================================================================
# Split-tunnel route computation
-# ------------------------------------------------------------------------------
+# ===================================================================
def split_tunnel_routes(dhcp_data):
"""
@@ -316,9 +317,9 @@ def split_tunnel_routes(dhcp_data):
routes.append(str(net))
return routes
-# ------------------------------------------------------------------------------
+# ===================================================================
# Client config
-# ------------------------------------------------------------------------------
+# ===================================================================
def build_client_conf(peer, private_key, server_public_key, endpoint,
allowed_ips, dns, domain, mtu):
@@ -348,9 +349,9 @@ def write_client_conf(peer, private_key, server_public_key, endpoint,
chown_to_script_dir_owner(conf_path)
return conf_path
-# ------------------------------------------------------------------------------
+# ===================================================================
# WireGuard server conf
-# ------------------------------------------------------------------------------
+# ===================================================================
def build_wg_conf(vlan, peers, server_private_key):
iface = vlan["interface"]
@@ -381,9 +382,9 @@ def build_wg_conf(vlan, peers, server_private_key):
]
return "\n".join(lines)
-# ------------------------------------------------------------------------------
+# ===================================================================
# Live peer sync
-# ------------------------------------------------------------------------------
+# ===================================================================
def sync_peers_live(iface, peers):
"""
@@ -418,9 +419,9 @@ def sync_peers_live(iface, peers):
run(["wg", "set", iface, "peer", key, "remove"])
print(f" Removed peer: {key[:16]}...")
-# ------------------------------------------------------------------------------
+# ===================================================================
# Interface selection
-# ------------------------------------------------------------------------------
+# ===================================================================
def validate_wg_vlans(wg_vlans):
"""Die with a clear message if any wg VLAN is missing a valid vpn_information block."""
@@ -432,8 +433,11 @@ def validate_wg_vlans(wg_vlans):
f"Add: \"vpn_information\": {{\"listen_port\": 51820, \"gateway\": \"...\"}}")
if not isinstance(info.get("listen_port"), int):
die(f"Interface '{iface}' vpn_information is missing a valid listen_port in core.json.")
- if not info.get("gateway"):
+ gw = info.get("gateway", "")
+ if not gw:
die(f"Interface '{iface}' vpn_information is missing gateway in core.json.")
+ elif not validate_ip(gw):
+ die(f"Interface '{iface}' vpn_information.gateway '{gw}' is not a valid IP address.")
def pick_wg_interface(wg_vlans):
"""
@@ -459,9 +463,9 @@ def pick_wg_interface(wg_vlans):
pass
print(" Invalid selection.")
-# ------------------------------------------------------------------------------
+# ===================================================================
# --add-peer
-# ------------------------------------------------------------------------------
+# ===================================================================
def cmd_add_peer(dhcp_data):
check_root()
@@ -569,9 +573,9 @@ def cmd_add_peer(dhcp_data):
print(" sudo python3 vpn.py --apply")
print()
-# ------------------------------------------------------------------------------
+# ===================================================================
# --list-peers
-# ------------------------------------------------------------------------------
+# ===================================================================
def cmd_list_peers(dhcp_data):
check_root()
@@ -718,9 +722,9 @@ def cmd_list_peers(dhcp_data):
print(" sudo python3 vpn.py --apply")
print()
-# ------------------------------------------------------------------------------
+# ===================================================================
# --apply
-# ------------------------------------------------------------------------------
+# ===================================================================
def cmd_apply(dhcp_data):
check_root()
@@ -808,9 +812,9 @@ def cmd_apply(dhcp_data):
else:
print(f"WARNING: {core_py} not found -- run core.py --apply manually to load VPN firewall rules.")
-# ------------------------------------------------------------------------------
+# ===================================================================
# --disable
-# ------------------------------------------------------------------------------
+# ===================================================================
def cmd_disable(dhcp_data):
check_root()
@@ -825,9 +829,9 @@ def cmd_disable(dhcp_data):
else:
print(f"WireGuard service {svc} stopped and disabled.")
-# ------------------------------------------------------------------------------
+# ===================================================================
# --status
-# ------------------------------------------------------------------------------
+# ===================================================================
def cmd_status(dhcp_data):
check_root()
@@ -844,8 +848,8 @@ def cmd_status(dhcp_data):
r_enabled = run(["systemctl", "is-enabled", svc], check=False)
active = r_active.stdout.strip()
enabled = r_enabled.stdout.strip()
- active_sym = "✓" if active == "active" else "✗"
- enabled_sym = "✓" if enabled == "enabled" else "✗"
+ active_sym = "+" if active == "active" else "x"
+ enabled_sym = "+" if enabled == "enabled" else "x"
print(f" {svc:<45} {active_sym} {active:<10} {enabled_sym} {enabled}")
if active == "active":
@@ -869,9 +873,9 @@ def cmd_status(dhcp_data):
enabled_peers = [p for p in peers if p.get("enabled") is True]
print(f" peers: {len(enabled_peers)} configured, {info.get('peers', 0)} connected")
-# ------------------------------------------------------------------------------
+# ===================================================================
# --logs
-# ------------------------------------------------------------------------------
+# ===================================================================
def cmd_logs(dhcp_data):
check_root()
@@ -940,9 +944,9 @@ def cmd_logs(dhcp_data):
print()
-# ------------------------------------------------------------------------------
+# ===================================================================
# Main
-# ------------------------------------------------------------------------------
+# ===================================================================
def main():
parser = argparse.ArgumentParser(