From 7163f167e0b14c23668991dd8c4a5719ea0ba8e1 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Fri, 23 Oct 2026 19:46:26 +0200 Subject: [PATCH] feature: Added an internally only exposed status web service. --- ADR.md | 49 +++ README.md | 71 ++++ debian-package/debian/changelog | 21 ++ debian-package/debian/install | 1 + debian-package/debian/rules | 1 + .../debian/vpn-router.vpn-router-web.service | 18 + debian-package/src/usr/lib/vpn-router/setup | 58 +++ .../src/usr/lib/vpn-router/vpnrouter.py | 14 + debian-package/src/usr/lib/vpn-router/web | 355 ++++++++++++++++++ .../doc/vpn-router/vpn-router.json.example | 5 + 10 files changed, 593 insertions(+) create mode 100644 debian-package/debian/vpn-router.vpn-router-web.service create mode 100644 debian-package/src/usr/lib/vpn-router/web diff --git a/ADR.md b/ADR.md index 2207fee..fabc356 100644 --- a/ADR.md +++ b/ADR.md @@ -26,3 +26,52 @@ because `vpn-router-docker` is already active. A published port is reachable from the external interface only when it is in `exposed_ports`. Other rules in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker restores the rules. The rules cover IPv4 only. + +## ADR-2: A status web service of the standard library, bound to the internal address + +Status: Accepted + +Context: The router has no way to see its state without a shell. A check from the protected +network, by a person or by a monitor, needs a page or an endpoint. The router is an +internet-facing host, so what the service shows and where it listens matter. + +Decision: The `vpn-router-web` unit runs one Python script that uses only the standard library, in +the way of a small test server. It binds to the address of the internal interface and to +`web.port`, on by default at 8080. `/` is for people, in plain text for `curl` and `wget` and as +a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the +state of the services, the package version and the state of the site-to-site, road-warrior and +WireGuard features, and nothing from the configuration. `vpn-router-setup` resolves the +interface address, writes it with the port and the features to `/run/vpn-router/web.json`, +opens the port on the internal interface in UFW and restarts the service, so the service does +not need the package's configuration code, which needs Jinja2. + +Consequences: Nothing outside the internal network can reach it, since it listens on the internal +address only and the firewall opens the port on the internal interface only. It has no +authentication, which is acceptable because it is read only and shows no configuration. It runs +as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`, `ProtectSystem=strict`, +`ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a browser on a network +without internet shows the unstyled page, and `curl` is unaffected. A change of the internal +address is picked up at the next run of `vpn-router-setup`. + +## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret + +Status: Accepted + +Context: A client such as manage-ui needs the configuration and the resolved state of the +router, which `/` and `/status` must not show. The service has only the standard library, listens +on plain HTTP on the internal network, and its configuration file is readable by root only. + +Decision: `/config` needs a JWT in an `Authorization: Bearer` header. The service verifies HS256 +tokens with `hmac` and `hashlib`, with the secret `web.jwt_secret`, and accepts no other +algorithm, because verifying RS256 or ES256 needs a library outside the standard library. The +token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for +each use and a leaked one expires. Without a secret the page is not served. The secrets in the +configuration shown are masked by name (`psk`, and options with `secret`, `password`, `token` or a +`_key` ending in the name). + +Consequences: The client and the router share one secret, and whoever holds it can make tokens, +so it is long and random; it is checked to have at least 32 characters. The secret is in the +configuration file and in `/run/vpn-router/web.json`, both readable by root only. The token and +the answer cross the network unencrypted, so the page is for a trusted internal network; HTTPS +would need a certificate that the package does not have. The masking is by option name, so a new +secret option needs a name that matches. diff --git a/README.md b/README.md index 4df97c5..4375858 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,9 @@ must have the type shown below; an unknown section or setting is an error. | `wireguard` | `listen_port` | integer | `51820` | UDP port | | `docker` | `mode` | string | `auto` | `auto` secures Docker when its service is installed, `on` requires it, `off` removes the rules | | `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two | +| `web` | `enabled` | boolean | `true` | The status web service on the internal address | +| `web` | `port` | integer | `8080` | TCP port of the status web service | +| `web` | `jwt_secret` | string | | Secret, at least 32 characters, that signs the tokens for `/config`; without it `/config` is not served | A minimal site-to-site configuration in `auto` mode: @@ -321,6 +324,74 @@ EXTRA_LOCAL_TS = '192.0.2.53/32' Set `general.platform` to `example` and restart the service. The debconf question offers only the modules shipped with the package; the configuration file accepts any module installed. +## Status web service + +The `vpn-router-web` service shows the state of the router on its internal address and +`web.port` (8080 by default). It uses only the Python standard library, serves HTTP without +authentication, and shows no configuration: no key, identity, address or subnet. + +| Page | Content | +| --- | --- | +| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) | +| `/status` | The same status as JSON | +| `/config` | The configuration with its secrets masked, and what the router resolved and runs, as JSON; needs a token | + +The status holds the state of `vpn-router-setup`, of `strongswan` when site-to-site or road-warrior +access is configured, and of `wg-quick@wg0` when WireGuard is; the package version; whether +Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker` +(the Docker rules), which do not change `status`; whether the +site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and +the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when +all these services are active, and `degraded` otherwise. + +```bash +curl http://10.0.0.4:8080/ +curl http://10.0.0.4:8080/status +``` + +### The /config page + +`/config` shows what `/` and `/status` leave out, for a client such as manage-ui: + +| Key | Content | +| --- | --- | +| `configuration` | `vpn-router.json` with the defaults applied and the secrets masked (`psk`, `jwt_secret`, and any option named like a password, token or key) | +| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes | +| `ipsec` | The output of `swanctl --list-conns` and `swanctl --list-sas`, as lists of lines | +| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key | +| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain | + +The page needs a JWT in an `Authorization: Bearer` header, signed with HS256 by `web.jwt_secret`. +The token must have an `exp` claim, not more than ten minutes from now (a minute of clock +difference is allowed); `nbf` is checked when present. Other algorithms are refused, with 401 and +`WWW-Authenticate: Bearer`. While `web.jwt_secret` is not set the page is not served (404). The +token and the page travel over plain HTTP on the internal network, and the token can be used +until it expires, so keep the lifetime short. A client makes a token from the secret with only +HMAC-SHA256; with Python: + +```python +import base64, hashlib, hmac, json, time + +def b64(data): + return base64.urlsafe_b64encode(data).rstrip(b'=').decode() + +secret = 'the web.jwt_secret value' +head = b64(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode()) +body = b64(json.dumps({'exp': int(time.time()) + 300}).encode()) +sign = b64(hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest()) +print(f'{head}.{body}.{sign}') +``` + +```bash +curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:8080/config +``` + +`vpn-router-setup` opens `web.port` on the internal interface in the firewall, writes the address, +the port, the secret and the facts for `/config` to `/run/vpn-router/web.json` (readable by root +only) and restarts the service, so a changed `web` section +applies with `systemctl restart vpn-router-setup`. The service ends without an error when +`web.enabled` is `false`, and keeps trying to start until the router has an internal address. + ## Removal `apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 9dd909c..935f7a1 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,24 @@ +vpn-router (0.4.0-1) unstable; urgency=medium + + * Add the vpn-router-web service, a small status web service that uses only + the Python standard library. It listens on the internal address and on + web.port, 8080 by default. / shows the state of the router for people, + as plain text for curl and wget and as a Tailwind CSS page for a + browser, and /status shows the same as JSON: the state of the services, + the package version, whether Docker is installed, and the state of + site-to-site, road-warrior and WireGuard, with no configuration data. + * Add the web configuration section. web.enabled (true by default) and + web.port. vpn-router-setup opens the port on the internal interface in + UFW and restarts the service. + * Add /config to the status web service, for a client such as manage-ui. It + shows the configuration with its secrets masked, the interfaces, + addresses, identities and routes the router resolved, the IPsec + connections and SAs, the WireGuard peers (no keys) and the Docker rules. + It needs a JWT signed with HS256 by web.jwt_secret, with an exp claim + within ten minutes, and is not served while web.jwt_secret is not set. + + -- Slawomir Koszewski Fri, 23 Oct 2026 19:00:00 +0200 + vpn-router (0.3.2-1) unstable; urgency=medium * Accept an IPv4 or IPv6 address as remote.id and as wan.local_fqdn. The diff --git a/debian-package/debian/install b/debian-package/debian/install index 037774b..2d48637 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -4,6 +4,7 @@ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/ src/usr/lib/vpn-router/configure usr/lib/vpn-router/ src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/ src/usr/lib/vpn-router/setup usr/lib/vpn-router/ +src/usr/lib/vpn-router/web usr/lib/vpn-router/ src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/ src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/ diff --git a/debian-package/debian/rules b/debian-package/debian/rules index 3999649..b6e26ad 100755 --- a/debian-package/debian/rules +++ b/debian-package/debian/rules @@ -5,3 +5,4 @@ override_dh_installsystemd: dh_installsystemd --name=vpn-router-setup dh_installsystemd --name=vpn-router-docker + dh_installsystemd --name=vpn-router-web diff --git a/debian-package/debian/vpn-router.vpn-router-web.service b/debian-package/debian/vpn-router.vpn-router-web.service new file mode 100644 index 0000000..dd516cb --- /dev/null +++ b/debian-package/debian/vpn-router.vpn-router-web.service @@ -0,0 +1,18 @@ +[Unit] +Description=VPN Router status web service +Documentation=file:/usr/share/doc/vpn-router/README.md +After=network-online.target vpn-router-setup.service +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web +Restart=on-failure +RestartSec=15 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index b9fa644..698aa8c 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -10,8 +10,10 @@ firewall and NAT rules are written against them, so choosing the wrong one would be a silent and security-relevant mistake. """ +import json import os import pathlib +import re import socket import subprocess import sys @@ -24,13 +26,18 @@ from vpnrouter import fail BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules') UFW_DEFAULTS = pathlib.Path('/etc/default/ufw') WG_DIR = pathlib.Path('/etc/wireguard') +WEB_STATE = pathlib.Path('/run/vpn-router/web.json') FILTER_ANCHOR = '# End required lines' +#: Configuration options whose values are masked wherever the configuration is shown. +SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$') + BLOCKS = ( ('# IPSEC RULES START', '# IPSEC RULES END'), ('# WIREGUARD RULES START', '# WIREGUARD RULES END'), ('# P2S DNS RULES START', '# P2S DNS RULES END'), + ('# WEB RULES START', '# WEB RULES END'), ('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'), ('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'), ('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'), @@ -149,6 +156,14 @@ def filter_block(ctx): ] lines += ['# P2S DNS RULES END'] + if ctx['web_enabled'] and ctx['int_iface']: + lines += [ + '', + '# WEB RULES START', + f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {ctx["web_port"]} -j ACCEPT', + '# WEB RULES END', + ] + if vpnrouter.routing_ready(ctx): lines += ['', '# ROUTER FORWARD RULES START'] for subnet in ctx['local_cidr_list']: @@ -309,6 +324,48 @@ def refresh_docker_rules(): run(['systemctl', 'restart', 'vpn-router-docker.service'], check=False) +def refresh_web(cfg, ctx): + """Hand the web service what it needs and restart it. + + The service takes the address to bind to, the port, the features of the + router, the secret that signs its tokens, the configuration with its + secrets masked and the facts resolved from the system from a file here, so + that it needs nothing but the standard library and the configuration code. + The file is readable by root only. The service ends quietly when + web.enabled is false. + """ + masked = { + section: {option: '********' if SECRET_OPTION.search(option) and value else value + for option, value in options.items()} + for section, options in cfg.items() + } + WEB_STATE.parent.mkdir(parents=True, exist_ok=True) + WEB_STATE.touch(mode=0o600) + WEB_STATE.chmod(0o600) + WEB_STATE.write_text(json.dumps({ + 'enabled': bool(ctx['web_enabled']), + 'bind': ctx['int_addr'], + 'port': ctx['web_port'], + 'jwt_secret': cfg['web']['jwt_secret'], + 'site_to_site': bool(vpnrouter.s2s_ready(ctx)), + 'p2s': bool(vpnrouter.p2s_ready(ctx)), + 'wireguard': bool(vpnrouter.wg_ready(ctx)), + 'configuration': masked, + 'network': { + 'external_interface': ctx['wan_iface'], + 'internal_interface': ctx['int_iface'], + 'external_addresses': ctx['local_addr_list'], + 'internal_address': ctx['int_addr'], + 'internal_gateway': ctx['int_gateway_ip'], + 'local_identity': ctx['local_id'], + 'remote_identity': ctx['remote_id'], + 'local_cidrs': ctx['local_cidr_list'], + 'remote_cidrs': ctx['remote_cidr_list'], + }, + }) + '\n', encoding='utf-8') + run(['systemctl', 'restart', 'vpn-router-web.service'], check=False) + + def main(): cfg = vpnrouter.load_config() ctx, changed = vpnrouter.configure(cfg) @@ -348,6 +405,7 @@ def main(): run_platform_setup(cfg, ctx) refresh_docker_rules() + refresh_web(cfg, ctx) return 0 diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 3a80a26..bea2da0 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -48,8 +48,12 @@ SCHEMA = { 'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'docker': {'mode': 'auto', 'exposed_ports': []}, + 'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''}, } +#: The shortest secret that signs the tokens of the status web service. +WEB_SECRET_MIN = 32 + DOCKER_MODES = ('auto', 'on', 'off') #: A port or a port range as iptables multiport takes it: 443 or 8000:8100. @@ -228,6 +232,14 @@ def validate(cfg): if not 1 <= port <= 65535: raise ConfigError(f'wireguard.listen_port: {port} is not a valid port') + web_port = _get(cfg, 'web', 'port') + if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535: + raise ConfigError(f'web.port: {web_port!r} is not a valid port') + + web_secret = _get(cfg, 'web', 'jwt_secret') + if web_secret and len(web_secret) < WEB_SECRET_MIN: + raise ConfigError(f'web.jwt_secret: use at least {WEB_SECRET_MIN} characters') + docker_mode = _get(cfg, 'docker', 'mode') if docker_mode not in DOCKER_MODES: raise ConfigError( @@ -465,6 +477,8 @@ def build_context(cfg, local_id_override=None): 'wg_enabled': _get(cfg, 'wireguard', 'enabled'), 'wg_address': _get(cfg, 'wireguard', 'address'), 'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'), + 'web_enabled': _get(cfg, 'web', 'enabled'), + 'web_port': _get(cfg, 'web', 'port'), } diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web new file mode 100644 index 0000000..376e7d7 --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/web @@ -0,0 +1,355 @@ +#!/usr/bin/python3 +"""Serve the status of the router on its internal address. + +Run by vpn-router-web.service. It needs nothing but the Python standard library. + +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; needs an HS256 JWT in an Authorization: Bearer header + that is signed with web.jwt_secret and expires within ten minutes; 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) and +WireGuard. No configuration data, key or address is shown. + +The address to bind to, the port, the secret and the configuration shown by /config +are not read from the configuration here: vpn-router-setup resolves them, with the +interfaces, and writes them to /run/vpn-router/web.json, readable by root only, before +it restarts this service. The service ends with +success when the file says the service is disabled, and with failure, to be +started again, when the file is missing or the router has no internal address yet. +""" + +import base64 +import hashlib +import hmac +import json +import os +import re +import signal +import subprocess +import sys +import threading +import time +from html import escape +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +STATE_FILE = '/run/vpn-router/web.json' +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' +SA_LINE = re.compile(r'^(\S+): #\d+, (\w+),') + +_cache = {'at': 0.0, 'data': None} +_lock = threading.Lock() + + +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 established(connection): + """The number of IKE SAs of the connection, or None when swanctl cannot be asked.""" + result = run('swanctl', '--list-sas') + if result is None or result.returncode != 0: + return None + return sum(1 for line in result.stdout.splitlines() + if (match := SA_LINE.match(line)) and match.group(1) == connection and match.group(2) == '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 collect(state): + """The status document. state says which features the router has.""" + units = {'vpn-router-setup': unit_state('vpn-router-setup.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') + + 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()} + 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 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=lines_of('ip', '-4', 'route', 'show')), + 'ipsec': { + 'connections': lines_of('swanctl', '--list-conns'), + 'sas': lines_of('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 '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' if status['status'] == 'ok' else 'bg-amber-600' + body = ''.join(f'
  • {escape(line)}
  • ' for line in text_of(status).splitlines()[4:] if line) + return f""" + + + + + VPN Router + + + +
    +
    +
    +

    VPN Router

    +

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

    +
    +
      {body}
    +

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

    +
    +
    + + +""" + + +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(self.server.state) + _cache['at'] = time.monotonic() + return _cache['data'] + + def answer(self, send_body): + path = self.path.split('?', 1)[0] + extra = [] + if path == '/config': + secret = self.server.state.get('jwt_secret') + header = self.headers.get('Authorization', '') + if not secret: + body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 + elif not header.startswith('Bearer ') or verify(header[7:].strip(), secret) is None: + body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401 + extra.append(('WWW-Authenticate', 'Bearer')) + else: + body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200 + 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 do_GET(self): + self.answer(True) + + def do_HEAD(self): + self.answer(False) + + 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 main(): + try: + with open(STATE_FILE, encoding='utf-8') as handle: + state = json.load(handle) + except (OSError, ValueError) as exc: + print(f'vpn-router-web: cannot read {STATE_FILE}: {exc}', file=sys.stderr) + return 1 + + if not state.get('enabled'): + print('vpn-router-web: the web service is disabled in the configuration') + return 0 + if not state.get('bind'): + print('vpn-router-web: the router has no internal address yet', file=sys.stderr) + return 1 + + server = ThreadingHTTPServer((state['bind'], state['port']), Handler) + server.state = state + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + print(f'vpn-router-web: serving on http://{state["bind"]}:{state["port"]}') + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example index 81af712..d304c52 100644 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -36,5 +36,10 @@ "docker": { "mode": "auto", "exposed_ports": ["443"] + }, + "web": { + "enabled": true, + "port": 8080, + "jwt_secret": "" } }