UI improvement
This commit is contained in:
parent
575edc836d
commit
9a272ee959
16 changed files with 2477 additions and 1604 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import ipaddress
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
|
|
@ -8,6 +10,29 @@ bp = Blueprint('action_apply_host_overrides', __name__)
|
|||
VIEW = '/view/view_host_overrides'
|
||||
|
||||
|
||||
def _vlan_networks(core):
|
||||
nets = []
|
||||
for v in core.get('vlans', []):
|
||||
subnet = v.get('subnet', '')
|
||||
mask = v.get('subnet_mask', '')
|
||||
if subnet and mask:
|
||||
try:
|
||||
nets.append(ipaddress.IPv4Network(f"{subnet}/{mask}", strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
return nets
|
||||
|
||||
|
||||
def _ip_in_vlan(ip_str, core):
|
||||
"""Return True if ip_str falls within at least one configured VLAN subnet."""
|
||||
try:
|
||||
addr = ipaddress.IPv4Address(ip_str)
|
||||
except ValueError:
|
||||
return False
|
||||
nets = _vlan_networks(core)
|
||||
return not nets or any(addr in net for net in nets)
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
|
|
@ -37,6 +62,10 @@ def add_host_override():
|
|||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
if not _ip_in_vlan(ip, core):
|
||||
flash('IP address does not fall within any configured VLAN subnet.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
core.setdefault('host_overrides', []).append({
|
||||
'description': description,
|
||||
'host': host,
|
||||
|
|
@ -94,6 +123,10 @@ def edit_host_override():
|
|||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
if not _ip_in_vlan(ip, core):
|
||||
flash('IP address does not fall within any configured VLAN subnet.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items = core.get('host_overrides', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
|
|
|
|||
63
docker/router-dash/app/action_apply_interface.py
Normal file
63
docker/router-dash/app/action_apply_interface.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import re
|
||||
import subprocess
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_interface', __name__)
|
||||
|
||||
_VIEW = '/view/view_general'
|
||||
|
||||
|
||||
def _get_system_interfaces():
|
||||
try:
|
||||
r = subprocess.run(['ip', 'link', 'show'], capture_output=True, text=True, timeout=5)
|
||||
names = re.findall(r'^\d+:\s+(\S+):', r.stdout, re.MULTILINE)
|
||||
return {n.split('@')[0] for n in names} - {'lo'}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
@bp.route('/action/apply_interface', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_interface():
|
||||
idx_raw = request.form.get('row_index', '').strip()
|
||||
interface = sanitize.interface_name(request.form.get('interface', ''))
|
||||
|
||||
try:
|
||||
idx = int(idx_raw)
|
||||
if idx not in (0, 1):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not interface:
|
||||
flash('Interface name is required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
core = load_core()
|
||||
gen = core.setdefault('general', {})
|
||||
|
||||
other_key = 'lan_interface' if idx == 0 else 'wan_interface'
|
||||
if interface == gen.get(other_key, ''):
|
||||
flash('WAN and LAN interfaces must be different.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
available = _get_system_interfaces()
|
||||
if available and interface not in available:
|
||||
flash(f"Interface '{interface}' does not exist on this system.", 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
key = 'wan_interface' if idx == 0 else 'lan_interface'
|
||||
gen[key] = interface
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
|
@ -2,6 +2,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
import ipaddress as _ipaddress
|
||||
|
||||
bp = Blueprint('action_apply_vlans', __name__)
|
||||
|
||||
|
|
@ -22,26 +23,46 @@ def _hash_ok():
|
|||
return True
|
||||
|
||||
|
||||
def _derive_vlan_id(subnet, prefix):
|
||||
"""Return VLAN ID (1–4094) derived from the active octet of the network address,
|
||||
or None if not derivable. byte_index = (prefix-1) // 8."""
|
||||
try:
|
||||
network = _ipaddress.ip_network(f'{subnet}/{prefix}', strict=False)
|
||||
octets = list(network.network_address.packed)
|
||||
byte_idx = (prefix - 1) // 8
|
||||
vlan_id = octets[byte_idx]
|
||||
if 1 <= vlan_id <= 4094:
|
||||
return vlan_id
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@bp.route('/action/add_vlan', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_vlan():
|
||||
vlan_id_raw = request.form.get('vlan_id', '').strip()
|
||||
name = sanitize.name(request.form.get('name', ''))
|
||||
interface = sanitize.interface_name(request.form.get('interface', ''))
|
||||
subnet = sanitize.ip_or_cidr(request.form.get('subnet', ''))
|
||||
name = sanitize.name(request.form.get('name', '')).lower()
|
||||
is_vpn = 'is_vpn' in request.form
|
||||
subnet = sanitize.ip(request.form.get('subnet', ''))
|
||||
subnet_mask = sanitize.subnet_mask(request.form.get('subnet_mask', ''))
|
||||
radius_default = 'radius_default' in request.form
|
||||
mdns_reflection = 'mdns_reflection' in request.form
|
||||
|
||||
if not vlan_id_raw or not name or not interface:
|
||||
flash('VLAN ID, name, and interface are required.', 'error')
|
||||
if not name:
|
||||
flash('Name is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
try:
|
||||
vlan_id = int(vlan_id_raw)
|
||||
if not (1 <= vlan_id <= 4094):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
flash('VLAN ID must be between 1 and 4094.', 'error')
|
||||
if not subnet:
|
||||
flash('Subnet IP is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if subnet_mask is None:
|
||||
flash('Invalid subnet prefix (must be 1-30).', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
vlan_id = _derive_vlan_id(subnet, subnet_mask)
|
||||
if vlan_id is None:
|
||||
flash('Cannot derive a valid VLAN ID (1–4094) from this subnet/prefix combination.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
|
|
@ -51,19 +72,24 @@ def add_vlan():
|
|||
vlans = core.setdefault('vlans', [])
|
||||
|
||||
if any(v.get('vlan_id') == vlan_id for v in vlans):
|
||||
flash(f'VLAN {vlan_id} already exists.', 'error')
|
||||
flash(f'VLAN {vlan_id} (derived from subnet) already exists.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
vlans.append({
|
||||
entry = {
|
||||
'vlan_id': vlan_id,
|
||||
'name': name,
|
||||
'interface': interface,
|
||||
'dhcp': {'subnet': subnet},
|
||||
'is_vpn': is_vpn,
|
||||
'subnet': subnet,
|
||||
'subnet_mask': subnet_mask,
|
||||
'use_blocklists': [],
|
||||
'radius_default': radius_default,
|
||||
'mdns_reflection': mdns_reflection,
|
||||
'reservations': [],
|
||||
})
|
||||
}
|
||||
if is_vpn:
|
||||
entry['peers'] = []
|
||||
else:
|
||||
entry['reservations'] = []
|
||||
vlans.append(entry)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
|
|
@ -78,14 +104,29 @@ def edit_vlan():
|
|||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
name = sanitize.name(request.form.get('name', ''))
|
||||
interface = sanitize.interface_name(request.form.get('interface', ''))
|
||||
subnet = sanitize.ip_or_cidr(request.form.get('subnet', ''))
|
||||
name = sanitize.name(request.form.get('name', '')).lower()
|
||||
subnet = sanitize.ip(request.form.get('subnet', ''))
|
||||
radius_default = 'radius_default' in request.form
|
||||
mdns_reflection = 'mdns_reflection' in request.form
|
||||
use_blocklists = request.form.getlist('use_blocklists')
|
||||
|
||||
if not name or not interface:
|
||||
flash('Name and interface are required.', 'error')
|
||||
# subnet_mask is only present when the column is visible (not all edit paths send it).
|
||||
# Validate if submitted; fall back to the stored value otherwise.
|
||||
subnet_mask_raw = request.form.get('subnet_mask')
|
||||
if subnet_mask_raw is not None:
|
||||
subnet_mask = sanitize.subnet_mask(subnet_mask_raw)
|
||||
if subnet_mask is None:
|
||||
flash('Invalid subnet prefix (must be 1-30).', 'error')
|
||||
return redirect(VIEW)
|
||||
else:
|
||||
subnet_mask = None # resolved below after loading core
|
||||
|
||||
if not name:
|
||||
flash('Name is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not subnet:
|
||||
flash('Subnet IP is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
|
|
@ -97,8 +138,36 @@ def edit_vlan():
|
|||
flash('VLAN not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
vlans[idx].update({'name': name, 'interface': interface, 'radius_default': radius_default, 'mdns_reflection': mdns_reflection})
|
||||
vlans[idx].setdefault('dhcp', {})['subnet'] = subnet
|
||||
existing = vlans[idx]
|
||||
# is_vpn is never changed via edit — toggling it would invalidate peers/reservations.
|
||||
is_vpn = existing.get('is_vpn', False)
|
||||
# Use submitted subnet_mask, or fall back to whatever is already stored.
|
||||
final_mask = subnet_mask if subnet_mask is not None else existing.get('subnet_mask', 24)
|
||||
|
||||
vlan_id = _derive_vlan_id(subnet, final_mask)
|
||||
if vlan_id is None:
|
||||
flash('Cannot derive a valid VLAN ID (1–4094) from this subnet/prefix combination.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
current_id = existing.get('vlan_id')
|
||||
if current_id == 1 and vlan_id != 1:
|
||||
flash('VLAN 1 is the physical interface; change its subnet so the derived ID remains 1.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if vlan_id != current_id and any(i != idx and v.get('vlan_id') == vlan_id for i, v in enumerate(vlans)):
|
||||
flash(f'VLAN {vlan_id} (derived from subnet) already exists.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
existing.update({
|
||||
'vlan_id': vlan_id,
|
||||
'name': name,
|
||||
'is_vpn': is_vpn,
|
||||
'subnet': subnet,
|
||||
'subnet_mask': final_mask,
|
||||
'radius_default': radius_default,
|
||||
'mdns_reflection': mdns_reflection,
|
||||
'use_blocklists': use_blocklists,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
import base64
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
from flask import Blueprint, make_response, redirect, flash, request
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg, _APPLY_CMD_VPN
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg, CONFIGS_DIR
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
|
|
@ -11,45 +15,136 @@ _MTU_MIN = 576
|
|||
_MTU_MAX = 9000
|
||||
|
||||
|
||||
def _wg_vlan(core):
|
||||
return next((v for v in core.get('vlans', []) if v.get('is_vpn')), None)
|
||||
|
||||
|
||||
def _wg_iface(vlan, core):
|
||||
"""Return the WireGuard interface name (wg0, wg1, ...) for a VPN VLAN."""
|
||||
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
|
||||
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
|
||||
return f'wg{idx}'
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _generate_wg_keypair():
|
||||
"""Generate an X25519 keypair compatible with WireGuard. Returns (private_b64, public_b64)."""
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding, PublicFormat, PrivateFormat, NoEncryption,
|
||||
)
|
||||
private = X25519PrivateKey.generate()
|
||||
priv_raw = private.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
||||
pub_raw = private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
return base64.b64encode(priv_raw).decode(), base64.b64encode(pub_raw).decode()
|
||||
|
||||
|
||||
def _server_pubkey(iface):
|
||||
"""Read the server public key written by core.py --apply."""
|
||||
try:
|
||||
with open(f'{CONFIGS_DIR}/.wg-{iface}.pub') as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pubkey):
|
||||
"""Build WireGuard client .conf content."""
|
||||
info = vlan.get('vpn_information', {})
|
||||
overrides = info.get('explicit_overrides', {})
|
||||
subnet = vlan.get('subnet', '')
|
||||
mask = vlan.get('subnet_mask', '')
|
||||
network = ipaddress.IPv4Network(f'{subnet}/{mask}', strict=False)
|
||||
ident_ips = [s['ip'] for s in vlan.get('server_identities', []) if s.get('ip')]
|
||||
default = str(min((ipaddress.IPv4Address(ip) for ip in ident_ips),
|
||||
key=lambda x: x.packed[-1])) if ident_ips else str(next(network.hosts()))
|
||||
gateway = overrides.get('gateway') or default
|
||||
dns = overrides.get('dns_server') or gateway
|
||||
prefix = network.prefixlen
|
||||
mtu = overrides.get('mtu', '')
|
||||
endpoint = info.get('server_endpoint', '')
|
||||
listen_port = info.get('listen_port', 51820)
|
||||
|
||||
split_tunnel = next(
|
||||
(p.get('split_tunnel', False) for p in vlan.get('peers', []) if p.get('name') == peer_name),
|
||||
False
|
||||
)
|
||||
allowed_ips = f'{subnet}/{prefix}' if split_tunnel else '0.0.0.0/0'
|
||||
|
||||
lines = [
|
||||
'# Generated by router-dash',
|
||||
'',
|
||||
'[Interface]',
|
||||
f'PrivateKey = {private_key}',
|
||||
f'Address = {peer_ip}/{prefix}',
|
||||
f'DNS = {dns}',
|
||||
]
|
||||
if mtu:
|
||||
lines.append(f'MTU = {mtu}')
|
||||
lines += ['', '[Peer]', f'PublicKey = {server_pubkey}']
|
||||
if endpoint:
|
||||
lines.append(f'Endpoint = {endpoint}:{listen_port}')
|
||||
lines += [f'AllowedIPs = {allowed_ips}', 'PersistentKeepalive = 25', '']
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _conf_response(vlan, peer_name, peer_ip, private_key):
|
||||
"""Return a .conf file download response, or redirect with error if pubkey unavailable."""
|
||||
core = load_core()
|
||||
iface = _wg_iface(vlan, core)
|
||||
server_pub = _server_pubkey(iface)
|
||||
if not server_pub:
|
||||
flash('Peer saved. Run sudo python3 ~/router/core.py --apply to generate the server '
|
||||
'public key, then regenerate this peer to download the client config.', 'warning')
|
||||
return redirect(_VIEW)
|
||||
conf = _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pub)
|
||||
safe = re.sub(r'[^A-Za-z0-9_\-]', '_', peer_name)
|
||||
resp = make_response(conf)
|
||||
resp.headers['Content-Type'] = 'text/plain; charset=utf-8'
|
||||
resp.headers['Content-Disposition'] = f'attachment; filename="vpn-client-{safe}.conf"'
|
||||
return resp
|
||||
|
||||
|
||||
@bp.route('/action/apply_vpn', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_vpn():
|
||||
listen_port_raw = request.form.get('vpn_listen_port', '').strip()
|
||||
gateway_raw = request.form.get('vpn_gateway', '').strip()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from action_save_preferences import bp as action_save_preferences_bp
|
|||
from action_change_password import bp as action_change_password_bp
|
||||
from action_clear_ddns_log import bp as action_clear_ddns_log_bp
|
||||
from action_apply_ddns_providers import bp as action_apply_ddns_providers_bp
|
||||
from action_apply_interface import bp as action_apply_interface_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
|
||||
|
|
@ -47,6 +48,7 @@ app.register_blueprint(action_save_preferences_bp)
|
|||
app.register_blueprint(action_change_password_bp)
|
||||
app.register_blueprint(action_clear_ddns_log_bp)
|
||||
app.register_blueprint(action_apply_ddns_providers_bp)
|
||||
app.register_blueprint(action_apply_interface_bp)
|
||||
|
||||
def _seed_initial_account():
|
||||
email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import re
|
||||
import ipaddress
|
||||
|
||||
# Curated IANA timezone list for the dropdown. Validation accepts any entry from this set.
|
||||
VALID_TIMEZONES = [
|
||||
|
|
@ -113,12 +114,25 @@ def hostname(value, max_len=253):
|
|||
return _strip(value.lower(), r'[^a-z0-9\-.]', max_len)
|
||||
|
||||
def ip(value, max_len=45):
|
||||
"""IPv4 or IPv6 address: digits, dots, colons, hex letters."""
|
||||
return _strip(value, r'[^0-9a-fA-F.:]', max_len)
|
||||
"""IPv4 or IPv6 address. Returns '' if not a valid address."""
|
||||
cleaned = _strip(value, r'[^0-9a-fA-F.:]', max_len)
|
||||
try:
|
||||
ipaddress.ip_address(cleaned)
|
||||
return cleaned
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
def ip_or_cidr(value, max_len=49):
|
||||
"""IP address or CIDR subnet: adds forward slash."""
|
||||
return _strip(value, r'[^0-9a-fA-F.:/]', max_len)
|
||||
"""IP address or CIDR subnet. Returns '' if not valid."""
|
||||
cleaned = _strip(value, r'[^0-9a-fA-F.:/]', max_len)
|
||||
try:
|
||||
if '/' in cleaned:
|
||||
ipaddress.ip_network(cleaned, strict=False)
|
||||
else:
|
||||
ipaddress.ip_address(cleaned)
|
||||
return cleaned
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
def mac(value, max_len=17):
|
||||
"""MAC address: hex digits and colons."""
|
||||
|
|
@ -154,3 +168,24 @@ def email(value, max_len=254):
|
|||
def timezone(value):
|
||||
"""Timezone string: must be in VALID_TIMEZONES list. Returns '' if not found."""
|
||||
return value if value in _TIMEZONE_SET else ''
|
||||
|
||||
_DOTTED_TO_PREFIX = {
|
||||
'255.0.0.0': 8, '255.255.0.0': 16, '255.255.255.0': 24,
|
||||
'255.255.255.128': 25, '255.255.255.192': 26,
|
||||
'255.255.255.224': 27, '255.255.255.240': 28,
|
||||
'255.255.255.248': 29, '255.255.255.252': 30,
|
||||
}
|
||||
|
||||
def subnet_mask(value):
|
||||
"""Subnet prefix length 1-30 (integer). Also accepts legacy dotted notation.
|
||||
Returns int on success, None if invalid."""
|
||||
s = str(value).strip()
|
||||
if s in _DOTTED_TO_PREFIX:
|
||||
return _DOTTED_TO_PREFIX[s]
|
||||
try:
|
||||
n = int(s)
|
||||
if 1 <= n <= 30:
|
||||
return n
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -67,6 +67,46 @@ def _run(cmd):
|
|||
return ''
|
||||
|
||||
|
||||
def _prefix_to_dotted(n):
|
||||
mask = (0xFFFFFFFF << (32 - n)) & 0xFFFFFFFF
|
||||
return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))
|
||||
|
||||
|
||||
def _get_system_interfaces():
|
||||
"""Return sorted list of physical-ish interface names from `ip link show`."""
|
||||
out = _run('ip link show')
|
||||
names = re.findall(r'^\d+:\s+(\S+):', out, re.MULTILINE)
|
||||
ifaces = sorted({n.split('@')[0] for n in names} - {'lo'})
|
||||
return ifaces
|
||||
|
||||
|
||||
def _iface_status(iface):
|
||||
"""Return link state for iface by reading /sys/class/net/<iface>/operstate.
|
||||
Returns INVALID if the interface does not exist, otherwise UP/DOWN/UNKNOWN/etc."""
|
||||
if not iface:
|
||||
return 'INVALID'
|
||||
safe = re.sub(r'[^A-Za-z0-9._-]', '', iface)
|
||||
if not safe:
|
||||
return 'INVALID'
|
||||
try:
|
||||
with open(f'/sys/class/net/{safe}/operstate') as f:
|
||||
state = f.read().strip().upper()
|
||||
return state if state else 'UP'
|
||||
except OSError:
|
||||
return 'INVALID'
|
||||
|
||||
|
||||
def _resolve_iface(vlan, core):
|
||||
"""Compute interface name from is_vpn + vlan_id + general.lan_interface."""
|
||||
if vlan.get('is_vpn'):
|
||||
wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')]
|
||||
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
|
||||
return f'wg{idx}'
|
||||
lan = core.get('general', {}).get('lan_interface', 'eth0')
|
||||
vid = vlan.get('vlan_id', 1)
|
||||
return lan if vid == 1 else f'{lan}.{vid}'
|
||||
|
||||
|
||||
# -- Live data loaders ---------------------------------------------------------
|
||||
|
||||
def _live_dhcp_leases():
|
||||
|
|
@ -91,11 +131,12 @@ def _live_dhcp_leases():
|
|||
def _vlan_name_for_ip(ip):
|
||||
import ipaddress
|
||||
for vlan in _load_core().get('vlans', []):
|
||||
subnet = vlan.get('dhcp', {}).get('subnet', '')
|
||||
subnet = vlan.get('subnet', '')
|
||||
mask = vlan.get('subnet_mask', 24)
|
||||
if not subnet:
|
||||
continue
|
||||
try:
|
||||
if ipaddress.ip_address(ip) in ipaddress.ip_network(subnet + '/24', strict=False):
|
||||
if ipaddress.ip_address(ip) in ipaddress.ip_network(f'{subnet}/{mask}', strict=False):
|
||||
return vlan.get('name', '-')
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -139,6 +180,15 @@ def _config_datasource(name):
|
|||
core = _load_core()
|
||||
vlans = core.get('vlans', [])
|
||||
|
||||
if name == 'interfaces':
|
||||
gen = core.get('general', {})
|
||||
wan = gen.get('wan_interface', '')
|
||||
lan = gen.get('lan_interface', '')
|
||||
return [
|
||||
{'iface_type': 'WAN', 'interface': wan, 'status': _iface_status(wan)},
|
||||
{'iface_type': 'LAN', 'interface': lan, 'status': _iface_status(lan)},
|
||||
]
|
||||
|
||||
if name == 'banned_ips':
|
||||
return core.get('banned_ips', [])
|
||||
|
||||
|
|
@ -161,10 +211,11 @@ def _config_datasource(name):
|
|||
return rows
|
||||
|
||||
if name == 'vlans':
|
||||
core = _load_core()
|
||||
rows = []
|
||||
for v in vlans:
|
||||
row = {k: v.get(k) for k in ('vlan_id', 'name', 'interface', 'dhcp', 'radius_default', 'mdns_reflection')}
|
||||
row['subnet'] = v.get('dhcp', {}).get('subnet', '')
|
||||
for v in sorted(vlans, key=lambda x: x.get('vlan_id', 0)):
|
||||
row = {k: v.get(k) for k in ('vlan_id', 'name', 'subnet', 'subnet_mask', 'radius_default', 'mdns_reflection', 'is_vpn')}
|
||||
row['interface'] = _resolve_iface(v, core)
|
||||
row['use_blocklists'] = json.dumps(v.get('use_blocklists', []))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
|
@ -208,6 +259,18 @@ def _config_datasource(name):
|
|||
rows.append(row)
|
||||
return rows
|
||||
|
||||
if name == 'vpn_peers':
|
||||
wg_vlan = next((v for v in vlans if v.get('is_vpn')), None)
|
||||
if not wg_vlan:
|
||||
return []
|
||||
rows = []
|
||||
for peer in wg_vlan.get('peers', []):
|
||||
row = dict(peer)
|
||||
row['split_tunnel'] = 'yes' if peer.get('split_tunnel') else 'no'
|
||||
row['pubkey_short'] = peer.get('public_key', '')[:20] + '...' if peer.get('public_key') else '-'
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
return []
|
||||
|
||||
|
||||
|
|
@ -369,7 +432,22 @@ def collect_tokens():
|
|||
dns = core.get('upstream_dns', {})
|
||||
vlans = core.get('vlans', [])
|
||||
tokens['GENERAL_WAN_INTERFACE'] = str(gen.get('wan_interface', '-'))
|
||||
tokens['GENERAL_LAN_INTERFACE'] = str(gen.get('lan_interface', '-'))
|
||||
tokens['GENERAL_LOG_MAX_KB'] = str(gen.get('log_max_kb', '-'))
|
||||
|
||||
sys_ifaces = _get_system_interfaces()
|
||||
# Always include currently-configured values so dropdowns are never blank.
|
||||
for configured in [gen.get('wan_interface', ''), gen.get('lan_interface', '')]:
|
||||
if configured and configured not in sys_ifaces:
|
||||
sys_ifaces.append(configured)
|
||||
sys_ifaces.sort()
|
||||
tokens['NETWORK_INTERFACE_OPTIONS'] = json.dumps(
|
||||
[{'value': i, 'label': i} for i in sys_ifaces]
|
||||
)
|
||||
tokens['NETWORK_INTERFACE_STATUS_OPTIONS'] = json.dumps(
|
||||
[{'value': i, 'label': f'{i} — {_iface_status(i).title()}'} for i in sys_ifaces]
|
||||
)
|
||||
|
||||
tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if gen.get('log_errors_only') else 'false'
|
||||
tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if gen.get('dnsmasq_log_queries') else 'false'
|
||||
tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(gen.get('daily_execute_time_24hr_local', '-'))
|
||||
|
|
@ -380,7 +458,7 @@ def collect_tokens():
|
|||
tokens['DNS_UPSTREAM_SERVERS_JSON'] = json.dumps(servers)
|
||||
tokens['OVERVIEW_UPSTREAM_SERVERS'] = ', '.join(servers) or '-'
|
||||
|
||||
non_vpn_vlans = [v for v in vlans if 'dhcp' in v]
|
||||
non_vpn_vlans = [v for v in vlans if not v.get('is_vpn')]
|
||||
vlan_names = [v.get('name', '') for v in vlans]
|
||||
tokens['OVERVIEW_VLAN_NAMES'] = ', '.join(vlan_names) or '-'
|
||||
tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans))
|
||||
|
|
@ -392,6 +470,10 @@ def collect_tokens():
|
|||
tokens['VLAN_FILTER_OPTIONS'] = filter_opts
|
||||
tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names])
|
||||
|
||||
tokens['VPN_VLAN_COUNT'] = str(sum(1 for v in vlans if v.get('is_vpn')))
|
||||
tokens['EXISTING_VLAN_IDS_JSON'] = json.dumps([v.get('vlan_id') for v in vlans])
|
||||
tokens['EXISTING_VLAN_NAMES_JSON'] = json.dumps([v.get('name', '') for v in vlans])
|
||||
tokens['EXISTING_VLAN_INTERFACES_JSON'] = json.dumps([_resolve_iface(v, core) for v in vlans])
|
||||
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
|
||||
tokens['STAT_BLOCKLIST_COUNT'] = str(sum(1 for b in core.get('blocklists', []) if b.get('enabled', True)))
|
||||
|
||||
|
|
@ -405,13 +487,28 @@ def collect_tokens():
|
|||
{'value': 'duckdns', 'label': 'DuckDNS'},
|
||||
])
|
||||
|
||||
vpn = _vpn_info()
|
||||
wg_vlan = next((v for v in vlans if v.get('is_vpn')), {})
|
||||
vpn = wg_vlan.get('vpn_information', {})
|
||||
overrides = vpn.get('explicit_overrides', {})
|
||||
tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
|
||||
tokens['VPN_GATEWAY'] = str(vpn.get('gateway', ''))
|
||||
tokens['VPN_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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
688
router/core.json
688
router/core.json
|
|
@ -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": "" }
|
||||
},
|
||||
"reservations": [],
|
||||
"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" }
|
||||
]
|
||||
"explicit_overrides": {
|
||||
"gateway": "",
|
||||
"dns_server": "",
|
||||
"mtu": ""
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"is_vpn": true
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
}
|
||||
|
|
|
|||
372
router/core.py
372
router/core.py
|
|
@ -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
229
router/create_vpn_peer.py
Normal 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()
|
||||
1005
router/vpn.py
1005
router/vpn.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue