Flask app progress

This commit is contained in:
Matthew Grotke 2026-05-17 03:26:01 -04:00
parent c4fe022d42
commit b0994069ad
38 changed files with 6631 additions and 220 deletions

View file

@ -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')

View file

@ -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)

View file

@ -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)

View file

@ -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')

View file

@ -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)

View file

@ -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')

View file

@ -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)

View file

@ -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)

View file

@ -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')

View file

@ -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)

View file

@ -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')

View file

@ -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)

View file

@ -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)

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -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

View file

@ -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'<code><strong>{command}</strong></code>'
)
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

View file

@ -1,12 +1,84 @@
import os, json, sys
from flask import Flask from flask import Flask
from page_dashboard import bp as dashboard_bp from view_page import bp as view_page_bp
from page_signup import bp as signup_bp from action_apply_general import bp as action_apply_general_bp
from page_signin import bp as signin_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 = Flask(__name__)
app.register_blueprint(dashboard_bp) app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
app.register_blueprint(signup_bp) app.register_blueprint(view_page_bp)
app.register_blueprint(signin_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__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=25327) app.run(host="0.0.0.0", port=25327)

View file

@ -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 ''

View file

@ -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',
]

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
{} {"accounts": []}

View file

@ -1 +0,0 @@
{}

View file

@ -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="
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -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 --apply # Run one immediate DDNS update (used by timer)
python3 ddns.py --force # Force update regardless of cached IP python3 ddns.py --force # Force update regardless of cached IP
python3 ddns.py --status # Timer/service status python3 ddns.py --status # Timer/service status
python3 ddns.py --getip # Print current public IP and exit
``` ```
--- ---

View file

@ -100,6 +100,7 @@
"name": "trusted", "name": "trusted",
"interface": "enp6s0", "interface": "enp6s0",
"radius_default": false, "radius_default": false,
"mdns_reflection": false,
"use_blocklists": ["oisd-big", "hagezi-light"], "use_blocklists": ["oisd-big", "hagezi-light"],
"server_identities": [ "server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.1.1" }, { "description": "Router/Gateway", "ip": "192.168.1.1" },
@ -134,6 +135,7 @@
"name": "iot", "name": "iot",
"interface": "enp6s0.10", "interface": "enp6s0.10",
"radius_default": false, "radius_default": false,
"mdns_reflection": true,
"use_blocklists": ["oisd-big", "hagezi-light"], "use_blocklists": ["oisd-big", "hagezi-light"],
"server_identities": [ "server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.10.1" } { "description": "Router/Gateway", "ip": "192.168.10.1" }
@ -168,6 +170,7 @@
"name": "guest", "name": "guest",
"interface": "enp6s0.20", "interface": "enp6s0.20",
"radius_default": true, "radius_default": true,
"mdns_reflection": true,
"use_blocklists": ["oisd-big", "hagezi-light"], "use_blocklists": ["oisd-big", "hagezi-light"],
"server_identities": [ "server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.20.1" } { "description": "Router/Gateway", "ip": "192.168.20.1" }
@ -196,6 +199,7 @@
"name": "kids", "name": "kids",
"interface": "enp6s0.30", "interface": "enp6s0.30",
"radius_default": false, "radius_default": false,
"mdns_reflection": true,
"use_blocklists": ["oisd-big", "hagezi-light", "hagezi-pro-plus"], "use_blocklists": ["oisd-big", "hagezi-light", "hagezi-pro-plus"],
"server_identities": [ "server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.30.1" } { "description": "Router/Gateway", "ip": "192.168.30.1" }
@ -226,6 +230,7 @@
"name": "vpn", "name": "vpn",
"interface": "wg0", "interface": "wg0",
"radius_default": false, "radius_default": false,
"mdns_reflection": false,
"use_blocklists": ["oisd-big", "hagezi-light"], "use_blocklists": ["oisd-big", "hagezi-light"],
"vpn_information": { "vpn_information": {
"listen_port": 51820, "listen_port": 51820,
@ -240,11 +245,6 @@
] ]
} }
], ]
"mdns_reflection": {
"enabled": true,
"reflect_vlans": ["iot", "guest", "kids"]
}
} }

View file

@ -100,6 +100,7 @@ import urllib.error
import argparse import argparse
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from validation import VALID_PROTOCOLS, VALID_BLOCKLIST_FORMATS
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json" CONFIG_FILE = SCRIPT_DIR / "core.json"
@ -119,14 +120,14 @@ NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service"
log = None log = None
# ------------------------------------------------------------------------------ # ===================================================================
# Logging # Logging
# ------------------------------------------------------------------------------ # ===================================================================
def chown_to_script_dir_owner(path): def chown_to_script_dir_owner(path):
"""Chown a file to the owner of the script directory. """Chown a file to the owner of the script directory.
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer), 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: try:
stat = SCRIPT_DIR.stat() stat = SCRIPT_DIR.stat()
@ -159,9 +160,9 @@ def setup_logging(max_kb, errors_only):
) )
log = logging.getLogger("dns-dhcp") log = logging.getLogger("dns-dhcp")
# ------------------------------------------------------------------------------ # ===================================================================
# Helpers # Helpers
# ------------------------------------------------------------------------------ # ===================================================================
def service_warning(action, svc, stderr): def service_warning(action, svc, stderr):
"""Print a service start/restart warning, adding --install hint if unit not found.""" """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): def die(msg):
print(f"ERROR: {msg}") print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1) sys.exit(1)
def check_root(): def check_root():
@ -279,9 +280,9 @@ def expand_protocols(rule):
return [("tcp", rule, " (tcp)"), ("udp", rule, " (udp)")] return [("tcp", rule, " (tcp)"), ("udp", rule, " (udp)")]
return [(proto, rule, "")] return [(proto, rule, "")]
# ------------------------------------------------------------------------------ # ===================================================================
# Load # Load
# ------------------------------------------------------------------------------ # ===================================================================
def load_config(): def load_config():
if not CONFIG_FILE.exists(): if not CONFIG_FILE.exists():
@ -292,9 +293,9 @@ def load_config():
die("No vlans defined in core.json.") die("No vlans defined in core.json.")
return data return data
# ------------------------------------------------------------------------------ # ===================================================================
# Validate # Validate
# ------------------------------------------------------------------------------ # ===================================================================
def validate_config(data): def validate_config(data):
errors = [] errors = []
@ -330,8 +331,8 @@ def validate_config(data):
for field in ("name", "description", "save_as", "url", "format"): for field in ("name", "description", "save_as", "url", "format"):
if not bl.get(field): if not bl.get(field):
errors.append(f"{label}: missing or empty field '{field}'.") errors.append(f"{label}: missing or empty field '{field}'.")
if bl.get("format") and bl["format"] not in ("dnsmasq", "hosts"): if bl.get("format") and bl["format"] not in VALID_BLOCKLIST_FORMATS:
errors.append(f"{label}: format must be 'dnsmasq' or 'hosts'.") errors.append(f"{label}: format must be one of: {', '.join(sorted(VALID_BLOCKLIST_FORMATS))}.")
if name: if name:
if name in blocklists_by_name: if name in blocklists_by_name:
errors.append(f"{label}: duplicate blocklist name '{name}'.") errors.append(f"{label}: duplicate blocklist name '{name}'.")
@ -365,6 +366,9 @@ def validate_config(data):
else: else:
seen_interfaces[iface] = name 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): if is_wg(vlan):
vpi = vlan.get("vpn_information") vpi = vlan.get("vpn_information")
if not isinstance(vpi, dict): if not isinstance(vpi, dict):
@ -538,7 +542,7 @@ def validate_config(data):
errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.") errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.")
# -- NAT / firewall validation --------------------------------------------- # -- NAT / firewall validation ---------------------------------------------
valid_protos = {"tcp", "udp", "both"} valid_protos = VALID_PROTOCOLS
known_interfaces = set(seen_interfaces.keys()) known_interfaces = set(seen_interfaces.keys())
def nat_check_port(label, port): def nat_check_port(label, port):
@ -621,25 +625,11 @@ def validate_config(data):
if r.get("dst_port") is not None: if r.get("dst_port") is not None:
nat_check_port(f"{label} dst_port", r.get("dst_port")) nat_check_port(f"{label} dst_port", r.get("dst_port"))
# -- mdns_reflection validation -------------------------------------------- # -- radius_default uniqueness check ---------------------------------------
mdns = data.get("mdns_reflection", {}) defaults = [v["name"] for v in data["vlans"] if v.get("radius_default") is True]
if mdns.get("enabled") is True: if len(defaults) > 1:
known_vlan_names = {v["name"] for v in data["vlans"]} errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). "
reflect_vlans = mdns.get("reflect_vlans", []) f"Only one VLAN may be the RADIUS default.")
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.")
# -- banned_ips validation ------------------------------------------------- # -- banned_ips validation -------------------------------------------------
for idx, entry in enumerate(data.get("banned_ips", [])): for idx, entry in enumerate(data.get("banned_ips", [])):
@ -654,14 +644,14 @@ def validate_config(data):
errors.append(f"{lbl}: {e}") errors.append(f"{lbl}: {e}")
if errors: if errors:
print("Validation failed:") print("Validation failed:", file=sys.stderr)
for e in errors: for e in errors:
print(f" - {e}") print(f" - {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
# ------------------------------------------------------------------------------ # ===================================================================
# Build systemd-networkd files # Build systemd-networkd files
# ------------------------------------------------------------------------------ # ===================================================================
def build_netdev(vlan): def build_netdev(vlan):
return "\n".join([ return "\n".join([
@ -787,9 +777,9 @@ def apply_networkd(data, dry_run=False, only_if_changed=False):
print("systemd-networkd: no changes. Good.") print("systemd-networkd: no changes. Good.")
# ------------------------------------------------------------------------------ # ===================================================================
# Blocklist management # Blocklist management
# ------------------------------------------------------------------------------ # ===================================================================
def combo_hash(names): def combo_hash(names):
"""Return a stable 8-char hex hash for a list/set of blocklist 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()) any_failed = any(content is None for content, _ in downloaded.values())
return not any_failed return not any_failed
# ------------------------------------------------------------------------------ # ===================================================================
# Build per-VLAN dnsmasq config # Build per-VLAN dnsmasq config
# ------------------------------------------------------------------------------ # ===================================================================
def _wan_has_ipv6(iface): def _wan_has_ipv6(iface):
"""Return True if the WAN interface has a non-link-local IPv6 address.""" """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) return "\n".join(L)
# ------------------------------------------------------------------------------ # ===================================================================
# Build per-VLAN systemd service unit # Build per-VLAN systemd service unit
# ------------------------------------------------------------------------------ # ===================================================================
def build_vlan_service(vlan): def build_vlan_service(vlan):
name = vlan["name"] name = vlan["name"]
@ -1133,9 +1123,9 @@ def build_vlan_service(vlan):
return "\n".join(lines) return "\n".join(lines)
# ------------------------------------------------------------------------------ # ===================================================================
# System dnsmasq / resolv.conf # System dnsmasq / resolv.conf
# ------------------------------------------------------------------------------ # ===================================================================
def ensure_resolv_conf(data): def ensure_resolv_conf(data):
"""Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1).""" """Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1)."""
@ -1297,9 +1287,9 @@ def restore_ntp():
else: else:
print("systemd-timesyncd is not available on this system.") print("systemd-timesyncd is not available on this system.")
# ------------------------------------------------------------------------------ # ===================================================================
# Apply dnsmasq instances # Apply dnsmasq instances
# ------------------------------------------------------------------------------ # ===================================================================
def wg_interface_up(iface): def wg_interface_up(iface):
"""Return True if the WireGuard interface exists and is up.""" """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: else:
print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)") print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)")
# ------------------------------------------------------------------------------ # ===================================================================
# Timer management # Timer management
# ------------------------------------------------------------------------------ # ===================================================================
def parse_time_to_calendar(time_str): def parse_time_to_calendar(time_str):
parts = time_str.strip().split(":") parts = time_str.strip().split(":")
@ -1519,9 +1509,9 @@ def remove_timer():
print(f"Not found, skipping: {f}") print(f"Not found, skipping: {f}")
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
# ------------------------------------------------------------------------------ # ===================================================================
# banned_ips expansion # banned_ips expansion
# ------------------------------------------------------------------------------ # ===================================================================
def _expand_banned_ipv4(ip_str): def _expand_banned_ipv4(ip_str):
"""Convert an IPv4 pattern (CIDR, wildcard, range) to nftables set elements.""" """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('.') parts = ip_str.split('.')
if len(parts) != 4: 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): def parse_octet(s, pos):
if s == '*': if s == '*':
@ -1587,7 +1577,7 @@ def _expand_banned_ipv4(ip_str):
_enum_cidr(idx + 1, chosen + [v]) _enum_cidr(idx + 1, chosen + [v])
_enum_cidr(0, []) _enum_cidr(0, [])
else: 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] outer_ranges = ranges[:3]
lo4, hi4 = ranges[3] lo4, hi4 = ranges[3]
@ -1682,9 +1672,9 @@ def banned_ip_sets(data):
return v4, v6 return v4, v6
# ------------------------------------------------------------------------------ # ===================================================================
# nftables config generation # nftables config generation
# ------------------------------------------------------------------------------ # ===================================================================
def build_nft_config(data, dry_run=False): def build_nft_config(data, dry_run=False):
wan = data["general"]["wan_interface"] wan = data["general"]["wan_interface"]
@ -1946,9 +1936,9 @@ def build_nft_config(data, dry_run=False):
return "\n".join(L) return "\n".join(L)
# ------------------------------------------------------------------------------ # ===================================================================
# nftables apply / disable / status # nftables apply / disable / status
# ------------------------------------------------------------------------------ # ===================================================================
def table_exists(family, name): def table_exists(family, name):
result = subprocess.run( result = subprocess.run(
@ -1977,8 +1967,8 @@ def apply_nft_config(config_text):
capture_output=True, text=True capture_output=True, text=True
) )
if result.returncode != 0: if result.returncode != 0:
print("ERROR: nft rejected the ruleset:") print("ERROR: nft rejected the ruleset:", file=sys.stderr)
print(result.stderr) print(result.stderr, file=sys.stderr)
sys.exit(1) sys.exit(1)
def apply_nftables(data, dry_run=False): def apply_nftables(data, dry_run=False):
@ -2075,9 +2065,9 @@ def show_rules():
else: else:
print(result.stdout) print(result.stdout)
# ------------------------------------------------------------------------------ # ===================================================================
# nftables boot service # nftables boot service
# ------------------------------------------------------------------------------ # ===================================================================
def install_nat_service(): def install_nat_service():
script_path = Path(__file__).resolve() script_path = Path(__file__).resolve()
@ -2121,13 +2111,13 @@ def remove_nat_service():
else: else:
print(f"Boot service not found, skipping: {NAT_SERVICE_NAME}.service") print(f"Boot service not found, skipping: {NAT_SERVICE_NAME}.service")
# ------------------------------------------------------------------------------ # ===================================================================
# Status # Status
# ------------------------------------------------------------------------------ # ===================================================================
# ------------------------------------------------------------------------------ # ===================================================================
# RADIUS # RADIUS
# ------------------------------------------------------------------------------ # ===================================================================
RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret" RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret"
RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf") RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf")
@ -2275,25 +2265,19 @@ def apply_radius(data):
service_warning("start", "freeradius", result.stderr) service_warning("start", "freeradius", result.stderr)
# ------------------------------------------------------------------------------ # ===================================================================
# Avahi mDNS Reflector # Avahi mDNS Reflector
# ------------------------------------------------------------------------------ # ===================================================================
AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf") AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf")
def avahi_enabled(data): def avahi_enabled(data):
"""Return True if mdns_reflection is enabled with at least two VLANs configured.""" """Return True if at least one non-WireGuard VLAN has mdns_reflection enabled."""
mdns = data.get("mdns_reflection", {}) return any(v.get("mdns_reflection") is True for v in data.get("vlans", []) if not is_wg(v))
return mdns.get("enabled") is True
def avahi_interfaces(data): def avahi_interfaces(data):
"""Return list of interface names for mDNS reflection based on reflect_vlans.""" """Return list of interface names for VLANs with mdns_reflection enabled."""
reflect = data.get("mdns_reflection", {}).get("reflect_vlans", []) return [v["interface"] for v in data.get("vlans", []) if v.get("mdns_reflection") is True and not is_wg(v)]
ifaces = []
for vlan in data["vlans"]:
if vlan["name"] in reflect and not is_wg(vlan):
ifaces.append(vlan["interface"])
return ifaces
def build_avahi_conf(data): def build_avahi_conf(data):
"""Patch avahi-daemon.conf directives needed for cross-VLAN mDNS reflection. """Patch avahi-daemon.conf directives needed for cross-VLAN mDNS reflection.
@ -2317,7 +2301,7 @@ def build_avahi_conf(data):
replacement = f"{directive}={value}" replacement = f"{directive}={value}"
if pattern.search(text): if pattern.search(text):
return pattern.sub(replacement, 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 # but append it to the relevant section if needed
return text + f"\n{replacement}\n" 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) r_enabled = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True)
active = r_active.stdout.strip() active = r_active.stdout.strip()
enabled = r_enabled.stdout.strip() enabled = r_enabled.stdout.strip()
active_sym = "" if active == "active" else "" active_sym = "+" if active == "active" else "x"
enabled_sym = "" if enabled == "enabled" else "" enabled_sym = "+" if enabled == "enabled" else "x"
active_ok = "(OK) " if active == expected_active else "(BAD)" active_ok = "(OK) " if active == expected_active else "(BAD)"
enabled_ok = "(OK) " if enabled == "enabled" else "(BAD)" enabled_ok = "(OK) " if enabled == "enabled" else "(BAD)"
return active_sym, active, active_ok, enabled_sym, enabled, enabled_ok return active_sym, active, active_ok, enabled_sym, enabled, enabled_ok
@ -2416,7 +2400,7 @@ def show_status(data):
else: else:
units.append((vlan_service_name(vlan), None, "active")) units.append((vlan_service_name(vlan), None, "active"))
units.append((f"{TIMER_NAME}.timer", 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(("freeradius", None, "active"))
units.append(("avahi-daemon", None, "active")) units.append(("avahi-daemon", None, "active"))
@ -2456,9 +2440,9 @@ def show_configs(data):
else: else:
print(f"No config found at {cf} (not yet applied).") print(f"No config found at {cf} (not yet applied).")
# ------------------------------------------------------------------------------ # ===================================================================
# Leases # Leases
# ------------------------------------------------------------------------------ # ===================================================================
def reset_leases(data, vlan_name=None): def reset_leases(data, vlan_name=None):
"""Stop dnsmasq instances, delete lease files, restart instances. """Stop dnsmasq instances, delete lease files, restart instances.
@ -2572,9 +2556,9 @@ def show_leases(data):
if not any_leases: if not any_leases:
print("No active leases found.") print("No active leases found.")
# ------------------------------------------------------------------------------ # ===================================================================
# Metrics # Metrics
# ------------------------------------------------------------------------------ # ===================================================================
def collect_metrics(data): def collect_metrics(data):
""" """
@ -2755,9 +2739,9 @@ def show_metrics(data):
print(f" NXDOMAIN : {s['nxdomain']:,}") print(f" NXDOMAIN : {s['nxdomain']:,}")
print(f" Latency : {s['avg_latency_ms']}ms (last recorded)") print(f" Latency : {s['avg_latency_ms']}ms (last recorded)")
# ------------------------------------------------------------------------------ # ===================================================================
# Stop / disable # Stop / disable
# ------------------------------------------------------------------------------ # ===================================================================
def stop_instances(data): def stop_instances(data):
"""Remove timer and stop all per-VLAN instances (config files preserved).""" """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]) chosen = max(non_gateway, key=lambda ip: ip.packed[-1])
return f"{chosen}/{prefix}" 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()) hosts = list(network.hosts())
candidates = [h for h in hosts if h not in known_ips and h.packed[-1] != 1] candidates = [h for h in hosts if h not in known_ips and h.packed[-1] != 1]
if candidates: if candidates:
chosen = random.choice(candidates) chosen = random.choice(candidates)
return f"{chosen}/{prefix}" return f"{chosen}/{prefix}"
# Degenerate fallback extremely small subnet # Degenerate fallback - extremely small subnet
return f"{list(network.hosts())[0]}/{prefix}" return f"{list(network.hosts())[0]}/{prefix}"
# ------------------------------------------------------------------------------ # ===================================================================
# Dry-run helpers # Dry-run helpers
# ------------------------------------------------------------------------------ # ===================================================================
def _svc_state(unit): def _svc_state(unit):
"""Return 'active', 'inactive', or 'unknown' for a systemd unit.""" """Return 'active', 'inactive', or 'unknown' for a systemd unit."""
@ -2900,12 +2884,12 @@ def _dry_run_conflicting_services(data):
if state == "active": if state == "active":
print(f" Would stop and disable: {label} (currently: active)") print(f" Would stop and disable: {label} (currently: active)")
else: else:
print(f" {label}: not active no action needed") print(f" {label}: not active - no action needed")
chrony_ok = subprocess.run(["systemctl", "cat", "chrony"], chrony_ok = subprocess.run(["systemctl", "cat", "chrony"],
capture_output=True, text=True).returncode == 0 capture_output=True, text=True).returncode == 0
if not chrony_ok: 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: else:
chrony_conf = Path("/etc/chrony/chrony.conf") chrony_conf = Path("/etc/chrony/chrony.conf")
if chrony_conf.exists(): if chrony_conf.exists():
@ -2922,7 +2906,7 @@ def _dry_run_conflicting_services(data):
if missing: if missing:
print(f" Would add chrony allow directives for: {', '.join(missing)}") print(f" Would add chrony allow directives for: {', '.join(missing)}")
else: 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") print(f" Would enable and restart: chrony")
if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode == 0: 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: if "Status: active" in status.stdout:
print(" Would disable: ufw (currently: active)") print(" Would disable: ufw (currently: active)")
else: else:
print(" ufw: not active no rule action needed") print(" ufw: not active - no rule action needed")
if _svc_enabled("ufw"): if _svc_enabled("ufw"):
print(" Would disable: ufw.service (currently: enabled at boot)") print(" Would disable: ufw.service (currently: enabled at boot)")
else: else:
print(" ufw.service: not enabled at boot no action needed") print(" ufw.service: not enabled at boot - no action needed")
else: else:
print(" ufw: not installed no action needed") print(" ufw: not installed - no action needed")
r = subprocess.run(["systemctl", "is-enabled", "dnsmasq"], r = subprocess.run(["systemctl", "is-enabled", "dnsmasq"],
capture_output=True, text=True) capture_output=True, text=True)
if r.stdout.strip() in ("enabled", "enabled-runtime"): if r.stdout.strip() in ("enabled", "enabled-runtime"):
print(f" Would stop and disable: system dnsmasq.service (currently: enabled)") print(f" Would stop and disable: system dnsmasq.service (currently: enabled)")
else: 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) physical = next((v for v in data["vlans"] if is_physical(v)), None)
if physical: if physical:
@ -2956,7 +2940,7 @@ def _dry_run_conflicting_services(data):
if wanted not in current: if wanted not in current:
print(f" Would update /etc/resolv.conf: nameserver {gw}") print(f" Would update /etc/resolv.conf: nameserver {gw}")
else: 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): def _dry_run_blocklists(data):
print("-- Blocklists (dry-run) ----------------------------------------------") 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")]: for path, label in [(TIMER_FILE, "timer unit"), (TIMER_SVC_FILE, "service unit")]:
action = "update" if path.exists() else "create and enable" action = "update" if path.exists() else "create and enable"
print(f" Would {action}: {path}") 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(): def _dry_run_boot_service():
print("-- Boot service (dry-run) --------------------------------------------") 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: if r.returncode == 0:
print(f" Would flush nftables table: {table}") print(f" Would flush nftables table: {table}")
else: 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(): if NAT_SERVICE_FILE.exists():
print(f" Would stop, disable, and remove: {NAT_SERVICE_NAME}.service") print(f" Would stop, disable, and remove: {NAT_SERVICE_NAME}.service")
else: 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()
print("-- Restoring NTP client (dry-run) ------------------------------------") 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": if state == "active":
print(f" Would stop and disable: chrony (currently: active)") print(f" Would stop and disable: chrony (currently: active)")
else: else:
print(f" chrony: not active no action needed") print(f" chrony: not active - no action needed")
r = subprocess.run(["systemctl", "cat", "systemd-timesyncd"], r = subprocess.run(["systemctl", "cat", "systemd-timesyncd"],
capture_output=True, text=True) capture_output=True, text=True)
if r.returncode == 0: 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(f" nameserver {static_nameserver}")
print() print()
# ------------------------------------------------------------------------------ # ===================================================================
# Disable wizard # Disable wizard
# ------------------------------------------------------------------------------ # ===================================================================
def cmd_disable(data, dry_run=False): def cmd_disable(data, dry_run=False):
"""Interactive wizard to revert the machine from router to plain network client.""" """Interactive wizard to revert the machine from router to plain network client."""
@ -3085,7 +3069,7 @@ def cmd_disable(data, dry_run=False):
print() print()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Step 1 Confirmation # Step 1 - Confirmation
# ------------------------------------------------------------------ # ------------------------------------------------------------------
while True: while True:
print(" [1] Proceed with reversion") print(" [1] Proceed with reversion")
@ -3100,7 +3084,7 @@ def cmd_disable(data, dry_run=False):
print() print()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Step 2 IP configuration # Step 2 - IP configuration
# ------------------------------------------------------------------ # ------------------------------------------------------------------
physical = next((v for v in data["vlans"] if is_physical(v)), None) physical = next((v for v in data["vlans"] if is_physical(v)), None)
if physical is 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(" How should this machine obtain its IP address after reversion?")
print() 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(" [2] Use a static IP")
print() print()
@ -3156,7 +3140,7 @@ def cmd_disable(data, dry_run=False):
print() 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. # 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() print()
if resolved_available: 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") print(" [2] Enter a static nameserver IP")
while True: while True:
choice = input(" Choice [1/2]: ").strip() choice = input(" Choice [1/2]: ").strip()
@ -3219,7 +3203,7 @@ def cmd_disable(data, dry_run=False):
print() print()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Step 4 Execute (or dry-run summary) # Step 4 - Execute (or dry-run summary)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
if dry_run: if dry_run:
_dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, static_nameserver) _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: else:
print(f" Interface {iface} will use static IP: {static_cidr}") print(f" Interface {iface} will use static IP: {static_cidr}")
# ------------------------------------------------------------------------------ # ===================================================================
# Main # Main
# ------------------------------------------------------------------------------ # ===================================================================
def cmd_install(data): def cmd_install(data):
@ -3455,7 +3439,7 @@ def main():
sys.exit(0) sys.exit(0)
if args.dry_run and not any([args.apply, args.disable]): 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) sys.exit(1)
data = load_config() data = load_config()

View file

@ -27,7 +27,8 @@
"username": "your-username", "username": "your-username",
"password": "your-password", "password": "your-password",
"hostnames": [ "hostnames": [
"grotke.ddns.net" "yoursubdomain.ddns.net",
"yourothersubdomain.ddns.net"
] ]
}, },
{ {
@ -36,16 +37,19 @@
"enabled": true, "enabled": true,
"api_token": "your-cloudflare-api-token", "api_token": "your-cloudflare-api-token",
"hostnames": [ "hostnames": [
"yourdomain.com" "yourdomain.com",
"yoursubdomain.yourdomain.com",
"yourothersubdomain.yourdomain.com"
] ]
}, },
{ {
"description": "DuckDNS Account", "description": "DuckDNS Account",
"provider": "duckdns", "provider": "duckdns",
"enabled": false, "enabled": false,
"token": "your-duckdns-token", "api_token": "your-duckdns-api-token",
"subdomains": [ "hostnames": [
"yoursubdomain" "yoursubdomain.duckdns.org",
"yourothersubdomain.duckdns.org"
] ]
} }
] ]

View file

@ -18,9 +18,10 @@ Log is cleared when it exceeds general.log_max_kb from config.
Usage: Usage:
sudo python3 ddns.py --start Run update and install systemd timer sudo python3 ddns.py --start Run update and install systemd timer
sudo python3 ddns.py --disable Stop updates and remove systemd timer sudo python3 ddns.py --disable Stop updates and remove systemd timer
sudo python3 ddns.py --apply Run update once (used by timer) python3 ddns.py --apply Run update once (used by timer)
sudo python3 ddns.py --force Force update regardless of cached IP python3 ddns.py --force Force update regardless of cached IP
sudo python3 ddns.py --status Show timer/service status python3 ddns.py --status Show timer/service status
python3 ddns.py --getip Print current public IP and exit
""" """
import json 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 is assigned in setup_logging() after config is loaded
log = None log = None
# ------------------------------------------------------------------------------ # ===================================================================
# Load config # Load config
# ------------------------------------------------------------------------------ # ===================================================================
def load_config(): def load_config():
if not CONFIG_FILE.exists(): 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) sys.exit(1)
with open(CONFIG_FILE) as f: with open(CONFIG_FILE) as f:
data = json.load(f) data = json.load(f)
@ -59,47 +60,47 @@ def load_config():
required_general = {"log_max_kb", "log_errors_only", "ip_check_services"} required_general = {"log_max_kb", "log_errors_only", "ip_check_services"}
missing = required_general - set(data.get("general", {}).keys()) missing = required_general - set(data.get("general", {}).keys())
if missing: if missing:
print(f"ERROR: Missing keys in general block: {missing}") print(f"ERROR: Missing keys in general block: {missing}", file=sys.stderr)
sys.exit(1) sys.exit(1)
if not data["general"]["ip_check_services"]: if not data["general"]["ip_check_services"]:
print("ERROR: ip_check_services list is empty.") print("ERROR: ip_check_services list is empty.", file=sys.stderr)
sys.exit(1) sys.exit(1)
# Validate providers block # Validate providers block
if not data.get("providers"): if not data.get("providers"):
print("ERROR: No providers defined in config.") print("ERROR: No providers defined in config.", file=sys.stderr)
sys.exit(1) sys.exit(1)
for p in data["providers"]: for p in data["providers"]:
base_required = {"description", "provider", "enabled"} base_required = {"description", "provider", "enabled"}
missing = base_required - set(p.keys()) missing = base_required - set(p.keys())
if missing: 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) sys.exit(1)
ptype = p.get("provider", "").lower() ptype = p.get("provider", "").lower()
if ptype == "noip": if ptype == "noip":
extra = {"username", "password", "hostnames"} extra = {"username", "password", "hostnames"}
elif ptype == "duckdns": elif ptype == "duckdns":
extra = {"token", "subdomains"} extra = {"api_token", "hostnames"}
elif ptype == "cloudflare": elif ptype == "cloudflare":
extra = {"api_token", "hostnames"} extra = {"api_token", "hostnames"}
else: 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) sys.exit(1)
missing = extra - set(p.keys()) missing = extra - set(p.keys())
if missing: 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) sys.exit(1)
return data return data
# ------------------------------------------------------------------------------ # ===================================================================
# Helpers # Helpers
# ------------------------------------------------------------------------------ # ===================================================================
def chown_to_script_dir_owner(path): def chown_to_script_dir_owner(path):
"""Chown a file to the owner of the script directory. """Chown a file to the owner of the script directory.
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer), 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: try:
stat = SCRIPT_DIR.stat() stat = SCRIPT_DIR.stat()
@ -107,9 +108,9 @@ def chown_to_script_dir_owner(path):
except OSError: except OSError:
pass # non-fatal pass # non-fatal
# ------------------------------------------------------------------------------ # ===================================================================
# Logging # Logging
# ------------------------------------------------------------------------------ # ===================================================================
def setup_logging(max_kb, errors_only): def setup_logging(max_kb, errors_only):
"""Clear log if oversized, then initialise logger. Must be called before log is used.""" """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") log = logging.getLogger("ddns")
# ------------------------------------------------------------------------------ # ===================================================================
# Per-provider IP cache # Per-provider IP cache
# ------------------------------------------------------------------------------ # ===================================================================
def cache_file_for(description): def cache_file_for(description):
"""Return the cache file path for a given provider 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) f.write_text(ip)
chown_to_script_dir_owner(f) chown_to_script_dir_owner(f)
# ------------------------------------------------------------------------------ # ===================================================================
# Service rotation # Service rotation
# ------------------------------------------------------------------------------ # ===================================================================
def get_next_service_index(total): def get_next_service_index(total):
"""Read last used index, increment, wrap around, return next index.""" """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)) CACHE_SERVICE_FILE.write_text(str(index))
chown_to_script_dir_owner(CACHE_SERVICE_FILE) chown_to_script_dir_owner(CACHE_SERVICE_FILE)
# ------------------------------------------------------------------------------ # ===================================================================
# Public IP detection # Public IP detection
# ===================================================================
def extract_ip(body): def extract_ip(body):
""" """
@ -225,7 +227,7 @@ def _get_ip_via_cf_dns(spec):
return None return None
# ------------------------------------------------------------------------------ # ===================================================================
def get_public_ip(services): 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.") log.error("Could not determine public IP from any configured service.")
sys.exit(1) sys.exit(1)
# ------------------------------------------------------------------------------ # ===================================================================
# No-IP update # No-IP update
# ------------------------------------------------------------------------------ # ===================================================================
def update_noip(provider, ip): def update_noip(provider, ip):
""" """
@ -325,9 +327,9 @@ def interpret_noip_response(response, hostnames, ip):
return False return False
# ------------------------------------------------------------------------------ # ===================================================================
# DuckDNS update # DuckDNS update
# ------------------------------------------------------------------------------ # ===================================================================
def update_duckdns(provider, ip): def update_duckdns(provider, ip):
""" """
@ -338,8 +340,8 @@ def update_duckdns(provider, ip):
as a comma-separated list. as a comma-separated list.
Returns True on success, False on failure. Returns True on success, False on failure.
""" """
token = provider["token"] token = provider["api_token"]
subdomains = ",".join(provider["subdomains"]) subdomains = ",".join(h.replace(".duckdns.org", "") for h in provider["hostnames"])
description = provider["description"] description = provider["description"]
url = f"https://www.duckdns.org/update?domains={subdomains}&token={token}&ip={ip}" 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}") log.error(f"Network error contacting DuckDNS: {e}")
return False return False
# ------------------------------------------------------------------------------ # ===================================================================
# Cloudflare DNS update # Cloudflare DNS update
# ------------------------------------------------------------------------------ # ===================================================================
def _cf_api_get(url, headers): def _cf_api_get(url, headers):
req = urllib.request.Request(url, headers=headers) req = urllib.request.Request(url, headers=headers)
@ -429,9 +431,9 @@ def update_cloudflare(provider, ip):
success = False success = False
return success return success
# ------------------------------------------------------------------------------ # ===================================================================
# Process a single provider block # Process a single provider block
# ------------------------------------------------------------------------------ # ===================================================================
def process_provider(provider, current_ip, force=False): def process_provider(provider, current_ip, force=False):
description = provider["description"] description = provider["description"]
@ -471,9 +473,9 @@ def process_provider(provider, current_ip, force=False):
save_cached_ip(description, current_ip) save_cached_ip(description, current_ip)
# ------------------------------------------------------------------------------ # ===================================================================
# Timer management # Timer management
# ------------------------------------------------------------------------------ # ===================================================================
def parse_interval(interval_str): def parse_interval(interval_str):
""" """
@ -557,16 +559,22 @@ def remove_timer():
else: else:
print("No timer found, nothing to remove.") print("No timer found, nothing to remove.")
# ------------------------------------------------------------------------------ # ===================================================================
# Main # 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. """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"] general = cfg["general"]
current_ip = get_public_ip(general["ip_check_services"]) 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: if not enabled:
log.error("No enabled providers found in config.") log.error("No enabled providers found in config.")
@ -594,20 +602,22 @@ def main():
"examples:\n" "examples:\n"
" sudo python3 ddns.py --start Run update and install systemd timer\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 --disable Stop updates and remove systemd timer\n"
" sudo python3 ddns.py --apply Run update once (used by timer)\n" " python3 ddns.py --apply Run update once (used by timer)\n"
" sudo python3 ddns.py --force Force update regardless of cached IP\n" " python3 ddns.py --force Force update regardless of cached IP\n"
" sudo python3 ddns.py --status Show timer/service status\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("--disable", action="store_true", help="Stop updates and remove systemd timer")
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)") parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP") parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP")
parser.add_argument("--status", action="store_true", help="Show timer/service status") parser.add_argument("--status", action="store_true", help="Show timer/service status")
parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
args = parser.parse_args() args = parser.parse_args()
if not any([args.start, args.disable, args.apply, args.force, args.status]): if not any([args.start, args.disable, args.apply, args.force, args.status, args.getip]):
parser.print_help() parser.print_help()
return return
@ -615,6 +625,15 @@ def main():
show_status() show_status()
return 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() cfg = load_config()
general = cfg["general"] general = cfg["general"]
setup_logging(general["log_max_kb"], general["log_errors_only"]) setup_logging(general["log_max_kb"], general["log_errors_only"])

164
router/validation.py Normal file
View file

@ -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}")

View file

@ -41,6 +41,7 @@ import sys
import argparse import argparse
from pathlib import Path from pathlib import Path
from datetime import datetime, timezone from datetime import datetime, timezone
from validation import ip as validate_ip
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent
DHCP_CONFIG_FILE = SCRIPT_DIR / "core.json" DHCP_CONFIG_FILE = SCRIPT_DIR / "core.json"
@ -48,12 +49,12 @@ DDNS_CONFIG_FILE = SCRIPT_DIR / "ddns.json"
WG_DIR = Path("/etc/wireguard") WG_DIR = Path("/etc/wireguard")
KEEPALIVE = 25 KEEPALIVE = 25
# ------------------------------------------------------------------------------ # ===================================================================
# Helpers # Helpers
# ------------------------------------------------------------------------------ # ===================================================================
def die(msg): def die(msg):
print(f"ERROR: {msg}") print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1) sys.exit(1)
def check_root(): def check_root():
@ -63,7 +64,7 @@ def check_root():
def chown_to_script_dir_owner(path): def chown_to_script_dir_owner(path):
"""Chown a file to the owner of the script directory. """Chown a file to the owner of the script directory.
Keeps SCRIPT_DIR files user-owned even when running as root. 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: try:
stat = SCRIPT_DIR.stat() stat = SCRIPT_DIR.stat()
@ -124,9 +125,9 @@ def _fmt_bytes(n):
else: else:
return f"{n / 1024**3:.2f} GB" return f"{n / 1024**3:.2f} GB"
# ------------------------------------------------------------------------------ # ===================================================================
# Load core.json / dotfiles # Load core.json / dotfiles
# ------------------------------------------------------------------------------ # ===================================================================
def load_dhcp(): def load_dhcp():
if not DHCP_CONFIG_FILE.exists(): if not DHCP_CONFIG_FILE.exists():
@ -169,9 +170,9 @@ def save_peers(iface, peers):
path.chmod(0o600) path.chmod(0o600)
chown_to_script_dir_owner(path) chown_to_script_dir_owner(path)
# ------------------------------------------------------------------------------ # ===================================================================
# IP allocation # IP allocation
# ------------------------------------------------------------------------------ # ===================================================================
def next_available_ip(vlan, peers): 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).") die(f"No available IPs in VPN subnet {network} (all .2-.254 allocated).")
# ------------------------------------------------------------------------------ # ===================================================================
# Key management # Key management
# ------------------------------------------------------------------------------ # ===================================================================
def generate_server_key(iface): def generate_server_key(iface):
"""Generate server private key and store at WG_DIR/<iface>.key (600).""" """Generate server private key and store at WG_DIR/<iface>.key (600)."""
@ -228,9 +229,9 @@ def generate_peer_keypair():
).stdout.strip() ).stdout.strip()
return private, public return private, public
# ------------------------------------------------------------------------------ # ===================================================================
# Endpoint resolution # Endpoint resolution
# ------------------------------------------------------------------------------ # ===================================================================
def resolve_endpoint(listen_port): def resolve_endpoint(listen_port):
""" """
@ -294,9 +295,9 @@ def resolve_endpoint(listen_port):
entry = f"{entry}:{listen_port}" entry = f"{entry}:{listen_port}"
return entry return entry
# ------------------------------------------------------------------------------ # ===================================================================
# Split-tunnel route computation # Split-tunnel route computation
# ------------------------------------------------------------------------------ # ===================================================================
def split_tunnel_routes(dhcp_data): def split_tunnel_routes(dhcp_data):
""" """
@ -316,9 +317,9 @@ def split_tunnel_routes(dhcp_data):
routes.append(str(net)) routes.append(str(net))
return routes return routes
# ------------------------------------------------------------------------------ # ===================================================================
# Client config # Client config
# ------------------------------------------------------------------------------ # ===================================================================
def build_client_conf(peer, private_key, server_public_key, endpoint, def build_client_conf(peer, private_key, server_public_key, endpoint,
allowed_ips, dns, domain, mtu): 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) chown_to_script_dir_owner(conf_path)
return conf_path return conf_path
# ------------------------------------------------------------------------------ # ===================================================================
# WireGuard server conf # WireGuard server conf
# ------------------------------------------------------------------------------ # ===================================================================
def build_wg_conf(vlan, peers, server_private_key): def build_wg_conf(vlan, peers, server_private_key):
iface = vlan["interface"] iface = vlan["interface"]
@ -381,9 +382,9 @@ def build_wg_conf(vlan, peers, server_private_key):
] ]
return "\n".join(lines) return "\n".join(lines)
# ------------------------------------------------------------------------------ # ===================================================================
# Live peer sync # Live peer sync
# ------------------------------------------------------------------------------ # ===================================================================
def sync_peers_live(iface, peers): def sync_peers_live(iface, peers):
""" """
@ -418,9 +419,9 @@ def sync_peers_live(iface, peers):
run(["wg", "set", iface, "peer", key, "remove"]) run(["wg", "set", iface, "peer", key, "remove"])
print(f" Removed peer: {key[:16]}...") print(f" Removed peer: {key[:16]}...")
# ------------------------------------------------------------------------------ # ===================================================================
# Interface selection # Interface selection
# ------------------------------------------------------------------------------ # ===================================================================
def validate_wg_vlans(wg_vlans): def validate_wg_vlans(wg_vlans):
"""Die with a clear message if any wg VLAN is missing a valid vpn_information block.""" """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\": \"...\"}}") f"Add: \"vpn_information\": {{\"listen_port\": 51820, \"gateway\": \"...\"}}")
if not isinstance(info.get("listen_port"), int): if not isinstance(info.get("listen_port"), int):
die(f"Interface '{iface}' vpn_information is missing a valid listen_port in core.json.") 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.") 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): def pick_wg_interface(wg_vlans):
""" """
@ -459,9 +463,9 @@ def pick_wg_interface(wg_vlans):
pass pass
print(" Invalid selection.") print(" Invalid selection.")
# ------------------------------------------------------------------------------ # ===================================================================
# --add-peer # --add-peer
# ------------------------------------------------------------------------------ # ===================================================================
def cmd_add_peer(dhcp_data): def cmd_add_peer(dhcp_data):
check_root() check_root()
@ -569,9 +573,9 @@ def cmd_add_peer(dhcp_data):
print(" sudo python3 vpn.py --apply") print(" sudo python3 vpn.py --apply")
print() print()
# ------------------------------------------------------------------------------ # ===================================================================
# --list-peers # --list-peers
# ------------------------------------------------------------------------------ # ===================================================================
def cmd_list_peers(dhcp_data): def cmd_list_peers(dhcp_data):
check_root() check_root()
@ -718,9 +722,9 @@ def cmd_list_peers(dhcp_data):
print(" sudo python3 vpn.py --apply") print(" sudo python3 vpn.py --apply")
print() print()
# ------------------------------------------------------------------------------ # ===================================================================
# --apply # --apply
# ------------------------------------------------------------------------------ # ===================================================================
def cmd_apply(dhcp_data): def cmd_apply(dhcp_data):
check_root() check_root()
@ -808,9 +812,9 @@ def cmd_apply(dhcp_data):
else: else:
print(f"WARNING: {core_py} not found -- run core.py --apply manually to load VPN firewall rules.") print(f"WARNING: {core_py} not found -- run core.py --apply manually to load VPN firewall rules.")
# ------------------------------------------------------------------------------ # ===================================================================
# --disable # --disable
# ------------------------------------------------------------------------------ # ===================================================================
def cmd_disable(dhcp_data): def cmd_disable(dhcp_data):
check_root() check_root()
@ -825,9 +829,9 @@ def cmd_disable(dhcp_data):
else: else:
print(f"WireGuard service {svc} stopped and disabled.") print(f"WireGuard service {svc} stopped and disabled.")
# ------------------------------------------------------------------------------ # ===================================================================
# --status # --status
# ------------------------------------------------------------------------------ # ===================================================================
def cmd_status(dhcp_data): def cmd_status(dhcp_data):
check_root() check_root()
@ -844,8 +848,8 @@ def cmd_status(dhcp_data):
r_enabled = run(["systemctl", "is-enabled", svc], check=False) r_enabled = run(["systemctl", "is-enabled", svc], check=False)
active = r_active.stdout.strip() active = r_active.stdout.strip()
enabled = r_enabled.stdout.strip() enabled = r_enabled.stdout.strip()
active_sym = "" if active == "active" else "" active_sym = "+" if active == "active" else "x"
enabled_sym = "" if enabled == "enabled" else "" enabled_sym = "+" if enabled == "enabled" else "x"
print(f" {svc:<45} {active_sym} {active:<10} {enabled_sym} {enabled}") print(f" {svc:<45} {active_sym} {active:<10} {enabled_sym} {enabled}")
if active == "active": 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] 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") print(f" peers: {len(enabled_peers)} configured, {info.get('peers', 0)} connected")
# ------------------------------------------------------------------------------ # ===================================================================
# --logs # --logs
# ------------------------------------------------------------------------------ # ===================================================================
def cmd_logs(dhcp_data): def cmd_logs(dhcp_data):
check_root() check_root()
@ -940,9 +944,9 @@ def cmd_logs(dhcp_data):
print() print()
# ------------------------------------------------------------------------------ # ===================================================================
# Main # Main
# ------------------------------------------------------------------------------ # ===================================================================
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(