#!/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'' f'{escape(name.strip())}' f'{escape(value)}') body = ''.join(rows) return f""" VPN Router

VPN Router

Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}

{body}

{escape(status['time'])} - JSON

""" def users(state, method, path, body): """Answer a /users request with vpnrouter_users: the HTTP status and the JSON document. GET /users the users POST /users add a user: {"name", "password", "first_name", "last_name", "email"} PUT /users/ change any of {"first_name", "last_name", "email"} PUT /users//password set the password: {"password": ...} POST /users//disable lock the user out of OpenVPN POST /users//enable let the user in again DELETE /users/ remove the user and revoke the certificate GET /users//profile the OpenVPN client profile """ if body == 'invalid' or (body is not None and not isinstance(body, dict)): return 400, {'error': 'the body must be a JSON object'} body = body or {} parts = path.strip('/').split('/') route = (method, len(parts), parts[2] if len(parts) == 3 else None) name = parts[1] if len(parts) > 1 else None actions = { ('GET', 1, None): lambda connection: vpnrouter_users.list_users(connection), ('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body), ('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body), ('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')), ('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name), ('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name), ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name), } if route not in actions: return 404, {'error': f'no route {method} {path}'} try: with vpnrouter_users.session() as connection: document = actions[route](connection) except vpnrouter_users.Failure as failure: return USER_STATUS.get(failure.status, 500), {'error': str(failure)} except (ldap.LDAPError, OSError) as exc: return 500, {'error': f'the user directory failed: {exc}'} except SystemExit: # vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason. return 500, {'error': 'the router configuration cannot be read'} return (201 if route == ('POST', 1, None) else 200), document def groups(state, method, path, body): """Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document. GET /groups the groups with their members POST /groups add a group: {"name", "members": [...], "description"?} PUT /groups/ rename it or change the description: {"name"?, "description"?} DELETE /groups/ remove the group; its members are kept POST /groups//members add a member: {"user": ...} DELETE /groups//members/ take a member out; the last member cannot leave """ if body == 'invalid' or (body is not None and not isinstance(body, dict)): return 400, {'error': 'the body must be a JSON object'} body = body or {} parts = path.strip('/').split('/') name = parts[1] if len(parts) > 1 else None route = (method, len(parts), parts[2] if len(parts) > 2 else None) actions = { ('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection), ('POST', 1, None): lambda connection: vpnrouter_users.group_add( connection, body.get('name'), body.get('description'), body.get('members')), ('PUT', 2, None): lambda connection: vpnrouter_users.group_update( connection, name, body.get('name'), body.get('description')), ('DELETE', 2, None): lambda connection: vpnrouter_users.group_remove(connection, name), ('POST', 3, 'members'): lambda connection: vpnrouter_users.group_member_add(connection, name, body.get('user')), ('DELETE', 4, 'members'): lambda connection: vpnrouter_users.group_member_remove(connection, name, parts[3]), } if route not in actions: return 404, {'error': f'no route {method} {path}'} try: with vpnrouter_users.session() as connection: document = actions[route](connection) except vpnrouter_users.Failure as failure: return USER_STATUS.get(failure.status, 500), {'error': str(failure)} except (ldap.LDAPError, OSError) as exc: return 500, {'error': f'the user directory failed: {exc}'} return (201 if route == ('POST', 1, None) else 200), document class Handler(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1' def status(self): with _lock: if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS: _cache['data'] = collect(current()) _cache['at'] = time.monotonic() return _cache['data'] def authorized(self, secret): """Whether the request carries a token signed with the secret; False also while no secret is set.""" header = self.headers.get('Authorization', '') return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None def answer(self, send_body): path = self.path.split('?', 1)[0] extra = [] # A body that is not read must not be taken for the next request. if self.command not in ('GET', 'HEAD'): self.close_connection = True directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None) protected = path == '/config' or directory is not None state = current() if protected else {} if protected and not state['jwt_secret']: body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 elif protected and not self.authorized(state['jwt_secret']): body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401 extra.append(('WWW-Authenticate', 'Bearer')) elif path == '/config' and self.command in ('GET', 'HEAD'): body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200 elif protected and path != '/config': code, document = (users if directory == 'users' else groups)(state, self.command, path, self.request_body()) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif self.command not in ('GET', 'HEAD'): body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 elif path == '/status': body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200 elif path == '/': agent = self.headers.get('User-Agent', '').lower() if 'curl' in agent or 'wget' in agent: body, kind = text_of(self.status()).encode(), 'text/plain; charset=utf-8' else: body, kind = html_of(self.status()).encode(), 'text/html; charset=utf-8' code = 200 else: body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 self.send_response(code) self.send_header('Content-Type', kind) self.send_header('Content-Length', str(len(body))) self.send_header('Cache-Control', 'no-store') for name, value in extra: self.send_header(name, value) self.end_headers() if send_body: self.wfile.write(body) def request_body(self): """The request body as JSON, None when there is none, or the string 'invalid'.""" length = self.headers.get('Content-Length') if not length: return None try: size = int(length) if not 0 <= size <= BODY_LIMIT: return 'invalid' return json.loads(self.rfile.read(size) or b'null') except ValueError: return 'invalid' def do_GET(self): self.answer(True) def do_HEAD(self): self.answer(False) def do_POST(self): self.answer(True) def do_PUT(self): self.answer(True) def do_DELETE(self): self.answer(True) def log_message(self, format, *args): sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args)) def wanted(): """Where the service should listen now, as (address, port), or the reason it should not listen.""" configuration = read_json(CONFIG_FILE) web = section(configuration, 'web') if web.get('enabled', True) is False: return None, 'the web service is disabled in the configuration' port = web.get('port', DEFAULT_PORT) if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: port = DEFAULT_PORT general, interfaces, local = (section(configuration, name) for name in ('general', 'interfaces', 'local')) address = vpnrouter_net.internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''), str(local.get('int_addr') or '')) if not address: return None, 'the internal address is not known yet' return (address, port), '' def log(message): print(f'vpn-router-web: {message}', file=sys.stderr, flush=True) def discover(): """Work out where to listen every DISCOVERY_SECONDS, and run the server thread there. When the address or the port changes, the server thread is stopped and a new one listens at the new place; while the service is disabled, the address is not known or it cannot be bound, no server runs and the next round tries again. """ server, thread, listening, reported = None, None, None, None while True: target, reason = wanted() if target != listening and server is not None: server.shutdown() server.server_close() thread.join() server, listening = None, None if target is not None and server is None: try: server = ThreadingHTTPServer(target, Handler) except OSError as exc: reason = f'cannot listen on {target[0]}:{target[1]}: {exc}' else: thread = threading.Thread(target=server.serve_forever, name='server', daemon=True) thread.start() listening, reason = target, f'serving on http://{target[0]}:{target[1]}' if reason and reason != reported: log(reason) reported = reason time.sleep(DISCOVERY_SECONDS) def main(): signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) discovery = threading.Thread(target=discover, name='discovery', daemon=True) discovery.start() try: discovery.join() except KeyboardInterrupt: pass return 0 if __name__ == '__main__': sys.exit(main())