feature: Added an internally only exposed status web service.
Release / release (push) Successful in 17s
Release / release (push) Successful in 17s
This commit is contained in:
1 parent
5402256ef6
commit
7163f167e0
10 files changed
+593
No files matched your search
@@ -26,3 +26,52 @@ because `vpn-router-docker` is already active. A published
|
||||
port is reachable from the external interface only when it is in `exposed_ports`. Other rules
|
||||
in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker
|
||||
restores the rules. The rules cover IPv4 only.
|
||||
|
||||
## ADR-2: A status web service of the standard library, bound to the internal address
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The router has no way to see its state without a shell. A check from the protected
|
||||
network, by a person or by a monitor, needs a page or an endpoint. The router is an
|
||||
internet-facing host, so what the service shows and where it listens matter.
|
||||
|
||||
Decision: The `vpn-router-web` unit runs one Python script that uses only the standard library, in
|
||||
the way of a small test server. It binds to the address of the internal interface and to
|
||||
`web.port`, on by default at 8080. `/` 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 and
|
||||
WireGuard features, and nothing from the configuration. `vpn-router-setup` resolves the
|
||||
interface address, writes it with the port and the features to `/run/vpn-router/web.json`,
|
||||
opens the port on the internal interface in UFW and restarts the service, so the service does
|
||||
not need the package's configuration code, which needs Jinja2.
|
||||
|
||||
Consequences: Nothing outside the internal network can reach it, since it listens on the internal
|
||||
address only and the firewall opens the port on the internal interface only. It has no
|
||||
authentication, which is acceptable because it is read only and shows no configuration. It runs
|
||||
as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`, `ProtectSystem=strict`,
|
||||
`ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a browser on a network
|
||||
without internet shows the unstyled page, and `curl` is unaffected. A change of the internal
|
||||
address is picked up at the next run of `vpn-router-setup`.
|
||||
|
||||
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: A client such as manage-ui needs the configuration and the resolved state of the
|
||||
router, which `/` and `/status` must not show. The service has only the standard library, listens
|
||||
on plain HTTP on the internal network, and its configuration file is readable by root only.
|
||||
|
||||
Decision: `/config` needs a JWT in an `Authorization: Bearer` header. The service verifies HS256
|
||||
tokens with `hmac` and `hashlib`, with the secret `web.jwt_secret`, and accepts no other
|
||||
algorithm, because verifying RS256 or ES256 needs a library outside the standard library. The
|
||||
token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for
|
||||
each use and a leaked one expires. Without a secret the page is not served. The secrets in the
|
||||
configuration shown are masked by name (`psk`, and options with `secret`, `password`, `token` or a
|
||||
`_key` ending in the name).
|
||||
|
||||
Consequences: The client and the router share one secret, and whoever holds it can make tokens,
|
||||
so it is long and random; it is checked to have at least 32 characters. The secret is in the
|
||||
configuration file and in `/run/vpn-router/web.json`, both readable by root only. The token and
|
||||
the answer cross the network unencrypted, so the page is for a trusted internal network; HTTPS
|
||||
would need a certificate that the package does not have. The masking is by option name, so a new
|
||||
secret option needs a name that matches.
|
||||
@@ -115,6 +115,9 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `wireguard` | `listen_port` | integer | `51820` | UDP port |
|
||||
| `docker` | `mode` | string | `auto` | `auto` secures Docker when its service is installed, `on` requires it, `off` removes the rules |
|
||||
| `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two |
|
||||
| `web` | `enabled` | boolean | `true` | The status web service on the internal address |
|
||||
| `web` | `port` | integer | `8080` | TCP port of the status web service |
|
||||
| `web` | `jwt_secret` | string | | Secret, at least 32 characters, that signs the tokens for `/config`; without it `/config` is not served |
|
||||
|
||||
A minimal site-to-site configuration in `auto` mode:
|
||||
|
||||
@@ -321,6 +324,74 @@ EXTRA_LOCAL_TS = '192.0.2.53/32'
|
||||
Set `general.platform` to `example` and restart the service. The debconf question offers only the
|
||||
modules shipped with the package; the configuration file accepts any module installed.
|
||||
|
||||
## Status web service
|
||||
|
||||
The `vpn-router-web` service shows the state of the router on its internal address and
|
||||
`web.port` (8080 by default). It uses only the Python standard library, serves HTTP without
|
||||
authentication, and shows no configuration: no key, identity, address or subnet.
|
||||
|
||||
| Page | Content |
|
||||
| --- | --- |
|
||||
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
|
||||
| `/status` | The same status as JSON |
|
||||
| `/config` | The configuration with its secrets masked, and what the router resolved and runs, as JSON; needs a token |
|
||||
|
||||
The status holds the state of `vpn-router-setup`, of `strongswan` when site-to-site or road-warrior
|
||||
access is configured, and of `wg-quick@wg0` when WireGuard is; the package version; whether
|
||||
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
|
||||
(the Docker rules), which do not change `status`; whether the
|
||||
site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and
|
||||
the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when
|
||||
all these services are active, and `degraded` otherwise.
|
||||
|
||||
```bash
|
||||
curl http://10.0.0.4:8080/
|
||||
curl http://10.0.0.4:8080/status
|
||||
```
|
||||
|
||||
### The /config page
|
||||
|
||||
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
|
||||
|
||||
| Key | Content |
|
||||
| --- | --- |
|
||||
| `configuration` | `vpn-router.json` with the defaults applied and the secrets masked (`psk`, `jwt_secret`, and any option named like a password, token or key) |
|
||||
| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes |
|
||||
| `ipsec` | The output of `swanctl --list-conns` and `swanctl --list-sas`, as lists of lines |
|
||||
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
|
||||
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
|
||||
|
||||
The page needs a JWT in an `Authorization: Bearer` header, signed with HS256 by `web.jwt_secret`.
|
||||
The token must have an `exp` claim, not more than ten minutes from now (a minute of clock
|
||||
difference is allowed); `nbf` is checked when present. Other algorithms are refused, with 401 and
|
||||
`WWW-Authenticate: Bearer`. While `web.jwt_secret` is not set the page is not served (404). The
|
||||
token and the page travel over plain HTTP on the internal network, and the token can be used
|
||||
until it expires, so keep the lifetime short. A client makes a token from the secret with only
|
||||
HMAC-SHA256; with Python:
|
||||
|
||||
```python
|
||||
import base64, hashlib, hmac, json, time
|
||||
|
||||
def b64(data):
|
||||
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
|
||||
|
||||
secret = 'the web.jwt_secret value'
|
||||
head = b64(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
|
||||
body = b64(json.dumps({'exp': int(time.time()) + 300}).encode())
|
||||
sign = b64(hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest())
|
||||
print(f'{head}.{body}.{sign}')
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:8080/config
|
||||
```
|
||||
|
||||
`vpn-router-setup` opens `web.port` on the internal interface in the firewall, writes the address,
|
||||
the port, the secret and the facts for `/config` to `/run/vpn-router/web.json` (readable by root
|
||||
only) and restarts the service, so a changed `web` section
|
||||
applies with `systemctl restart vpn-router-setup`. The service ends without an error when
|
||||
`web.enabled` is `false`, and keeps trying to start until the router has an internal address.
|
||||
|
||||
## Removal
|
||||
|
||||
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
vpn-router (0.4.0-1) unstable; urgency=medium
|
||||
|
||||
* Add the vpn-router-web service, a small status web service that uses only
|
||||
the Python standard library. It listens on the internal address and on
|
||||
web.port, 8080 by default. / shows the state of the router for people,
|
||||
as plain text for curl and wget and as a Tailwind CSS page for a
|
||||
browser, and /status shows the same as JSON: the state of the services,
|
||||
the package version, whether Docker is installed, and the state of
|
||||
site-to-site, road-warrior and WireGuard, with no configuration data.
|
||||
* Add the web configuration section. web.enabled (true by default) and
|
||||
web.port. vpn-router-setup opens the port on the internal interface in
|
||||
UFW and restarts the service.
|
||||
* Add /config to the status web service, for a client such as manage-ui. It
|
||||
shows the configuration with its secrets masked, the interfaces,
|
||||
addresses, identities and routes the router resolved, the IPsec
|
||||
connections and SAs, the WireGuard peers (no keys) and the Docker rules.
|
||||
It needs a JWT signed with HS256 by web.jwt_secret, with an exp claim
|
||||
within ten minutes, and is not served while web.jwt_secret is not set.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 19:00:00 +0200
|
||||
|
||||
vpn-router (0.3.2-1) unstable; urgency=medium
|
||||
|
||||
* Accept an IPv4 or IPv6 address as remote.id and as wan.local_fqdn. The
|
||||
|
||||
@@ -4,6 +4,7 @@ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/configure usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/web usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
||||
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
override_dh_installsystemd:
|
||||
dh_installsystemd --name=vpn-router-setup
|
||||
dh_installsystemd --name=vpn-router-docker
|
||||
dh_installsystemd --name=vpn-router-web
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=VPN Router status web service
|
||||
Documentation=file:/usr/share/doc/vpn-router/README.md
|
||||
After=network-online.target vpn-router-setup.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -10,8 +10,10 @@ firewall and NAT rules are written against them, so choosing the wrong one
|
||||
would be a silent and security-relevant mistake.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -24,13 +26,18 @@ from vpnrouter import fail
|
||||
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
|
||||
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
|
||||
WG_DIR = pathlib.Path('/etc/wireguard')
|
||||
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
|
||||
|
||||
FILTER_ANCHOR = '# End required lines'
|
||||
|
||||
#: Configuration options whose values are masked wherever the configuration is shown.
|
||||
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
|
||||
|
||||
BLOCKS = (
|
||||
('# IPSEC RULES START', '# IPSEC RULES END'),
|
||||
('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
|
||||
('# P2S DNS RULES START', '# P2S DNS RULES END'),
|
||||
('# WEB RULES START', '# WEB RULES END'),
|
||||
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
|
||||
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
|
||||
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
|
||||
@@ -149,6 +156,14 @@ def filter_block(ctx):
|
||||
]
|
||||
lines += ['# P2S DNS RULES END']
|
||||
|
||||
if ctx['web_enabled'] and ctx['int_iface']:
|
||||
lines += [
|
||||
'',
|
||||
'# WEB RULES START',
|
||||
f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {ctx["web_port"]} -j ACCEPT',
|
||||
'# WEB RULES END',
|
||||
]
|
||||
|
||||
if vpnrouter.routing_ready(ctx):
|
||||
lines += ['', '# ROUTER FORWARD RULES START']
|
||||
for subnet in ctx['local_cidr_list']:
|
||||
@@ -309,6 +324,48 @@ def refresh_docker_rules():
|
||||
run(['systemctl', 'restart', 'vpn-router-docker.service'], check=False)
|
||||
|
||||
|
||||
def refresh_web(cfg, ctx):
|
||||
"""Hand the web service what it needs and restart it.
|
||||
|
||||
The service takes the address to bind to, the port, the features of the
|
||||
router, the secret that signs its tokens, the configuration with its
|
||||
secrets masked and the facts resolved from the system from a file here, so
|
||||
that it needs nothing but the standard library and the configuration code.
|
||||
The file is readable by root only. The service ends quietly when
|
||||
web.enabled is false.
|
||||
"""
|
||||
masked = {
|
||||
section: {option: '********' if SECRET_OPTION.search(option) and value else value
|
||||
for option, value in options.items()}
|
||||
for section, options in cfg.items()
|
||||
}
|
||||
WEB_STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
WEB_STATE.touch(mode=0o600)
|
||||
WEB_STATE.chmod(0o600)
|
||||
WEB_STATE.write_text(json.dumps({
|
||||
'enabled': bool(ctx['web_enabled']),
|
||||
'bind': ctx['int_addr'],
|
||||
'port': ctx['web_port'],
|
||||
'jwt_secret': cfg['web']['jwt_secret'],
|
||||
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
|
||||
'p2s': bool(vpnrouter.p2s_ready(ctx)),
|
||||
'wireguard': bool(vpnrouter.wg_ready(ctx)),
|
||||
'configuration': masked,
|
||||
'network': {
|
||||
'external_interface': ctx['wan_iface'],
|
||||
'internal_interface': ctx['int_iface'],
|
||||
'external_addresses': ctx['local_addr_list'],
|
||||
'internal_address': ctx['int_addr'],
|
||||
'internal_gateway': ctx['int_gateway_ip'],
|
||||
'local_identity': ctx['local_id'],
|
||||
'remote_identity': ctx['remote_id'],
|
||||
'local_cidrs': ctx['local_cidr_list'],
|
||||
'remote_cidrs': ctx['remote_cidr_list'],
|
||||
},
|
||||
}) + '\n', encoding='utf-8')
|
||||
run(['systemctl', 'restart', 'vpn-router-web.service'], check=False)
|
||||
|
||||
|
||||
def main():
|
||||
cfg = vpnrouter.load_config()
|
||||
ctx, changed = vpnrouter.configure(cfg)
|
||||
@@ -348,6 +405,7 @@ def main():
|
||||
|
||||
run_platform_setup(cfg, ctx)
|
||||
refresh_docker_rules()
|
||||
refresh_web(cfg, ctx)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -48,8 +48,12 @@ SCHEMA = {
|
||||
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
||||
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
|
||||
'docker': {'mode': 'auto', 'exposed_ports': []},
|
||||
'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''},
|
||||
}
|
||||
|
||||
#: The shortest secret that signs the tokens of the status web service.
|
||||
WEB_SECRET_MIN = 32
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
|
||||
#: A port or a port range as iptables multiport takes it: 443 or 8000:8100.
|
||||
@@ -228,6 +232,14 @@ def validate(cfg):
|
||||
if not 1 <= port <= 65535:
|
||||
raise ConfigError(f'wireguard.listen_port: {port} is not a valid port')
|
||||
|
||||
web_port = _get(cfg, 'web', 'port')
|
||||
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
|
||||
raise ConfigError(f'web.port: {web_port!r} is not a valid port')
|
||||
|
||||
web_secret = _get(cfg, 'web', 'jwt_secret')
|
||||
if web_secret and len(web_secret) < WEB_SECRET_MIN:
|
||||
raise ConfigError(f'web.jwt_secret: use at least {WEB_SECRET_MIN} characters')
|
||||
|
||||
docker_mode = _get(cfg, 'docker', 'mode')
|
||||
if docker_mode not in DOCKER_MODES:
|
||||
raise ConfigError(
|
||||
@@ -465,6 +477,8 @@ def build_context(cfg, local_id_override=None):
|
||||
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
|
||||
'wg_address': _get(cfg, 'wireguard', 'address'),
|
||||
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
|
||||
'web_enabled': _get(cfg, 'web', 'enabled'),
|
||||
'web_port': _get(cfg, 'web', 'port'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/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.
|
||||
|
||||
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
|
||||
|
||||
/ 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
|
||||
WireGuard. No configuration data, key or address is shown.
|
||||
|
||||
The address to bind to, the port, the secret and the configuration shown by /config
|
||||
are not read from the configuration here: vpn-router-setup resolves them, with the
|
||||
interfaces, and writes them to /run/vpn-router/web.json, readable by root only, before
|
||||
it restarts this service. The service ends with
|
||||
success when the file says the service is disabled, and with failure, to be
|
||||
started again, when the file is missing or the router has no internal address yet.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from html import escape
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
STATE_FILE = '/run/vpn-router/web.json'
|
||||
COMMAND_TIMEOUT = 5
|
||||
CACHE_SECONDS = 2
|
||||
|
||||
#: Seconds of clock difference accepted between a token and this router.
|
||||
LEEWAY = 60
|
||||
|
||||
#: The longest a token may be valid, from now, in seconds.
|
||||
MAX_LIFETIME = 600
|
||||
|
||||
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
|
||||
SA_LINE = re.compile(r'^(\S+): #\d+, (\w+),')
|
||||
|
||||
_cache = {'at': 0.0, 'data': None}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def run(*args):
|
||||
"""The finished process of a command, or None when it cannot be run."""
|
||||
try:
|
||||
return subprocess.run(args, capture_output=True, text=True, timeout=COMMAND_TIMEOUT)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def unit_state(unit):
|
||||
result = run('systemctl', 'is-active', unit)
|
||||
return result.stdout.strip() if result and result.stdout.strip() else 'unknown'
|
||||
|
||||
|
||||
def package_version():
|
||||
result = run('dpkg-query', '-W', '-f=${Version}', 'vpn-router')
|
||||
return result.stdout.strip() if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def docker_status():
|
||||
"""Whether Docker is installed, and the state of its service and of the Docker rules when it is."""
|
||||
result = run('systemctl', 'list-unit-files', '--no-legend', 'docker.service')
|
||||
if result is None or result.returncode != 0 or not result.stdout.strip():
|
||||
return {'installed': False}
|
||||
return {
|
||||
'installed': True,
|
||||
'service': unit_state('docker.service'),
|
||||
'rules': unit_state('vpn-router-docker.service'),
|
||||
}
|
||||
|
||||
|
||||
def established(connection):
|
||||
"""The number of IKE SAs of the connection, or None when swanctl cannot be asked."""
|
||||
result = run('swanctl', '--list-sas')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
return sum(1 for line in result.stdout.splitlines()
|
||||
if (match := SA_LINE.match(line)) and match.group(1) == connection and match.group(2) == 'ESTABLISHED')
|
||||
|
||||
|
||||
def wireguard_peers():
|
||||
"""One entry per WireGuard peer, with its handshake age and traffic, or None when wg0 is down."""
|
||||
result = run('wg', 'show', 'wg0', 'dump')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
now = int(time.time())
|
||||
peers = []
|
||||
for line in result.stdout.splitlines()[1:]:
|
||||
fields = line.split('\t')
|
||||
if len(fields) < 7:
|
||||
continue
|
||||
handshake = int(fields[4])
|
||||
peers.append({
|
||||
'handshake_seconds_ago': now - handshake if handshake else None,
|
||||
'received_bytes': int(fields[5]),
|
||||
'sent_bytes': int(fields[6]),
|
||||
})
|
||||
return peers
|
||||
|
||||
|
||||
def collect(state):
|
||||
"""The status document. state says which features the router has."""
|
||||
units = {'vpn-router-setup': unit_state('vpn-router-setup.service')}
|
||||
if state.get('site_to_site') or state.get('p2s'):
|
||||
units['strongswan'] = unit_state('strongswan.service')
|
||||
if state.get('wireguard'):
|
||||
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
|
||||
|
||||
status = {
|
||||
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
|
||||
'version': package_version(),
|
||||
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
'units': units,
|
||||
'docker': docker_status(),
|
||||
}
|
||||
if state.get('site_to_site'):
|
||||
count = established('remote-site')
|
||||
status['site_to_site'] = {'established': None if count is None else count > 0}
|
||||
if state.get('p2s'):
|
||||
status['p2s'] = {'clients': established('road-warrior')}
|
||||
if state.get('wireguard'):
|
||||
status['wireguard'] = {'peers': wireguard_peers()}
|
||||
return status
|
||||
|
||||
|
||||
def b64decode(part):
|
||||
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
|
||||
|
||||
|
||||
def verify(token, secret):
|
||||
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
|
||||
|
||||
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token
|
||||
must expire (exp), and not later than MAX_LIFETIME seconds from now, so that a
|
||||
stolen token is of little use; LEEWAY seconds of clock difference are allowed.
|
||||
"""
|
||||
try:
|
||||
header, payload, signature = token.split('.')
|
||||
if json.loads(b64decode(header)).get('alg') != 'HS256':
|
||||
return None
|
||||
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(expected, b64decode(signature)):
|
||||
return None
|
||||
claims = json.loads(b64decode(payload))
|
||||
now = time.time()
|
||||
expires = claims['exp']
|
||||
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not now - LEEWAY <= expires <= now + MAX_LIFETIME + LEEWAY:
|
||||
return None
|
||||
not_before = claims.get('nbf')
|
||||
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
|
||||
return None
|
||||
except (ValueError, KeyError, TypeError, AttributeError):
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
def lines_of(*args):
|
||||
"""The output lines of a command, or None when it fails."""
|
||||
result = run(*args)
|
||||
return result.stdout.splitlines() if result and result.returncode == 0 else None
|
||||
|
||||
|
||||
def wireguard_details():
|
||||
"""The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
|
||||
result = run('wg', 'show', 'wg0', 'dump')
|
||||
if result is None or result.returncode != 0:
|
||||
return None
|
||||
rows = [line.split('\t') for line in result.stdout.splitlines()]
|
||||
now = int(time.time())
|
||||
return {
|
||||
'public_key': rows[0][1] if rows and len(rows[0]) > 2 else None,
|
||||
'listen_port': int(rows[0][2]) if rows and len(rows[0]) > 2 else None,
|
||||
'peers': [{
|
||||
'public_key': row[0],
|
||||
'endpoint': None if row[2] == '(none)' else row[2],
|
||||
'allowed_ips': row[3].split(','),
|
||||
'handshake_seconds_ago': now - int(row[4]) if int(row[4]) else None,
|
||||
'received_bytes': int(row[5]),
|
||||
'sent_bytes': int(row[6]),
|
||||
} for row in rows[1:] if len(row) >= 7],
|
||||
}
|
||||
|
||||
|
||||
def details(state):
|
||||
"""What /config shows: the configuration without its secrets, and what the router resolved and runs."""
|
||||
configuration = state.get('configuration', {})
|
||||
return {
|
||||
'configuration': configuration,
|
||||
'network': dict(state.get('network', {}), routes=lines_of('ip', '-4', 'route', 'show')),
|
||||
'ipsec': {
|
||||
'connections': lines_of('swanctl', '--list-conns'),
|
||||
'sas': lines_of('swanctl', '--list-sas'),
|
||||
},
|
||||
'wireguard': wireguard_details(),
|
||||
'docker': {
|
||||
'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []),
|
||||
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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 += [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"]}')
|
||||
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"}')
|
||||
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 'wireguard' in status:
|
||||
peers = status['wireguard']['peers']
|
||||
if peers is None:
|
||||
lines.append('WireGuard: down')
|
||||
else:
|
||||
lines.append(f'WireGuard: {len(peers)} peers')
|
||||
for number, peer in enumerate(peers, 1):
|
||||
age = peer['handshake_seconds_ago']
|
||||
lines.append(f' peer {number}: {"no handshake yet" if age is None else f"handshake {age} s ago"}, '
|
||||
f'received {peer["received_bytes"]} B, sent {peer["sent_bytes"]} B')
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
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)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VPN Router</title>
|
||||
<script src="{TAILWIND}"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-900">
|
||||
<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="{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>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def status(self):
|
||||
with _lock:
|
||||
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
|
||||
_cache['data'] = collect(self.server.state)
|
||||
_cache['at'] = time.monotonic()
|
||||
return _cache['data']
|
||||
|
||||
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
|
||||
elif path == '/status':
|
||||
body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200
|
||||
elif path == '/':
|
||||
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'
|
||||
else:
|
||||
body, kind = html_of(self.status()).encode(), 'text/html; charset=utf-8'
|
||||
code = 200
|
||||
else:
|
||||
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', kind)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
for name, value in extra:
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
self.answer(True)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.answer(False)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
with open(STATE_FILE, encoding='utf-8') as handle:
|
||||
state = json.load(handle)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f'vpn-router-web: cannot read {STATE_FILE}: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not state.get('enabled'):
|
||||
print('vpn-router-web: the web service is disabled in the configuration')
|
||||
return 0
|
||||
if not state.get('bind'):
|
||||
print('vpn-router-web: the router has no internal address yet', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
server = ThreadingHTTPServer((state['bind'], state['port']), Handler)
|
||||
server.state = state
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||
print(f'vpn-router-web: serving on http://{state["bind"]}:{state["port"]}')
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -36,5 +36,10 @@
|
||||
"docker": {
|
||||
"mode": "auto",
|
||||
"exposed_ports": ["443"]
|
||||
},
|
||||
"web": {
|
||||
"enabled": true,
|
||||
"port": 8080,
|
||||
"jwt_secret": ""
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user