Compare commits

...
5 Commits
Author SHA1 Message Date
slawek b9003bacb9 update: the status web service listens on port 7081 by default.
Release / release (push) Successful in 11s
2026-10-24 01:14:21 +02:00
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
11 changed files with 251 additions and 122 deletions

No files matched your search

+103 -54
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 ## 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 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 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. 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 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 the way of a small test server. It listens on the address of the internal interface and on
`web.port`, on by default at 8080. `/` is for people, in plain text for `curl` and `wget` and as `web.port`, 7081 by default, and is on unless `web.enabled` is `false` (ADR-7). `/` is for
a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the people, in plain text for `curl` and `wget` and as a Tailwind CSS page for a browser, and
state of the services, the package version and the state of the site-to-site, road-warrior and `/status` is the same status as JSON. It shows the state of the services, the package version
WireGuard features, and nothing from the configuration. `vpn-router-setup` resolves the and the state of the site-to-site, road-warrior, OpenVPN and WireGuard features, and nothing from
interface address, writes it with the port and the features to `/run/vpn-router/web.json`, the configuration. `vpn-router-setup` opens the port on the internal interface in UFW and writes
opens the port on the internal interface in UFW and restarts the service, so the service does the features and the facts it resolves to `/run/vpn-router/web.json`, so the service does not
not need the package's configuration code, which needs Jinja2. need the package's configuration code, which needs Jinja2.
Consequences: Nothing outside the internal network can reach it, since it listens on the internal 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 address only and the firewall opens the port on the internal interface only. `/` and `/status`
authentication, which is acceptable because it is read only and shows no configuration. It runs have no authentication, which is acceptable because they are read only and show no
as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`, `ProtectSystem=strict`, configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`,
`ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a browser on a network `ProtectSystem=strict`, `ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a
without internet shows the unstyled page, and `curl` is unaffected. A change of the internal browser on a network without internet shows the unstyled page, and `curl` is unaffected.
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 ## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
Status: Accepted; amended by ADR-7 Status: Accepted
Context: A client such as manage-ui needs the configuration and the resolved state of the Context: A client such as the Router Manager needs the configuration and the resolved state of
router, which `/` and `/status` must not show. The service has only the standard library, listens the router, which `/` and `/status` must not show. The service has only the standard library,
on plain HTTP on the internal network, and its configuration file is readable by root only. 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 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 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 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 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 each use and a leaked one expires. Without a secret the page is not served. The configuration
configuration shown are masked by name (`psk`, and options with `secret`, `password`, `token` or a shown has no secret options (ADR-8).
`_key` ending in the name).
Consequences: The client and the router share one secret, and whoever holds it can make tokens, 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 so it is long and random; it is checked to have at least 32 characters. The secret is only in the
configuration file and in `/run/vpn-router/web.json`, both readable by root only. The token and configuration file, readable by root only. The token and the answer cross the network
the answer cross the network unencrypted, so the page is for a trusted internal network; HTTPS unencrypted, so the page is for a trusted internal network; HTTPS would need a certificate that
would need a certificate that the package does not have. The masking is by option name, so a new the package does not have.
secret option needs a name that matches.
## ADR-4: Users are kept in a local OpenLDAP directory ## ADR-4: Users are kept in a local OpenLDAP directory
Status: Accepted Status: Accepted
Context: OpenVPN needs user accounts with passwords, and the users are meant to cover every P2S 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) system of the router, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
with openvpn-auth-oauth2, an OIDC server embedded on the router, a file of password hashes, and authenticate. Everything the router runs comes from the Ubuntu archive. slapd, its Python module
OpenLDAP on the router. OIDC for OpenVPN needs a browser on the client and a program from outside `python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the plugin
the Ubuntu archive, and it cannot authenticate IKEv2 EAP-TLS or WireGuard. slapd, its Python checks a password by binding as the user, so the hash scheme is slapd's alone. An OIDC provider
module `python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the can read the directory through its LDAP connector.
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.
Decision: The users are entries in slapd. The package keeps its own database, 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 `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`. runs at every start of `vpn-router-setup`.
Consequences: The package depends on `slapd`, `python3-ldap`, `openvpn` and 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 `openvpn-auth-ldap`, and `vpn-router-setup` and `vpn-router-user` import `python3-ldap`. The
library outside the standard library besides `jinja2`. The service account's password is a service account's password is a root-only file. The directory is user data: it is kept on purge,
root-only file. The directory is user data: it is kept when the features are switched off and and it is backed up with `slapcat`, not by copying files.
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 ## ADR-5: OpenVPN clients need a certificate and the password of the user it names
Status: Accepted Status: Accepted
Context: The router CA already issues client certificates for IKEv2. OpenVPN can check a Context: The router CA issues client certificates for IKEv2. OpenVPN can check a certificate, a
certificate, a password, or both. With both, a certificate alone and a password alone are not password, or both. With both, a certificate alone and a password alone are not enough, but
enough, but `openvpn-auth-ldap` checks only the password of the user name given at login, which `openvpn-auth-ldap` checks only the password of the user name given at login, which need not be
need not be the user of the certificate. the user of the certificate.
Decision: OpenVPN checks the certificate against the CA and the CRL, the password with 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`, `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 for the published CRL. A lock serializes the program's runs, because `simple-ca` keeps its state
in one file. in one file.
Consequences: The web service still imports nothing outside the standard library, and the same Consequences: The web service imports nothing outside the standard library, and the same program
program serves the command line and the API. Each request starts a process, which is fine for serves the command line and the API. Each request starts a process, which is fine for user
user administration. The API is protected by the token of `/config`, so whoever has the secret administration. The API is protected by the token of `/config`, so whoever has the secret can
can manage users. manage users.
## ADR-7: The web service starts on its own and reads its inputs at each request ## ADR-7: The web service starts on its own and reads its inputs at each request
Status: Accepted Status: Accepted
Context: The web service took the address to listen on, the port, the token secret and the Context: The status page is the tool to see a problem of `vpn-router-setup`, so it must run
features from `/run/vpn-router/web.json`, written by `vpn-router-setup`, and ended with a failure whatever state setup is in, also before setup has run and while it fails. The internal address,
while the file or the internal address was missing. It was ordered after `vpn-router-setup`, so a `web.port` and `web.enabled` can change at any time.
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 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 thread works out, every 30 seconds, the internal address from `vpn-router.json` as
@@ -165,12 +159,67 @@ 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 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 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 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 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. 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 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 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 restart. A change of the secret applies to the next request. The interfaces are read every 30
`vpn-router.json`. A change of the secret applies to the next request. The interfaces are read seconds, which costs a few `ip` commands.
every 30 seconds, which costs a few `ip` commands.
## ADR-8: /config leaves secret options out
Status: Accepted
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: `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 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.
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: 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.
`/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.
+32 -22
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` | `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 | | `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` | `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 | | `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: A minimal site-to-site configuration in `auto` mode:
@@ -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 `<label>` is the first component of the router FQDN, so `router.example.com` gives
`router_cert.pem`. `router_cert.pem`.
If road-warrior or OpenVPN access is enabled and this directory is empty, a certificate Once a platform is chosen and while this directory is empty, a certificate authority and a CRL
authority, a server certificate and a CRL valid for ten years are created there valid for ten years are created there automatically, whether or not road-warrior or OpenVPN
automatically; a CA without a CRL gets one. User certificates are issued by access is enabled. A CA of the router's own (with `ca_key.pem`) without a CRL gets one, and
`vpn-router-user` into `users/`. To use your own instead, place the without a server certificate gets one once `wan.local_fqdn` is set. User certificates are issued
files in that layout before enabling the feature; nothing is generated or overwritten when by `vpn-router-user` into `users/`. To use your own CA instead, place the files in that layout
material is already present. before choosing the platform; nothing present is overwritten.
Either way, the package copies what each consumer needs into place: the CA into 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 `/etc/swanctl/x509ca/`, the server certificate and key into `/etc/swanctl/x509/` and
@@ -314,8 +314,9 @@ certificate names:
### The user directory ### The user directory
While OpenVPN or road-warrior access is configured, `vpn-router-setup` keeps a directory of Once a platform is chosen, `vpn-router-setup` keeps a directory of users in the local slapd,
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 - 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 slapd package creates; root manages it through the local socket, with no administrator
@@ -328,7 +329,7 @@ users in the local slapd:
(`pwdAccountLockedTime`); (`pwdAccountLockedTime`);
- slapd listens on its local socket only (`SLAPD_SERVICES` in `/etc/default/slapd`). - 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 ### Managing users
@@ -406,19 +407,19 @@ modules shipped with the package; the configuration file accepts any module inst
## Status web service ## Status web service
The `vpn-router-web` service shows the state of the router on its internal address and 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. authentication, and shows no configuration: no key, identity, address or subnet.
| Page | Content | | Page | Content |
| --- | --- | | --- | --- |
| `/` | 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) | | `/` | 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 | | `/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 | | `/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 The status holds the state of `vpn-router-setup` and `slapd`, of `strongswan` when site-to-site or
access is configured, of `wg-quick@wg0` when WireGuard is, of `openvpn-server@vpn-router` when road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
OpenVPN is, and of `slapd` when either OpenVPN or road-warrior access is; the number of connected `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 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` 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 (the Docker rules), which do not change `status`; whether the
@@ -427,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. all these services are active, and `degraded` otherwise.
```bash ```bash
curl http://10.0.0.4:8080/ curl http://10.0.0.4:7081/
curl http://10.0.0.4:8080/status curl http://10.0.0.4:7081/status
``` ```
### The /config page ### The /config page
@@ -437,7 +438,7 @@ curl http://10.0.0.4:8080/status
| Key | Content | | 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 | | `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 | | `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 | | `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 |
@@ -465,7 +466,7 @@ print(f'{head}.{body}.{sign}')
``` ```
```bash ```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 ### The /users API
@@ -484,13 +485,13 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile | | `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 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 exist, 409 for a user that exists and 500 otherwise. `/users` works whether or not OpenVPN or
access is configured, `/users` answers 404. Passwords and private keys cross the internal road-warrior access is enabled; the profile needs OpenVPN. Passwords and private keys cross the internal
network in plain HTTP, like the token. network in plain HTTP, like the token.
```bash ```bash
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ 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 The service starts on its own, without waiting for `vpn-router-setup`, and keeps running. Every
@@ -502,6 +503,15 @@ request reads the token secret from `vpn-router.json`, and the features and the
before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the
internal interface in the firewall. 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`,
7080 by default, HTTP; 7443 is kept for it with TLS) on the internal interface; restart
`vpn-router-setup` after changing it.
## Removal ## Removal
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the `apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
View File
Whitespace-only changes.
+54
View File
@@ -1,3 +1,57 @@
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.
* 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 vpn-router (0.5.1-1) unstable; urgency=medium
fix: the web service works in every state of the router. fix: the web service works in every state of the router.
+2 -2
View File
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
tmp="$(mktemp)" tmp="$(mktemp)"
awk ' 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 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 = 0; next
} }
skip { next } skip { next }
+35 -18
View File
@@ -31,9 +31,14 @@ UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard') WG_DIR = pathlib.Path('/etc/wireguard')
WEB_STATE = pathlib.Path('/run/vpn-router/web.json') 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 = 7080
FILTER_ANCHOR = '# End required lines' 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$') SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
BLOCKS = ( BLOCKS = (
@@ -42,6 +47,7 @@ BLOCKS = (
('# OPENVPN RULES START', '# OPENVPN RULES END'), ('# OPENVPN RULES START', '# OPENVPN RULES END'),
('# P2S DNS RULES START', '# P2S DNS RULES END'), ('# P2S DNS RULES START', '# P2S DNS RULES END'),
('# WEB RULES START', '# WEB RULES END'), ('# WEB RULES START', '# WEB RULES END'),
('# MANAGER RULES START', '# MANAGER RULES END'),
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'), ('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'), ('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'), ('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
@@ -129,6 +135,15 @@ def is_configured(ctx):
vpnrouter.openvpn_ready(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): def filter_block(ctx):
lines = [ lines = [
'', '',
@@ -185,6 +200,15 @@ def filter_block(ctx):
'# WEB RULES END', '# 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): if vpnrouter.routing_ready(ctx):
lines += ['', '# ROUTER FORWARD RULES START'] lines += ['', '# ROUTER FORWARD RULES START']
for subnet in ctx['local_cidr_list']: for subnet in ctx['local_cidr_list']:
@@ -323,13 +347,8 @@ def manage_wireguard(ctx, changed):
run(['systemctl', 'restart', unit], check=False) run(['systemctl', 'restart', unit], check=False)
def manage_directory(ctx): def manage_directory():
"""Provision the user directory in slapd while a P2S system that has users is configured. """Provision the user directory in slapd, whether or not a P2S system 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
if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'): if vpnrouter_ldap.ensure_services() or not unit_is_active('slapd'):
run(['systemctl', 'restart', 'slapd']) run(['systemctl', 'restart', 'slapd'])
try: try:
@@ -381,14 +400,13 @@ def refresh_web(cfg, ctx):
"""Hand the web service the features and facts it shows. """Hand the web service the features and facts it shows.
The service reads the file at each request: the features of the router, The service reads the file at each request: the features of the router,
the configuration with its secrets masked and the facts resolved from the the configuration without its secret options and the facts resolved from
system, which it cannot work out with the standard library alone. The file the system, which it cannot work out with the standard library alone. The
is readable by root only. The service follows a changed address or port file is readable by root only. The service follows a changed address or
itself. port itself.
""" """
masked = { public = {
section: {option: '********' if SECRET_OPTION.search(option) and value else value section: {option: value for option, value in options.items() if not SECRET_OPTION.search(option)}
for option, value in options.items()}
for section, options in cfg.items() for section, options in cfg.items()
} }
WEB_STATE.parent.mkdir(parents=True, exist_ok=True) WEB_STATE.parent.mkdir(parents=True, exist_ok=True)
@@ -399,8 +417,7 @@ def refresh_web(cfg, ctx):
'p2s': bool(vpnrouter.p2s_ready(ctx)), 'p2s': bool(vpnrouter.p2s_ready(ctx)),
'wireguard': bool(vpnrouter.wg_ready(ctx)), 'wireguard': bool(vpnrouter.wg_ready(ctx)),
'openvpn': bool(vpnrouter.openvpn_ready(ctx)), 'openvpn': bool(vpnrouter.openvpn_ready(ctx)),
'users': bool(vpnrouter.p2s_ready(ctx) or vpnrouter.openvpn_ready(ctx)), 'configuration': public,
'configuration': masked,
'network': { 'network': {
'external_interface': ctx['wan_iface'], 'external_interface': ctx['wan_iface'],
'internal_interface': ctx['int_iface'], 'internal_interface': ctx['int_iface'],
@@ -446,7 +463,7 @@ def main():
elif rules_changed: elif rules_changed:
run(['ufw', 'reload'], check=False) run(['ufw', 'reload'], check=False)
manage_directory(ctx) manage_directory()
manage_strongswan(ctx, changed) manage_strongswan(ctx, changed)
manage_wireguard(ctx, changed) manage_wireguard(ctx, changed)
manage_openvpn(ctx, changed) manage_openvpn(ctx, changed)
@@ -54,7 +54,7 @@ SCHEMA = {
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []}, 'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''}, 'web': {'enabled': True, 'port': 7081, 'jwt_secret': ''},
} }
#: The shortest secret that signs the tokens of the status web service. #: The shortest secret that signs the tokens of the status web service.
@@ -565,24 +565,28 @@ def _simple_ca(*args):
def provision_pki(ctx): 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) PKI_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PKI_DIR, 0o700) 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(): if not SIMPLE_CA.is_file():
fail(f'{SIMPLE_CA} is missing') fail(f'{SIMPLE_CA} is missing')
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name']) if not any(PKI_DIR.iterdir()):
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR), if not ctx['p2s_ca_name']:
ctx['local_fqdn'], ctx['local_fqdn']) warn('p2s.ca_name is empty, not creating a local CA')
_simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS)) 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): def distribute_pki(ctx):
@@ -628,8 +632,7 @@ def configure(cfg=None, local_id_override=None):
changed = set() changed = set()
if p2s_ready(ctx) or openvpn_ready(ctx): provision_pki(ctx)
provision_pki(ctx)
if distribute_pki(ctx): if distribute_pki(ctx):
changed.add('swanctl') changed.add('swanctl')
+3 -7
View File
@@ -10,7 +10,7 @@ Pages:
/status the status as JSON /status the status as JSON
/config the configuration without its secrets, and what the router resolved and /config the configuration without its secrets, and what the router resolved and
runs, as JSON 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()) remove, and the OpenVPN client profile (see users())
/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed /config and /users need an HS256 JWT in an Authorization: Bearer header that is signed
@@ -52,7 +52,7 @@ STATE_FILE = '/run/vpn-router/web.json'
CONFIG_FILE = '/etc/vpn-router/vpn-router.json' CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
#: The port when web.port is not set. #: The port when web.port is not set.
DEFAULT_PORT = 8080 DEFAULT_PORT = 7081
#: Seconds between two discoveries of where the service should listen. #: Seconds between two discoveries of where the service should listen.
DISCOVERY_SECONDS = 30 DISCOVERY_SECONDS = 30
@@ -224,15 +224,13 @@ def openvpn_clients():
def collect(state): def collect(state):
"""The status document. state says which features the router has.""" """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'): if state.get('site_to_site') or state.get('p2s'):
units['strongswan'] = unit_state('strongswan.service') units['strongswan'] = unit_state('strongswan.service')
if state.get('wireguard'): if state.get('wireguard'):
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service') units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
if state.get('openvpn'): if state.get('openvpn'):
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service') units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
if state.get('users'):
units['slapd'] = unit_state('slapd.service')
status = { status = {
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded', 'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
@@ -435,8 +433,6 @@ def users(state, method, path, body):
DELETE /users/<name> remove the user and revoke the certificate DELETE /users/<name> remove the user and revoke the certificate
GET /users/<name>/profile the OpenVPN client profile 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)): if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'} return 400, {'error': 'the body must be a JSON object'}
body = body or {} body = body or {}
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/python3 #!/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 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 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): def add(connection, name):
secret = password() secret = password()
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file(): 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: try:
connection.add_s(dn_of(name), ldap.modlist.addModlist({ connection.add_s(dn_of(name), ldap.modlist.addModlist({
'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()], 'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()],
@@ -45,7 +45,7 @@
}, },
"web": { "web": {
"enabled": true, "enabled": true,
"port": 8080, "port": 7081,
"jwt_secret": "" "jwt_secret": ""
} }
} }
View File
File renamed without changes.