#!/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, the OpenVPN client profile, and TOTP enrolment (see users()) /groups the groups of the router's directory: list, add, rename or describe, remove, and add and take out members (see groups()) /auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth()) /config, /users and /groups need an HS256 JWT in an Authorization: Bearer header signed with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub, who must be a member of vpnrouter_users.ADMIN_GROUP; a token of vpn-router-token has the admin claim, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it, add or take out its members, and remove one of its members. These paths and /auth are not served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by default the address the service listens on, is the last entry of its X-Forwarded-For header. / 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 first initializes, without listening: it makes the signing key and the refresh tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found libpq in the linker cache, where psycopg looks it up. The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it works out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with web.port and web.enabled (vpnrouter_net.web_listen), and runs the server there (vpnrouter_serve.serve): 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 signing key, 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 collections import json import os import signal import subprocess import sys import threading import time from html import escape from http.server import BaseHTTPRequestHandler sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import ldap import vpnrouter import vpnrouter_auth import vpnrouter_net import vpnrouter_serve import vpnrouter_users STATE_FILE = '/run/vpn-router/web.json' CONFIG_FILE = '/etc/vpn-router/vpn-router.json' #: Seconds between two attempts of the initialization. INIT_RETRY_SECONDS = 15 #: 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 #: The calls of /auth allowed per client address in AUTH_WINDOW seconds. AUTH_LIMIT = 10 AUTH_WINDOW = 60 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, 5: 401} _cache = {'at': 0.0, 'data': None} _lock = threading.Lock() #: The times of the recent /auth calls of each client address. _auth_calls = collections.defaultdict(collections.deque) _auth_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 signing key. Before setup has run the router has no features, and without a key the protected paths and /auth are not served. """ return dict(read_json(STATE_FILE), key=vpnrouter_auth.key()) 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 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 POST /users//unlock end a lockout after failed logins DELETE /users/ remove the user and revoke the certificate GET /users//profile the OpenVPN client profile POST /users//otp a new TOTP secret and its otpauth URI; nothing is stored PUT /users//otp store a TOTP secret: {"secret", "code"}, code a code of the secret DELETE /users//otp remove the TOTP token """ 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), ('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name), ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name), ('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name), ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')), ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(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 vpnrouter_auth.DatabaseError as exc: return 500, {'error': str(exc)} except SystemExit as exc: # vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read. return 500, {'error': str(exc.code)} 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 def changes_admins(connection, method, parts, body): """Whether a /users or /groups request creates, renames to, changes or removes ADMIN_GROUP, or removes a member of it.""" admins = vpnrouter_users.ADMIN_GROUP if parts[0] == 'groups': if method == 'POST' and len(parts) == 1: return body.get('name') == admins if method != 'GET' and len(parts) > 1 and parts[1] == admins: return True return method == 'PUT' and len(parts) == 2 and body.get('name') == admins if parts[0] == 'users' and method == 'DELETE' and len(parts) == 2: return admins in vpnrouter_users.groups_of(connection, parts[1]) return False def refusal(claims, method, path, body): """Why the holder of the claims may not make the request to a protected path, or '' when it may.""" if claims.get('admin') is True: return '' name = claims.get('sub') if not isinstance(name, str): return 'the token names no user' admins = vpnrouter_users.ADMIN_GROUP with vpnrouter_users.session() as connection: if not vpnrouter_users.is_admin(connection, name): return f'user {name} is not a member of {admins}' if changes_admins(connection, method, path.strip('/').split('/'), body if isinstance(body, dict) else {}): return f'only a token of vpn-router-token may change the group {admins} or remove its members' return '' def auth(path, body): """Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document. POST /auth sign in: {"name", "password", "code"?}; the tokens POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up POST /auth/logout {"refresh_token"}; the refresh token revoked """ if not isinstance(body, dict): return 400, {'error': 'the body must be a JSON object'} try: cfg = vpnrouter.load_config() if path == '/auth': vpnrouter_users.login(body.get('name'), body.get('password'), body.get('code')) return 200, vpnrouter_auth.issue(cfg, body['name']) name = vpnrouter_auth.take(cfg, body.get('refresh_token')) if path == '/auth/logout': return 200, {'revoked': name is not None} if name is None: return 401, {'error': 'the refresh token is not valid'} with vpnrouter_users.session() as connection: user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name)) if not user['enabled'] or user['locked']: return 401, {'error': f'user {name} is disabled or locked'} return 200, vpnrouter_auth.issue(cfg, name) except vpnrouter_users.Failure as failure: return (401 if failure.status == 3 else 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 vpnrouter_auth.DatabaseError as exc: return 500, {'error': str(exc)} except SystemExit as exc: return 500, {'error': str(exc.code)} def auth_wait(address): """Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted.""" now = time.monotonic() with _auth_lock: for idle in [key for key, times in _auth_calls.items() if times[-1] <= now - AUTH_WINDOW]: del _auth_calls[idle] calls = _auth_calls[address] while calls and calls[0] <= now - AUTH_WINDOW: calls.popleft() if len(calls) >= AUTH_LIMIT: return int(calls[0] + AUTH_WINDOW - now) + 1 calls.append(now) return 0 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 claims(self, secret): """The claims of the request's token signed with the secret, or None without an acceptable one.""" header = self.headers.get('Authorization', '') return vpnrouter_auth.verify(header[7:].strip(), secret) if header.startswith('Bearer ') else None def client(self): """The client address: the last X-Forwarded-For entry when the peer is a trusted proxy, else the peer.""" peer = self.client_address[0] trusted = section(read_json(CONFIG_FILE), 'web').get('trusted_proxies') or [self.server.server_address[0]] forwarded = self.headers.get('X-Forwarded-For', '') if isinstance(trusted, list) and peer in trusted and forwarded.strip(): return forwarded.split(',')[-1].strip() return peer def refusal(self, claims, path, payload): """The HTTP status and the message that refuse a request to a protected path, or None when it may be made.""" try: reason = refusal(claims, self.command, path, payload) except (ldap.LDAPError, OSError) as exc: return 500, f'the user directory failed: {exc}' return (403, reason) if reason else None def answer(self, send_body): path = self.path.split('?', 1)[0] extra = [] self.principal = None # 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 signing = path in ('/auth', '/auth/refresh', '/auth/logout') state = current() if protected or signing else {} claims = self.claims(state['key']) if protected and state['key'] else None payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None if claims is not None: self.principal = 'admin' if claims.get('admin') is True else claims.get('sub') if (protected or signing) and not state['key']: body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 elif signing and self.command != 'POST': body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 elif signing: wait = auth_wait(self.client()) if path != '/auth/logout' else 0 if wait: code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'} extra.append(('Retry-After', str(wait))) else: code, document = auth(path, payload) body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' elif protected and claims is None: body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401 extra.append(('WWW-Authenticate', 'Bearer')) elif protected and (refused := self.refusal(claims, path, payload)): code, kind = refused[0], 'application/json' body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n' 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, payload) 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): principal = getattr(self, 'principal', None) or '-' sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args)) def log(message): print(f'vpn-router-web: {message}', file=sys.stderr, flush=True) def initialize(): """Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready.""" reported = None while True: phase = 'reading the configuration' try: cfg = vpnrouter.load_config() phase = 'making the signing key' vpnrouter_auth.ensure_key() phase = 'creating the token database' vpnrouter_auth.provision(cfg) phase = 'connecting to the token database' with vpnrouter_auth.database(cfg): pass except (OSError, SystemExit, vpnrouter_auth.DatabaseError) as exc: error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}' if error != reported: log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds') reported = error time.sleep(INIT_RETRY_SECONDS) continue log('ready') return def main(): signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) initialize() try: vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log, DISCOVERY_SECONDS) except KeyboardInterrupt: pass return 0 if __name__ == '__main__': sys.exit(main())