feature: Added an internally only exposed status web service.
Release / release (push) Successful in 17s

This commit is contained in:
slawek committed 2026-10-23 19:46:26 +02:00
1 parent 5402256ef6
commit 7163f167e0
10 files changed
+593

No files matched your search

@@ -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
@@ -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'),
}
+355
View File
@@ -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'<li>{escape(line)}</li>' for line in text_of(status).splitlines()[4:] if line)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VPN Router</title>
<script src="{TAILWIND}"></script>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
<div class="mx-auto max-w-2xl px-4 py-10">
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg">
<div class="{colour} px-6 py-4 text-white">
<h1 class="text-xl font-semibold">VPN Router</h1>
<p class="mt-1 text-sm">Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}</p>
</div>
<ul class="space-y-2 px-6 py-6 font-mono text-sm">{body}</ul>
<p class="px-6 pb-6 text-xs text-slate-500">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
</div>
</div>
</body>
</html>
"""
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())
@@ -36,5 +36,10 @@
"docker": {
"mode": "auto",
"exposed_ports": ["443"]
},
"web": {
"enabled": true,
"port": 8080,
"jwt_secret": ""
}
}