Development

This commit is contained in:
Matthew Grotke 2026-05-20 17:10:18 -04:00
parent 270856b391
commit 2bfa5ff29a
18 changed files with 814 additions and 565 deletions

View file

@ -2,7 +2,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validate
import validation as validate
bp = Blueprint('action_apply_banned_ips', __name__)
@ -53,6 +53,11 @@ def add_banned_ip():
'ip': ip,
'enabled': True,
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -77,6 +82,11 @@ def toggle_banned_ip():
return redirect(VIEW)
items[idx]['enabled'] = not items[idx].get('enabled', True)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -107,6 +117,11 @@ def edit_banned_ip():
return redirect(VIEW)
items[idx].update({'description': description, 'ip': ip, 'enabled': enabled})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -131,6 +146,11 @@ def delete_banned_ip():
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -3,7 +3,7 @@ from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import re
import sanitize
import validate
import validation as validate
bp = Blueprint('action_apply_blocklists', __name__)
@ -76,6 +76,11 @@ def add_blocklist():
'url': fields['url'],
'save_as': _save_as_from_name(fields['name']),
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -110,6 +115,11 @@ def edit_blocklist():
'format': fields['format'],
'url': fields['url'],
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -134,6 +144,11 @@ def delete_blocklist():
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -2,7 +2,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
import json
import sanitize
import validate
import validation as validate
bp = Blueprint('action_apply_ddns_providers', __name__)

View file

@ -1,8 +1,10 @@
import ipaddress
from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validate
import validation as validate
bp = Blueprint('action_apply_dhcp_reservations', __name__)
@ -46,12 +48,30 @@ def _parse_ip():
return ip
def _check_ip_conflicts(ip, vlan):
"""Return an error message if ip conflicts with pool range or server identities, else None."""
dhcp = vlan.get('dhcp_information', {})
pool_start = dhcp.get('dynamic_pool_start')
pool_end = dhcp.get('dynamic_pool_end')
if pool_start and pool_end:
try:
if (ipaddress.IPv4Address(pool_start) <= ipaddress.IPv4Address(ip)
<= ipaddress.IPv4Address(pool_end)):
return f'{ip} falls within the dynamic pool range ({pool_start}{pool_end}).'
except Exception:
pass
identity_ips = {s['ip'] for s in vlan.get('server_identities', []) if s.get('ip')}
if ip in identity_ips:
return f'{ip} is already assigned as a server identity IP.'
return None
@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.domainname(request.form.get('hostname', ''))
hostname = validate.domainname(request.form.get('hostname', ''))
mac = sanitize.mac(request.form.get('mac', ''))
ip = _parse_ip()
radius_client = 'radius_client' in request.form
@ -76,6 +96,11 @@ def add_dhcp_reservation():
flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error')
return redirect(VIEW)
conflict = _check_ip_conflicts(ip, vlan)
if conflict:
flash(f'The configuration has not been saved because {conflict}', 'error')
return redirect(VIEW)
vlan.setdefault('reservations', []).append({
'description': description,
'hostname': hostname,
@ -84,6 +109,11 @@ def add_dhcp_reservation():
'radius_client': radius_client,
'enabled': True,
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -110,6 +140,11 @@ def toggle_dhcp_reservation():
res = vlans[vi]['reservations'][ri]
res['enabled'] = not res.get('enabled', True)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -125,7 +160,7 @@ def edit_dhcp_reservation():
return redirect(VIEW)
description = sanitize.text(request.form.get('description', ''))
hostname = sanitize.domainname(request.form.get('hostname', ''))
hostname = validate.domainname(request.form.get('hostname', ''))
mac = sanitize.mac(request.form.get('mac', ''))
ip = _parse_ip()
radius_client = 'radius_client' in request.form
@ -146,6 +181,11 @@ def edit_dhcp_reservation():
flash('Entry not found.', 'error')
return redirect(VIEW)
conflict = _check_ip_conflicts(ip, vlans[vi])
if conflict:
flash(f'The configuration has not been saved because {conflict}', 'error')
return redirect(VIEW)
res = vlans[vi]['reservations'][ri]
res.update({
'description': description,
@ -155,6 +195,11 @@ def edit_dhcp_reservation():
'radius_client': radius_client,
'enabled': 'enabled' in request.form,
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -180,6 +225,11 @@ def delete_dhcp_reservation():
return redirect(VIEW)
removed = vlans[vi]['reservations'].pop(ri)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -2,6 +2,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validation as validate
bp = Blueprint('action_apply_general', __name__)
@ -14,11 +15,8 @@ def apply_general():
dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form
daily_execute_time = sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', ''))
try:
log_max_kb = int(log_max_kb_raw)
if log_max_kb < 64:
raise ValueError
except (ValueError, TypeError):
log_max_kb = validate.int_range(log_max_kb_raw, 64, None)
if log_max_kb is None:
flash('Max Log Size must be a number >= 64.', 'error')
return redirect('/view/view_general')
@ -33,6 +31,11 @@ def apply_general():
'dnsmasq_log_queries': dnsmasq_log_queries,
'daily_execute_time_24hr_local': daily_execute_time,
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect('/view/view_general')
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -4,6 +4,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validation as validate
bp = Blueprint('action_apply_host_overrides', __name__)
@ -51,7 +52,7 @@ def _hash_ok():
@require_level('administrator')
def add_host_override():
description = sanitize.text(request.form.get('description', ''))
host = sanitize.domainname(request.form.get('host', ''))
host = validate.domainname(request.form.get('host', ''))
ip = sanitize.ip(request.form.get('ip', ''))
if not host or not ip:
@ -72,6 +73,11 @@ def add_host_override():
'ip': ip,
'enabled': True,
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -96,6 +102,11 @@ def toggle_host_override():
return redirect(VIEW)
items[idx]['enabled'] = not items[idx].get('enabled', True)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -111,7 +122,7 @@ def edit_host_override():
return redirect(VIEW)
description = sanitize.text(request.form.get('description', ''))
host = sanitize.domainname(request.form.get('host', ''))
host = validate.domainname(request.form.get('host', ''))
ip = sanitize.ip(request.form.get('ip', ''))
enabled = request.form.get('enabled') == 'on'
@ -133,6 +144,11 @@ def edit_host_override():
return redirect(VIEW)
items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -157,6 +173,11 @@ def delete_host_override():
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -4,6 +4,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import verify_core_hash, queued_msg, queue_command
import sanitize
import validation as validate
bp = Blueprint('action_apply_iface_config', __name__)
@ -47,11 +48,8 @@ def apply_iface_config():
mtu_int = None
if mtu:
try:
mtu_int = int(mtu)
if not (68 <= mtu_int <= 9000):
raise ValueError
except ValueError:
mtu_int = validate.int_range(mtu, 68, 9000)
if mtu_int is None:
flash('MTU must be an integer between 68 and 9000.', 'error')
return redirect(_VIEW)

View file

@ -2,7 +2,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validate
import validation as validate
bp = Blueprint('action_apply_inter_vlan', __name__)
@ -83,6 +83,11 @@ def add_inter_vlan():
core = load_core()
core.setdefault('inter_vlan_exceptions', []).append(entry)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -107,6 +112,11 @@ def toggle_inter_vlan():
return redirect(VIEW)
items[idx]['enabled'] = not items[idx].get('enabled', True)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -136,6 +146,11 @@ def edit_inter_vlan():
items[idx] = entry
items[idx]['enabled'] = request.form.get('enabled') == 'on'
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -160,6 +175,11 @@ def delete_inter_vlan():
return redirect(VIEW)
items.pop(idx)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -4,6 +4,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validation as validate
bp = Blueprint('action_apply_interface', __name__)
@ -53,6 +54,11 @@ def apply_interface():
gen = core.setdefault('general', {})
gen['wan_interface'] = wan
gen['lan_interface'] = lan
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -2,6 +2,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validation as validate
bp = Blueprint('action_apply_mdns', __name__)
@ -23,6 +24,11 @@ def apply_mdns():
'enabled': mdns_enabled,
'reflect_vlans': mdns_reflect_vlans,
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect('/view/view_mdns')
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -2,7 +2,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validate
import validation as validate
bp = Blueprint('action_apply_port_forwarding', __name__)
@ -84,6 +84,11 @@ def add_port_forward():
core = load_core()
core.setdefault('port_forwarding', []).append(entry)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -108,6 +113,11 @@ def toggle_port_forward():
return redirect(VIEW)
items[idx]['enabled'] = not items[idx].get('enabled', True)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -137,6 +147,11 @@ def edit_port_forward():
items[idx] = entry
items[idx]['enabled'] = request.form.get('enabled') == 'on'
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -161,6 +176,11 @@ def delete_port_forward():
return redirect(VIEW)
removed = items.pop(idx)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -2,6 +2,7 @@ from flask import Blueprint, request, redirect, flash
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import validation as validate
bp = Blueprint('action_apply_upstream_dns', __name__)
@ -26,11 +27,8 @@ def apply_upstream_dns():
return redirect('/view/view_upstream_dns')
upstream_servers.append(clean)
try:
cache_size = int(cache_size_raw)
if cache_size < 0:
raise ValueError
except (ValueError, TypeError):
cache_size = validate.int_range(cache_size_raw, 0, None)
if cache_size is None:
flash('Cache Size must be a non-negative integer.', 'error')
return redirect('/view/view_upstream_dns')
@ -51,6 +49,11 @@ def apply_upstream_dns():
'cache_size': cache_size,
'upstream_servers': upstream_servers,
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect('/view/view_upstream_dns')
save_core(core)
flash(queued_msg('core apply'), 'success')
return redirect('/view/view_upstream_dns')

View file

@ -3,6 +3,7 @@ from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg
import sanitize
import ipaddress as _ipaddress
import validation as validate
bp = Blueprint('action_apply_vlans', __name__)
@ -77,6 +78,10 @@ def add_vlan():
flash(f'VLAN {vlan_id} (derived from subnet) already exists.', 'error')
return redirect(VIEW)
if radius_default and any(v.get('radius_default') for v in vlans):
flash('Only one VLAN can be the RADIUS default.', 'error')
return redirect(VIEW)
entry = {
'vlan_id': vlan_id,
'name': name,
@ -92,6 +97,11 @@ def add_vlan():
else:
entry['reservations'] = []
vlans.append(entry)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -161,6 +171,10 @@ def edit_vlan():
flash(f'VLAN {vlan_id} (derived from subnet) already exists.', 'error')
return redirect(VIEW)
if radius_default and any(i != idx and v.get('radius_default') for i, v in enumerate(vlans)):
flash('Only one VLAN can be the RADIUS default.', 'error')
return redirect(VIEW)
existing.update({
'vlan_id': vlan_id,
'name': name,
@ -171,6 +185,11 @@ def edit_vlan():
'mdns_reflection': mdns_reflection,
'use_blocklists': use_blocklists,
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
@ -195,6 +214,11 @@ def delete_vlan():
return redirect(VIEW)
removed = vlans.pop(idx)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')

View file

@ -6,7 +6,7 @@ from flask import Blueprint, make_response, redirect, flash, request
from auth import require_level
from config_utils import load_core, save_core, verify_core_hash, queued_msg, CONFIGS_DIR
import sanitize
import validate
import validation as validate
bp = Blueprint('action_apply_vpn', __name__)
@ -140,19 +140,16 @@ def _conf_response(vlan, peer_name, peer_ip, private_key):
@require_level('administrator')
def apply_vpn():
listen_port_raw = request.form.get('vpn_listen_port', '').strip()
server_endpoint = sanitize.domainname(request.form.get('vpn_server_endpoint', ''))
domain = sanitize.domainname(request.form.get('vpn_domain', ''))
server_endpoint = validate.domainname(request.form.get('vpn_server_endpoint', ''))
domain = validate.domainname(request.form.get('vpn_domain', ''))
dns_raw = request.form.get('vpn_dns_server', '').strip()
mtu_raw = request.form.get('vpn_mtu', '').strip()
if not listen_port_raw:
flash('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):
listen_port = validate.int_range(listen_port_raw, 1, 65535)
if listen_port is None:
flash(f'"{listen_port_raw}" is not a valid port number (1-65535).', 'error')
return redirect(_VIEW)
@ -165,11 +162,8 @@ def apply_vpn():
mtu = None
if mtu_raw:
try:
mtu = int(mtu_raw)
if not (_MTU_MIN <= mtu <= _MTU_MAX):
raise ValueError
except (ValueError, TypeError):
mtu = validate.int_range(mtu_raw, _MTU_MIN, _MTU_MAX)
if mtu is None:
flash(f'"{mtu_raw}" is not a valid MTU (must be {_MTU_MIN}-{_MTU_MAX}).', 'error')
return redirect(_VIEW)
@ -182,6 +176,11 @@ def apply_vpn():
flash('No WireGuard VLAN found in configuration.', 'error')
return redirect(_VIEW)
for v in core.get('vlans', []):
if v.get('is_vpn') and v is not vpn_vlan and v.get('vpn_information', {}).get('listen_port') == listen_port:
flash(f'Listen port {listen_port} is already used by another VPN VLAN.', 'error')
return redirect(_VIEW)
info = vpn_vlan.setdefault('vpn_information', {})
info['listen_port'] = listen_port
info['server_endpoint'] = server_endpoint
@ -197,6 +196,11 @@ def apply_vpn():
else:
overrides.pop('mtu', None)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
return redirect(_VIEW)
@ -258,6 +262,11 @@ def add_vpn_peer():
'split_tunnel': split_tunnel,
'enabled': enabled,
})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
return _conf_response(vpn_vlan, peer_name, peer_ip, private_key)
@ -293,6 +302,11 @@ def edit_vpn_peer():
return redirect(_VIEW)
peers[peer_idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled})
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
return redirect(_VIEW)
@ -316,6 +330,11 @@ def toggle_vpn_peer():
peers = vlan.get('peers', [])
peers[peer_idx]['enabled'] = not peers[peer_idx].get('enabled', True)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
return redirect(_VIEW)
@ -338,6 +357,11 @@ def delete_vpn_peer():
return redirect(_VIEW)
vlan.get('peers', []).pop(peer_idx)
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
flash(queued_msg('core apply'), 'success')
return redirect(_VIEW)
@ -362,6 +386,11 @@ def regenerate_vpn_peer():
private_key, public_key = _generate_wg_keypair()
peer = vlan['peers'][peer_idx]
peer['public_key'] = public_key
errors = validate.validate_config(core)
if errors:
for msg in errors:
flash(msg, 'error')
return redirect(_VIEW)
save_core(core)
return _conf_response(vlan, peer['name'], peer['ip'], private_key)

View file

@ -1,28 +0,0 @@
"""
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,
)
VALID_DDNS_PROVIDERS = ('noip', 'cloudflare', 'duckdns')
__all__ = [
'VALID_PROTOCOLS',
'VALID_BLOCKLIST_FORMATS',
'VALID_DDNS_PROVIDERS',
'ip',
'ip_or_cidr',
'port',
'banned_ip',
]

View file

@ -2,7 +2,7 @@ from flask import Blueprint, session, redirect, get_flashed_messages
from markupsafe import Markup
import json, re, subprocess, os, sys, html as html_mod
import sanitize
import validate
import validation as validate
from datetime import datetime, timezone
from config_utils import core_hash, get_pending_entries, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime