feature: Added an internally only exposed status web service.
Release / release (push) Successful in 17s
Release / release (push) Successful in 17s
This commit is contained in:
1 parent
5402256ef6
commit
7163f167e0
10 files changed
+593
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user