UI improvement

This commit is contained in:
Matthew Grotke 2026-05-18 14:38:23 -04:00
parent 575edc836d
commit 9a272ee959
16 changed files with 2477 additions and 1604 deletions

View file

@ -6,19 +6,13 @@ 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')
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', ''))
try:
log_max_kb = int(log_max_kb_raw)
@ -34,10 +28,9 @@ def apply_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,
'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)

View file

@ -1,3 +1,5 @@
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, apply_msg
@ -8,6 +10,29 @@ bp = Blueprint('action_apply_host_overrides', __name__)
VIEW = '/view/view_host_overrides'
def _vlan_networks(core):
nets = []
for v in core.get('vlans', []):
subnet = v.get('subnet', '')
mask = v.get('subnet_mask', '')
if subnet and mask:
try:
nets.append(ipaddress.IPv4Network(f"{subnet}/{mask}", strict=False))
except ValueError:
pass
return nets
def _ip_in_vlan(ip_str, core):
"""Return True if ip_str falls within at least one configured VLAN subnet."""
try:
addr = ipaddress.IPv4Address(ip_str)
except ValueError:
return False
nets = _vlan_networks(core)
return not nets or any(addr in net for net in nets)
def _row_index():
try:
return int(request.form.get('row_index', ''))
@ -37,6 +62,10 @@ def add_host_override():
return redirect(VIEW)
core = load_core()
if not _ip_in_vlan(ip, core):
flash('IP address does not fall within any configured VLAN subnet.', 'error')
return redirect(VIEW)
core.setdefault('host_overrides', []).append({
'description': description,
'host': host,
@ -94,6 +123,10 @@ def edit_host_override():
return redirect(VIEW)
core = load_core()
if not _ip_in_vlan(ip, core):
flash('IP address does not fall within any configured VLAN subnet.', 'error')
return redirect(VIEW)
items = core.get('host_overrides', [])
if idx < 0 or idx >= len(items):
flash('Entry not found.', 'error')

View file

@ -0,0 +1,63 @@
import re
import subprocess
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_interface', __name__)
_VIEW = '/view/view_general'
def _get_system_interfaces():
try:
r = subprocess.run(['ip', 'link', 'show'], capture_output=True, text=True, timeout=5)
names = re.findall(r'^\d+:\s+(\S+):', r.stdout, re.MULTILINE)
return {n.split('@')[0] for n in names} - {'lo'}
except Exception:
return set()
@bp.route('/action/apply_interface', methods=['POST'])
@require_level('administrator')
def apply_interface():
idx_raw = request.form.get('row_index', '').strip()
interface = sanitize.interface_name(request.form.get('interface', ''))
try:
idx = int(idx_raw)
if idx not in (0, 1):
raise ValueError
except (ValueError, TypeError):
flash('Invalid request.', 'error')
return redirect(_VIEW)
if not interface:
flash('Interface name is required.', 'error')
return redirect(_VIEW)
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()
gen = core.setdefault('general', {})
other_key = 'lan_interface' if idx == 0 else 'wan_interface'
if interface == gen.get(other_key, ''):
flash('WAN and LAN interfaces must be different.', 'error')
return redirect(_VIEW)
available = _get_system_interfaces()
if available and interface not in available:
flash(f"Interface '{interface}' does not exist on this system.", 'error')
return redirect(_VIEW)
key = 'wan_interface' if idx == 0 else 'lan_interface'
gen[key] = interface
save_core(core)
flash(apply_msg(), 'success')
return redirect(_VIEW)

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, apply_msg
import sanitize
import ipaddress as _ipaddress
bp = Blueprint('action_apply_vlans', __name__)
@ -22,26 +23,46 @@ def _hash_ok():
return True
def _derive_vlan_id(subnet, prefix):
"""Return VLAN ID (14094) derived from the active octet of the network address,
or None if not derivable. byte_index = (prefix-1) // 8."""
try:
network = _ipaddress.ip_network(f'{subnet}/{prefix}', strict=False)
octets = list(network.network_address.packed)
byte_idx = (prefix - 1) // 8
vlan_id = octets[byte_idx]
if 1 <= vlan_id <= 4094:
return vlan_id
return None
except Exception:
return None
@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', ''))
name = sanitize.name(request.form.get('name', '')).lower()
is_vpn = 'is_vpn' in request.form
subnet = sanitize.ip(request.form.get('subnet', ''))
subnet_mask = sanitize.subnet_mask(request.form.get('subnet_mask', ''))
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')
if not name:
flash('Name is 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')
if not subnet:
flash('Subnet IP is required.', 'error')
return redirect(VIEW)
if subnet_mask is None:
flash('Invalid subnet prefix (must be 1-30).', 'error')
return redirect(VIEW)
vlan_id = _derive_vlan_id(subnet, subnet_mask)
if vlan_id is None:
flash('Cannot derive a valid VLAN ID (14094) from this subnet/prefix combination.', 'error')
return redirect(VIEW)
if not _hash_ok():
@ -51,19 +72,24 @@ def add_vlan():
vlans = core.setdefault('vlans', [])
if any(v.get('vlan_id') == vlan_id for v in vlans):
flash(f'VLAN {vlan_id} already exists.', 'error')
flash(f'VLAN {vlan_id} (derived from subnet) already exists.', 'error')
return redirect(VIEW)
vlans.append({
entry = {
'vlan_id': vlan_id,
'name': name,
'interface': interface,
'dhcp': {'subnet': subnet},
'is_vpn': is_vpn,
'subnet': subnet,
'subnet_mask': subnet_mask,
'use_blocklists': [],
'radius_default': radius_default,
'mdns_reflection': mdns_reflection,
'reservations': [],
})
}
if is_vpn:
entry['peers'] = []
else:
entry['reservations'] = []
vlans.append(entry)
save_core(core)
flash(apply_msg(), 'success')
@ -78,14 +104,29 @@ def edit_vlan():
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', ''))
name = sanitize.name(request.form.get('name', '')).lower()
subnet = sanitize.ip(request.form.get('subnet', ''))
radius_default = 'radius_default' in request.form
mdns_reflection = 'mdns_reflection' in request.form
use_blocklists = request.form.getlist('use_blocklists')
if not name or not interface:
flash('Name and interface are required.', 'error')
# subnet_mask is only present when the column is visible (not all edit paths send it).
# Validate if submitted; fall back to the stored value otherwise.
subnet_mask_raw = request.form.get('subnet_mask')
if subnet_mask_raw is not None:
subnet_mask = sanitize.subnet_mask(subnet_mask_raw)
if subnet_mask is None:
flash('Invalid subnet prefix (must be 1-30).', 'error')
return redirect(VIEW)
else:
subnet_mask = None # resolved below after loading core
if not name:
flash('Name is required.', 'error')
return redirect(VIEW)
if not subnet:
flash('Subnet IP is required.', 'error')
return redirect(VIEW)
if not _hash_ok():
@ -97,8 +138,36 @@ def edit_vlan():
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
existing = vlans[idx]
# is_vpn is never changed via edit — toggling it would invalidate peers/reservations.
is_vpn = existing.get('is_vpn', False)
# Use submitted subnet_mask, or fall back to whatever is already stored.
final_mask = subnet_mask if subnet_mask is not None else existing.get('subnet_mask', 24)
vlan_id = _derive_vlan_id(subnet, final_mask)
if vlan_id is None:
flash('Cannot derive a valid VLAN ID (14094) from this subnet/prefix combination.', 'error')
return redirect(VIEW)
current_id = existing.get('vlan_id')
if current_id == 1 and vlan_id != 1:
flash('VLAN 1 is the physical interface; change its subnet so the derived ID remains 1.', 'error')
return redirect(VIEW)
if vlan_id != current_id and any(i != idx and v.get('vlan_id') == vlan_id for i, v in enumerate(vlans)):
flash(f'VLAN {vlan_id} (derived from subnet) already exists.', 'error')
return redirect(VIEW)
existing.update({
'vlan_id': vlan_id,
'name': name,
'is_vpn': is_vpn,
'subnet': subnet,
'subnet_mask': final_mask,
'radius_default': radius_default,
'mdns_reflection': mdns_reflection,
'use_blocklists': use_blocklists,
})
save_core(core)
flash(apply_msg(), 'success')

View file

@ -1,6 +1,10 @@
from flask import Blueprint, request, redirect, flash
import base64
import ipaddress
import re
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, apply_msg, _APPLY_CMD_VPN
from config_utils import load_core, save_core, verify_core_hash, apply_msg, CONFIGS_DIR
import sanitize
import validate
@ -11,45 +15,136 @@ _MTU_MIN = 576
_MTU_MAX = 9000
def _wg_vlan(core):
return next((v for v in core.get('vlans', []) if v.get('is_vpn')), None)
def _wg_iface(vlan, core):
"""Return the WireGuard interface name (wg0, wg1, ...) for a VPN VLAN."""
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
return f'wg{idx}'
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 _generate_wg_keypair():
"""Generate an X25519 keypair compatible with WireGuard. Returns (private_b64, public_b64)."""
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding, PublicFormat, PrivateFormat, NoEncryption,
)
private = X25519PrivateKey.generate()
priv_raw = private.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
pub_raw = private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
return base64.b64encode(priv_raw).decode(), base64.b64encode(pub_raw).decode()
def _server_pubkey(iface):
"""Read the server public key written by core.py --apply."""
try:
with open(f'{CONFIGS_DIR}/.wg-{iface}.pub') as f:
return f.read().strip()
except OSError:
return None
def _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pubkey):
"""Build WireGuard client .conf content."""
info = vlan.get('vpn_information', {})
overrides = info.get('explicit_overrides', {})
subnet = vlan.get('subnet', '')
mask = vlan.get('subnet_mask', '')
network = ipaddress.IPv4Network(f'{subnet}/{mask}', strict=False)
ident_ips = [s['ip'] for s in vlan.get('server_identities', []) if s.get('ip')]
default = str(min((ipaddress.IPv4Address(ip) for ip in ident_ips),
key=lambda x: x.packed[-1])) if ident_ips else str(next(network.hosts()))
gateway = overrides.get('gateway') or default
dns = overrides.get('dns_server') or gateway
prefix = network.prefixlen
mtu = overrides.get('mtu', '')
endpoint = info.get('server_endpoint', '')
listen_port = info.get('listen_port', 51820)
split_tunnel = next(
(p.get('split_tunnel', False) for p in vlan.get('peers', []) if p.get('name') == peer_name),
False
)
allowed_ips = f'{subnet}/{prefix}' if split_tunnel else '0.0.0.0/0'
lines = [
'# Generated by router-dash',
'',
'[Interface]',
f'PrivateKey = {private_key}',
f'Address = {peer_ip}/{prefix}',
f'DNS = {dns}',
]
if mtu:
lines.append(f'MTU = {mtu}')
lines += ['', '[Peer]', f'PublicKey = {server_pubkey}']
if endpoint:
lines.append(f'Endpoint = {endpoint}:{listen_port}')
lines += [f'AllowedIPs = {allowed_ips}', 'PersistentKeepalive = 25', '']
return '\n'.join(lines)
def _conf_response(vlan, peer_name, peer_ip, private_key):
"""Return a .conf file download response, or redirect with error if pubkey unavailable."""
core = load_core()
iface = _wg_iface(vlan, core)
server_pub = _server_pubkey(iface)
if not server_pub:
flash('Peer saved. Run sudo python3 ~/router/core.py --apply to generate the server '
'public key, then regenerate this peer to download the client config.', 'warning')
return redirect(_VIEW)
conf = _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pub)
safe = re.sub(r'[^A-Za-z0-9_\-]', '_', peer_name)
resp = make_response(conf)
resp.headers['Content-Type'] = 'text/plain; charset=utf-8'
resp.headers['Content-Disposition'] = f'attachment; filename="vpn-client-{safe}.conf"'
return resp
@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_raw = request.form.get('vpn_listen_port', '').strip()
server_endpoint = sanitize.hostname(request.form.get('vpn_server_endpoint', ''))
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')
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):
flash(f'The configuration has not been saved because "{listen_port_raw}" is not a valid port number (1-65535).', 'error')
flash(f'"{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')
flash(f'"{dns_raw}" is not a valid IP address for DNS server.', 'error')
return redirect(_VIEW)
# -- MTU (optional) --------------------------------------------------------
mtu = None
if mtu_raw:
try:
@ -57,25 +152,22 @@ def apply_vpn():
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')
flash(f'"{mtu_raw}" is not a valid MTU (must be {_MTU_MIN}-{_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')
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vpn_vlan = next((v for v in core.get('vlans', []) if 'vpn_information' in v), None)
core = load_core()
vpn_vlan = _wg_vlan(core)
if vpn_vlan is None:
flash('The configuration has not been saved because no VPN VLAN was found in the configuration.', 'error')
flash('No WireGuard VLAN found in configuration.', 'error')
return redirect(_VIEW)
info = vpn_vlan.setdefault('vpn_information', {})
info['listen_port'] = listen_port
info['gateway'] = gateway
info['domain'] = domain
info['listen_port'] = listen_port
info['server_endpoint'] = server_endpoint
info['domain'] = domain
overrides = info.setdefault('explicit_overrides', {})
if dns_server:
@ -88,6 +180,173 @@ def apply_vpn():
overrides.pop('mtu', None)
save_core(core)
flash(apply_msg(_APPLY_CMD_VPN), 'success')
flash(apply_msg(), 'success')
return redirect(_VIEW)
@bp.route('/action/add_vpn_peer', methods=['POST'])
@require_level('administrator')
def add_vpn_peer():
peer_name = sanitize.name(request.form.get('peer_name', ''))
peer_ip_raw = request.form.get('peer_ip', '').strip()
split_tunnel = 'split_tunnel' in request.form
if not peer_name:
flash('Peer name is required.', 'error')
return redirect(_VIEW)
peer_ip = validate.ip(peer_ip_raw)
if not peer_ip:
flash(f'"{peer_ip_raw}" is not a valid IP address.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vpn_vlan = _wg_vlan(core)
if vpn_vlan is None:
flash('No WireGuard VLAN found in configuration.', 'error')
return redirect(_VIEW)
peers = vpn_vlan.setdefault('peers', [])
if any(p.get('name') == peer_name for p in peers):
flash(f'A peer named "{peer_name}" already exists.', 'error')
return redirect(_VIEW)
if any(p.get('ip') == peer_ip for p in peers):
flash(f'IP address {peer_ip} is already assigned to another peer.', 'error')
return redirect(_VIEW)
private_key, public_key = _generate_wg_keypair()
peers.append({
'name': peer_name,
'ip': peer_ip,
'public_key': public_key,
'split_tunnel': split_tunnel,
'enabled': True,
})
save_core(core)
return _conf_response(vpn_vlan, peer_name, peer_ip, private_key)
@bp.route('/action/edit_vpn_peer', methods=['POST'])
@require_level('administrator')
def edit_vpn_peer():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
peer_name = sanitize.name(request.form.get('name', ''))
split_tunnel = request.form.get('split_tunnel') in ('true', '1', 'on', 'yes')
enabled = request.form.get('enabled') not in ('false', '0', '')
if not peer_name:
flash('Peer name is required.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vpn_vlan = _wg_vlan(core)
if vpn_vlan is None:
flash('No WireGuard VLAN found.', 'error')
return redirect(_VIEW)
peers = vpn_vlan.get('peers', [])
if idx < 0 or idx >= len(peers):
flash('Peer not found.', 'error')
return redirect(_VIEW)
# Reject duplicate name if it belongs to a different peer
if any(i != idx and p.get('name') == peer_name for i, p in enumerate(peers)):
flash(f'A peer named "{peer_name}" already exists.', 'error')
return redirect(_VIEW)
peers[idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled})
save_core(core)
flash(apply_msg(), 'success')
return redirect(_VIEW)
@bp.route('/action/toggle_vpn_peer', methods=['POST'])
@require_level('administrator')
def toggle_vpn_peer():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vpn_vlan = _wg_vlan(core)
if vpn_vlan is None:
flash('No WireGuard VLAN found.', 'error')
return redirect(_VIEW)
peers = vpn_vlan.get('peers', [])
if idx < 0 or idx >= len(peers):
flash('Peer not found.', 'error')
return redirect(_VIEW)
peers[idx]['enabled'] = not peers[idx].get('enabled', True)
save_core(core)
flash(apply_msg(), 'success')
return redirect(_VIEW)
@bp.route('/action/delete_vpn_peer', methods=['POST'])
@require_level('administrator')
def delete_vpn_peer():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vpn_vlan = _wg_vlan(core)
if vpn_vlan is None:
flash('No WireGuard VLAN found.', 'error')
return redirect(_VIEW)
peers = vpn_vlan.get('peers', [])
if idx < 0 or idx >= len(peers):
flash('Peer not found.', 'error')
return redirect(_VIEW)
peers.pop(idx)
save_core(core)
flash(apply_msg(), 'success')
return redirect(_VIEW)
@bp.route('/action/regenerate_vpn_peer', methods=['POST'])
@require_level('administrator')
def regenerate_vpn_peer():
idx = _row_index()
if idx is None:
flash('Invalid request.', 'error')
return redirect(_VIEW)
if not _hash_ok():
return redirect(_VIEW)
core = load_core()
vpn_vlan = _wg_vlan(core)
if vpn_vlan is None:
flash('No WireGuard VLAN found.', 'error')
return redirect(_VIEW)
peers = vpn_vlan.get('peers', [])
if idx < 0 or idx >= len(peers):
flash('Peer not found.', 'error')
return redirect(_VIEW)
private_key, public_key = _generate_wg_keypair()
peer = peers[idx]
peer['public_key'] = public_key
save_core(core)
return _conf_response(vpn_vlan, peer['name'], peer['ip'], private_key)

View file

@ -1,16 +1,14 @@
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'
_APPLY_CMD = 'sudo python3 ~/router/core.py --apply'
def apply_msg(cmd=None):
def apply_msg():
"""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>'
f'<code><strong>{_APPLY_CMD}</strong></code>'
)
CONFIGS_DIR = '/configs'
@ -54,16 +52,6 @@ def run_apply():
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(

View file

@ -22,6 +22,7 @@ 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
from action_apply_interface import bp as action_apply_interface_bp
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
@ -47,6 +48,7 @@ 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)
app.register_blueprint(action_apply_interface_bp)
def _seed_initial_account():
email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()

View file

@ -1,4 +1,5 @@
import re
import ipaddress
# Curated IANA timezone list for the dropdown. Validation accepts any entry from this set.
VALID_TIMEZONES = [
@ -113,12 +114,25 @@ def hostname(value, max_len=253):
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)
"""IPv4 or IPv6 address. Returns '' if not a valid address."""
cleaned = _strip(value, r'[^0-9a-fA-F.:]', max_len)
try:
ipaddress.ip_address(cleaned)
return cleaned
except ValueError:
return ''
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)
"""IP address or CIDR subnet. Returns '' if not valid."""
cleaned = _strip(value, r'[^0-9a-fA-F.:/]', max_len)
try:
if '/' in cleaned:
ipaddress.ip_network(cleaned, strict=False)
else:
ipaddress.ip_address(cleaned)
return cleaned
except ValueError:
return ''
def mac(value, max_len=17):
"""MAC address: hex digits and colons."""
@ -154,3 +168,24 @@ def email(value, max_len=254):
def timezone(value):
"""Timezone string: must be in VALID_TIMEZONES list. Returns '' if not found."""
return value if value in _TIMEZONE_SET else ''
_DOTTED_TO_PREFIX = {
'255.0.0.0': 8, '255.255.0.0': 16, '255.255.255.0': 24,
'255.255.255.128': 25, '255.255.255.192': 26,
'255.255.255.224': 27, '255.255.255.240': 28,
'255.255.255.248': 29, '255.255.255.252': 30,
}
def subnet_mask(value):
"""Subnet prefix length 1-30 (integer). Also accepts legacy dotted notation.
Returns int on success, None if invalid."""
s = str(value).strip()
if s in _DOTTED_TO_PREFIX:
return _DOTTED_TO_PREFIX[s]
try:
n = int(s)
if 1 <= n <= 30:
return n
except (ValueError, TypeError):
pass
return None

View file

@ -67,6 +67,46 @@ def _run(cmd):
return ''
def _prefix_to_dotted(n):
mask = (0xFFFFFFFF << (32 - n)) & 0xFFFFFFFF
return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))
def _get_system_interfaces():
"""Return sorted list of physical-ish interface names from `ip link show`."""
out = _run('ip link show')
names = re.findall(r'^\d+:\s+(\S+):', out, re.MULTILINE)
ifaces = sorted({n.split('@')[0] for n in names} - {'lo'})
return ifaces
def _iface_status(iface):
"""Return link state for iface by reading /sys/class/net/<iface>/operstate.
Returns INVALID if the interface does not exist, otherwise UP/DOWN/UNKNOWN/etc."""
if not iface:
return 'INVALID'
safe = re.sub(r'[^A-Za-z0-9._-]', '', iface)
if not safe:
return 'INVALID'
try:
with open(f'/sys/class/net/{safe}/operstate') as f:
state = f.read().strip().upper()
return state if state else 'UP'
except OSError:
return 'INVALID'
def _resolve_iface(vlan, core):
"""Compute interface name from is_vpn + vlan_id + general.lan_interface."""
if vlan.get('is_vpn'):
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
return f'wg{idx}'
lan = core.get('general', {}).get('lan_interface', 'eth0')
vid = vlan.get('vlan_id', 1)
return lan if vid == 1 else f'{lan}.{vid}'
# -- Live data loaders ---------------------------------------------------------
def _live_dhcp_leases():
@ -91,11 +131,12 @@ def _live_dhcp_leases():
def _vlan_name_for_ip(ip):
import ipaddress
for vlan in _load_core().get('vlans', []):
subnet = vlan.get('dhcp', {}).get('subnet', '')
subnet = vlan.get('subnet', '')
mask = vlan.get('subnet_mask', 24)
if not subnet:
continue
try:
if ipaddress.ip_address(ip) in ipaddress.ip_network(subnet + '/24', strict=False):
if ipaddress.ip_address(ip) in ipaddress.ip_network(f'{subnet}/{mask}', strict=False):
return vlan.get('name', '-')
except Exception:
pass
@ -139,6 +180,15 @@ def _config_datasource(name):
core = _load_core()
vlans = core.get('vlans', [])
if name == 'interfaces':
gen = core.get('general', {})
wan = gen.get('wan_interface', '')
lan = gen.get('lan_interface', '')
return [
{'iface_type': 'WAN', 'interface': wan, 'status': _iface_status(wan)},
{'iface_type': 'LAN', 'interface': lan, 'status': _iface_status(lan)},
]
if name == 'banned_ips':
return core.get('banned_ips', [])
@ -161,10 +211,11 @@ def _config_datasource(name):
return rows
if name == 'vlans':
core = _load_core()
rows = []
for v in vlans:
row = {k: v.get(k) for k in ('vlan_id', 'name', 'interface', 'dhcp', 'radius_default', 'mdns_reflection')}
row['subnet'] = v.get('dhcp', {}).get('subnet', '')
for v in sorted(vlans, key=lambda x: x.get('vlan_id', 0)):
row = {k: v.get(k) for k in ('vlan_id', 'name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn')}
row['interface'] = _resolve_iface(v, core)
row['use_blocklists'] = json.dumps(v.get('use_blocklists', []))
rows.append(row)
return rows
@ -208,6 +259,18 @@ def _config_datasource(name):
rows.append(row)
return rows
if name == 'vpn_peers':
wg_vlan = next((v for v in vlans if v.get('is_vpn')), None)
if not wg_vlan:
return []
rows = []
for peer in wg_vlan.get('peers', []):
row = dict(peer)
row['split_tunnel'] = 'yes' if peer.get('split_tunnel') else 'no'
row['pubkey_short'] = peer.get('public_key', '')[:20] + '...' if peer.get('public_key') else '-'
rows.append(row)
return rows
return []
@ -369,7 +432,22 @@ def collect_tokens():
dns = core.get('upstream_dns', {})
vlans = core.get('vlans', [])
tokens['GENERAL_WAN_INTERFACE'] = str(gen.get('wan_interface', '-'))
tokens['GENERAL_LAN_INTERFACE'] = str(gen.get('lan_interface', '-'))
tokens['GENERAL_LOG_MAX_KB'] = str(gen.get('log_max_kb', '-'))
sys_ifaces = _get_system_interfaces()
# Always include currently-configured values so dropdowns are never blank.
for configured in [gen.get('wan_interface', ''), gen.get('lan_interface', '')]:
if configured and configured not in sys_ifaces:
sys_ifaces.append(configured)
sys_ifaces.sort()
tokens['NETWORK_INTERFACE_OPTIONS'] = json.dumps(
[{'value': i, 'label': i} for i in sys_ifaces]
)
tokens['NETWORK_INTERFACE_STATUS_OPTIONS'] = json.dumps(
[{'value': i, 'label': f'{i}{_iface_status(i).title()}'} for i in sys_ifaces]
)
tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if gen.get('log_errors_only') else 'false'
tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if gen.get('dnsmasq_log_queries') else 'false'
tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(gen.get('daily_execute_time_24hr_local', '-'))
@ -380,7 +458,7 @@ def collect_tokens():
tokens['DNS_UPSTREAM_SERVERS_JSON'] = json.dumps(servers)
tokens['OVERVIEW_UPSTREAM_SERVERS'] = ', '.join(servers) or '-'
non_vpn_vlans = [v for v in vlans if 'dhcp' in v]
non_vpn_vlans = [v for v in vlans if not v.get('is_vpn')]
vlan_names = [v.get('name', '') for v in vlans]
tokens['OVERVIEW_VLAN_NAMES'] = ', '.join(vlan_names) or '-'
tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans))
@ -392,6 +470,10 @@ def collect_tokens():
tokens['VLAN_FILTER_OPTIONS'] = filter_opts
tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names])
tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn')))
tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([v.get('vlan_id') for v in vlans])
tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans])
tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, core) for v in vlans])
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
tokens['STAT_BLOCKLIST_COUNT'] = str(sum(1 for b in core.get('blocklists', []) if b.get('enabled', True)))
@ -405,13 +487,28 @@ def collect_tokens():
{'value': 'duckdns', 'label': 'DuckDNS'},
])
vpn = _vpn_info()
wg_vlan = next((v for v in vlans if v.get('is_vpn')), {})
vpn = wg_vlan.get('vpn_information', {})
overrides = vpn.get('explicit_overrides', {})
tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
tokens['VPN_GATEWAY'] = str(vpn.get('gateway', ''))
tokens['VPN_DOMAIN'] = str(vpn.get('domain', ''))
tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_server', ''))
tokens['VPN_MTU'] = str(overrides.get('mtu', ''))
tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
tokens['VPN_SERVER_ENDPOINT'] = str(vpn.get('server_endpoint', ''))
tokens['VPN_DOMAIN'] = str(vpn.get('domain', ''))
tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_server', ''))
tokens['VPN_MTU'] = str(overrides.get('mtu', ''))
# Compute gateway from server_identities (lowest last-octet), fallback to first subnet host
try:
import ipaddress as _ipaddress
ident_ips = [s['ip'] for s in wg_vlan.get('server_identities', []) if s.get('ip')]
if ident_ips:
default_gw = str(min((_ipaddress.IPv4Address(ip) for ip in ident_ips),
key=lambda x: x.packed[-1]))
else:
wg_net = _ipaddress.IPv4Network(
f"{wg_vlan['subnet']}/{wg_vlan['subnet_mask']}", strict=False)
default_gw = str(next(wg_net.hosts()))
tokens['VPN_GATEWAY'] = overrides.get('gateway') or default_gw
except Exception:
tokens['VPN_GATEWAY'] = ''
ip_str, sub_str, next_interval = _public_ip_info(ddns)
tokens['STAT_PUBLIC_IP'] = ip_str
@ -441,6 +538,28 @@ def collect_tokens():
blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]
)
tokens['PROTOCOL_OPTIONS'] = json.dumps([
{'value': 'tcp', 'label': 'TCP'},
{'value': 'udp', 'label': 'UDP'},
{'value': 'both', 'label': 'TCP/UDP'},
])
tokens['BLOCKLIST_FORMAT_OPTIONS'] = json.dumps([
{'value': 'hosts', 'label': 'hosts (hosts file format)'},
{'value': 'dnsmasq', 'label': 'dnsmasq (local=/ syntax)'},
])
tokens['BLOCKLIST_NAME_OPTIONS'] = json.dumps([
{'value': bl.get('name', ''), 'label': bl.get('description', bl.get('name', ''))}
for bl in core.get('blocklists', [])
])
tokens['ACCOUNT_LEVEL_OPTIONS'] = json.dumps([
{'value': 'viewer', 'label': 'Viewer (read-only access to live data)'},
{'value': 'administrator', 'label': 'Administrator (can modify configuration)'},
{'value': 'manager', 'label': 'Manager (full access including account management)'},
])
return tokens
@ -511,6 +630,9 @@ def _render_item(item, tokens, inherited_req=None):
if t == 'spacer':
return '<div class="spacer"></div>'
if t == 'divider':
return '<hr class="divider">'
if t in ('button_primary', 'button_secondary', 'button_danger', 'button_ghost'):
cls_map = {
'button_primary': 'btn-primary',
@ -522,11 +644,12 @@ def _render_item(item, tokens, inherited_req=None):
extra = item.get('class', '')
if extra:
cls = f'{cls} {extra}'
text = e(apply_tokens(item.get('text', ''), tokens))
action = e(apply_tokens(item.get('action', '#'), tokens))
text = e(apply_tokens(item.get('text', ''), tokens))
action = e(apply_tokens(item.get('action', '#'), tokens))
disabled = ' disabled' if item.get('disabled') else ''
if item.get('method', '').lower() == 'post':
return (f'<form method="post" action="{action}" style="display:inline">'
f'<button type="submit" class="btn {e(cls)}">{text}</button></form>')
f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button></form>')
return f'<a href="{action}" class="btn {e(cls)}">{text}</a>'
if t == 'button_cancel':
@ -616,13 +739,46 @@ def _render_item(item, tokens, inherited_req=None):
if t == 'field':
return _render_field(item, tokens)
if t == 'field_row':
inner = render_items(item.get('items', []), tokens, req)
cols = item.get('cols', 2)
return f'<div class="form-row-{cols}">{inner}</div>'
if t == 'subnet_row':
subnet_name = e(item.get('subnet_name', 'subnet'))
prefix_name = e(item.get('prefix_name', 'subnet_mask'))
subnet_val = apply_tokens(item.get('subnet_value', ''), tokens)
prefix_raw = apply_tokens(item.get('prefix_value', '24'), tokens)
subnet_ph = e(apply_tokens(item.get('subnet_placeholder', ''), tokens))
show_derived = item.get('show_derived_vlan_id', False)
try:
pf = max(1, min(30, int(prefix_raw)))
except (ValueError, TypeError):
pf = 24
dotted = _prefix_to_dotted(pf)
return (
f'<div class="form-group">'
f'<label class="form-label">Subnet</label>'
f'<div class="subnet-row-wrap">'
f'<input type="text" name="{subnet_name}" value="{e(subnet_val)}" placeholder="{subnet_ph}" class="form-input">'
f'<span class="subnet-sep">/</span>'
f'<input type="number" name="{prefix_name}" value="{pf}" min="1" max="30" class="form-input subnet-prefix-input">'
f'<span class="subnet-dotted">{e(dotted)}</span>'
f'</div>'
f'<p class="form-hint field-dyn-hint" style="display:none"></p>'
f'</div>'
)
if t == 'editable_list':
return _render_editable_list(item, tokens)
if t == 'select':
name = e(item.get('name', ''))
options = apply_tokens(item.get('options', ''), tokens)
return f'<select name="{name}" class="form-select">{options}</select>'
name = e(item.get('name', ''))
options = apply_tokens(item.get('options', ''), tokens)
filter_col = item.get('filter_col', '')
extra = f' data-filter-col="{e(filter_col)}"' if filter_col else ''
return f'<select name="{name}" class="form-select"{extra}>{options}</select>'
if t == 'button_row':
inner = render_items(item.get('items', []), tokens, req)
@ -642,12 +798,22 @@ def _render_field(item, tokens):
placeholder = e(apply_tokens(item.get('placeholder', ''), tokens))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'<p class="form-hint">{hint}</p>' if hint else ''
extra_cls = f' {e(item["class"])}' if item.get('class') else ''
readonly = ' readonly' if item.get('readonly') else ''
if input_type == 'hidden':
return f'<input type="hidden" name="{name}" value="{e(value)}">'
if input_type == 'checkbox':
checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
cb_label = item.get('checkbox_label')
if cb_label:
return (f'<div class="form-group">'
f'<label class="form-label">{label}</label>'
f'<label class="form-checkbox-row">'
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox">'
f' <span class="form-checkbox-label">{e(cb_label)}</span>'
f'</label>{hint_html}</div>')
return (f'<div class="form-group">'
f'<label class="form-label">'
f'<input type="checkbox" name="{name}" {checked} class="form-checkbox"> {label}'
@ -689,7 +855,7 @@ def _render_field(item, tokens):
min_attr = f' min="{item["min"]}"' if 'min' in item else ''
max_attr = f' max="{item["max"]}"' if 'max' in item else ''
return (f'<div class="form-group"><label class="form-label">{label}</label>'
f'<input type="number" name="{name}" value="{e(value)}"{min_attr}{max_attr} class="form-input">'
f'<input type="number" name="{name}" value="{e(value)}"{min_attr}{max_attr} class="form-input{extra_cls}"{readonly}>'
f'{hint_html}</div>')
if input_type == 'textarea':
@ -699,9 +865,10 @@ def _render_field(item, tokens):
f' class="form-input">{e(value)}</textarea>'
f'{hint_html}</div>')
dyn_hint = '<p class="form-hint field-dyn-hint" style="display:none"></p>' if item.get('readonly') else ''
return (f'<div class="form-group"><label class="form-label">{label}</label>'
f'<input type="{e(input_type)}" name="{name}" value="{e(value)}"'
f' placeholder="{placeholder}" class="form-input">{hint_html}</div>')
f' placeholder="{placeholder}" class="form-input{extra_cls}"{readonly}>{hint_html}{dyn_hint}</div>')
def _render_editable_list(item, tokens):
@ -784,6 +951,10 @@ def _render_table(item, tokens, inherited_req=None):
action = e(apply_tokens(ra.get('action', '#'), tokens))
method = ra.get('method', 'post').lower()
if method == 'post':
disable_if = ra.get('disable_if')
if disable_if and row.get(disable_if.get('field')) == disable_if.get('value'):
btns += f'<button type="button" class="btn {cls}" disabled>{text}</button>'
continue
btns += (f'<form method="post" action="{action}" style="display:inline">'
f'<input type="hidden" name="row_index" value="{idx}">'
f'<input type="hidden" name="config_hash" value="{e(hash_val)}">'
@ -859,6 +1030,18 @@ def _render_table_cell(value, render_fn, col_class='', field='', row_idx=None,
tags = ''.join(f'<span class="tag">{e(str(t))}</span>' for t in items if str(t).strip())
return f'{td_open}<div class="tag-list">{tags}</div></td>'
if render_fn == 'interface_status':
v = value.upper()
if v == 'INVALID':
inner = '<span class="badge badge-danger">Invalid</span>'
elif v == 'UP':
inner = '<span class="badge badge-enabled">Up</span>'
elif v == 'DOWN':
inner = '<span class="badge badge-warning">Down</span>'
else:
inner = f'<span class="badge badge-disabled">{e(value.title())}</span>'
return f'{td_open}{inner}</td>'
return f'{td_open}{e(value)}</td>'
@ -882,7 +1065,12 @@ def render_layout(view_id, content_html, tokens):
navbar_html = _render_navbar(view_id, level, tokens)
footer_html = '<footer class="footer">Router Dashboard</footer>'
page_hash = core_hash()
page_hash = core_hash()
lan_iface = e(tokens.get('GENERAL_LAN_INTERFACE', ''))
vpn_count = tokens.get('VPN_VLAN_COUNT', '0')
existing_ids = tokens.get('EXISTING_VLAN_IDS_JSON', '[]')
existing_names = tokens.get('EXISTING_VLAN_NAMES_JSON', '[]')
existing_interfaces = tokens.get('EXISTING_VLAN_INTERFACES_JSON', '[]')
return (f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
f' <meta charset="UTF-8">\n'
f' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n'
@ -893,7 +1081,7 @@ def render_layout(view_id, content_html, tokens):
f'{navbar_html}\n'
f'<main class="main-content">\n{content_html}\n</main>\n'
f'{footer_html}\n'
f'<script>var CONFIG_HASH = "{page_hash}";</script>\n'
f'<script>var CONFIG_HASH="{page_hash}";var LAN_IFACE="{lan_iface}";var VPN_VLAN_COUNT={vpn_count};var EXISTING_VLAN_IDS={existing_ids};var EXISTING_VLAN_NAMES={existing_names};var EXISTING_VLAN_INTERFACES={existing_interfaces};</script>\n'
f'<script>{_inline_js()}</script>\n'
f'</body>\n</html>')
@ -956,6 +1144,156 @@ def _render_nav_item(item, active_view, level, in_dropdown=False, inherited_req=
def _inline_js():
return r"""
function prefixToDotted(n) {
if (n < 1 || n > 30) return '';
var mask = ((0xFFFFFFFF << (32 - n)) >>> 0);
return [(mask >>> 24) & 0xFF, (mask >>> 16) & 0xFF, (mask >>> 8) & 0xFF, mask & 0xFF].join('.');
}
function deriveVlanId(subnet, prefix) {
var parts = subnet.split('.');
if (parts.length !== 4) return null;
var octets = parts.map(function(p) { return parseInt(p, 10); });
if (octets.some(function(o) { return isNaN(o) || o < 0 || o > 255; })) return null;
var byteIdx = Math.floor((prefix - 1) / 8);
var id = octets[byteIdx];
return (id >= 0 && id <= 4094) ? id : null;
}
function classifySubnet(s) {
if (!s) return 'empty';
if (/[^0-9.]/.test(s)) return 'invalid_char';
if (/\.\./.test(s) || s.charAt(0) === '.') return 'invalid_struct';
var parts = s.split('.');
if (parts.length > 4) return 'too_many';
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
if (!p) continue;
var n = parseInt(p, 10);
if (isNaN(n) || n > 255) return 'range';
}
if (parts.length < 4 || parts[3] === '') return 'incomplete';
return 'complete';
}
function setFieldHint(input, message, state) {
// state: 'error' | 'warning' | 'ok'
var fg = input.closest('.form-group');
if (fg) {
var hint = fg.querySelector('.field-dyn-hint');
if (hint) {
hint.textContent = message;
hint.style.display = message ? '' : 'none';
hint.style.color = (state === 'error') ? 'var(--danger)' : 'var(--text-muted)';
}
}
input.classList.remove('field-invalid', 'field-warning');
if (state === 'error' && message) input.classList.add('field-invalid');
else if (state === 'warning') input.classList.add('field-warning');
}
function updateAddVlanForm(form) {
var nameInp = form.querySelector('input[name="name"]');
var subnetInp = form.querySelector('input[name="subnet"]');
var prefixInp = form.querySelector('input.subnet-prefix-input');
var vpnChk = form.querySelector('input[name="is_vpn"]');
var ifacePrev = form.querySelector('.vlan-iface-preview');
var derivedPrev = form.querySelector('.vlan-derived-id-preview');
var submitBtn = form.querySelector('.add-vlan-btn');
if (!subnetInp || !prefixInp) return;
var subnet = subnetInp.value.trim();
var prefix = parseInt(prefixInp.value, 10);
var isVpn = vpnChk && vpnChk.checked;
var lan = typeof LAN_IFACE !== 'undefined' ? LAN_IFACE : 'eth0';
var sClass = classifySubnet(subnet);
var id = (sClass === 'complete') ? deriveVlanId(subnet, prefix) : null;
// Derived VLAN ID preview
if (derivedPrev) derivedPrev.value = (id !== null) ? String(id) : '';
// Interface preview
var ifaceVal = '';
if (isVpn) {
ifaceVal = 'wg' + (typeof VPN_VLAN_COUNT !== 'undefined' ? VPN_VLAN_COUNT : 0);
} else if (id !== null) {
ifaceVal = (id === 1) ? lan : lan + '.' + id;
}
if (ifacePrev) ifacePrev.value = ifaceVal;
// Subnet sub-text + colour
var subnetMsg = '', subnetState = 'ok', subnetOk = false;
if (sClass === 'empty' || sClass === 'incomplete') {
subnetState = 'warning';
} else if (sClass === 'invalid_char' || sClass === 'invalid_struct' || sClass === 'too_many') {
subnetMsg = 'Invalid'; subnetState = 'error';
} else if (sClass === 'range') {
subnetMsg = 'Quartet out of range'; subnetState = 'error';
} else {
if (id === 0) {
subnetMsg = 'Reserved'; subnetState = 'warning';
} else if (id === null || EXISTING_VLAN_IDS.indexOf(id) !== -1) {
subnetMsg = id === null ? '' : 'Duplicate'; subnetState = id === null ? 'warning' : 'error';
} else {
subnetOk = true;
}
}
setFieldHint(subnetInp, subnetMsg, subnetState);
// Interface duplicate/reserved sub-text
if (ifacePrev) {
if (id === 0) {
setFieldHint(ifacePrev, 'Reserved', 'error');
} else {
var ifaceDupe = ifaceVal.length > 0 && EXISTING_VLAN_INTERFACES.indexOf(ifaceVal) !== -1;
setFieldHint(ifacePrev, ifaceDupe ? 'Duplicate' : '', ifaceDupe ? 'error' : 'ok');
}
}
// VLAN ID duplicate/reserved sub-text
if (derivedPrev) {
if (id === 0) {
setFieldHint(derivedPrev, 'Reserved', 'error');
} else {
var derivedDupe = id !== null && EXISTING_VLAN_IDS.indexOf(id) !== -1;
setFieldHint(derivedPrev, derivedDupe ? 'Duplicate' : '', derivedDupe ? 'error' : 'ok');
}
}
// Name validation + colour
if (submitBtn) {
var name = nameInp ? nameInp.value.trim().toLowerCase() : '';
var nameValid = name.length > 0 && /^[a-z0-9-]+$/.test(name);
var nameDupe = nameValid && EXISTING_VLAN_NAMES.indexOf(name) !== -1;
var nameOk = nameValid && !nameDupe;
if (nameInp) {
nameInp.classList.remove('field-invalid', 'field-warning');
if (name.length === 0) nameInp.classList.add('field-warning');
else if (!nameOk) nameInp.classList.add('field-invalid');
}
submitBtn.disabled = !(nameOk && subnetOk);
}
}
document.addEventListener('input', function(e) {
var wrap = e.target.closest('.subnet-row-wrap');
if (wrap) {
var dotLabel = wrap.querySelector('.subnet-dotted');
if (dotLabel) {
var n = parseInt(wrap.querySelector('.subnet-prefix-input').value, 10);
dotLabel.textContent = (n >= 1 && n <= 30) ? prefixToDotted(n) : '';
}
}
var form = e.target.closest('form');
if (form && form.querySelector('.add-vlan-btn')) updateAddVlanForm(form);
});
document.addEventListener('change', function(e) {
if (e.target.name !== 'is_vpn') return;
var form = e.target.closest('form');
if (form && form.querySelector('.add-vlan-btn')) updateAddVlanForm(form);
});
document.querySelectorAll('.row-edit-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var row = JSON.parse(this.dataset.row);
@ -1019,6 +1357,19 @@ document.addEventListener('click', function(e) {
var checked = (val === true || val === 'true' || val === 1 || val === '1');
td.innerHTML = '<input type="checkbox" name="' + field + '"' +
(checked ? ' checked' : '') + ' class="inline-edit-checkbox">';
} else if (inputType === 'checkbox_multi') {
var opts = fDef.options || [];
var checked = [];
try { var parsed = JSON.parse(val); if (Array.isArray(parsed)) checked = parsed; } catch(ex) {}
var cbHtml = '<div class="checkbox-multi-group">';
opts.forEach(function(o) {
var isChecked = checked.indexOf(o.value) !== -1;
cbHtml += '<label class="checkbox-multi-item">' +
'<input type="checkbox" name="' + field + '" value="' + esc(o.value) + '"' +
(isChecked ? ' checked' : '') + ' class="inline-edit-checkbox-multi"> ' + esc(o.label) + '</label>';
});
cbHtml += '</div>';
td.innerHTML = cbHtml;
} else if (inputType === 'select') {
var opts = fDef.options || [];
var selHtml = '<select name="' + field + '" class="form-select inline-edit-select">';
@ -1028,6 +1379,11 @@ document.addEventListener('click', function(e) {
});
selHtml += '</select>';
td.innerHTML = selHtml;
} else if (inputType === 'number') {
var minAttr = fDef.min !== undefined ? ' min="' + esc(String(fDef.min)) + '"' : '';
var maxAttr = fDef.max !== undefined ? ' max="' + esc(String(fDef.max)) + '"' : '';
td.innerHTML = '<input type="number" name="' + field + '" value="' + esc(String(val)) +
'"' + minAttr + maxAttr + ' class="form-input inline-edit-input">';
} else if (inputType === 'textarea') {
var textVal;
try { var arr = JSON.parse(val); textVal = Array.isArray(arr) ? arr.join('\n') : String(val||''); }
@ -1075,7 +1431,11 @@ document.addEventListener('click', function(e) {
addHidden('config_hash', typeof CONFIG_HASH !== 'undefined' ? CONFIG_HASH : '');
tr.querySelectorAll('td[data-field] input[name], td[data-field] textarea[name], td[data-field] select[name]').forEach(function(inp) {
if (inp.type === 'checkbox') {
if (inp.checked) addHidden(inp.name, 'on');
if (inp.classList.contains('inline-edit-checkbox-multi')) {
if (inp.checked) addHidden(inp.name, inp.value);
} else {
if (inp.checked) addHidden(inp.name, 'on');
}
} else {
addHidden(inp.name, inp.value);
}
@ -1093,6 +1453,26 @@ document.addEventListener('click', function(e) {
}
});
document.querySelectorAll('select[data-filter-col]').forEach(function(sel) {
function applyFilter() {
var col = sel.dataset.filterCol;
var val = sel.value;
var toolbar = sel.closest('.table-toolbar');
if (!toolbar) return;
var wrapper = toolbar.nextElementSibling;
if (!wrapper || !wrapper.classList.contains('table-wrapper')) return;
wrapper.querySelectorAll('tbody tr').forEach(function(tr) {
if (val === 'all') {
tr.style.display = '';
} else {
var td = tr.querySelector('td[data-field="' + col + '"]');
tr.style.display = (td && td.textContent.trim() === val) ? '' : 'none';
}
});
}
sel.addEventListener('change', applyFilter);
});
document.querySelectorAll('.js-hide-card').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();

View file

@ -488,6 +488,51 @@
}
]
},
{
"type": "card",
"label": "Network Interfaces",
"client_requirement": "client_is_viewer+",
"items": [
{
"type": "table",
"datasource": "config:interfaces",
"empty_message": "No interfaces configured.",
"columns": [
{
"label": "Type",
"field": "iface_type",
"class": "col-mono"
},
{
"label": "Interface",
"field": "interface",
"class": "col-mono"
},
{
"label": "Status",
"field": "status",
"render": "interface_status"
}
],
"row_actions": [
{
"text": "Edit",
"class": "btn-ghost btn-sm",
"action": "/action/apply_interface",
"method": "inline_edit",
"client_requirement": "client_is_administrator+",
"fields": [
{
"col": "interface",
"input_type": "select",
"options": "%NETWORK_INTERFACE_STATUS_OPTIONS%"
}
]
}
]
}
]
},
{
"type": "card",
"label": "General",
@ -497,15 +542,6 @@
"action": "/action/apply_general",
"method": "post",
"items": [
{
"type": "field",
"label": "WAN Interface",
"name": "wan_interface",
"input_type": "text",
"value": "%GENERAL_WAN_INTERFACE%",
"placeholder": "e.g. eno2",
"hint": "The network interface facing your ISP modem or ONT."
},
{
"type": "field",
"label": "Max Log Size (KB)",
@ -955,16 +991,7 @@
{
"col": "format",
"input_type": "select",
"options": [
{
"value": "hosts",
"label": "hosts \u2014 /etc/hosts format"
},
{
"value": "dnsmasq",
"label": "dnsmasq \u2014 local=/ syntax"
}
]
"options": "%BLOCKLIST_FORMAT_OPTIONS%"
},
{
"col": "url",
@ -1015,16 +1042,7 @@
"label": "Format",
"name": "format",
"input_type": "select",
"options": [
{
"value": "hosts",
"label": "hosts \u2014 /etc/hosts format"
},
{
"value": "dnsmasq",
"label": "dnsmasq \u2014 local=/ syntax"
}
]
"options": "%BLOCKLIST_FORMAT_OPTIONS%"
},
{
"type": "field",
@ -1070,7 +1088,7 @@
{
"type": "info_bar",
"variant": "info",
"text": "For a basic flat network with no VLAN segmentation, only use VLAN 1 and delete the others."
"text": "VLAN ID is derived automatically from the subnet and prefix using the active-octet rule: for /24 the third octet is used (192.168.10.0/24 → VLAN 10), for /16 the second octet, for /8 the first octet, for /25/30 the fourth. For a basic flat network with no VLAN segmentation, only use VLAN 1 and delete the others."
},
{
"type": "table",
@ -1096,6 +1114,11 @@
"field": "subnet",
"class": "col-mono"
},
{
"label": "Mask",
"field": "subnet_mask",
"class": "col-mono"
},
{
"label": "Blocklists",
"field": "use_blocklists",
@ -1125,12 +1148,14 @@
"input_type": "text"
},
{
"col": "interface",
"col": "subnet",
"input_type": "text"
},
{
"col": "subnet",
"input_type": "text"
"col": "subnet_mask",
"input_type": "number",
"min": 1,
"max": 30
},
{
"col": "radius_default",
@ -1139,6 +1164,11 @@
{
"col": "mdns_reflection",
"input_type": "checkbox"
},
{
"col": "use_blocklists",
"input_type": "checkbox_multi",
"options": "%BLOCKLIST_NAME_OPTIONS%"
}
]
},
@ -1147,7 +1177,11 @@
"class": "btn-danger btn-sm",
"action": "/action/delete_vlan",
"method": "post",
"client_requirement": "client_is_administrator+"
"client_requirement": "client_is_administrator+",
"disable_if": {
"field": "vlan_id",
"value": 1
}
}
]
},
@ -1163,35 +1197,70 @@
"method": "post",
"items": [
{
"type": "field",
"label": "VLAN ID",
"name": "vlan_id",
"input_type": "number",
"min": 1,
"max": 4094,
"placeholder": "e.g. 10"
"type": "field_row",
"cols": 2,
"items": [
{
"type": "field",
"label": "VLAN Name",
"name": "name",
"input_type": "text",
"hint": "Lowercase letters, digits, hyphens. E.g. iot"
},
{
"type": "subnet_row",
"subnet_name": "subnet",
"prefix_name": "subnet_mask",
"subnet_placeholder": "e.g. 192.168.x.0",
"prefix_value": "24"
}
]
},
{
"type": "field_row",
"cols": 3,
"items": [
{
"type": "field",
"label": "Interface",
"name": "",
"input_type": "text",
"readonly": true,
"class": "vlan-iface-preview form-input-mono",
"value": ""
},
{
"type": "field",
"label": "VLAN ID",
"name": "",
"input_type": "text",
"readonly": true,
"class": "vlan-derived-id-preview form-input-mono",
"value": ""
},
{
"type": "field",
"label": "VLAN Type",
"name": "is_vpn",
"input_type": "checkbox",
"checkbox_label": "Is VPN",
"hint": "Check if this VLAN uses a WireGuard interface (e.g. wg0, wg1, etc)."
}
]
},
{
"type": "divider"
},
{
"type": "field",
"label": "Name",
"name": "name",
"input_type": "text",
"placeholder": "e.g. IoT"
"label": "Blocklists",
"name": "use_blocklists",
"input_type": "checkbox_group",
"options": "%BLOCKLIST_NAME_OPTIONS%",
"hint": "Note: Selected lists will be merged and de-duplicated prior to use."
},
{
"type": "field",
"label": "Interface",
"name": "interface",
"input_type": "text",
"placeholder": "e.g. eth0.10"
},
{
"type": "field",
"label": "Subnet",
"name": "subnet",
"input_type": "text",
"placeholder": "e.g. 192.168.10.0/24",
"hint": "DHCP subnet for this VLAN."
"type": "divider"
},
{
"type": "field",
@ -1214,7 +1283,9 @@
"type": "button_primary",
"text": "Add VLAN",
"action": "/action/add_vlan",
"method": "post"
"method": "post",
"class": "add-vlan-btn",
"disabled": true
}
]
}
@ -1291,20 +1362,7 @@
{
"col": "protocol",
"input_type": "select",
"options": [
{
"value": "tcp",
"label": "TCP"
},
{
"value": "udp",
"label": "UDP"
},
{
"value": "both",
"label": "TCP/UDP"
}
]
"options": "%PROTOCOL_OPTIONS%"
},
{
"col": "src_ip_or_subnet",
@ -1356,20 +1414,7 @@
"label": "Protocol",
"name": "protocol",
"input_type": "select",
"options": [
{
"value": "tcp",
"label": "TCP"
},
{
"value": "udp",
"label": "UDP"
},
{
"value": "both",
"label": "TCP/UDP"
}
]
"options": "%PROTOCOL_OPTIONS%"
},
{
"type": "field",
@ -1476,20 +1521,7 @@
{
"col": "protocol",
"input_type": "select",
"options": [
{
"value": "tcp",
"label": "TCP"
},
{
"value": "udp",
"label": "UDP"
},
{
"value": "both",
"label": "TCP/UDP"
}
]
"options": "%PROTOCOL_OPTIONS%"
},
{
"col": "dest_port",
@ -1541,20 +1573,7 @@
"label": "Protocol",
"name": "protocol",
"input_type": "select",
"options": [
{
"value": "tcp",
"label": "TCP"
},
{
"value": "udp",
"label": "UDP"
},
{
"value": "both",
"label": "TCP/UDP"
}
]
"options": "%PROTOCOL_OPTIONS%"
},
{
"type": "field",
@ -1685,7 +1704,8 @@
"type": "select",
"name": "vlan_filter",
"value": "all",
"options": "%VLAN_FILTER_OPTIONS%"
"options": "%VLAN_FILTER_OPTIONS%",
"filter_col": "vlan_name"
}
]
},
@ -1816,12 +1836,13 @@
},
{
"type": "p",
"text": "Active WireGuard peer connections and server interface configuration."
"text": "WireGuard peer management and server interface configuration."
}
]
},
{
"type": "table",
"label": "Active Sessions",
"datasource": "live:vpn_sessions",
"empty_message": "No active VPN sessions.",
"columns": [
@ -1860,6 +1881,126 @@
}
]
},
{
"type": "table",
"label": "Peers",
"datasource": "config:vpn_peers",
"empty_message": "No peers configured. Use Add Peer below.",
"columns": [
{
"label": "Name",
"field": "name"
},
{
"label": "IP",
"field": "ip",
"class": "col-mono"
},
{
"label": "Split Tunnel",
"field": "split_tunnel"
},
{
"label": "Enabled",
"field": "enabled",
"render": "badge_enabled_disabled"
},
{
"label": "Public Key",
"field": "pubkey_short",
"class": "col-mono"
}
],
"row_actions": [
{
"text": "Edit",
"class": "btn-ghost btn-sm",
"action": "/action/edit_vpn_peer",
"method": "inline_edit",
"client_requirement": "client_is_administrator+",
"fields": [
{
"col": "name",
"input_type": "text"
},
{
"col": "split_tunnel",
"input_type": "checkbox"
},
{
"col": "enabled",
"input_type": "checkbox"
}
]
},
{
"text": "Regen Conf",
"class": "btn-ghost btn-sm",
"action": "/action/regenerate_vpn_peer",
"method": "post",
"client_requirement": "client_is_administrator+"
},
{
"text": "Delete",
"class": "btn-danger btn-sm",
"action": "/action/delete_vpn_peer",
"method": "post",
"client_requirement": "client_is_administrator+"
}
]
},
{
"type": "card",
"label": "Add Peer",
"client_requirement": "client_is_administrator+",
"items": [
{
"type": "form",
"action": "/action/add_vpn_peer",
"method": "post",
"items": [
{
"type": "field",
"label": "Name",
"name": "peer_name",
"input_type": "text",
"placeholder": "e.g. laptop",
"hint": "Friendly name for this peer."
},
{
"type": "field",
"label": "IP Address",
"name": "peer_ip",
"input_type": "text",
"placeholder": "e.g. 192.168.40.2",
"hint": "Static IP assigned to this peer within the VPN subnet."
},
{
"type": "field",
"label": "Split Tunnel",
"name": "split_tunnel",
"input_type": "checkbox",
"hint": "Route only VPN subnet traffic through the tunnel. When unchecked all traffic is routed through the VPN."
},
{
"type": "button_row",
"items": [
{
"type": "button_primary",
"text": "Add Peer & Download Conf",
"action": "/action/add_vpn_peer",
"method": "post"
},
{
"type": "button_cancel",
"text": "Cancel"
}
]
}
]
}
]
},
{
"type": "card",
"client_requirement": "client_is_administrator+",
@ -1882,12 +2023,12 @@
},
{
"type": "field",
"label": "Gateway IP",
"name": "vpn_gateway",
"label": "Server Endpoint",
"name": "vpn_server_endpoint",
"input_type": "text",
"value": "%VPN_GATEWAY%",
"placeholder": "e.g. 192.168.40.1",
"hint": "Router IP on the VPN subnet, assigned to the WireGuard interface."
"value": "%VPN_SERVER_ENDPOINT%",
"placeholder": "e.g. vpn.example.com",
"hint": "Publicly reachable hostname or IP of this server, embedded in client config files."
},
{
"type": "field",
@ -1904,7 +2045,7 @@
"name": "vpn_dns_server",
"input_type": "text",
"value": "%VPN_DNS_SERVER%",
"placeholder": "Leave blank to use gateway IP",
"placeholder": "Leave blank to use gateway IP (%VPN_GATEWAY%)",
"hint": "Explicit DNS server pushed to peers. Defaults to the gateway IP."
},
{
@ -2381,20 +2522,7 @@
"label": "Access Level",
"name": "access_level",
"input_type": "select",
"options": [
{
"value": "viewer",
"label": "Viewer \u2014 read-only access to live data"
},
{
"value": "administrator",
"label": "Administrator \u2014 can modify configuration"
},
{
"value": "manager",
"label": "Manager \u2014 full access including account management"
}
]
"options": "%ACCOUNT_LEVEL_OPTIONS%"
},
{
"type": "button_row",

View file

@ -10,6 +10,7 @@ services:
- ./data:/data
- $HOME/router:/configs
- $HOME/router/validation.py:/app/validation.py
- /sys/class/net:/sys/class/net:ro
environment:
- INITIAL_MANAGER_EMAIL=mgrotke@gmail.com
- SECRET_KEY=ey8hSQCCYE5kQXV8nOg1CB44LSd3AoUet2ZBc3aZlFrwBbazE7aHcxXWyuT97eAObet5jmOL0CjMg0rB1hE4d2SBVYHPfl8De55EiFv307r1QP3Mf5XgOSSCxD3TuD