diff --git a/ADR.md b/ADR.md index 62e3220..4322a10 100644 --- a/ADR.md +++ b/ADR.md @@ -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 (`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` -and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the -state of the services, the package version and the state of the site-to-site, road-warrior, -OpenVPN and WireGuard features, and nothing from the configuration. `vpn-router-setup` opens the +and as a Tailwind CSS page for a browser, and `/status` is the same status as JSON. Both show as +much as `web.status_content` says, since the router is reachable from the internet and 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 writes the features and the facts it resolves to `/run/vpn-router/web.json`. diff --git a/README.md b/README.md index 816c5e6..089f8bf 100644 --- a/README.md +++ b/README.md @@ -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` | `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` | `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` | `email` | string | | The e-mail address of the Let's Encrypt account | | `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 `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 -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 seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens. diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index d21f313..20cf7dc 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -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 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. diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 7f17291..f18a9bd 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -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) diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index fac644e..c9906e0 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -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):

VPN Router

-

Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}

+

Status: {escape(status['status'] + version)}

{body}
@@ -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 diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example index 77dbec4..84d71eb 100644 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -48,7 +48,8 @@ "listen": "both", "port": 7081, "database": "", - "trusted_proxies": [] + "trusted_proxies": [], + "status_content": "full" }, "https": { "enabled": true,