#!/usr/bin/python3 """Serve the status of the router on its internal address. Run by vpn-router-web.service. Users are managed with vpnrouter_users, the module that vpn-router-user uses too. Pages: / the status for people: plain text for curl and wget, a styled page (Tailwind CSS from a CDN) for a browser /status the status as JSON /config the configuration without its secrets, and what the router resolved and runs, as JSON /users the users in the router's directory, whether or not a P2S system is enabled: list, add, change, set the password, disable, enable, remove, and the OpenVPN client profile (see users()) /groups the groups of the router's directory: list, add, rename or describe, remove, and add and take out members (see groups()) /config, /users and /groups need an HS256 JWT in an Authorization: Bearer header that is signed with web.jwt_secret and expires within ten minutes; they are not served (404) while web.jwt_secret is not set. / and /status show only the status: whether the services run, the package version, whether Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and WireGuard. No configuration data, key or address is shown. The service needs nothing else to start and does not end on its own. A discovery thread works out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts it, moves it when the address or the port changes, and stops it while the service is disabled or the address is not known. Every request reads what it needs when it is made: the token secret from the configuration, and the features of the router and the facts shown by /config from /run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file the router has no features, and /config shows no configuration or interfaces. """ import base64 import hashlib import hmac import json import os import signal import subprocess import sys import threading import time from html import escape from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import ldap import vpnrouter_net import vpnrouter_users STATE_FILE = '/run/vpn-router/web.json' CONFIG_FILE = '/etc/vpn-router/vpn-router.json' #: The port when web.port is not set. DEFAULT_PORT = 7081 #: Seconds between two discoveries of where the service should listen. DISCOVERY_SECONDS = 30 COMMAND_TIMEOUT = 5 CACHE_SECONDS = 2 #: Seconds of clock difference accepted between a token and this router. LEEWAY = 60 #: The longest a token may be valid, from now, in seconds. MAX_LIFETIME = 600 TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4' #: Seconds after which the status page in a browser reloads itself. REFRESH_SECONDS = 60 OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log' #: The largest request body accepted, in bytes. BODY_LIMIT = 4096 #: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500. USER_STATUS = {2: 400, 3: 404, 4: 409} _cache = {'at': 0.0, 'data': None} _lock = threading.Lock() def read_json(path): """The object in a JSON file, or {} when the file is missing or holds no object.""" try: with open(path, encoding='utf-8') as handle: data = json.load(handle) except (OSError, ValueError): return {} return data if isinstance(data, dict) else {} def section(configuration, name): """A section of the configuration, or {} when it is missing or not an object.""" value = configuration.get(name) return value if isinstance(value, dict) else {} def current(): """What a request needs, read now: the features and facts setup resolved, and the token secret. Before setup has run the router has no features, and without a secret /config and /users are not served. """ state = read_json(STATE_FILE) secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret') return dict(state, jwt_secret=secret if isinstance(secret, str) else '') def run(*args): """The finished process of a command, or None when it cannot be run.""" try: return subprocess.run(args, capture_output=True, text=True, timeout=COMMAND_TIMEOUT) except (OSError, subprocess.TimeoutExpired): return None def unit_state(unit): result = run('systemctl', 'is-active', unit) return result.stdout.strip() if result and result.stdout.strip() else 'unknown' def package_version(): result = run('dpkg-query', '-W', '-f=${Version}', 'vpn-router') return result.stdout.strip() if result and result.returncode == 0 else None def docker_status(): """Whether Docker is installed, and the state of its service and of the Docker rules when it is.""" result = run('systemctl', 'list-unit-files', '--no-legend', 'docker.service') if result is None or result.returncode != 0 or not result.stdout.strip(): return {'installed': False} return { 'installed': True, 'service': unit_state('docker.service'), 'rules': unit_state('vpn-router-docker.service'), } def swanctl(command): """The entries of a swanctl list command, or None when swanctl cannot be asked. The --pretty output has one item per line: "name {" opens a section, "key = [" a list whose items follow one per line, "}" and "]" close them, and "key = value" is a pair whose value may contain spaces. Every "... event {" section at the top holds one connection or SA; each becomes an entry with its name under "name". The closing "... reply {" section is left out. Values stay strings, as swanctl prints them. Output with more closing than opening lines gives None. """ result = run('swanctl', command, '--pretty') if result is None or result.returncode != 0: return None events = [] stack = [] for line in result.stdout.splitlines(): line = line.strip() if not line: continue if line in ('}', ']'): if not stack: return None stack.pop() elif not stack: section = {} if line.endswith(' event {'): events.append(section) stack.append(section) elif isinstance(stack[-1], list): stack[-1].append(line) elif line.endswith(' {'): stack[-1][line[:-2]] = section = {} stack.append(section) elif line.endswith(' = ['): stack[-1][line[:-4]] = items = [] stack.append(items) else: key, _, value = line.partition(' = ') stack[-1][key] = value return [dict(body, name=name) for event in events for name, body in event.items()] def established(connection): """The number of established IKE SAs of the connection, or None when swanctl cannot be asked.""" sas = swanctl('--list-sas') if sas is None: return None return sum(1 for sa in sas if sa['name'] == connection and sa.get('state') == 'ESTABLISHED') def wireguard_peers(): """One entry per WireGuard peer, with its handshake age and traffic, or None when wg0 is down.""" result = run('wg', 'show', 'wg0', 'dump') if result is None or result.returncode != 0: return None now = int(time.time()) peers = [] for line in result.stdout.splitlines()[1:]: fields = line.split('\t') if len(fields) < 7: continue handshake = int(fields[4]) peers.append({ 'handshake_seconds_ago': now - handshake if handshake else None, 'received_bytes': int(fields[5]), 'sent_bytes': int(fields[6]), }) return peers def openvpn_clients(): """The number of connected OpenVPN clients, from the server's status file, or None when it cannot be read.""" try: with open(OPENVPN_STATUS, encoding='utf-8') as handle: return sum(1 for line in handle if line.startswith('CLIENT_LIST,')) except OSError: return None def collect(state): """The status document. state says which features the router has.""" units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service')} if state.get('site_to_site') or state.get('p2s'): units['strongswan'] = unit_state('strongswan.service') if state.get('wireguard'): units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service') if state.get('openvpn'): units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service') status = { 'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded', 'version': package_version(), 'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'units': units, 'docker': docker_status(), } if state.get('site_to_site'): count = established('remote-site') status['site_to_site'] = {'established': None if count is None else count > 0} if state.get('p2s'): status['p2s'] = {'clients': established('road-warrior')} if state.get('wireguard'): status['wireguard'] = {'peers': wireguard_peers()} if state.get('openvpn'): status['openvpn'] = {'clients': openvpn_clients()} return status def b64decode(part): return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)) def verify(token, secret): """The claims of a token signed with HS256 by the secret, or None when it is not acceptable. Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token must expire (exp), and not later than MAX_LIFETIME seconds from now, so that a stolen token is of little use; LEEWAY seconds of clock difference are allowed. """ try: header, payload, signature = token.split('.') if json.loads(b64decode(header)).get('alg') != 'HS256': return None expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest() if not hmac.compare_digest(expected, b64decode(signature)): return None claims = json.loads(b64decode(payload)) now = time.time() expires = claims['exp'] if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not now - LEEWAY <= expires <= now + MAX_LIFETIME + LEEWAY: return None not_before = claims.get('nbf') if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY): return None except (ValueError, KeyError, TypeError, AttributeError): return None return claims def lines_of(*args): """The output lines of a command, or None when it fails.""" result = run(*args) return result.stdout.splitlines() if result and result.returncode == 0 else None def routes(): """The IPv4 routes as ip -j prints them, or None when ip fails.""" result = run('ip', '-j', '-4', 'route', 'show') if result is None or result.returncode != 0: return None try: return json.loads(result.stdout) except ValueError: return None def wireguard_details(): """The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key.""" result = run('wg', 'show', 'wg0', 'dump') if result is None or result.returncode != 0: return None rows = [line.split('\t') for line in result.stdout.splitlines()] now = int(time.time()) return { 'public_key': rows[0][1] if rows and len(rows[0]) > 2 else None, 'listen_port': int(rows[0][2]) if rows and len(rows[0]) > 2 else None, 'peers': [{ 'public_key': row[0], 'endpoint': None if row[2] == '(none)' else row[2], 'allowed_ips': row[3].split(','), 'handshake_seconds_ago': now - int(row[4]) if int(row[4]) else None, 'received_bytes': int(row[5]), 'sent_bytes': int(row[6]), } for row in rows[1:] if len(row) >= 7], } def details(state): """What /config shows: the configuration without its secrets, and what the router resolved and runs.""" configuration = state.get('configuration', {}) return { 'configuration': configuration, 'network': dict(state.get('network', {}), routes=routes()), 'ipsec': { 'connections': swanctl('--list-conns'), 'sas': swanctl('--list-sas'), }, 'wireguard': wireguard_details(), 'docker': { 'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []), 'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'), }, } def text_of(status): """The status for people, as plain text.""" lines = ['VPN Router', f'Status: {status["status"]}', f'Version: {status["version"] or "unknown"}', ''] lines += [f'{name}: {value}' for name, value in status['units'].items()] docker = status['docker'] lines.append('Docker: not installed' if not docker['installed'] else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}') if 'site_to_site' in status: value = status['site_to_site']['established'] lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}') if 'p2s' in status: clients = status['p2s']['clients'] lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}') if 'openvpn' in status: clients = status['openvpn']['clients'] lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}') if 'wireguard' in status: peers = status['wireguard']['peers'] if peers is None: lines.append('WireGuard: down') else: lines.append(f'WireGuard: {len(peers)} peers') for number, peer in enumerate(peers, 1): age = peer['handshake_seconds_ago'] lines.append(f' peer {number}: {"no handshake yet" if age is None else f"handshake {age} s ago"}, ' f'received {peer["received_bytes"]} B, sent {peer["sent_bytes"]} B') return '\n'.join(lines) + '\n' def html_of(status): """The status for a browser, as a page styled with Tailwind CSS.""" colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700' # Each "name: value" line of the text is a row; indented lines, such as WireGuard peers, are indented here too. rows = [] for line in text_of(status).splitlines()[4:]: if not line: continue name, _, value = line.partition(': ') indent = ' pl-8' if line.startswith(' ') else '' rows.append(f'
Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}
{escape(status['time'])} - JSON