UI improvement
This commit is contained in:
parent
575edc836d
commit
9a272ee959
16 changed files with 2477 additions and 1604 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
63
docker/router-dash/app/action_apply_interface.py
Normal file
63
docker/router-dash/app/action_apply_interface.py
Normal 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)
|
||||
|
|
@ -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 (1–4094) 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 (1–4094) 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 (1–4094) 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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue