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
+100 -24

No files matched your search

+9 -3
View File
@@ -42,9 +42,15 @@ the package depends on, and no web framework. It listens on `web.port`, 7081 by
address of the internal interface, of the external interface or of both, as `web.listen` sets address of the internal interface, of the external interface or of both, as `web.listen` sets
(`internal`, `external` or `both`, the default), with a server for each address, and is on (`internal`, `external` or `both`, the default), with a server for each address, and is on
unless `web.enabled` is `false` (ADR-7). `/` is for people, in plain text for `curl` and `wget` unless `web.enabled` is `false` (ADR-7). `/` is for people, in plain text for `curl` and `wget`
and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. Both show as
state of the services, the package version and the state of the site-to-site, road-warrior, much as `web.status_content` says, since the router is reachable from the internet and the
OpenVPN and WireGuard features, and nothing from the configuration. `vpn-router-setup` opens the operator decides how much it tells strangers: `basic` the overall status and the Client IP,
`standard` also the package versions and the number of active and other services, and `full`,
the default, also each service and the state of the site-to-site, road-warrior, OpenVPN and
WireGuard features. The Client IP is the address the request came from, through the trusted
proxies, so a person can see the address the router sees. Nothing comes from the configuration.
`/status` names its content, so clients such as manage-ui and the Router Manager show what it
has. `vpn-router-setup` opens the
port on both interfaces in UFW, so the choice of `web.listen` is made by the service alone, and port on both interfaces in UFW, so the choice of `web.listen` is made by the service alone, and
writes the features and the facts it resolves to `/run/vpn-router/web.json`. writes the features and the facts it resolves to `/run/vpn-router/web.json`.
+15 -2
View File
@@ -127,7 +127,8 @@ must have the type shown below; an unknown section or setting is an error.
| `web` | `listen` | string | `both` | The interfaces the status web service listens on: `internal`, `external` or `both` | | `web` | `listen` | string | `both` | The interfaces the status web service listens on: `internal`, `external` or `both` |
| `web` | `port` | integer | `7081` | TCP port of the status web service | | `web` | `port` | integer | `7081` | TCP port of the status web service |
| `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates | | `web` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
| `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit | | `web` | `trusted_proxies` | list | the address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
| `web` | `status_content` | string | `full` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` | | `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account | | `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service | | `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
@@ -500,7 +501,19 @@ modules shipped with the package; the configuration file accepts any module inst
The `vpn-router-web` service shows the state of the router on the addresses of the interfaces The `vpn-router-web` service shows the state of the router on the addresses of the interfaces
`web.listen` names, the internal, the external or both (the default), and on `127.0.0.1`, where `web.listen` names, the internal, the external or both (the default), and on `127.0.0.1`, where
Caddy calls it (see HTTPS), with `web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no Caddy calls it (see HTTPS), with `web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no
configuration: no key, identity, address or subnet. configuration: no key, identity, subnet, or address but the client's. They show as much as
`web.status_content` says, read at each request:
| `status_content` | `/` and `/status` show |
|---|---|
| `basic` | The overall status (`ok` or `degraded`), the time and the Client IP |
| `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names |
| `full` (default) | Also each service and its state, Docker, and the state of site-to-site, road-warrior, OpenVPN and WireGuard |
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
request from one of `web.trusted_proxies`, by default the address the request came in on, so
behind Caddy or a Router Manager on the router it is the browser's. `/status` names the content in
`content` and the Client IP in `client_ip`; `/` keeps the time and the link to `/status`.
The service first makes its signing key and the refresh tokens' database, retrying every 15 The service first makes its signing key and the refresh tokens' database, retrying every 15
seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens. seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens.
+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 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. 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}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []}, '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}, '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. #: The port of Let's Encrypt's HTTP challenge, which Caddy answers while HTTPS is on.
ACME_HTTP_PORT = 80 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') DOCKER_MODES = ('auto', 'on', 'off')
OPENVPN_PROTOCOLS = ('udp', 'tcp') OPENVPN_PROTOCOLS = ('udp', 'tcp')
@@ -268,6 +272,11 @@ def validate(cfg):
raise ConfigError( raise ConfigError(
f'web.listen: {web_listen!r} is not one of {", ".join(LISTEN_CHOICES)}') 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 = [] https_ports = []
for option in ('web_port', 'manager_port'): for option in ('web_port', 'manager_port'):
port = _get(cfg, 'https', option) port = _get(cfg, 'https', option)
+49 -17
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 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. 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 / and /status show only the status, as much as web.status_content says (see shown): basic the
installed version of vpn-router-manager (manager_version, null when it is not installed), whether overall status, the time and the client's address (its address as for the rate limit below);
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and standard also the package version, the installed version of vpn-router-manager (manager_version,
WireGuard. No configuration data, key or address is shown. 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 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 tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
@@ -289,6 +292,26 @@ def collect(state):
return status 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): def lines_of(*args):
"""The output lines of a command, or None when it fails.""" """The output lines of a command, or None when it fails."""
result = run(*args) result = run(*args)
@@ -346,10 +369,17 @@ def details(state):
def text_of(status): def text_of(status):
"""The status for people, as plain text.""" """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"]}', f'Version: {status["version"] or "unknown"}', ''] 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"}') lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
lines += [f'{name}: {value}' for name, value in status['units'].items()] 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'] docker = status['docker']
lines.append('Docker: not installed' if not docker['installed'] lines.append('Docker: not installed' if not docker['installed']
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}') else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
@@ -376,11 +406,12 @@ def text_of(status):
def html_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' 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 = [] rows = []
for line in text_of(status).splitlines()[4:]: for line in text_of(status).split('\n\n', 1)[1].splitlines():
if not line: if not line:
continue continue
name, _, value = line.partition(': ') 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="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"> <div class="{colour} px-6 py-4 text-white">
<h1 class="text-xl font-semibold">VPN Router</h1> <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>
<div class="px-6 py-4"> <div class="px-6 py-4">
<table class="w-full text-sm"><tbody>{body}</tbody></table> <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' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif self.command not in ('GET', 'HEAD'): elif self.command not in ('GET', 'HEAD'):
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
elif path == '/status': elif path in ('/status', '/'):
body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200 document = shown(self.status(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
elif path == '/':
agent = self.headers.get('User-Agent', '').lower() agent = self.headers.get('User-Agent', '').lower()
if 'curl' in agent or 'wget' in agent: if path == '/status':
body, kind = text_of(self.status()).encode(), 'text/plain; charset=utf-8' 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: 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 code = 200
else: else:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
@@ -48,7 +48,8 @@
"listen": "both", "listen": "both",
"port": 7081, "port": 7081,
"database": "", "database": "",
"trusted_proxies": [] "trusted_proxies": [],
"status_content": "full"
}, },
"https": { "https": {
"enabled": true, "enabled": true,