update: Added OpenLDAP and OpenVPN to the router.
Release / release (push) Successful in 18s

This commit is contained in:
slawek committed 2026-10-23 21:55:03 +02:00
1 parent 609921348b
commit cc6748a622
17 files changed
+1011 -63

No files matched your search

+163 -21
View File
@@ -1,19 +1,24 @@
#!/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.
Run by vpn-router-web.service. It needs nothing but the Python standard library; users are
managed by running vpn-router-user.
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
runs, as JSON
/users the users of the P2S systems: list, add, set the password, disable, enable,
remove, and the OpenVPN client profile (see users())
/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed
with web.jwt_secret and expires within ten minutes; they are 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
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 address to bind to, the port, the secret and the configuration shown by /config
@@ -49,6 +54,21 @@ MAX_LIFETIME = 600
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
#: Seconds after which the status page in a browser reloads itself.
REFRESH_SECONDS = 60
USER_TOOL = '/usr/sbin/vpn-router-user'
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
#: Seconds a user command may take; adding a user creates an RSA key.
USER_TIMEOUT = 120
#: The largest request body accepted, in bytes.
BODY_LIMIT = 4096
#: The HTTP status for each exit status of vpn-router-user; any other is 500.
USER_STATUS = {0: 200, 2: 400, 3: 404, 4: 409}
_cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()
@@ -153,6 +173,15 @@ def wireguard_peers():
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')}
@@ -160,6 +189,10 @@ def collect(state):
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')
if state.get('users'):
units['slapd'] = unit_state('slapd.service')
status = {
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
@@ -175,6 +208,8 @@ def collect(state):
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
@@ -278,6 +313,9 @@ def text_of(status):
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:
@@ -293,25 +331,39 @@ def text_of(status):
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)
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'<tr class="border-t border-slate-200 first:border-t-0 dark:border-slate-700">'
f'<th class="py-2 pr-4{indent} text-left font-medium text-slate-600 dark:text-slate-300">{escape(name.strip())}</th>'
f'<td class="py-2">{escape(value)}</td></tr>')
body = ''.join(rows)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta http-equiv="refresh" content="{REFRESH_SECONDS}">
<title>VPN Router</title>
<script src="{TAILWIND}"></script>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
<body class="min-h-screen bg-slate-100 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
<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="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
<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 class="px-6 py-4">
<table class="w-full text-sm"><tbody>{body}</tbody></table>
</div>
<p class="px-6 pb-6 text-xs text-slate-500 dark:text-slate-400">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
</div>
</div>
</body>
@@ -319,6 +371,62 @@ def html_of(status):
"""
def user_tool(*args, password=None):
"""Run vpn-router-user; the HTTP status for its exit status and its JSON answer."""
try:
result = subprocess.run([USER_TOOL, *args], input='' if password is None else password + '\n',
capture_output=True, text=True, timeout=USER_TIMEOUT)
document = json.loads(result.stdout)
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
return 500, {'error': f'vpn-router-user failed: {exc}'}
if not isinstance(document, dict):
return 500, {'error': 'vpn-router-user gave no object'}
return USER_STATUS.get(result.returncode, 500), document
def users(state, method, path, body):
"""Answer a /users request: the HTTP status and the JSON document.
GET /users the users
POST /users add a user: {"name": ..., "password": ...}
PUT /users/<name>/password set the password: {"password": ...}
POST /users/<name>/disable lock the user out of OpenVPN
POST /users/<name>/enable let the user in again
DELETE /users/<name> remove the user and revoke the certificate
GET /users/<name>/profile the OpenVPN client profile
"""
if not state.get('users'):
return 404, {'error': 'no P2S system with users is configured'}
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 {}
password = body.get('password')
if password is not None and (not isinstance(password, str) or '\n' in password or '\r' in password):
return 400, {'error': 'the password must be a string of one line'}
parts = path.strip('/').split('/')
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
if route == ('GET', 1, None):
return user_tool('list')
if route == ('POST', 1, None):
name = body.get('name')
if not isinstance(name, str) or password is None:
return 400, {'error': 'name and password are required strings'}
code, document = user_tool('add', name, password=password)
return (201 if code == 200 else code), document
if route == ('PUT', 3, 'password'):
if password is None:
return 400, {'error': 'password is a required string'}
return user_tool('passwd', parts[1], password=password)
if route in (('POST', 3, 'disable'), ('POST', 3, 'enable')):
return user_tool(parts[2], parts[1])
if route == ('DELETE', 2, None):
return user_tool('remove', parts[1])
if route == ('GET', 3, 'profile'):
return user_tool('profile', parts[1])
return 404, {'error': f'no route {method} {path}'}
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
@@ -329,19 +437,31 @@ class Handler(BaseHTTPRequestHandler):
_cache['at'] = time.monotonic()
return _cache['data']
def authorized(self):
"""Whether the request carries a token signed with the secret; False also while no secret is set."""
secret = self.server.state.get('jwt_secret')
header = self.headers.get('Authorization', '')
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None
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
# 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
protected =path == '/config' or path == '/users' or path.startswith('/users/')
if protected and not self.server.state.get('jwt_secret'):
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
elif protected and not self.authorized():
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
extra.append(('WWW-Authenticate', 'Bearer'))
elif path == '/config' and self.command in ('GET', 'HEAD'):
body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
elif protected and path != '/config':
code, document = users(self.server.state, self.command, path, self.request_body())
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 == '/':
@@ -363,12 +483,34 @@ class Handler(BaseHTTPRequestHandler):
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):
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))