"""The status of the router: / for people, /status, /status/full, /config and /postgresql, with the cache of the status that the requests of CACHE_SECONDS share (cached).""" import json import threading import time from html import escape import vpnrouter from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers from .common import current, unit_state #: Seconds a collected status is answered again. CACHE_SECONDS = 2 TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4' #: Seconds after which the status page in a browser reloads itself. REFRESH_SECONDS = 60 #: The PostgreSQL settings /config shows for each cluster. POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers', 'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone') _cache = {'at': 0.0, 'data': None} _lock = threading.Lock() def package_version(package='vpn-router'): """The installed version of the package, or None when it is not installed.""" result = run('dpkg-query', '-W', '-f=${Version}', package) return (result.stdout.strip() or None) 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 postgresql_clusters(): """The state of each PostgreSQL cluster unit (postgresql@-), or {'postgresql': 'no cluster'} without one. postgresql.service, which only runs /bin/true, is not reported. """ lines = lines_of('systemctl', 'list-units', 'postgresql@*.service', '--all', '--plain', '--no-legend') or [] names = [line.split()[0].removesuffix('.service') for line in lines if line.strip()] return {name: unit_state(f'{name}.service') for name in names} or {'postgresql': 'no cluster'} 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'), **postgresql_clusters()} manager_version = package_version('vpn-router-manager') if manager_version: units['vpn-router-manager'] = unit_state('vpn-router-manager.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(), 'manager_version': manager_version, 'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'units': units, 'docker': docker_status(), } names = state.get('site_to_site') if isinstance(names, list) and names: counts = established_each(names).values() status['site_to_site'] = {'connections': len(names), 'established': None if None in counts else sum(1 for count in counts if count)} 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 shown(status, content, client): """The status document as web.status_content shows it to the client at its address. Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing more; standard adds version, manager_version and services, the number of active units and of units in every other state; full adds the whole status document of collect. Content that is not one of vpnrouter.STATUS_CONTENTS, or not set, is basic. """ if content not in vpnrouter.STATUS_CONTENTS: content = 'basic' if content == 'full': return {**status, 'content': content, 'client_ip': client} document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client} if content == 'standard': active = sum(1 for value in status['units'].values() if value == 'active') document.update(version=status['version'], manager_version=status['manager_version'], services={'active': active, 'failed': len(status['units']) - active}) return document 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 postgresql_query(cluster, sql): """The JSON value an SQL statement answers on a cluster (version/name), run by psql as postgres, or None when it fails.""" result = run('runuser', '-u', 'postgres', '--', 'psql', '--cluster', cluster, '-X', '-q', '-t', '-A', '-v', 'ON_ERROR_STOP=1', '-d', 'postgres', '-c', sql) if result is None or result.returncode != 0: return None try: return json.loads(result.stdout) except ValueError: return None def postgresql_clusters_of(): """The PostgreSQL clusters of pg_lsclusters, each with version, name, port, status and data_directory, or None when it fails.""" lines = lines_of('pg_lsclusters', '--no-header') if lines is None: return None clusters = [] for line in lines: fields = line.split() if len(fields) >= 6: clusters.append({'version': fields[0], 'name': fields[1], 'port': int(fields[2]) if fields[2].isdigit() else None, 'status': fields[3], 'data_directory': fields[5]}) return clusters def postgresql_configuration(): """The PostgreSQL clusters on the router with the settings of POSTGRESQL_SETTINGS of each one that is online (None for one that is not), or None when pg_lsclusters fails.""" clusters = postgresql_clusters_of() if clusters is None: return None names = ', '.join(f"'{name}'" for name in POSTGRESQL_SETTINGS) for cluster in clusters: cluster['settings'] = postgresql_query( f"{cluster['version']}/{cluster['name']}", f'SELECT json_object_agg(name, current_setting(name) ORDER BY name) FROM pg_settings WHERE name IN ({names})', ) if cluster['status'].startswith('online') else None return {'clusters': clusters} def postgresql_databases(): """What /postgresql shows: the PostgreSQL clusters on the router, each with its databases (name, owner, encoding and size in bytes) and their total size, None for a cluster that is not online; clusters is None when pg_lsclusters fails.""" clusters = postgresql_clusters_of() if clusters is None: return {'clusters': None} for cluster in clusters: databases = postgresql_query( f"{cluster['version']}/{cluster['name']}", "SELECT coalesce(json_agg(json_build_object('name', datname, 'owner', pg_get_userbyid(datdba), " "'encoding', pg_encoding_to_char(encoding), 'size', pg_database_size(oid)) ORDER BY datname), '[]') " 'FROM pg_database', ) if cluster['status'].startswith('online') else None cluster['databases'] = databases cluster['size'] = sum(database['size'] for database in databases) if isinstance(databases, list) else None return {'clusters': clusters} def details(state): """What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the installed Caddy, {version, source, service}, service the state of caddy.service, null when it is not installed; oidc is {enabled, issuer} of the OpenID Provider, issuer null while it is disabled.""" configuration = state.get('configuration', {}) caddy = package_version('caddy') oidc = configuration.get('oidc', {}).get('enabled') is True 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'), }, 'postgresql': postgresql_configuration(), # Caddy is upstream's once its repository is configured (vpn-router-caddy). 'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu', 'service': unit_state('caddy.service')} if caddy else None, 'oidc': {'enabled': oidc, 'issuer': vpnrouter.oidc_issuer(configuration) if oidc else None}, } def text_of(status): """The status document of shown for people, as plain text: a header, a blank line, then name: value lines.""" lines = ['VPN Router', f'Status: {status["status"]}'] if 'version' in status: lines.append(f'Version: {status["version"] or "unknown"}') lines += ['', f'Client IP: {status["client_ip"]}'] if 'manager_version' in status: lines.append(f'Router Manager: {status["manager_version"] or "not installed"}') if 'services' in status: lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed') lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()] if 'docker' in status: 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: established, connections = status['site_to_site']['established'], status['site_to_site']['connections'] lines.append(f'Site to site: {"unknown" if established is None else established} of {connections} established') if 'p2s' in status: clients = status['p2s']['clients'] lines.append(f'IKEv2: {"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 document of shown 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' version = f', version {status["version"] or "unknown"}' if 'version' in status else '' # Each "name: value" line after the header of the text is a row; indented lines, such as WireGuard peers, are indented here too. rows = [] for line in text_of(status).split('\n\n', 1)[1].splitlines(): 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)}

{body}

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

""" def cached(): """The status of collect, collected again when it is older than CACHE_SECONDS.""" 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']