Compare commits

...
5 Commits
Author SHA1 Message Date
slawek 0bf78b16b1 feature: users and their certificates no longer need a P2S system enabled.
Release / release (push) Successful in 21s
2026-10-24 00:00:44 +02:00
slawek 0ff74cc8c9 feature: the firewall lets the Router Manager in on the internal interface.
Release / release (push) Successful in 11s
2026-10-23 23:24:59 +02:00
slawek a7b29e0655 fix: the status always shows the state of slapd.
Release / release (push) Successful in 19s
2026-10-23 22:59:17 +02:00
slawek c377801be0 fix: /config no longer shows secret options.
Release / release (push) Successful in 11s
2026-10-23 22:48:31 +02:00
slawek a4cffb67e4 fix: the web service works in every state of the router.
Release / release (push) Successful in 11s
2026-10-23 22:35:53 +02:00
13 changed files with 552 additions and 194 deletions

No files matched your search

+86 -2
View File
@@ -29,7 +29,7 @@ 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
Status: Accepted; amended by ADR-7
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
@@ -55,7 +55,7 @@ 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
Status: Accepted; amended by ADR-7 and ADR-8
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
@@ -148,3 +148,87 @@ Consequences: The web service still imports nothing outside the standard library
program serves the command line and the API. Each request starts a process, which is fine for
user administration. The API is protected by the token of `/config`, so whoever has the secret
can manage users.
## ADR-7: The web service starts on its own and reads its inputs at each request
Status: Accepted
Context: The web service took the address to listen on, the port, the token secret and the
features from `/run/vpn-router/web.json`, written by `vpn-router-setup`, and ended with a failure
while the file or the internal address was missing. It was ordered after `vpn-router-setup`, so a
problem in the setup kept the status page, the tool to see that problem, from running.
Decision: The service depends on nothing to start and does not end on its own. A discovery
thread works out, every 30 seconds, the internal address from `vpn-router.json` as
`general.mode` directs, with the discovery code in `vpnrouter_net.py`, which uses only the
standard library and which `vpnrouter.py` uses too, and `web.port` and `web.enabled` from the
same file. It runs the HTTP server in a thread of its own at that address and port, stops it and
starts a new one when they change, and runs none while the service is disabled, the address is
not known or it cannot be bound. Each request reads the token secret from `vpn-router.json` and
the features and resolved facts from `web.json`, which setup still writes, because they need the
configuration code; while that file is missing the router shows no features. The unit is not
ordered after `vpn-router-setup`, and setup does not restart it.
Consequences: The status page answers as soon as the internal address exists, whatever state
setup is in, and follows a changed address, port or `web.enabled` within 30 seconds without a
restart. `web.json` no longer holds the address, the port or the secret, so the secret is only in
`vpn-router.json`. A change of the secret applies to the next request. The interfaces are read
every 30 seconds, which costs a few `ip` commands.
## ADR-8: /config leaves secret options out
Status: Accepted
Context: `/config` showed the secret options of the configuration with their values replaced by
`********`. That still tells a reader which secrets are set, and a secret has no use outside the
router, so a client never needs it.
Decision: The configuration that `vpn-router-setup` writes for the web service has no secret
options at all. They are recognised by name as before: `psk`, and options with `secret`,
`password`, `token` or a `_key` ending in the name.
Consequences: No secret reaches the web service's state file or any answer of the API. A client
cannot tell whether a secret is set. A new secret option still needs a name that matches.
## ADR-9: The Router Manager is a separate package with a small Python server
Status: Accepted
Context: The router needs a web application to manage it, which may run on the router or on
another Linux system. The router's web service needs a token signed with `web.jwt_secret` for
`/config` and `/users` and sends no CORS headers, so a browser cannot call it with the secret
kept away from the page.
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository `linux-cloud-router-manager`. A React and
MUI application in the style of manage-ui is built with Vite when the package is built and
shipped as static files. A Python server, using the Ubuntu packages `python3-jwt` and `python3-requests`, serves them and
passes every `/api/<path>` call to the router's `/<path>`, signing a short-lived token with the
router's secret; it holds no logic of the router's own. It reads its settings from
`/etc/vpn-router-manager/manager.json` at each request; on the router the settings not given
come from `vpn-router.json`, and it listens on the internal address like the status web
service, with the same discovery thread. `vpn-router-setup` opens the manager's port on the
internal interface while the manager is installed, so the firewall stays in one place. The
manager asks for no authentication yet.
Consequences: The router needs no Node.js; the package needs only `python3` and packages of the Ubuntu archive. The secret never
reaches the browser. The application checks the shape of every answer of the router. Anyone who
reaches the manager's port manages the router, so on another system it listens on `127.0.0.1`
unless told otherwise. The router's firewall depends on whether the manager is installed, and a
changed manager port needs `vpn-router-setup` to run again.
## ADR-10: Users and their certificates do not depend on a P2S system being enabled
Status: Accepted
Context: The user directory and the CA were provisioned, and `/users` was served, only while
OpenVPN or road-warrior access was enabled. Users could not be prepared before a P2S system was
switched on, and the Router Manager could not manage them on a router without one.
Decision: Once a platform is chosen, `vpn-router-setup` always provisions the user directory in
slapd and the CA with its CRL; the server certificate follows once `wan.local_fqdn` is set.
`/users` and `vpn-router-user` rely only on the directory and the CA. Only the OpenVPN client
profile needs OpenVPN to be enabled.
Consequences: slapd always holds the directory and the CA always exists on a configured router,
even when no P2S system uses them. An operator who wants a CA of their own must place it before
choosing the platform.
+33 -21
View File
@@ -272,12 +272,12 @@ ca_cert.pem ca_key.pem ca_bundle.pem <label>_cert.pem <label>_key.pem crl.p
`<label>` is the first component of the router FQDN, so `router.example.com` gives
`router_cert.pem`.
If road-warrior or OpenVPN access is enabled and this directory is empty, a certificate
authority, a server certificate and a CRL valid for ten years are created there
automatically; a CA without a CRL gets one. User certificates are issued by
`vpn-router-user` into `users/`. To use your own instead, place the
files in that layout before enabling the feature; nothing is generated or overwritten when
material is already present.
Once a platform is chosen and while this directory is empty, a certificate authority and a CRL
valid for ten years are created there automatically, whether or not road-warrior or OpenVPN
access is enabled. A CA of the router's own (with `ca_key.pem`) without a CRL gets one, and
without a server certificate gets one once `wan.local_fqdn` is set. User certificates are issued
by `vpn-router-user` into `users/`. To use your own CA instead, place the files in that layout
before choosing the platform; nothing present is overwritten.
Either way, the package copies what each consumer needs into place: the CA into
`/etc/swanctl/x509ca/`, the server certificate and key into `/etc/swanctl/x509/` and
@@ -314,8 +314,9 @@ certificate names:
### The user directory
While OpenVPN or road-warrior access is configured, `vpn-router-setup` keeps a directory of
users in the local slapd:
Once a platform is chosen, `vpn-router-setup` keeps a directory of users in the local slapd,
whether or not OpenVPN or road-warrior access is enabled, so users can be managed before either
is switched on:
- its own database, `dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router`, next to the one the
slapd package creates; root manages it through the local socket, with no administrator
@@ -328,7 +329,7 @@ users in the local slapd:
(`pwdAccountLockedTime`);
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`).
The directory is kept when OpenVPN and road-warrior access are switched off, and on purge.
The directory is kept on purge.
### Managing users
@@ -413,12 +414,12 @@ authentication, and shows no configuration: no key, identity, address or subnet.
| --- | --- |
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (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 |
| `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token |
| `/users` | The users of OpenVPN and road-warrior access; needs a token |
The status holds the state of `vpn-router-setup`, of `strongswan` when site-to-site or road-warrior
access is configured, of `wg-quick@wg0` when WireGuard is, of `openvpn-server@vpn-router` when
OpenVPN is, and of `slapd` when either OpenVPN or road-warrior access is; the number of connected
The status holds the state of `vpn-router-setup` and `slapd`, of `strongswan` when site-to-site or
road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
`openvpn-server@vpn-router` when OpenVPN is; the number of connected
OpenVPN clients, from the server's status file, which OpenVPN rewrites every minute; 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
@@ -437,7 +438,7 @@ curl http://10.0.0.4:8080/status
| 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) |
| `configuration` | `vpn-router.json` with the defaults applied, without its secret options (`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 as `ip -j -4 route` prints them |
| `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values |
| `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 |
@@ -484,8 +485,8 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
exist, 409 for a user that exists and 500 otherwise. While neither OpenVPN nor road-warrior
access is configured, `/users` answers 404. Passwords and private keys cross the internal
exist, 409 for a user that exists and 500 otherwise. `/users` works whether or not OpenVPN or
road-warrior access is enabled; the profile needs OpenVPN. Passwords and private keys cross the internal
network in plain HTTP, like the token.
```bash
@@ -493,11 +494,22 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:8080/users
```
`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.
The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs,
and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port
changes, and stops listening while `web.enabled` is `false` or the address is not known. Each
request reads the token secret from `vpn-router.json`, and the features and the facts for
`/config` from `/run/vpn-router/web.json` (readable by root only), which `vpn-router-setup` writes;
before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the
internal interface in the firewall.
## Router Manager
The `vpn-router-manager` package, built in the repository `linux-cloud-router-manager`, is a web
application that shows the state of the router and manages its users through the status web
service. It can be installed on the router or on another Linux system. While it is installed on
the router, `vpn-router-setup` opens its port (`port` in `/etc/vpn-router-manager/manager.json`,
8090 by default) on the internal interface; restart `vpn-router-setup` after changing it.
## Removal
View File
Whitespace-only changes.
+62
View File
@@ -1,3 +1,65 @@
vpn-router (0.5.5-1) unstable; urgency=medium
feature: users and their certificates no longer need a P2S system enabled.
* vpn-router-setup provisions the user directory, the CA and its CRL once
a platform is chosen, whether or not OpenVPN or road-warrior access is
enabled. The server certificate is issued once wan.local_fqdn is set,
also for an existing CA that has none.
* /users and vpn-router-user manage users and issue and revoke their
certificates without a P2S system; only the OpenVPN client profile
needs OpenVPN.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 16:00:00 +0200
vpn-router (0.5.4-1) unstable; urgency=medium
feature: the firewall lets the Router Manager in on the internal interface.
* While vpn-router-manager is installed, vpn-router-setup opens its port,
port in /etc/vpn-router-manager/manager.json or 8090, on the internal
interface. Purging the package removes the rule.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 13:00:00 +0200
vpn-router (0.5.3-1) unstable; urgency=medium
fix: the status always shows the state of slapd.
* /status and the status page show the state of slapd whether or not
OpenVPN or road-warrior access is configured, since the package always
runs it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 12:00:00 +0200
vpn-router (0.5.2-1) unstable; urgency=medium
fix: /config no longer shows secret options.
* The configuration shown by /config leaves out the secret options (psk,
jwt_secret, and options named like a password, token or key) instead of
showing them masked.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 11:00:00 +0200
vpn-router (0.5.1-1) unstable; urgency=medium
fix: the web service works in every state of the router.
* vpn-router-web works whether /etc/vpn-router/pki and
/etc/swanctl/x509crl exist or not; they exist only while OpenVPN or
road-warrior access is configured. The paths the unit may write are
used when they exist and ignored when they do not.
* vpn-router-web starts on its own and keeps running. Every 30 seconds a
discovery thread works out the internal address from vpn-router.json as
general.mode directs, with web.port and web.enabled, and starts, moves
or stops the server thread to match. Each request reads the token
secret, the features and the facts it shows. The service is no longer
ordered after vpn-router-setup, which no longer restarts it, and
web.json no longer holds the address, the port or the secret.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 10:00:00 +0200
vpn-router (0.5.0-1) unstable; urgency=medium
* OpenVPN P2S access, set in the openvpn section (enabled, address_pool,
+1
View File
@@ -1,6 +1,7 @@
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_ldap.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_net.py usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/
src/usr/sbin/vpn-router-user usr/sbin/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
+2 -2
View File
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
tmp="$(mktemp)"
awk '
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
skip = 1; pending = 0; next
}
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
skip = 0; next
}
skip { next }
@@ -1,7 +1,7 @@
[Unit]
Description=VPN Router status web service
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target vpn-router-setup.service
After=network-online.target
Wants=network-online.target
[Service]
@@ -11,7 +11,7 @@ Restart=on-failure
RestartSec=15
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/run/vpn-router /etc/vpn-router/pki /etc/swanctl/x509crl
ReadWritePaths=-/run/vpn-router -/etc/vpn-router/pki -/etc/swanctl/x509crl
ProtectHome=yes
PrivateTmp=yes
+37 -27
View File
@@ -31,9 +31,14 @@ UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard')
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
#: The Router Manager, vpn-router-manager, when it is installed, and its settings.
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
MANAGER_PORT = 8090
FILTER_ANCHOR = '# End required lines'
#: Configuration options whose values are masked wherever the configuration is shown.
#: Configuration options left out wherever the configuration is shown.
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
BLOCKS = (
@@ -42,6 +47,7 @@ BLOCKS = (
('# OPENVPN RULES START', '# OPENVPN RULES END'),
('# P2S DNS RULES START', '# P2S DNS RULES END'),
('# WEB RULES START', '# WEB RULES END'),
('# MANAGER RULES START', '# MANAGER 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'),
@@ -129,6 +135,15 @@ def is_configured(ctx):
vpnrouter.openvpn_ready(ctx)))
def manager_port():
"""The port of the Router Manager: port in its settings, or MANAGER_PORT when it is not set or not a port."""
try:
port = json.loads(MANAGER_CONFIG.read_text(encoding='utf-8')).get('port')
except (OSError, ValueError, AttributeError):
return MANAGER_PORT
return port if isinstance(port, int) and not isinstance(port, bool) and 1 <= port <= 65535 else MANAGER_PORT
def filter_block(ctx):
lines = [
'',
@@ -185,6 +200,15 @@ def filter_block(ctx):
'# WEB RULES END',
]
if MANAGER.exists() and ctx['int_iface']:
port = manager_port()
lines += [
'',
'# MANAGER RULES START',
f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT',
'# MANAGER RULES END',
]
if vpnrouter.routing_ready(ctx):
lines += ['', '# ROUTER FORWARD RULES START']
for subnet in ctx['local_cidr_list']:
@@ -323,13 +347,8 @@ def manage_wireguard(ctx, changed):
run(['systemctl', 'restart', unit], check=False)
def manage_directory(ctx):
"""Provision the user directory in slapd while a P2S system that has users is configured.
The directory is left in place when they are switched off, so the users are kept.
"""
if not (vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)):
return
def manage_directory():
"""Provision the user directory in slapd, whether or not a P2S system is configured."""
if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'):
run(['systemctl', 'restart', 'slapd'])
try:
@@ -378,35 +397,27 @@ def refresh_docker_rules():
def refresh_web(cfg, ctx):
"""Hand the web service what it needs and restart it.
"""Hand the web service the features and facts it shows.
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. The restart is queued, not waited for, because the
unit is ordered after vpn-router-setup.
The service reads the file at each request: the features of the router,
the configuration without its secret options and the facts resolved from
the system, which it cannot work out with the standard library alone. The
file is readable by root only. The service follows a changed address or
port itself.
"""
masked = {
section: {option: '********' if SECRET_OPTION.search(option) and value else value
for option, value in options.items()}
public = {
section: {option: value for option, value in options.items() if not SECRET_OPTION.search(option)}
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)),
'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
'users': bool(vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)),
'configuration': masked,
'configuration': public,
'network': {
'external_interface': ctx['wan_iface'],
'internal_interface': ctx['int_iface'],
@@ -419,7 +430,6 @@ def refresh_web(cfg, ctx):
'remote_cidrs': ctx['remote_cidr_list'],
},
}) + '\n', encoding='utf-8')
run(['systemctl', 'restart', '--no-block', 'vpn-router-web.service'], check=False)
def main():
@@ -453,7 +463,7 @@ def main():
elif rules_changed:
run(['ufw', 'reload'], check=False)
manage_directory(ctx)
manage_directory()
manage_strongswan(ctx, changed)
manage_wireguard(ctx, changed)
manage_openvpn(ctx, changed)
@@ -21,6 +21,7 @@ if str(LIB_DIR) not in sys.path:
import vpnrouter_ldap
import vpnrouter_platforms
from vpnrouter_net import ConfigError, detect_interfaces, interface_addresses, ip_output
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
@@ -92,10 +93,6 @@ MODES = ('manual', 'interfaces', 'auto')
PLATFORM_NONE = 'none'
class ConfigError(Exception):
"""A setting is present but malformed."""
def warn(message):
print(f'vpn-router: warning: {message}', file=sys.stderr)
@@ -327,55 +324,6 @@ def deferred(cfg):
return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE
def _ip_route(*args):
result = subprocess.run(['ip', *args], capture_output=True, text=True)
return result.stdout if result.returncode == 0 else ''
def detect_interfaces():
"""Work out the external and internal interface names.
Only reached in auto mode, where the operator has accepted that a wrong
guess is possible. The external interface is whichever one carries the
default route; the internal one is the only other addressed interface.
"""
tokens = _ip_route('route', 'get', '1.1.1.1').split()
external = ''
for index, token in enumerate(tokens):
if token == 'dev' and index + 1 < len(tokens):
external = tokens[index + 1]
break
if not external:
raise ConfigError('auto mode: cannot determine the external interface')
candidates = [name for name in _addressed_interfaces() if name != external]
if len(candidates) != 1:
raise ConfigError(
f'auto mode: expected one internal interface, found {len(candidates)}; '
'use mode = interfaces and name them')
return external, candidates[0]
def _is_virtual(name):
"""True for an interface the kernel creates without a device behind it.
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
which is where the Docker bridge is; a NIC of the virtual machine does not.
"""
link = pathlib.Path('/sys/class/net') / name
return '/devices/virtual/' in str(link.resolve())
def _addressed_interfaces():
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
names = []
for line in _ip_route('-o', '-4', 'addr', 'show').splitlines():
fields = line.split()
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
names.append(fields[1])
return names
def detect_gateway(iface, address):
"""Derive the next hop on the internal side.
@@ -383,12 +331,12 @@ def detect_gateway(iface, address):
back to the first host address of the connected subnet, which is a
convention rather than a fact - so say so.
"""
for line in _ip_route('-o', 'route', 'show', 'dev', iface).splitlines():
for line in ip_output('-o', 'route', 'show', 'dev', iface).splitlines():
fields = line.split()
if 'via' in fields:
return fields[fields.index('via') + 1]
for line in _ip_route('-o', '-4', 'addr', 'show', 'dev', iface).splitlines():
for line in ip_output('-o', '-4', 'addr', 'show', 'dev', iface).splitlines():
fields = line.split()
if len(fields) >= 4 and fields[3].split('/')[0] == address:
network = ipaddress.ip_interface(fields[3]).network
@@ -399,34 +347,6 @@ def detect_gateway(iface, address):
return ''
def interface_addresses(name, setting):
"""IPv4 addresses configured on an interface.
The interface name is configuration; its addresses are read from the
system. Looking up the addresses of a named interface is deterministic -
unlike working out which interface to use, which is a guess the package
deliberately does not make.
"""
if not name:
return []
if not (pathlib.Path('/sys/class/net') / name).exists():
raise ConfigError(f'{setting}: interface {name!r} does not exist')
result = subprocess.run(
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
capture_output=True, text=True,
)
if result.returncode != 0:
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
addresses = []
for line in result.stdout.splitlines():
fields = line.split()
if len(fields) >= 4:
addresses.append(fields[3].split('/')[0])
return addresses
def resolve_interfaces(cfg):
"""Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip).
@@ -645,24 +565,28 @@ def _simple_ca(*args):
def provision_pki(ctx):
"""Create the CA, server certificate and CRL when the PKI directory is empty; a CA without a CRL gets one."""
"""Create the CA and CRL when the PKI directory is empty, and the server certificate once wan.local_fqdn is set.
A CA of the router's own (ca_key.pem present) without a CRL gets one, and without a
certificate for wan.local_fqdn gets one too; nothing present is overwritten.
"""
PKI_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PKI_DIR, 0o700)
if any(PKI_DIR.iterdir()):
if (PKI_DIR / 'ca_key.pem').is_file() and not (PKI_DIR / 'crl.pem').is_file():
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
return
if not ctx['p2s_ca_name']:
warn('p2s.ca_name is empty, not creating a local CA')
return
if not SIMPLE_CA.is_file():
fail(f'{SIMPLE_CA} is missing')
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR),
ctx['local_fqdn'], ctx['local_fqdn'])
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
if not any(PKI_DIR.iterdir()):
if not ctx['p2s_ca_name']:
warn('p2s.ca_name is empty, not creating a local CA')
return
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
if not (PKI_DIR / 'ca_key.pem').is_file():
return
if not (PKI_DIR / 'crl.pem').is_file():
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS))
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
if label and not (PKI_DIR / f'{label}_cert.pem').is_file():
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR), ctx['local_fqdn'], ctx['local_fqdn'])
def distribute_pki(ctx):
@@ -708,8 +632,7 @@ def configure(cfg=None, local_id_override=None):
changed = set()
if p2s_ready(ctx) or openvpn_ready(ctx):
provision_pki(ctx)
provision_pki(ctx)
if distribute_pki(ctx):
changed.add('swanctl')
@@ -0,0 +1,109 @@
"""Interfaces and addresses of the router, read from the system.
Only the Python standard library is used, so the web service can use this module too.
"""
import pathlib
import subprocess
class ConfigError(Exception):
"""A setting is present but malformed."""
def ip_output(*args):
"""The output of an ip command, or '' when it fails."""
try:
result = subprocess.run(['ip', *args], capture_output=True, text=True)
except OSError:
return ''
return result.stdout if result.returncode == 0 else ''
def detect_interfaces():
"""Work out the external and internal interface names.
Only reached in auto mode, where the operator has accepted that a wrong
guess is possible. The external interface is whichever one carries the
default route; the internal one is the only other addressed interface.
"""
tokens = ip_output('route', 'get', '1.1.1.1').split()
external = ''
for index, token in enumerate(tokens):
if token == 'dev' and index + 1 < len(tokens):
external = tokens[index + 1]
break
if not external:
raise ConfigError('auto mode: cannot determine the external interface')
candidates = [name for name in _addressed_interfaces() if name != external]
if len(candidates) != 1:
raise ConfigError(
f'auto mode: expected one internal interface, found {len(candidates)}; '
'use mode = interfaces and name them')
return external, candidates[0]
def _is_virtual(name):
"""True for an interface the kernel creates without a device behind it.
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
which is where the Docker bridge is; a NIC of the virtual machine does not.
"""
link = pathlib.Path('/sys/class/net') / name
return '/devices/virtual/' in str(link.resolve())
def _addressed_interfaces():
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
names = []
for line in ip_output('-o', '-4', 'addr', 'show').splitlines():
fields = line.split()
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
names.append(fields[1])
return names
def interface_addresses(name, setting):
"""IPv4 addresses configured on an interface.
The interface name is configuration; its addresses are read from the
system. Looking up the addresses of a named interface is deterministic -
unlike working out which interface to use, which is a guess the package
deliberately does not make.
"""
if not name:
return []
if not (pathlib.Path('/sys/class/net') / name).exists():
raise ConfigError(f'{setting}: interface {name!r} does not exist')
result = subprocess.run(
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
capture_output=True, text=True,
)
if result.returncode != 0:
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
addresses = []
for line in result.stdout.splitlines():
fields = line.split()
if len(fields) >= 4:
addresses.append(fields[3].split('/')[0])
return addresses
def internal_address(mode, internal, int_addr):
"""The router's address on the internal network, as general.mode directs, or '' when it is not known yet.
manual takes local.int_addr; interfaces reads the address of interfaces.internal; auto also
works out which interface that is.
"""
if mode == 'manual':
return int_addr
try:
if mode == 'auto':
internal = detect_interfaces()[1]
addresses = interface_addresses(internal, 'interfaces.internal')
except ConfigError:
return ''
return addresses[0] if addresses else ''
+110 -40
View File
@@ -10,7 +10,7 @@ Pages:
/status the status as JSON
/config the configuration without its secrets, and what the router resolved and
runs, as JSON
/users the users of the P2S systems: list, add, set the password, disable, enable,
/users the users in the router's directory, whether or not a P2S system is enabled: 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
@@ -21,12 +21,14 @@ web.jwt_secret is not set.
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
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.
The service needs nothing else to start and does not end on its own. A discovery thread works
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
it, moves it when the address or the port changes, and stops it while the service is disabled
or the address is not known. Every request reads what it needs when it is made: the token secret
from the configuration, and the features of the router and the facts shown by /config from
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
the router has no features, and /config shows no configuration or interfaces.
"""
import base64
@@ -42,7 +44,18 @@ import time
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter_net
STATE_FILE = '/run/vpn-router/web.json'
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
#: The port when web.port is not set.
DEFAULT_PORT = 8080
#: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30
COMMAND_TIMEOUT = 5
CACHE_SECONDS = 2
@@ -73,6 +86,33 @@ _cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()
def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object."""
try:
with open(path, encoding='utf-8') as handle:
data = json.load(handle)
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def section(configuration, name):
"""A section of the configuration, or {} when it is missing or not an object."""
value = configuration.get(name)
return value if isinstance(value, dict) else {}
def current():
"""What a request needs, read now: the features and facts setup resolved, and the token secret.
Before setup has run the router has no features, and without a secret /config and /users are
not served.
"""
state = read_json(STATE_FILE)
secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret')
return dict(state, jwt_secret=secret if isinstance(secret, str) else '')
def run(*args):
"""The finished process of a command, or None when it cannot be run."""
try:
@@ -184,15 +224,13 @@ def openvpn_clients():
def collect(state):
"""The status document. state says which features the router has."""
units = {'vpn-router-setup': unit_state('vpn-router-setup.service')}
units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.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')
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',
@@ -395,8 +433,6 @@ def users(state, method, path, body):
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 {}
@@ -433,13 +469,12 @@ class Handler(BaseHTTPRequestHandler):
def status(self):
with _lock:
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
_cache['data'] = collect(self.server.state)
_cache['data'] = collect(current())
_cache['at'] = time.monotonic()
return _cache['data']
def authorized(self):
def authorized(self, secret):
"""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
@@ -449,16 +484,17 @@ class Handler(BaseHTTPRequestHandler):
# 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'):
protected = path == '/config' or path == '/users' or path.startswith('/users/')
state = current() if protected else {}
if protected and not state['jwt_secret']:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
elif protected and not self.authorized():
elif protected and not self.authorized(state['jwt_secret']):
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
body, kind, code = json.dumps(details(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())
code, document = users(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
@@ -515,31 +551,65 @@ class Handler(BaseHTTPRequestHandler):
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
def wanted():
"""Where the service should listen now, as (address, port), or the reason it should not listen."""
configuration = read_json(CONFIG_FILE)
web = section(configuration, 'web')
if web.get('enabled', True) is False:
return None, 'the web service is disabled in the configuration'
port = web.get('port', DEFAULT_PORT)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
port = DEFAULT_PORT
general, interfaces, local = (section(configuration, name) for name in ('general', 'interfaces', 'local'))
address = vpnrouter_net.internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
str(local.get('int_addr') or ''))
if not address:
return None, 'the internal address is not known yet'
return (address, port), ''
def log(message):
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
def discover():
"""Work out where to listen every DISCOVERY_SECONDS, and run the server thread there.
When the address or the port changes, the server thread is stopped and a new one listens at
the new place; while the service is disabled, the address is not known or it cannot be bound,
no server runs and the next round tries again.
"""
server, thread, listening, reported = None, None, None, None
while True:
target, reason = wanted()
if target != listening and server is not None:
server.shutdown()
server.server_close()
thread.join()
server, listening = None, None
if target is not None and server is None:
try:
server = ThreadingHTTPServer(target, Handler)
except OSError as exc:
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}'
else:
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True)
thread.start()
listening, reason = target, f'serving on http://{target[0]}:{target[1]}'
if reason and reason != reported:
log(reason)
reported = reason
time.sleep(DISCOVERY_SECONDS)
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"]}')
discovery = threading.Thread(target=discover, name='discovery', daemon=True)
discovery.start()
try:
server.serve_forever()
discovery.join()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/python3
"""Manage the users of the router's P2S systems.
"""Manage the users of the router's directory, whether or not a P2S system is enabled.
A user is an entry in the router's directory, whose password OpenVPN checks, and a client
certificate of the router CA with the user name as its common name, which OpenVPN and
@@ -103,7 +103,7 @@ def list_users(connection):
def add(connection, name):
secret = password()
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
raise Failure('the router CA does not exist; enable OpenVPN or IKEv2 road-warrior access first')
raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen')
try:
connection.add_s(dn_of(name), ldap.modlist.addModlist({
'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()],
Executable
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Commit everything, tag the version of the newest changelog entry and push main with the tags.
The tag is the upstream version of the first entry of debian-package/debian/changelog,
without the Debian revision: vpn-router (0.5.1-1) is tagged 0.5.1, which starts the
release workflow.
"""
import argparse
import pathlib
import shlex
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parent
CHANGELOG = ROOT / 'debian-package' / 'debian' / 'changelog'
#: Print the git commands instead of running them; set by -n/--dry-run.
DRY_RUN = False
def git(*args):
"""Run git in the repository, or print the command on a dry run; stop with git's exit status when it fails."""
command = ['git', *args]
if DRY_RUN:
print(shlex.join(command))
return
result = subprocess.run(command, cwd=ROOT)
if result.returncode != 0:
sys.exit(result.returncode)
def summary():
"""The summary line of the first changelog entry, or None when it has none.
The summary is the entry's first text line: indented by two spaces, not a "*" item,
with a blank line between it and the header and a blank line after it.
"""
lines = CHANGELOG.read_text(encoding='utf-8').splitlines()
if len(lines) < 4 or lines[1].strip() or lines[3].strip():
return None
line = lines[2]
text = line.strip()
if not line.startswith(' ') or line.startswith(' ') or not text or text.startswith('*') or line.startswith(' -- '):
return None
return text
def version():
"""The upstream version of the first changelog entry."""
first = CHANGELOG.read_text(encoding='utf-8').splitlines()[0]
start, end = first.find('('), first.find(')')
if start == -1 or end < start:
sys.exit(f'{CHANGELOG}: the first line has no version: {first}')
full = first[start + 1:end]
return full.rsplit('-', 1)[0] if '-' in full else full
def main():
global DRY_RUN
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('-m', '--message', help='commit message; default: the summary line of the newest changelog entry, a line without "*" between blank lines right after its header')
parser.add_argument('-n', '--dry-run', action='store_true', help='print the git commands instead of running them')
parser.add_argument('--no-push', action='store_true', help='commit and tag, but do not push')
args = parser.parse_args()
DRY_RUN = args.dry_run
message = args.message or summary()
if not message:
sys.exit(f'no commit message: pass -m, or start the newest entry of {CHANGELOG} with a summary line '
'without "*", indented by two spaces, with a blank line above and below it')
tag = version()
if subprocess.run(['git', 'rev-parse', '-q', '--verify', f'refs/tags/{tag}'], cwd=ROOT,
capture_output=True).returncode == 0:
sys.exit(f'tag {tag} exists; add a new changelog entry first')
git('add', '-A')
git('commit', '-m', message)
git('tag', tag)
if not args.no_push:
git('push', 'origin', 'main', '--tags')
return 0
if __name__ == '__main__':
sys.exit(main())