feature: the status page shows the Client IP, and web.status_content sets how much it shows.
Release / release (push) Successful in 12s

This commit is contained in:
slawek committed 2026-10-25 13:08:08 +01:00
1 parent 45d73abe7b
commit dbc2046e24
6 files changed
+104 -28

No files matched your search

+15
View File
@@ -1,3 +1,18 @@
vpn-router (0.14.0-1) unstable; urgency=medium
feature: the status page shows the Client IP, and web.status_content sets how much it shows.
* / and /status show the Client IP, the address the request came from:
the last X-Forwarded-For entry for a request from a trusted proxy, so
behind Caddy or the Router Manager the browser's; /status has it as
client_ip.
* web.status_content sets what / and /status show: basic the overall
status, the time and the Client IP; standard also the package
versions and the number of active and failed services; full, the
default, everything as before. /status names it in content.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 16:00:00 +0200
vpn-router (0.13.0-1) unstable; urgency=medium
feature: Caddy serves the status page and the Router Manager over HTTPS with a certificate from Let's Encrypt.
@@ -54,13 +54,17 @@ SCHEMA = {
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
'status_content': 'full'},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443},
}
#: The port of Let's Encrypt's HTTP challenge, which Caddy answers while HTTPS is on.
ACME_HTTP_PORT = 80
#: What / and /status of the web service show, web.status_content, from the least to the most.
STATUS_CONTENTS = ('basic', 'standard', 'full')
DOCKER_MODES = ('auto', 'on', 'off')
OPENVPN_PROTOCOLS = ('udp', 'tcp')
@@ -268,6 +272,11 @@ def validate(cfg):
raise ConfigError(
f'web.listen: {web_listen!r} is not one of {", ".join(LISTEN_CHOICES)}')
status_content = _get(cfg, 'web', 'status_content')
if status_content not in STATUS_CONTENTS:
raise ConfigError(
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
https_ports = []
for option in ('web_port', 'manager_port'):
port = _get(cfg, 'https', option)
+53 -21
View File
@@ -29,10 +29,13 @@ served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
default the address the call came in on, is the last entry of its X-Forwarded-For header.
/ and /status show only the status: whether the services run, the package version and the
installed version of vpn-router-manager (manager_version, null when it is not installed), whether
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.
/ and /status show only the status, as much as web.status_content says (see shown): basic the
overall status, the time and the client's address (its address as for the rate limit below);
standard also the package version, the installed version of vpn-router-manager (manager_version,
null when it is not installed) and the number of active and other services; full, the default,
whether each service runs, whether Docker is installed, and the state of site-to-site, road-warrior
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
client's.
The service first initializes, without listening: it makes the signing key and the refresh
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
@@ -289,6 +292,26 @@ def collect(state):
return status
def shown(status, content, client):
"""The status document as web.status_content shows it to the client at its address.
Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing
more; standard adds version, manager_version and services, the number of active units and of units in every
other state; full adds the whole status document of collect. Content that is not one of
vpnrouter.STATUS_CONTENTS is full.
"""
if content not in vpnrouter.STATUS_CONTENTS:
content = 'full'
if content == 'full':
return {**status, 'content': content, 'client_ip': client}
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
if content == 'standard':
active = sum(1 for value in status['units'].values() if value == 'active')
document.update(version=status['version'], manager_version=status['manager_version'],
services={'active': active, 'failed': len(status['units']) - active})
return document
def lines_of(*args):
"""The output lines of a command, or None when it fails."""
result = run(*args)
@@ -346,13 +369,20 @@ def details(state):
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.append(f'Router Manager: {status["manager_version"] or "not installed"}')
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"]}')
"""The status document of shown for people, as plain text: a header, a blank line, then name: value lines."""
lines = ['VPN Router', f'Status: {status["status"]}']
if 'version' in status:
lines.append(f'Version: {status["version"] or "unknown"}')
lines += ['', f'Client IP: {status["client_ip"]}']
if 'manager_version' in status:
lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
if 'services' in status:
lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
if 'docker' in status:
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"}')
@@ -376,11 +406,12 @@ def text_of(status):
def html_of(status):
"""The status for a browser, as a page styled with Tailwind CSS."""
"""The status document of shown for a browser, as a page styled with Tailwind CSS."""
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.
version = f', version {status["version"] or "unknown"}' if 'version' in status else ''
# Each "name: value" line after the header 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:]:
for line in text_of(status).split('\n\n', 1)[1].splitlines():
if not line:
continue
name, _, value = line.partition(': ')
@@ -404,7 +435,7 @@ def html_of(status):
<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>
<p class="mt-1 text-sm">Status: {escape(status['status'] + version)}</p>
</div>
<div class="px-6 py-4">
<table class="w-full text-sm"><tbody>{body}</tbody></table>
@@ -718,14 +749,15 @@ class Handler(BaseHTTPRequestHandler):
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 == '/':
elif path in ('/status', '/'):
document = shown(self.status(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
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'
if path == '/status':
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif 'curl' in agent or 'wget' in agent:
body, kind = text_of(document).encode(), 'text/plain; charset=utf-8'
else:
body, kind = html_of(self.status()).encode(), 'text/html; charset=utf-8'
body, kind = html_of(document).encode(), 'text/html; charset=utf-8'
code = 200
else:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
@@ -48,7 +48,8 @@
"listen": "both",
"port": 7081,
"database": "",
"trusted_proxies": []
"trusted_proxies": [],
"status_content": "full"
},
"https": {
"enabled": true,