update: the status web service listens on port 7081 by default.
Release / release (push) Successful in 11s

This commit is contained in:
slawek committed 2026-10-24 01:14:21 +02:00
1 parent 0bf78b16b1
commit b9003bacb9
7 files changed
+91 -89

No files matched your search

+69 -78
View File
@@ -29,65 +29,61 @@ 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; amended by ADR-7
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.
the way of a small test server. It listens on the address of the internal interface and on
`web.port`, 7081 by default, 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 port on the internal interface in UFW and writes
the features and the facts it resolves to `/run/vpn-router/web.json`, 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`.
address only and the firewall opens the port on the internal interface only. `/` and `/status`
have no authentication, which is acceptable because they are read only and show 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.
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
Status: Accepted; amended by ADR-7 and ADR-8
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.
Context: A client such as the Router Manager 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).
each use and a leaked one expires. Without a secret the page is not served. The configuration
shown has no secret options (ADR-8).
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.
so it is long and random; it is checked to have at least 32 characters. The secret is only in the
configuration file, 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.
## ADR-4: Users are kept in a local OpenLDAP directory
Status: Accepted
Context: OpenVPN needs user accounts with passwords, and the users are meant to cover every P2S
system of the router. The options weighed were an external OIDC provider (Entra ID, Keycloak)
with openvpn-auth-oauth2, an OIDC server embedded on the router, a file of password hashes, and
OpenLDAP on the router. OIDC for OpenVPN needs a browser on the client and a program from outside
the Ubuntu archive, and it cannot authenticate IKEv2 EAP-TLS or WireGuard. slapd, its Python
module `python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the
plugin checks a password by binding as the user, so the hash scheme is slapd's alone. A directory
can also be read later by an OIDC provider through its LDAP connector.
system of the router, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
authenticate. Everything the router runs comes from the Ubuntu archive. slapd, its Python module
`python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the plugin
checks a password by binding as the user, so the hash scheme is slapd's alone. An OIDC provider
can read the directory through its LDAP connector.
Decision: The users are entries in slapd. The package keeps its own database,
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
@@ -101,19 +97,18 @@ slapd listens on the local socket only. Provisioning checks every step before ch
runs at every start of `vpn-router-setup`.
Consequences: The package depends on `slapd`, `python3-ldap`, `openvpn` and
`openvpn-auth-ldap`, and `vpn-router-setup` and `vpn-router-user` import `python3-ldap`, the first
library outside the standard library besides `jinja2`. The service account's password is a
root-only file. The directory is user data: it is kept when the features are switched off and
on purge, and it is backed up with `slapcat`, not by copying files.
`openvpn-auth-ldap`, and `vpn-router-setup` and `vpn-router-user` import `python3-ldap`. The
service account's password is a root-only file. The directory is user data: it is kept on purge,
and it is backed up with `slapcat`, not by copying files.
## ADR-5: OpenVPN clients need a certificate and the password of the user it names
Status: Accepted
Context: The router CA already issues client certificates for IKEv2. OpenVPN can check a
certificate, a password, or both. With both, a certificate alone and a password alone are not
enough, but `openvpn-auth-ldap` checks only the password of the user name given at login, which
need not be the user of the certificate.
Context: The router CA issues client certificates for IKEv2. OpenVPN can check a certificate, a
password, or both. With both, a certificate alone and a password alone are not enough, but
`openvpn-auth-ldap` checks only the password of the user name given at login, which need not be
the user of the certificate.
Decision: OpenVPN checks the certificate against the CA and the CRL, the password with
`openvpn-auth-ldap`, and runs `openvpn-same-user` as `auth-user-pass-verify` with `via-env`,
@@ -144,19 +139,18 @@ lock, `/etc/vpn-router/pki` for the certificates and the CA's state, and `/etc/s
for the published CRL. A lock serializes the program's runs, because `simple-ca` keeps its state
in one file.
Consequences: The web service still imports nothing outside the standard library, and the same
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.
Consequences: The web service imports nothing outside the standard library, and the same 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.
Context: The status page is the tool to see a problem of `vpn-router-setup`, so it must run
whatever state setup is in, also before setup has run and while it fails. The internal address,
`web.port` and `web.enabled` can change at any time.
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
@@ -165,30 +159,28 @@ standard library and which `vpnrouter.py` uses too, and `web.port` and `web.enab
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
the features and resolved facts from `web.json`, which setup 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.
restart. 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.
Context: A secret has no use outside the router, so a client never needs it, and showing even
that a secret is set tells a reader more than it needs.
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.
options at all. They are recognised by name: `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.
cannot tell whether a secret is set. A new secret option needs a name that matches.
## ADR-9: The Router Manager is a separate package with a small Python server
@@ -199,30 +191,29 @@ another Linux system. The router's web service needs a token signed with `web.jw
`/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.
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.
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.
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.
Context: Users are prepared before a P2S system is switched on, and the Router Manager manages
them on any configured router, with or 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.
+8 -7
View File
@@ -122,7 +122,7 @@ must have the type shown below; an unknown section or setting is an error.
| `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` | `port` | integer | `7081` | 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:
@@ -407,7 +407,7 @@ modules shipped with the package; the configuration file accepts any module inst
## 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
`web.port` (7081 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 |
@@ -428,8 +428,8 @@ the WireGuard peers with the age of their last handshake and their traffic. `sta
all these services are active, and `degraded` otherwise.
```bash
curl http://10.0.0.4:8080/
curl http://10.0.0.4:8080/status
curl http://10.0.0.4:7081/
curl http://10.0.0.4:7081/status
```
### The /config page
@@ -466,7 +466,7 @@ print(f'{head}.{body}.{sign}')
```
```bash
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:8080/config
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
```
### The /users API
@@ -491,7 +491,7 @@ network in plain HTTP, like the token.
```bash
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:8080/users
-d '{"name": "alice", "password": "s3cr3t-pass"}' http://10.0.0.4:7081/users
```
The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
@@ -509,7 +509,8 @@ The `vpn-router-manager` package, built in the repository `linux-cloud-router-ma
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.
7080 by default, HTTP; 7443 is kept for it with TLS) on the internal interface; restart
`vpn-router-setup` after changing it.
## Removal
+10
View File
@@ -1,3 +1,13 @@
vpn-router (0.5.6-1) unstable; urgency=medium
update: the status web service listens on port 7081 by default.
* web.port defaults to 7081.
* vpn-router-setup opens the Router Manager's port, 7080 by default, on
the internal interface.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 18:00:00 +0200
vpn-router (0.5.5-1) unstable; urgency=medium
feature: users and their certificates no longer need a P2S system enabled.
+1 -1
View File
@@ -34,7 +34,7 @@ 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
MANAGER_PORT = 7080
FILTER_ANCHOR = '# End required lines'
@@ -54,7 +54,7 @@ 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, 'port': 8080, 'jwt_secret': ''},
'web': {'enabled': True, 'port': 7081, 'jwt_secret': ''},
}
#: The shortest secret that signs the tokens of the status web service.
+1 -1
View File
@@ -52,7 +52,7 @@ 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
DEFAULT_PORT = 7081
#: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30
@@ -45,7 +45,7 @@
},
"web": {
"enabled": true,
"port": 8080,
"port": 7081,
"jwt_secret": ""
}
}