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,20 +6,14 @@ import sanitize
bp = Blueprint('action_apply_general', __name__)
@bp.route('/action/apply_general', methods=['POST'])
@require_level('administrator')
def apply_general():
wan_interface = sanitize.interface_name(request.form.get('wan_interface', ''))
log_max_kb_raw = request.form.get('log_max_kb', '').strip()
log_errors_only = 'log_errors_only' in request.form
dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form
daily_execute_time = sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', ''))
if not wan_interface:
flash('WAN Interface is required.', 'error')
return redirect('/view/view_general')
try:
log_max_kb = int(log_max_kb_raw)
if log_max_kb < 64:
@ -34,7 +28,6 @@ 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,

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()
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,24 +152,21 @@ 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)
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['server_endpoint'] = server_endpoint
info['domain'] = domain
overrides = info.setdefault('explicit_overrides', {})
@ -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

@ -2,15 +2,13 @@ import json, subprocess, hashlib
from markupsafe import Markup
_APPLY_CMD = 'sudo python3 ~/router/core.py --apply'
_APPLY_CMD_VPN = 'sudo python3 ~/router/vpn.py --apply'
def apply_msg(cmd=None):
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_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',
@ -524,9 +646,10 @@ def _render_item(item, tokens, inherited_req=None):
cls = f'{cls} {extra}'
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>'
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 ''
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>'
@ -883,6 +1066,11 @@ def render_layout(view_id, content_html, tokens):
footer_html = '<footer class="footer">Router Dashboard</footer>'
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.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": "Name",
"label": "VLAN Name",
"name": "name",
"input_type": "text",
"placeholder": "e.g. IoT"
"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": "interface",
"name": "",
"input_type": "text",
"placeholder": "e.g. eth0.10"
"readonly": true,
"class": "vlan-iface-preview form-input-mono",
"value": ""
},
{
"type": "field",
"label": "Subnet",
"name": "subnet",
"label": "VLAN ID",
"name": "",
"input_type": "text",
"placeholder": "e.g. 192.168.10.0/24",
"hint": "DHCP subnet for this VLAN."
"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": "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": "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

View file

@ -48,19 +48,19 @@ The suite is organized into three independent but complementary scripts, each ma
- Manages a `.radius-secret` shared secret file (generated automatically on first `--apply` if RADIUS is enabled)
- Configures `avahi-daemon` as an mDNS reflector to forward service discovery announcements (AirPrint, AirPlay, Chromecast, etc.) across VLANs
### Optional: VPN (`vpn.py`)
### Optional: WireGuard VPN (managed by `core.py` and the dashboard)
- Supports any number of WireGuard interfaces defined in `core.json` (any VLAN with an interface name starting with `wg`)
- Allocates IP addresses to remote peers automatically from the VPN VLAN subnet
- Generates per-peer client config files ready for import into any WireGuard client, with per-peer choice of split tunnel or full tunnel routing
- Resolves the server's public endpoint from the DDNS config or manual entry
- Stores peer data in per-interface dotfiles (`.vpn-wg0`, etc.) alongside the scripts
- Reports per-peer handshake times and RX/TX byte counts
- `core.py --apply` generates the server keypair on first run, writes the server conf to `/etc/wireguard/`, and brings the interface up with `wg-quick`. Subsequent applies sync peer changes live without restarting the interface
- Peer management is done through the router dashboard: add a peer, set its IP and tunnel mode, and the dashboard generates and downloads the ready-to-import client `.conf` file immediately — the private key is never stored
- Peer data (name, IP, public key, enabled state) is stored directly in `core.json` alongside the rest of the network config
- Supports per-peer choice of split-tunnel (VPN subnet only) or full-tunnel (all traffic) routing
- Reports active peer connections, handshake times, and RX/TX byte counts on the dashboard VPN view
### Optional: DDNS (`ddns.py`)
- Detects the current public IP by rotating through multiple IP-check services
- Updates the specified DNS providers (currently supporting No-IP and DuckDNS), supporting multiple hostnames and subdomains per provider
- Updates the specified DNS providers (currently supporting Cloudflare, No-IP and DuckDNS), supporting multiple hostnames and subdomains per provider
- Caches the last known IP per provider to avoid unnecessary API calls
- Installs a `systemd` timer that runs every 5 minutes by default
- Logs all updates and errors to `ddns.log`
@ -80,7 +80,7 @@ These packages are required. `core.py --install` checks that they are installed
| `chrony` | NTP server - synchronizes system clock and serves time to VLAN clients | `core.py` |
| `freeradius` | RADIUS server for dynamic VLAN assignment via MAC auth | `core.py` |
| `avahi-daemon` | mDNS reflector for cross-VLAN service discovery | `core.py` |
| `wireguard-tools` | WireGuard VPN (`wg`, `wg-quick`) | `vpn.py` |
| `wireguard-tools` | WireGuard VPN (`wg`, `wg-quick`) | `core.py` (when WireGuard VLANs are configured) |
---
@ -108,7 +108,7 @@ All configuration lives in two JSON files. Edit these to match your network befo
| File | Controls |
|---|---|
| `core.json` | VLANs, subnets, gateways, dynamic pools, static/dynamic reservations, RADIUS client flags, mDNS reflection scope, WireGuard interface and listen port, upstream DNS servers, blocklist sources, per-VLAN blocklist assignments, host overrides, banned IPs, WAN interface, port forwarding rules, port wrangling, inter-VLAN exceptions |
| `core.json` | VLANs, subnets, gateways, dynamic pools, static/dynamic reservations, RADIUS client flags, mDNS reflection scope, WireGuard interface settings and peers, upstream DNS servers, blocklist sources, per-VLAN blocklist assignments, host overrides, banned IPs, WAN interface, port forwarding rules, port wrangling, inter-VLAN exceptions |
| `ddns.json` | DDNS provider credentials, hostnames/subdomains, update interval, IP-check services |
### Dotfiles (auto-generated, do not edit)
@ -116,7 +116,7 @@ All configuration lives in two JSON files. Edit these to match your network befo
| File | Purpose |
|---|---|
| `.radius-secret` | Shared secret between FreeRADIUS and RADIUS clients (APs, switches). Generated automatically on first `--apply` when RADIUS is enabled. Root-owned intentionally. |
| `.vpn-wg0` (etc.) | WireGuard peer data per interface. Managed by `vpn.py`. |
| `.wg-<iface>.pub` | WireGuard server public key per interface (e.g. `.wg-wg0.pub`). Written by `core.py --apply`; read by the dashboard to embed in client config downloads. |
| `.ddns-last-ip-*` | Cached public IP per DDNS provider. Managed by `ddns.py`. |
| `.ddns-last-service` | Tracks IP-check service rotation. Managed by `ddns.py`. |
@ -144,33 +144,41 @@ Edit the `vlans` array to match your network topology. For each VLAN:
- Set `interface` to the NIC name for VLAN 1 (e.g. `enp6s0`); sub-interfaces are named automatically (e.g. `enp6s0.10`). For WireGuard VLANs, use `wg0`, `wg1`, etc.
- Set `radius_default` to `true` on exactly one VLAN - unknown MACs will be placed here (typically guest). All other VLANs set this to `false`.
- Set `use_blocklists` to a list of blocklist names for this VLAN - leave empty for unfiltered DNS
- Set `server_identities` to the IPs the router itself will hold on this VLAN. The lowest last-octet IP is auto-used as gateway, DNS, and NTP server unless overridden in `dhcp.explicit_overrides`.
- Set `dhcp` fields: `subnet`, `subnet_mask`, pool start/end, `lease_time`, and optionally `explicit_overrides` for gateway, dns_server, or ntp_server
- Set `server_identities` to the IPs the router itself will hold on this VLAN. The lowest last-octet IP is auto-used as gateway, DNS, and NTP server unless overridden in `dhcp_information.explicit_overrides`.
- Set `subnet` and `subnet_mask` at the top level of the VLAN object
- Set `dhcp_information` fields: pool start/end, `lease_time`, and optionally `explicit_overrides` for gateway, dns_server, or ntp_server
- Add `reservations` for devices that need a known VLAN assignment by MAC address. The `ip` field is optional:
- Omit `ip`, set it to `""`, or set it to `"dynamic"` to let DHCP assign from the pool (hostname is still set)
- Set `ip` to a specific address outside the dynamic pool to pin the device to that IP
- Set `radius_client: true` on any device (AP, switch) that will authenticate other devices via RADIUS
- Add per-VLAN `port_wrangling` entries to redirect DNS or NTP requests to the local resolver
- For WireGuard VLANs, include a `vpn_information` block instead of `dhcp` and `server_identities`:
- For WireGuard VLANs, include a `vpn_information` block instead of `dhcp_information` and `server_identities`, and a `peers` array instead of `reservations`. Peer management (add, edit, regenerate conf, delete) is done through the dashboard:
```json
{
"vlan_id": 40,
"name": "vpn",
"interface": "wg0",
"subnet": "192.168.40.0",
"subnet_mask": "255.255.255.0",
"radius_default": false,
"use_blocklists": ["oisd-big"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.40.1" }
],
"vpn_information": {
"listen_port": 51820,
"gateway": "192.168.40.1",
"server_endpoint": "vpn.example.com",
"domain": "local",
"explicit_overrides": { "dns_server": "", "mtu": "" }
"explicit_overrides": { "gateway": "", "dns_server": "", "mtu": "" }
},
"reservations": [],
"peers": [],
"port_wrangling": [...]
}
```
The gateway IP is derived from the `server_identities` entry with the lowest value in the last octet (same rule as non-WG VLANs). If `explicit_overrides.gateway` is set, it must match one of the `server_identities` IPs.
### Banned IPs
The top-level `banned_ips` array blocks inbound and outbound traffic to/from specific IPs or networks at the firewall level. This is useful for blocking known malicious hosts, entire ASNs, or geographic ranges. Entries support a flexible address syntax:
@ -272,15 +280,26 @@ Optional (if DDNS is desired):
sudo python3 ddns.py --start # Run an immediate IP update and install the update timer
```
Optional (if VPN is desired):
Optional (if WireGuard VPN is desired):
1. Add a WireGuard VLAN to `core.json` with `interface: "wg0"` (see configuration example above)
2. Run `sudo python3 core.py --apply` — this generates the server keypair, writes `/etc/wireguard/wg0.conf`, and brings the interface up
3. Add peers using one of the two methods below, then run `sudo python3 core.py --apply` again to sync them to the live interface
**With the router dashboard:**
Open the VPN view, fill in the Server Endpoint (your public hostname or IP), and add peers — each peer triggers an immediate `.conf` file download ready to import into any WireGuard client.
**Without the dashboard (`create_vpn_peer.py`):**
```bash
sudo python3 vpn.py --add-peer # Add a VPN peer interactively
sudo python3 vpn.py --apply # Write WireGuard config and start the interface
sudo python3 core.py --apply # Run again after VPN to start dnsmasq for the VPN VLAN(s)
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2 --iface wg0
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2 --vlan-id 40
python3 create_vpn_peer.py --name phone --ip 192.168.40.3 --split-tunnel
python3 create_vpn_peer.py --name tablet --ip 192.168.40.4 --output ~/tablet.conf
```
After adding VPN peers, transfer `vpn-client-<n>.conf` to the peer device by secure means, then delete it from this server.
The script reads the specified WireGuard VLAN from `core.json`, validates the IP against the VLAN subnet, generates a keypair, appends the peer to `core.json`, and writes the client `.conf` file. If the config has exactly one WireGuard VLAN, `--iface` and `--vlan-id` are optional. Transfer the `.conf` to the peer device by secure means, then delete it from the server.
---
@ -309,19 +328,23 @@ python3 core.py --view-rules # Active nftables ruleset
python3 core.py --view-metrics # Lifetime DNS metrics across all VLAN instances
```
### vpn.py
### create_vpn_peer.py
All `vpn.py` commands require `sudo`.
Does not require `sudo`. Requires `wireguard-tools` (`wg` must be on PATH) and a prior `core.py --apply` to generate the server keypair.
```
sudo python3 vpn.py --add-peer # Add a VPN peer interactively
sudo python3 vpn.py --manage-peers # Rename, regenerate keys, or delete a peer
sudo python3 vpn.py --apply # Write WireGuard config and start/restart the interface
sudo python3 vpn.py --disable # Stop WireGuard on all interfaces
sudo python3 vpn.py --status # WireGuard service and interface status
sudo python3 vpn.py --view-peers # Per-peer handshake times and traffic stats
python3 create_vpn_peer.py --name NAME --ip IP [--iface IFACE | --vlan-id ID] [--split-tunnel] [--output FILE]
--name NAME Peer name (e.g. laptop)
--ip IP Peer IP within the VPN subnet (e.g. 192.168.40.2)
--iface IFACE WireGuard interface to add the peer to (e.g. wg0)
--vlan-id ID VLAN ID of the WireGuard VLAN (e.g. 40); alternative to --iface
--split-tunnel Route only VPN subnet traffic through the tunnel (default: full tunnel)
--output FILE Output path for the client .conf file (default: vpn-client-<name>.conf)
```
`--iface` and `--vlan-id` are mutually exclusive. Both are optional when the config contains exactly one WireGuard VLAN.
### ddns.py
Only `--start` and `--disable` require `sudo` as they install/remove systemd timer files. All other commands run as a normal user.
@ -342,6 +365,7 @@ python3 ddns.py --getip # Print current public IP and exit
```bash
sudo python3 core.py --disable # Revert to network client (interactive wizard)
sudo python3 vpn.py --disable # Stop WireGuard on all interfaces
sudo python3 ddns.py --disable # Stop and remove DDNS timer
```
WireGuard interfaces are brought down automatically by `core.py --disable`. To stop a WireGuard interface independently: `sudo wg-quick down wg0`.

View file

@ -1,12 +1,12 @@
{
"general": {
"wan_interface": "eno2",
"lan_interface": "enp6s0",
"log_max_kb": 1024,
"log_errors_only": false,
"dnsmasq_log_queries": false,
"daily_execute_time_24hr_local": "02:30"
},
"upstream_dns": {
"strict_order": false,
"cache_size": 10000,
@ -17,19 +17,53 @@
"2606:4700:4700::1001"
]
},
"banned_ips": [
{ "description": "Example: single IPv4 ban", "enabled": false, "ip": "94.130.52.18" },
{ "description": "Example: ban IPv4 /24 by wildcard", "enabled": false, "ip": "94.130.52.*" },
{ "description": "Example: ban IPv4 /16 by wildcard", "enabled": false, "ip": "94.130.*.*" },
{ "description": "Example: ban IPv4 CIDR", "enabled": false, "ip": "94.130.0.0/16" },
{ "description": "Example: ban IPv4 range in one quartet", "enabled": false, "ip": "94.130.52.1-20" },
{ "description": "Example: ban IPv4 range and wildcard", "enabled": false, "ip": "94.130-133.52.*" },
{ "description": "Example: single IPv6 ban", "enabled": false, "ip": "2a01:4f8:c17:b0f::2" },
{ "description": "Example: ban IPv6 /48 by wildcard", "enabled": false, "ip": "2a01:4f8:c17:*" },
{ "description": "Example: ban IPv6 CIDR", "enabled": false, "ip": "2a01:4f8::/32" }
{
"description": "Example: single IPv4 ban",
"enabled": false,
"ip": "94.130.52.18"
},
{
"description": "Example: ban IPv4 /24 by wildcard",
"enabled": false,
"ip": "94.130.52.*"
},
{
"description": "Example: ban IPv4 /16 by wildcard",
"enabled": false,
"ip": "94.130.*.*"
},
{
"description": "Example: ban IPv4 CIDR",
"enabled": false,
"ip": "94.130.0.0/16"
},
{
"description": "Example: ban IPv4 range in one quartet",
"enabled": false,
"ip": "94.130.52.1-20"
},
{
"description": "Example: ban IPv4 range and wildcard",
"enabled": false,
"ip": "94.130-133.52.*"
},
{
"description": "Example: single IPv6 ban",
"enabled": false,
"ip": "2a01:4f8:c17:b0f::2"
},
{
"description": "Example: ban IPv6 /48 by wildcard",
"enabled": false,
"ip": "2a01:4f8:c17:*"
},
{
"description": "Example: ban IPv6 CIDR",
"enabled": false,
"ip": "2a01:4f8::/32"
}
],
"host_overrides": [
{
"description": "LAN DNS override for home server DDNS hostname",
@ -38,213 +72,615 @@
"ip": "192.168.1.20"
}
],
"blocklists": [
{
"name": "oisd-big",
"description": "OISD Big - ads, phishing, malware, telemetry",
"description": "OISD Big (ads, phishing, malware, telemetry)",
"save_as": "oisd-big.conf",
"url": "https://big.oisd.nl/dnsmasq2",
"format": "dnsmasq"
},
{
"name": "hagezi-light",
"description": "Hagezi Light - ads, tracking, metrics, badware",
"description": "Hagezi Light (ads, tracking, metrics, badware)",
"save_as": "hagezi-light.conf",
"url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/light.txt",
"format": "dnsmasq"
},
{
"name": "hagezi-pro-plus",
"description": "Hagezi Pro Plus - ads, tracking, porn, gambling combined",
"description": "Hagezi Pro Plus (ads, tracking, porn, gambling)",
"save_as": "hagezi-pro-plus.conf",
"url": "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/dnsmasq/pro.plus.txt",
"format": "dnsmasq"
}
],
"inter_vlan_exceptions": [
{ "description": "IoT TV -> Plex", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.10.3", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 32400 },
{ "description": "IoT Streaming Box -> Plex", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.10.4", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 32400 },
{ "description": "Kids -> Plex", "enabled": true, "protocol": "both", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 32400 },
{ "description": "Kids -> SMB", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 445 },
{ "description": "Kids -> Game Server", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 25565 },
{ "description": "Kids -> Web Server HTTP", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 80 },
{ "description": "Kids -> Web Server HTTPS", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 443 },
{ "description": "Trusted -> Printer (RAW)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.1.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 9100 },
{ "description": "Trusted -> Printer (IPP)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.1.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 631 },
{ "description": "Kids -> Printer (RAW)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 9100 },
{ "description": "Kids -> Printer (IPP)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 631 },
{ "description": "Guest -> Printer (RAW)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.20.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 9100 },
{ "description": "Guest -> Printer (IPP)", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.20.0/24", "dst_ip_or_subnet": "192.168.10.2", "dst_port": 631 },
{ "description": "VPN -> SSH + Rsync", "enabled": true, "protocol": "tcp", "src_ip_or_subnet": "192.168.40.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 22 },
{ "description": "VPN -> SMB", "enabled": false, "protocol": "tcp", "src_ip_or_subnet": "192.168.40.0/24", "dst_ip_or_subnet": "192.168.1.20", "dst_port": 445 },
{ "description": "Trusted -> Kids (LAN Gaming)", "enabled": false, "protocol": "both", "src_ip_or_subnet": "192.168.1.0/24", "dst_ip_or_subnet": "192.168.30.0/24" },
{ "description": "Parent PC -> Kids (LAN Gaming)", "enabled": false, "protocol": "both", "src_ip_or_subnet": "192.168.1.50", "dst_ip_or_subnet": "192.168.30.0/24" },
{ "description": "Kids -> Parent PC (LAN Gaming)", "enabled": false, "protocol": "both", "src_ip_or_subnet": "192.168.30.0/24", "dst_ip_or_subnet": "192.168.1.50" }
{
"description": "IoT TV -> Plex",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.10.3",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 32400
},
{
"description": "IoT Streaming Box -> Plex",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.10.4",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 32400
},
{
"description": "Kids -> Plex",
"enabled": true,
"protocol": "both",
"src_ip_or_subnet": "192.168.30.0/24",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 32400
},
{
"description": "Kids -> SMB",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.30.0/24",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 445
},
{
"description": "Kids -> Game Server",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.30.0/24",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 25565
},
{
"description": "Kids -> Web Server HTTP",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.30.0/24",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 80
},
{
"description": "Kids -> Web Server HTTPS",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.30.0/24",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 443
},
{
"description": "Trusted -> Printer (RAW)",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.1.0/24",
"dst_ip_or_subnet": "192.168.10.2",
"dst_port": 9100
},
{
"description": "Trusted -> Printer (IPP)",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.1.0/24",
"dst_ip_or_subnet": "192.168.10.2",
"dst_port": 631
},
{
"description": "Kids -> Printer (RAW)",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.30.0/24",
"dst_ip_or_subnet": "192.168.10.2",
"dst_port": 9100
},
{
"description": "Kids -> Printer (IPP)",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.30.0/24",
"dst_ip_or_subnet": "192.168.10.2",
"dst_port": 631
},
{
"description": "Guest -> Printer (RAW)",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.20.0/24",
"dst_ip_or_subnet": "192.168.10.2",
"dst_port": 9100
},
{
"description": "Guest -> Printer (IPP)",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.20.0/24",
"dst_ip_or_subnet": "192.168.10.2",
"dst_port": 631
},
{
"description": "VPN -> SSH + Rsync",
"enabled": true,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.40.0/24",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 22
},
{
"description": "VPN -> SMB",
"enabled": false,
"protocol": "tcp",
"src_ip_or_subnet": "192.168.40.0/24",
"dst_ip_or_subnet": "192.168.1.20",
"dst_port": 445
},
{
"description": "Trusted -> Kids (LAN Gaming)",
"enabled": false,
"protocol": "both",
"src_ip_or_subnet": "192.168.1.0/24",
"dst_ip_or_subnet": "192.168.30.0/24"
},
{
"description": "Parent PC -> Kids (LAN Gaming)",
"enabled": false,
"protocol": "both",
"src_ip_or_subnet": "192.168.1.50",
"dst_ip_or_subnet": "192.168.30.0/24"
},
{
"description": "Kids -> Parent PC (LAN Gaming)",
"enabled": false,
"protocol": "both",
"src_ip_or_subnet": "192.168.30.0/24",
"dst_ip_or_subnet": "192.168.1.50"
}
],
"port_forwarding": [
{ "description": "WireGuard VPN", "enabled": true, "protocol": "udp", "dest_port": 51820, "nat_ip": "192.168.1.20", "nat_port": 51820 },
{ "description": "Plex Server", "enabled": true, "protocol": "both", "dest_port": 32400, "nat_ip": "192.168.1.20", "nat_port": 32400 },
{ "description": "Web Server HTTP", "enabled": true, "protocol": "tcp", "dest_port": 80, "nat_ip": "192.168.1.20", "nat_port": 80 },
{ "description": "Web Server HTTPS", "enabled": true, "protocol": "tcp", "dest_port": 443, "nat_ip": "192.168.1.20", "nat_port": 443 },
{ "description": "Game Server", "enabled": true, "protocol": "tcp", "dest_port": 25565, "nat_ip": "192.168.1.20", "nat_port": 25565 },
{ "description": "SSH", "enabled": false, "protocol": "tcp", "dest_port": 22, "nat_ip": "192.168.1.20", "nat_port": 22 }
{
"description": "WireGuard VPN",
"enabled": true,
"protocol": "udp",
"dest_port": 51820,
"nat_ip": "192.168.1.20",
"nat_port": 51820
},
{
"description": "Plex Server",
"enabled": true,
"protocol": "both",
"dest_port": 32400,
"nat_ip": "192.168.1.20",
"nat_port": 32400
},
{
"description": "Web Server HTTP",
"enabled": true,
"protocol": "tcp",
"dest_port": 80,
"nat_ip": "192.168.1.20",
"nat_port": 80
},
{
"description": "Web Server HTTPS",
"enabled": true,
"protocol": "tcp",
"dest_port": 443,
"nat_ip": "192.168.1.20",
"nat_port": 443
},
{
"description": "Game Server",
"enabled": true,
"protocol": "tcp",
"dest_port": 25565,
"nat_ip": "192.168.1.20",
"nat_port": 25565
},
{
"description": "SSH",
"enabled": false,
"protocol": "tcp",
"dest_port": 22,
"nat_ip": "192.168.1.20",
"nat_port": 22
}
],
"vlans": [
{
"vlan_id": 1,
"name": "trusted",
"interface": "enp6s0",
"subnet": "192.168.1.0",
"subnet_mask": 24,
"radius_default": false,
"mdns_reflection": false,
"use_blocklists": ["oisd-big", "hagezi-light"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.1.1" },
{ "description": "Home Server", "ip": "192.168.1.20", "hostname": "homeserver" },
{ "description": "UniFi Controller Inform Host", "ip": "192.168.1.10", "hostname": "unifi-controller" }
"use_blocklists": [
"oisd-big",
"hagezi-light"
],
"dhcp": {
"subnet": "192.168.1.0",
"subnet_mask": "255.255.255.0",
"server_identities": [
{
"description": "Router/Gateway",
"ip": "192.168.1.1"
},
{
"description": "Home Server",
"ip": "192.168.1.20",
"hostname": "homeserver"
},
{
"description": "UniFi Controller Inform Host",
"ip": "192.168.1.10",
"hostname": "unifi-controller"
}
],
"dhcp_information": {
"dynamic_pool_start": "192.168.1.100",
"dynamic_pool_end": "192.168.1.245",
"lease_time": "24h",
"domain": "local",
"explicit_overrides": { "gateway": "", "dns_server": "", "ntp_server": "" }
"explicit_overrides": {
"gateway": "",
"dns_server": "",
"ntp_server": ""
}
},
"reservations": [
{ "enabled": true, "description": "UniFi Switch", "hostname": "unifi-switch", "mac": "aa:bb:cc:dd:ee:01", "ip": "192.168.1.2", "radius_client": true },
{ "enabled": true, "description": "UniFi AP (Kitchen)", "hostname": "unifi-ap-kitchen", "mac": "aa:bb:cc:dd:ee:02", "ip": "192.168.1.3", "radius_client": true },
{ "enabled": true, "description": "UniFi AP (Lounge)", "hostname": "unifi-ap-lounge", "mac": "aa:bb:cc:dd:ee:03", "ip": "192.168.1.4", "radius_client": true },
{ "enabled": true, "description": "UniFi AP (Upstairs)", "hostname": "unifi-ap-upstairs", "mac": "aa:bb:cc:dd:ee:04", "ip": "192.168.1.5", "radius_client": true },
{ "enabled": true, "description": "Home Server", "hostname": "homeserver", "mac": "aa:bb:cc:dd:ee:05", "ip": "192.168.1.20" },
{ "enabled": true, "description": "Desktop PC", "hostname": "desktop-pc", "mac": "aa:bb:cc:dd:ee:06", "ip": "192.168.1.50" }
{
"enabled": true,
"description": "UniFi Switch",
"hostname": "unifi-switch",
"mac": "aa:bb:cc:dd:ee:01",
"ip": "192.168.1.2",
"radius_client": true
},
{
"enabled": true,
"description": "UniFi AP (Kitchen)",
"hostname": "unifi-ap-kitchen",
"mac": "aa:bb:cc:dd:ee:02",
"ip": "192.168.1.3",
"radius_client": true
},
{
"enabled": true,
"description": "UniFi AP (Lounge)",
"hostname": "unifi-ap-lounge",
"mac": "aa:bb:cc:dd:ee:03",
"ip": "192.168.1.4",
"radius_client": true
},
{
"enabled": true,
"description": "UniFi AP (Upstairs)",
"hostname": "unifi-ap-upstairs",
"mac": "aa:bb:cc:dd:ee:04",
"ip": "192.168.1.5",
"radius_client": true
},
{
"enabled": true,
"description": "Home Server",
"hostname": "homeserver",
"mac": "aa:bb:cc:dd:ee:05",
"ip": "192.168.1.20"
},
{
"enabled": true,
"description": "Desktop PC",
"hostname": "desktop-pc",
"mac": "aa:bb:cc:dd:ee:06",
"ip": "192.168.1.50"
}
],
"port_wrangling": [
{ "description": "DNS wrangling - redirect Trusted DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.1.1" },
{ "description": "NTP wrangling - redirect Trusted NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.1.1" }
]
{
"description": "DNS wrangling - redirect Trusted DNS to local resolver",
"enabled": true,
"protocol": "both",
"dest_port": 53,
"redirect_to": "192.168.1.1"
},
{
"description": "NTP wrangling - redirect Trusted NTP to local time server",
"enabled": false,
"protocol": "udp",
"dest_port": 123,
"redirect_to": "192.168.1.1"
}
],
"is_vpn": false
},
{
"vlan_id": 10,
"name": "iot",
"interface": "enp6s0.10",
"subnet": "192.168.10.0",
"subnet_mask": 24,
"radius_default": false,
"mdns_reflection": true,
"use_blocklists": ["oisd-big", "hagezi-light"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.10.1" }
"use_blocklists": [
"oisd-big",
"hagezi-light"
],
"dhcp": {
"subnet": "192.168.10.0",
"subnet_mask": "255.255.255.0",
"server_identities": [
{
"description": "Router/Gateway",
"ip": "192.168.10.1"
}
],
"dhcp_information": {
"dynamic_pool_start": "192.168.10.100",
"dynamic_pool_end": "192.168.10.245",
"lease_time": "24h",
"domain": "local",
"explicit_overrides": { "gateway": "", "dns_server": "", "ntp_server": "" }
"explicit_overrides": {
"gateway": "",
"dns_server": "",
"ntp_server": ""
}
},
"reservations": [
{ "enabled": true, "description": "Network Printer", "hostname": "printer", "mac": "aa:bb:cc:dd:ee:10", "ip": "192.168.10.2" },
{ "enabled": true, "description": "Smart TV", "hostname": "smart-tv", "mac": "aa:bb:cc:dd:ee:11", "ip": "192.168.10.3" },
{ "enabled": true, "description": "Streaming Box (Eth)", "hostname": "streaming-box-eth", "mac": "aa:bb:cc:dd:ee:12", "ip": "192.168.10.4" },
{ "enabled": true, "description": "Streaming Box (Wifi)", "hostname": "streaming-box-wifi", "mac": "aa:bb:cc:dd:ee:13", "ip": "192.168.10.4" },
{ "enabled": true, "description": "Raspberry Pi", "hostname": "rpi", "mac": "aa:bb:cc:dd:ee:14", "ip": "192.168.10.12" },
{ "enabled": true, "description": "NAS", "hostname": "nas", "mac": "aa:bb:cc:dd:ee:15", "ip": "192.168.10.14" },
{ "enabled": true, "description": "Doorbell Camera", "hostname": "doorbell-camera", "mac": "aa:bb:cc:dd:ee:16", "ip": "dynamic" },
{ "enabled": true, "description": "Smart Speaker", "hostname": "smart-speaker", "mac": "aa:bb:cc:dd:ee:17", "ip": "dynamic" }
{
"enabled": true,
"description": "Network Printer",
"hostname": "printer",
"mac": "aa:bb:cc:dd:ee:10",
"ip": "192.168.10.2"
},
{
"enabled": true,
"description": "Smart TV",
"hostname": "smart-tv",
"mac": "aa:bb:cc:dd:ee:11",
"ip": "192.168.10.3"
},
{
"enabled": true,
"description": "Streaming Box (Eth)",
"hostname": "streaming-box-eth",
"mac": "aa:bb:cc:dd:ee:12",
"ip": "192.168.10.4"
},
{
"enabled": true,
"description": "Streaming Box (Wifi)",
"hostname": "streaming-box-wifi",
"mac": "aa:bb:cc:dd:ee:13",
"ip": "192.168.10.4"
},
{
"enabled": true,
"description": "Raspberry Pi",
"hostname": "rpi",
"mac": "aa:bb:cc:dd:ee:14",
"ip": "192.168.10.12"
},
{
"enabled": true,
"description": "NAS",
"hostname": "nas",
"mac": "aa:bb:cc:dd:ee:15",
"ip": "192.168.10.14"
},
{
"enabled": true,
"description": "Doorbell Camera",
"hostname": "doorbell-camera",
"mac": "aa:bb:cc:dd:ee:16",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Smart Speaker",
"hostname": "smart-speaker",
"mac": "aa:bb:cc:dd:ee:17",
"ip": "dynamic"
}
],
"port_wrangling": [
{ "description": "DNS wrangling - redirect IoT DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.10.1" },
{ "description": "NTP wrangling - redirect IoT NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.10.1" }
]
{
"description": "DNS wrangling - redirect IoT DNS to local resolver",
"enabled": true,
"protocol": "both",
"dest_port": 53,
"redirect_to": "192.168.10.1"
},
{
"description": "NTP wrangling - redirect IoT NTP to local time server",
"enabled": false,
"protocol": "udp",
"dest_port": 123,
"redirect_to": "192.168.10.1"
}
],
"is_vpn": false
},
{
"vlan_id": 20,
"name": "guest",
"interface": "enp6s0.20",
"subnet": "192.168.20.0",
"subnet_mask": 24,
"radius_default": true,
"mdns_reflection": true,
"use_blocklists": ["oisd-big", "hagezi-light"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.20.1" }
"use_blocklists": [
"oisd-big",
"hagezi-light"
],
"dhcp": {
"subnet": "192.168.20.0",
"subnet_mask": "255.255.255.0",
"server_identities": [
{
"description": "Router/Gateway",
"ip": "192.168.20.1"
}
],
"dhcp_information": {
"dynamic_pool_start": "192.168.20.100",
"dynamic_pool_end": "192.168.20.245",
"lease_time": "4h",
"domain": "local",
"explicit_overrides": { "gateway": "", "dns_server": "", "ntp_server": "" }
"explicit_overrides": {
"gateway": "",
"dns_server": "",
"ntp_server": ""
}
},
"reservations": [
{ "enabled": true, "description": "Family Member Phone 1", "hostname": "phone-1", "mac": "aa:bb:cc:dd:ee:20", "ip": "dynamic" },
{ "enabled": true, "description": "Family Member Phone 2", "hostname": "phone-2", "mac": "aa:bb:cc:dd:ee:21", "ip": "dynamic" }
{
"enabled": true,
"description": "Family Member Phone 1",
"hostname": "phone-1",
"mac": "aa:bb:cc:dd:ee:20",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Family Member Phone 2",
"hostname": "phone-2",
"mac": "aa:bb:cc:dd:ee:21",
"ip": "dynamic"
}
],
"port_wrangling": [
{ "description": "DNS wrangling - redirect Guest DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.20.1" },
{ "description": "NTP wrangling - redirect Guest NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.20.1" }
]
{
"description": "DNS wrangling - redirect Guest DNS to local resolver",
"enabled": true,
"protocol": "both",
"dest_port": 53,
"redirect_to": "192.168.20.1"
},
{
"description": "NTP wrangling - redirect Guest NTP to local time server",
"enabled": false,
"protocol": "udp",
"dest_port": 123,
"redirect_to": "192.168.20.1"
}
],
"is_vpn": false
},
{
"vlan_id": 30,
"name": "kids",
"interface": "enp6s0.30",
"subnet": "192.168.30.0",
"subnet_mask": 24,
"radius_default": false,
"mdns_reflection": true,
"use_blocklists": ["oisd-big", "hagezi-light", "hagezi-pro-plus"],
"server_identities": [
{ "description": "Router/Gateway", "ip": "192.168.30.1" }
"use_blocklists": [
"oisd-big",
"hagezi-light",
"hagezi-pro-plus"
],
"dhcp": {
"subnet": "192.168.30.0",
"subnet_mask": "255.255.255.0",
"server_identities": [
{
"description": "Router/Gateway",
"ip": "192.168.30.1"
}
],
"dhcp_information": {
"dynamic_pool_start": "192.168.30.100",
"dynamic_pool_end": "192.168.30.245",
"lease_time": "24h",
"domain": "local",
"explicit_overrides": { "gateway": "", "dns_server": "", "ntp_server": "" }
"explicit_overrides": {
"gateway": "",
"dns_server": "",
"ntp_server": ""
}
},
"reservations": [
{ "enabled": true, "description": "Child 1 Laptop", "hostname": "child1-laptop", "mac": "aa:bb:cc:dd:ee:30", "ip": "dynamic" },
{ "enabled": true, "description": "Child 2 Laptop", "hostname": "child2-laptop", "mac": "aa:bb:cc:dd:ee:31", "ip": "dynamic" },
{ "enabled": true, "description": "Child 3 Laptop", "hostname": "child3-laptop", "mac": "aa:bb:cc:dd:ee:32", "ip": "dynamic" },
{ "enabled": true, "description": "Child Tablet", "hostname": "child-tablet", "mac": "aa:bb:cc:dd:ee:33", "ip": "dynamic" }
{
"enabled": true,
"description": "Child 1 Laptop",
"hostname": "child1-laptop",
"mac": "aa:bb:cc:dd:ee:30",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Child 2 Laptop",
"hostname": "child2-laptop",
"mac": "aa:bb:cc:dd:ee:31",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Child 3 Laptop",
"hostname": "child3-laptop",
"mac": "aa:bb:cc:dd:ee:32",
"ip": "dynamic"
},
{
"enabled": true,
"description": "Child Tablet",
"hostname": "child-tablet",
"mac": "aa:bb:cc:dd:ee:33",
"ip": "dynamic"
}
],
"port_wrangling": [
{ "description": "DNS wrangling - redirect Kids DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.30.1" },
{ "description": "NTP wrangling - redirect Kids NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.30.1" }
]
{
"description": "DNS wrangling - redirect Kids DNS to local resolver",
"enabled": true,
"protocol": "both",
"dest_port": 53,
"redirect_to": "192.168.30.1"
},
{
"description": "NTP wrangling - redirect Kids NTP to local time server",
"enabled": false,
"protocol": "udp",
"dest_port": 123,
"redirect_to": "192.168.30.1"
}
],
"is_vpn": false
},
{
"vlan_id": 40,
"name": "vpn",
"interface": "wg0",
"subnet": "192.168.40.0",
"subnet_mask": 24,
"radius_default": false,
"mdns_reflection": false,
"use_blocklists": ["oisd-big", "hagezi-light"],
"use_blocklists": [
"oisd-big",
"hagezi-light"
],
"server_identities": [
{
"description": "Router/Gateway",
"ip": "192.168.40.1"
}
],
"vpn_information": {
"listen_port": 51820,
"gateway": "192.168.40.1",
"server_endpoint": "",
"domain": "local",
"explicit_overrides": { "dns_server": "", "mtu": "" }
"explicit_overrides": {
"gateway": "",
"dns_server": "",
"mtu": ""
}
},
"reservations": [],
"peers": [],
"port_wrangling": [
{ "description": "DNS wrangling - redirect VPN DNS to local resolver", "enabled": true, "protocol": "both", "dest_port": 53, "redirect_to": "192.168.40.1" },
{ "description": "NTP wrangling - redirect VPN NTP to local time server", "enabled": false, "protocol": "udp", "dest_port": 123, "redirect_to": "192.168.40.1" }
{
"description": "DNS wrangling - redirect VPN DNS to local resolver",
"enabled": true,
"protocol": "both",
"dest_port": 53,
"redirect_to": "192.168.40.1"
},
{
"description": "NTP wrangling - redirect VPN NTP to local time server",
"enabled": false,
"protocol": "udp",
"dest_port": 123,
"redirect_to": "192.168.40.1"
}
],
"is_vpn": true
}
]
}
]
}

View file

@ -4,7 +4,7 @@ core.py -- Apply core.json to systemd-networkd, per-VLAN dnsmasq instances, and
Each VLAN defined in core.json gets its own dnsmasq instance that handles
both DHCP and DNS for that VLAN. WireGuard VLANs get a DNS-only instance
(no DHCP, since WireGuard peers get IPs from vpn.py).
(no DHCP, since peers have statically assigned IPs).
Each instance binds exclusively to its VLAN gateway IP on port 53, so
instances do not conflict with each other or with the system dnsmasq.service,
@ -117,6 +117,8 @@ TIMER_SVC_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.service"
RESOLV_CONF = Path("/etc/resolv.conf")
NAT_SERVICE_NAME = "core-nat"
NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service"
WG_DIR = Path("/etc/wireguard")
WG_KEEPALIVE = 25
log = None
@ -180,13 +182,15 @@ def check_root():
if os.geteuid() != 0:
die("This script must be run as root (sudo).")
def prefix_to_dotted(n):
mask = (0xFFFFFFFF << (32 - int(n))) & 0xFFFFFFFF
return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))
def network_for(vlan):
d = vlan["dhcp"]
return ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False)
return ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
def lowest_quartet_ip(vlan):
"""Return the server_identity IP with the lowest value in the last octet.
Only called for non-WG VLANs which have a server_identities list."""
"""Return the server_identity IP with the lowest value in the last octet."""
identities = vlan.get("server_identities", [])
ips = []
for s in identities:
@ -202,26 +206,28 @@ def resolve_vlan_options(vlan):
"""
Resolve gateway, dns_server, and ntp_server for a VLAN.
For WG VLANs: gateway comes directly from vpn_information.gateway.
dns_server defaults to gateway unless explicit_overrides.dns_server
is set. ntp_server is None -- WireGuard has no DHCP so NTP cannot
be advertised to peers.
For both WG and non-WG VLANs: gateway defaults to the lowest-last-octet
server_identity IP unless overridden in explicit_overrides. The gateway
override must be one of the server_identity IPs.
For non-WG VLANs: all three default to the lowest-last-octet
server_identity IP unless overridden in dhcp.explicit_overrides.
WG VLANs: ntp_server is None (WireGuard has no DHCP so NTP cannot be
advertised to peers). Overrides live in vpn_information.explicit_overrides.
Non-WG VLANs: overrides live in dhcp_information.explicit_overrides.
Returns a dict with keys: gateway, dns_server, ntp_server.
"""
if is_wg(vlan):
vpi = vlan["vpn_information"]
gateway = vpi["gateway"]
overrides = vpi.get("explicit_overrides", {})
default = lowest_quartet_ip(vlan) or str(next(network_for(vlan).hosts()))
gateway = overrides.get("gateway", "") or default
dns = overrides.get("dns_server", "") or gateway
return {
"gateway": gateway,
"dns_server": dns,
"ntp_server": None,
}
overrides = vlan.get("dhcp", {}).get("explicit_overrides", {})
overrides = vlan.get("dhcp_information", {}).get("explicit_overrides", {})
default = lowest_quartet_ip(vlan)
return {
"gateway": overrides.get("gateway", "") or default,
@ -233,7 +239,27 @@ def is_physical(vlan):
return vlan["vlan_id"] == 1
def is_wg(vlan):
return vlan.get("interface", "").startswith("wg")
return vlan.get("is_vpn", False)
def inject_interfaces(data):
"""Compute and inject the 'interface' field for every VLAN from is_vpn + vlan_id.
is_vpn=False (regular VLAN):
vlan_id 1 general.lan_interface (e.g. enp6s0)
vlan_id N lan_interface.N (e.g. enp6s0.10)
is_vpn=True (WireGuard VLAN):
1st WG VLAN wg0, 2nd wg1, etc. (order in vlans array)
"""
lan = data.get("general", {}).get("lan_interface", "eth0")
wg_idx = 0
for vlan in data.get("vlans", []):
if vlan.get("is_vpn"):
vlan["interface"] = f"wg{wg_idx}"
wg_idx += 1
else:
vid = vlan.get("vlan_id", 1)
vlan["interface"] = lan if vid == 1 else f"{lan}.{vid}"
def networkd_stem(vlan):
return f"10-router-{vlan['name']}"
@ -298,6 +324,7 @@ def load_config():
# ===================================================================
def validate_config(data):
inject_interfaces(data)
errors = []
seen_vlan_ids = {}
seen_interfaces = {}
@ -308,11 +335,15 @@ def validate_config(data):
if not data.get("upstream_dns", {}).get("upstream_servers"):
errors.append("upstream_dns.upstream_servers is missing or empty.")
# -- WAN interface ---------------------------------------------------------
wan = data.get("general", {}).get("wan_interface", "")
# -- WAN / LAN interfaces --------------------------------------------------
gen = data.get("general", {})
wan = gen.get("wan_interface", "")
lan = gen.get("lan_interface", "")
if not wan:
errors.append("general.wan_interface is missing or empty.")
else:
if not lan:
errors.append("general.lan_interface is missing or empty.")
if wan and lan:
available_interfaces = set()
try:
result = subprocess.run(["ip", "link", "show"], capture_output=True, text=True)
@ -320,8 +351,13 @@ def validate_config(data):
available_interfaces = {i.split("@")[0] for i in available_interfaces}
except Exception:
pass
if available_interfaces and wan not in available_interfaces:
if available_interfaces:
if wan not in available_interfaces:
errors.append(f"general.wan_interface: '{wan}' does not exist on this system.")
if lan not in available_interfaces:
errors.append(f"general.lan_interface: '{lan}' does not exist on this system.")
if wan == lan:
errors.append(f"general.wan_interface and general.lan_interface must be different (both set to '{wan}').")
# -- Blocklist library -----------------------------------------------------
blocklists_by_name = {}
@ -370,9 +406,11 @@ def validate_config(data):
errors.append(f"{label}: mdns_reflection must be false for WireGuard interfaces.")
if is_wg(vlan):
# -- vpn_information -----------------------------------------------
vpi = vlan.get("vpn_information")
if not isinstance(vpi, dict):
errors.append(f"{label}: vpn_information must be a plain object.")
vpi = {}
else:
lp = vpi.get("listen_port")
if not isinstance(lp, int) or not (1 <= lp <= 65535):
@ -382,18 +420,55 @@ def validate_config(data):
f"'{seen_listen_ports[lp]}'.")
else:
seen_listen_ports[lp] = name
gw = vpi.get("gateway", "")
if not gw:
errors.append(f"{label}: vpn_information.gateway is required.")
else:
# -- subnet/subnet_mask --------------------------------------------
for field in ("subnet", "subnet_mask"):
if not vlan.get(field):
errors.append(f"{label}: missing required field '{field}'.")
wg_net = None
if vlan.get("subnet") and vlan.get("subnet_mask"):
try:
ipaddress.IPv4Address(gw)
wg_net = ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
vlan_networks[iface] = wg_net
except ValueError as e:
errors.append(f"{label}: invalid subnet/subnet_mask: {e}")
# -- server_identities ---------------------------------------------
if not vlan.get("server_identities"):
errors.append(f"{label}: server_identities is empty or missing.")
identity_ips = []
for idx, ident in enumerate(vlan.get("server_identities", [])):
ip_str = ident.get("ip", "")
ilabel = f"{label} server_identities[{idx}] '{ident.get('description', '?')}'"
if not ip_str:
errors.append(f"{ilabel}: missing 'ip' field.")
continue
try:
ip = ipaddress.IPv4Address(ip_str)
if wg_net and ip not in wg_net:
errors.append(f"{ilabel}: ip '{ip_str}' is not within subnet {wg_net}.")
else:
identity_ips.append(ip)
except ValueError:
errors.append(f"{label}: vpn_information.gateway '{gw}' is not a valid IPv4 address.")
eo = vpi.get("explicit_overrides", {})
errors.append(f"{ilabel}: ip '{ip_str}' is not a valid IPv4 address.")
# -- vpn_information.explicit_overrides ----------------------------
eo = vpi.get("explicit_overrides", {}) if isinstance(vpi, dict) else {}
if not isinstance(eo, dict):
errors.append(f"{label}: vpn_information.explicit_overrides must be a plain object.")
else:
gw = eo.get("gateway", "")
if gw:
try:
gw_ip = ipaddress.IPv4Address(gw)
if identity_ips and gw_ip not in identity_ips:
errors.append(
f"{label}: vpn_information.explicit_overrides.gateway '{gw}' does not match "
f"any server_identity IP. Must be one of: "
f"{[str(ip) for ip in identity_ips]}."
)
except ValueError:
errors.append(f"{label}: vpn_information.explicit_overrides.gateway '{gw}' is not a valid IPv4 address.")
dns = eo.get("dns_server", "")
if dns:
try:
@ -408,29 +483,66 @@ def validate_config(data):
errors.append(f"{label}: vpn_information.explicit_overrides.mtu {mtu} is out of valid range (576-9000).")
except (ValueError, TypeError):
errors.append(f"{label}: vpn_information.explicit_overrides.mtu '{mtu}' is not a valid integer.")
# WG VLANs have no server_identities or dhcp block -- skip remaining validation
# -- peers ---------------------------------------------------------
seen_peer_names = {}
seen_peer_ips = {}
for pidx, peer in enumerate(vlan.get("peers", [])):
pname = peer.get("name", "")
plabel = f"{label} peer[{pidx}] '{pname}'"
if not pname:
errors.append(f"{plabel}: missing 'name' field.")
elif pname in seen_peer_names:
errors.append(f"{plabel}: duplicate peer name '{pname}'.")
else:
seen_peer_names[pname] = pidx
if not peer.get("public_key"):
errors.append(f"{plabel}: missing 'public_key' field.")
pip_str = peer.get("ip", "")
if not pip_str:
errors.append(f"{plabel}: missing 'ip' field.")
else:
try:
pip = ipaddress.IPv4Address(pip_str)
if wg_net and pip not in wg_net:
errors.append(f"{plabel}: ip '{pip_str}' is not within subnet {wg_net}.")
if pip in identity_ips:
errors.append(f"{plabel}: ip '{pip_str}' conflicts with a server_identity.")
if pip_str in seen_peer_ips:
errors.append(
f"{plabel}: duplicate peer ip '{pip_str}' "
f"(also used by peer '{seen_peer_ips[pip_str]}')."
)
else:
seen_peer_ips[pip_str] = pname
except ValueError:
errors.append(f"{plabel}: ip '{pip_str}' is not a valid IPv4 address.")
continue
if not vlan.get("server_identities"):
errors.append(f"{label}: server_identities is empty or missing.")
continue
d = vlan.get("dhcp", {})
required_dhcp = {"subnet", "subnet_mask", "dynamic_pool_start",
"dynamic_pool_end", "lease_time"}
missing = required_dhcp - set(d.keys())
if missing:
errors.append(f"{label}: missing dhcp fields: {missing}")
for field in ("subnet", "subnet_mask"):
if not vlan.get(field):
errors.append(f"{label}: missing required top-level field '{field}'.")
if not vlan.get("subnet") or not vlan.get("subnet_mask"):
continue
if not is_wg(vlan):
try:
network = ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False)
network = ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
vlan_networks[iface] = network
except ValueError as e:
errors.append(f"{label}: invalid subnet/subnet_mask: {e}")
continue
d = vlan.get("dhcp_information", {})
required_dhcp = {"dynamic_pool_start", "dynamic_pool_end", "lease_time"}
missing = required_dhcp - set(d.keys())
if missing:
errors.append(f"{label}: missing dhcp_information fields: {missing}")
continue
def check_ip(field_label, ip_str, allow_none=False):
if ip_str is None:
if not allow_none:
@ -631,6 +743,25 @@ def validate_config(data):
errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). "
f"Only one VLAN may be the RADIUS default.")
# -- host_overrides validation ---------------------------------------------
all_vlan_nets = list(vlan_networks.values())
for idx, entry in enumerate(data.get("host_overrides", [])):
lbl = f"host_overrides[{idx}] '{entry.get('host', '?')}'"
if not entry.get("host"):
errors.append(f"{lbl}: missing 'host' field.")
ip_str = entry.get("ip", "")
if not ip_str:
errors.append(f"{lbl}: missing 'ip' field.")
else:
try:
ip_addr = ipaddress.IPv4Address(ip_str)
if all_vlan_nets and not any(ip_addr in net for net in all_vlan_nets):
errors.append(
f"{lbl}: '{ip_str}' does not fall within any configured VLAN subnet."
)
except ValueError:
errors.append(f"{lbl}: '{ip_str}' is not a valid IPv4 address.")
# -- banned_ips validation -------------------------------------------------
for idx, entry in enumerate(data.get("banned_ips", [])):
ip = entry.get("ip", "")
@ -947,7 +1078,7 @@ def build_vlan_dnsmasq_conf(vlan, data):
overrides = [o for o in data.get("host_overrides", []) if o.get("enabled") is True]
name = vlan["name"]
iface = vlan["interface"]
d = vlan.get("dhcp", {})
d = vlan.get("dhcp_information", {})
opts = resolve_vlan_options(vlan)
gateway = opts["gateway"]
@ -982,7 +1113,8 @@ def build_vlan_dnsmasq_conf(vlan, data):
if not is_wg(vlan):
line("# -- DHCP -----------------------------------------------------------")
line(f"dhcp-range=set:{name},{d['dynamic_pool_start']},{d['dynamic_pool_end']},{d['subnet_mask']},{d['lease_time']}")
dotted_mask = prefix_to_dotted(vlan['subnet_mask'])
line(f"dhcp-range=set:{name},{d['dynamic_pool_start']},{d['dynamic_pool_end']},{dotted_mask},{d['lease_time']}")
line(f"domain={d.get('domain', 'local')}")
line()
line(f"dhcp-option=tag:{name},option:router,{gateway}")
@ -1207,12 +1339,6 @@ def ensure_chrony(data):
content = chrony_conf.read_text()
subnets = []
for v in data["vlans"]:
if is_wg(v):
# Derive subnet from gateway IP -- always a /24
gw = v["vpn_information"]["gateway"]
net = ipaddress.IPv4Network(f"{gw}/24", strict=False)
subnets.append(str(net))
else:
subnets.append(str(network_for(v)))
added = []
for subnet in subnets:
@ -1297,6 +1423,123 @@ def wg_interface_up(iface):
capture_output=True, text=True)
return result.returncode == 0
def wg_server_key_path(iface):
return WG_DIR / f"{iface}.key"
def wg_server_pubkey_path(iface):
"""Public key written to the configs dir so the Flask app can read it."""
return SCRIPT_DIR / f".wg-{iface}.pub"
def wg_conf_path_for(iface):
return WG_DIR / f"{iface}.conf"
def generate_wg_server_key(iface):
WG_DIR.mkdir(exist_ok=True)
result = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True)
private = result.stdout.strip()
kf = wg_server_key_path(iface)
kf.write_text(private + "\n")
kf.chmod(0o600)
return private
def build_wg_server_conf(vlan, server_private_key):
"""Build the /etc/wireguard/<iface>.conf content from core.json peers."""
iface = vlan["interface"]
info = vlan["vpn_information"]
gateway = resolve_vlan_options(vlan)["gateway"]
network = network_for(vlan)
server_ip = f"{gateway}/{network.prefixlen}"
listen_port = info["listen_port"]
domain = info.get("domain", "local")
L = [
"# Generated by core.py -- do not edit manually.",
"# Run: sudo python3 core.py --apply",
"",
"[Interface]",
f"PrivateKey = {server_private_key}",
f"Address = {server_ip}",
f"ListenPort = {listen_port}",
"",
]
for peer in vlan.get("peers", []):
if not peer.get("enabled", True):
L += [f"# DISABLED: {peer['name']}", ""]
continue
L += [
f"# {peer['name']}",
"[Peer]",
f"PublicKey = {peer['public_key']}",
f"AllowedIPs = {peer['ip']}/32",
f"PersistentKeepalive = {WG_KEEPALIVE}",
"",
]
return "\n".join(L)
def ensure_wg_interfaces(data):
"""Generate WireGuard server confs and bring up / sync all WG interfaces."""
wg_vlans = [v for v in data.get("vlans", []) if is_wg(v)]
if not wg_vlans:
return
for vlan in wg_vlans:
iface = vlan["interface"]
print(f" [{iface}]")
kf = wg_server_key_path(iface)
if not kf.exists():
print(f" Generating server private key...")
private = generate_wg_server_key(iface)
else:
private = kf.read_text().strip()
pub_result = subprocess.run(
["wg", "pubkey"], input=private, capture_output=True, text=True, check=True
)
public = pub_result.stdout.strip()
pubkey_file = wg_server_pubkey_path(iface)
pubkey_file.write_text(public + "\n")
chown_to_script_dir_owner(pubkey_file)
print(f" Server public key: {public[:20]}...")
WG_DIR.mkdir(exist_ok=True)
conf_file = wg_conf_path_for(iface)
new_conf = build_wg_server_conf(vlan, private)
listen_port = vlan["vpn_information"]["listen_port"]
port_changed = False
if conf_file.exists():
m = re.search(r'ListenPort\s*=\s*(\d+)', conf_file.read_text())
if m and int(m.group(1)) != listen_port:
port_changed = True
conf_file.write_text(new_conf)
conf_file.chmod(0o600)
peer_count = len([p for p in vlan.get("peers", []) if p.get("enabled", True)])
print(f" Wrote {conf_file} ({peer_count} enabled peer(s))")
if not wg_interface_up(iface):
print(f" Bringing up {iface}...")
r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True)
if r.returncode != 0:
print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}")
else:
print(f" {iface} is up.")
elif port_changed:
print(f" Listen port changed -- restarting {iface}...")
subprocess.run(["wg-quick", "down", iface], capture_output=True, text=True)
r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True)
if r.returncode != 0:
print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}")
else:
print(f" {iface} restarted.")
else:
print(f" Syncing peers to live {iface}...")
subprocess.run(["wg", "syncconf", iface, str(conf_file)], capture_output=True, text=True)
def get_container_bridges():
"""Return all active bridge interfaces not managed by our VLAN config.
Works universally for Docker, Podman, LXC, libvirt, etc. -- anything
@ -1358,9 +1601,7 @@ def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True):
for vlan in data["vlans"]:
if is_wg(vlan) and not dry_run and not wg_interface_up(vlan["interface"]):
print(f"Skipped VLAN '{vlan['name']}': {vlan['interface']} is not up (WireGuard not running).")
print(" To enable the VPN VLAN, start WireGuard with vpn.py --apply")
print(" (core.py --apply will be called again automatically).")
print(f"Skipped VLAN '{vlan['name']}': {vlan['interface']} is not up. Run --apply again after WireGuard is up.")
continue
conf_content = build_vlan_dnsmasq_conf(vlan, data)
@ -1690,10 +1931,8 @@ def build_nft_config(data, dry_run=False):
# Build interface -> network map for nat_ip -> iface lookup in forward chain
vlan_networks = {}
for v in vlans:
if not is_wg(v):
d = v.get("dhcp", {})
try:
net = ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False)
net = network_for(v)
vlan_networks[v["interface"]] = net
except (KeyError, ValueError):
pass
@ -2000,16 +2239,14 @@ def apply_nftables(data, dry_run=False):
print("nftables rules applied successfully.")
# Build set of active subnets for filtering exception display
import ipaddress as _ipaddress
active_subnets = []
for v in data["vlans"]:
if is_wg(v):
if wg_interface_up(v["interface"]):
gw = v["vpn_information"]["gateway"]
active_subnets.append(_ipaddress.IPv4Network(f"{gw}/24", strict=False))
else:
d = v["dhcp"]
active_subnets.append(_ipaddress.IPv4Network(f"{d['subnet']}/{d['subnet_mask']}", strict=False))
if is_wg(v) and not wg_interface_up(v["interface"]):
continue
try:
active_subnets.append(network_for(v))
except (KeyError, ValueError):
pass
def dst_is_active(r):
dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "")
@ -2894,14 +3131,7 @@ def _dry_run_conflicting_services(data):
chrony_conf = Path("/etc/chrony/chrony.conf")
if chrony_conf.exists():
content = chrony_conf.read_text()
subnets = []
for v in data["vlans"]:
if is_wg(v):
gw = v["vpn_information"]["gateway"]
net = ipaddress.IPv4Network(f"{gw}/24", strict=False)
subnets.append(str(net))
else:
subnets.append(str(network_for(v)))
subnets = [str(network_for(v)) for v in data["vlans"]]
missing = [s for s in subnets if f"allow {s}" not in content]
if missing:
print(f" Would add chrony allow directives for: {', '.join(missing)}")
@ -3261,6 +3491,7 @@ def cmd_apply(data, dry_run=False):
dnsmasq confs, start/restart all services whose interface is up, nftables,
timer, and boot service. Safe to run repeatedly.
"""
inject_interfaces(data)
if dry_run:
print("[DRY RUN] --apply would perform the following actions:")
print()
@ -3307,14 +3538,16 @@ def cmd_apply(data, dry_run=False):
total_enabled = sum(
len([r for r in v.get("reservations", []) if r.get("enabled") is True])
for v in data["vlans"]
for v in data["vlans"] if not is_wg(v)
)
total_disabled = sum(
len([r for r in v.get("reservations", []) if r.get("enabled") is not True])
for v in data["vlans"]
for v in data["vlans"] if not is_wg(v)
)
total_wg_peers = sum(len(v.get("peers", [])) for v in data["vlans"] if is_wg(v))
wg_part = f", {total_wg_peers} WG peer(s)" if total_wg_peers else ""
print(f"Applying config: {len(data['vlans'])} VLAN(s), "
f"{total_enabled} reservation(s), {total_disabled} skipped.")
f"{total_enabled} reservation(s), {total_disabled} skipped{wg_part}.")
print()
print("-- Conflicting services ----------------------------------------------")
@ -3327,6 +3560,11 @@ def cmd_apply(data, dry_run=False):
apply_networkd(data, only_if_changed=True)
print()
if any(is_wg(v) for v in data["vlans"]):
print("-- WireGuard interfaces ----------------------------------------------")
ensure_wg_interfaces(data)
print()
print("-- dnsmasq instances -------------------------------------------------")
if not blocklists_available(data):
print(" NOTE: No merged blocklist files found -- blocklist rules will be absent.")

229
router/create_vpn_peer.py Normal file
View file

@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
create_vpn_peer.py -- Add a WireGuard peer to core.json and write the client .conf file.
Generates a fresh keypair, appends the peer to the specified WireGuard VLAN in core.json,
and saves a ready-to-import client config file.
Use --iface or --vlan-id to select the target VLAN. If the config contains exactly one
WireGuard VLAN, both flags are optional and it is selected automatically.
Run core.py --apply after adding peers to sync the changes to the live interface.
Usage:
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2 --iface wg0
python3 create_vpn_peer.py --name laptop --ip 192.168.40.2 --vlan-id 40
python3 create_vpn_peer.py --name phone --ip 192.168.40.3 --split-tunnel
python3 create_vpn_peer.py --name tablet --ip 192.168.40.4 --output ~/tablet.conf
"""
import argparse
import ipaddress
import json
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "core.json"
def die(msg):
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def load_config():
if not CONFIG_FILE.exists():
die(f"Config file not found: {CONFIG_FILE}")
with open(CONFIG_FILE) as f:
return json.load(f)
def save_config(data):
with open(CONFIG_FILE, "w") as f:
json.dump(data, f, indent=2)
def resolve_wg_iface(vlan, data):
"""Return wg0, wg1, ... based on position among is_vpn VLANs."""
wg_vlans = [v for v in data.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 find_wg_vlan(data, iface=None, vlan_id=None):
"""Return the target WireGuard VLAN, or die with a helpful message."""
wg_vlans = [v for v in data.get("vlans", []) if v.get("is_vpn")]
if iface is not None:
vlan = next((v for v in wg_vlans if resolve_wg_iface(v, data) == iface), None)
if vlan is None:
known = ", ".join(resolve_wg_iface(v, data) for v in wg_vlans) or "none"
die(f"No WireGuard VLAN with interface '{iface}' found in core.json. "
f"Known WireGuard interfaces: {known}.")
return vlan
if vlan_id is not None:
vlan = next((v for v in wg_vlans if v.get("vlan_id") == vlan_id), None)
if vlan is None:
known = ", ".join(
f"{v['vlan_id']} ({resolve_wg_iface(v, data)})" for v in wg_vlans
) or "none"
die(f"No WireGuard VLAN with vlan_id {vlan_id} found in core.json. "
f"Known WireGuard VLANs: {known}.")
return vlan
if not wg_vlans:
die("No WireGuard VLANs found in core.json. "
"Add a VLAN with is_vpn set to true.")
if len(wg_vlans) > 1:
options = " " + "\n ".join(
f"--iface {resolve_wg_iface(v, data)} or --vlan-id {v['vlan_id']} ({v.get('name', '?')})"
for v in wg_vlans
)
die(f"Multiple WireGuard VLANs found. Specify one:\n{options}")
return wg_vlans[0]
def server_pubkey(iface):
path = SCRIPT_DIR / f".wg-{iface}.pub"
if not path.exists():
die(
f"Server public key not found: {path}\n"
f"Run 'sudo python3 core.py --apply' first to generate the server keypair."
)
return path.read_text().strip()
def generate_keypair():
try:
private = subprocess.run(
["wg", "genkey"], capture_output=True, text=True, check=True
).stdout.strip()
public = subprocess.run(
["wg", "pubkey"], input=private, capture_output=True, text=True, check=True
).stdout.strip()
return private, public
except FileNotFoundError:
die("'wg' not found. Install wireguard-tools: sudo apt install wireguard-tools")
except subprocess.CalledProcessError as e:
die(f"Key generation failed: {e.stderr.strip()}")
def build_client_conf(vlan, peer_ip, private_key, server_pub, split_tunnel):
info = vlan.get("vpn_information", {})
overrides = info.get("explicit_overrides", {})
subnet = vlan["subnet"]
mask = vlan["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)
allowed_ips = f"{subnet}/{prefix}" if split_tunnel else "0.0.0.0/0"
lines = [
"# Generated by create_vpn_peer.py",
"",
"[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_pub}"]
if endpoint:
lines.append(f"Endpoint = {endpoint}:{listen_port}")
lines += [f"AllowedIPs = {allowed_ips}", "PersistentKeepalive = 25", ""]
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Add a WireGuard peer to core.json and write the client .conf file."
)
parser.add_argument("--name", required=True, help="Peer name (e.g. laptop)")
parser.add_argument("--ip", required=True, help="Peer IP within the VPN subnet (e.g. 192.168.40.2)")
parser.add_argument("--split-tunnel", action="store_true",
help="Route only VPN subnet traffic through the tunnel (default: full tunnel)")
parser.add_argument("--output", default=None,
help="Output path for the client .conf file (default: vpn-client-<name>.conf)")
sel = parser.add_mutually_exclusive_group()
sel.add_argument("--iface", default=None, metavar="IFACE",
help="WireGuard interface to add the peer to (e.g. wg0)")
sel.add_argument("--vlan-id", default=None, type=int, metavar="ID",
help="VLAN ID of the WireGuard VLAN to add the peer to (e.g. 40)")
args = parser.parse_args()
# -- Validate IP -----------------------------------------------------------
try:
peer_ip = str(ipaddress.IPv4Address(args.ip))
except ValueError:
die(f"'{args.ip}' is not a valid IPv4 address.")
# -- Load config and find WG VLAN ------------------------------------------
data = load_config()
vlan = find_wg_vlan(data, iface=args.iface, vlan_id=args.vlan_id)
iface = resolve_wg_iface(vlan, data)
# -- Validate peer IP is within subnet -------------------------------------
try:
network = ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
except (KeyError, ValueError) as e:
die(f"Invalid subnet in WireGuard VLAN: {e}")
if ipaddress.IPv4Address(peer_ip) not in network:
die(f"IP {peer_ip} is not within the VPN subnet {network}.")
# -- Check for duplicates --------------------------------------------------
peers = vlan.setdefault("peers", [])
if any(p.get("name") == args.name for p in peers):
die(f"A peer named '{args.name}' already exists.")
if any(p.get("ip") == peer_ip for p in peers):
die(f"IP {peer_ip} is already assigned to another peer.")
# -- Generate keypair and read server public key ---------------------------
print(f"Generating keypair for '{args.name}'...")
private_key, public_key = generate_keypair()
srv_pub = server_pubkey(iface)
# -- Update core.json ------------------------------------------------------
peers.append({
"name": args.name,
"ip": peer_ip,
"public_key": public_key,
"split_tunnel": args.split_tunnel,
"enabled": True,
})
save_config(data)
print(f"Added peer '{args.name}' to core.json.")
# -- Write client conf -----------------------------------------------------
conf_content = build_client_conf(vlan, peer_ip, private_key, srv_pub, args.split_tunnel)
if args.output:
out_path = Path(args.output)
else:
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in args.name)
out_path = SCRIPT_DIR / f"vpn-client-{safe}.conf"
out_path.write_text(conf_content)
print(f"Client config saved: {out_path}")
print()
print("Next steps:")
print(f" 1. Transfer {out_path.name} to the peer device by secure means, then delete it.")
print(f" 2. Run 'sudo python3 core.py --apply' to sync the new peer to the live interface.")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff