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