This commit is contained in:
1 parent
609921348b
commit
cc6748a622
17 files changed
+1011
-63
No files matched your search
@@ -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))
|
||||
|
||||
|
||||
Reference in new issue
Block a user