Compare commits

..
6 Commits
Author SHA1 Message Date
slawek cda33fc4c7 fix: an upgrade keeps the configuration and asks nothing.
Release / release (push) Successful in 18s
2026-10-24 02:08:23 +02:00
slawek 08693f7e68 feature: users have a first name, a last name and an e-mail address.
Release / release (push) Successful in 11s
2026-10-24 01:47:50 +02:00
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
17 changed files with 1158 additions and 427 deletions

No files matched your search

+136 -78
View File
@@ -27,67 +27,63 @@ port is reachable from the external interface only when it is in `exposed_ports`
in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker
restores the rules. The rules cover IPv4 only. 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 small status web service, 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 on the standard library's
the way of a small test server. It binds to the address of the internal interface and to `http.server`, with the package's own modules and the Python packages of the Ubuntu archive that
`web.port`, on by default at 8080. `/` is for people, in plain text for `curl` and `wget` and as the package depends on, and no web framework. It listens on the address of the internal interface and on
a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the `web.port`, 7081 by default, and is on unless `web.enabled` is `false` (ADR-7). `/` is for
state of the services, the package version and the state of the site-to-site, road-warrior and people, in plain text for `curl` and `wget` and as a Tailwind CSS page for a browser, and
WireGuard features, and nothing from the configuration. `vpn-router-setup` resolves the `/status` is the same status as JSON. It shows the state of the services, the package version
interface address, writes it with the port and the features to `/run/vpn-router/web.json`, and the state of the site-to-site, road-warrior, OpenVPN and WireGuard features, and nothing from
opens the port on the internal interface in UFW and restarts the service, so the service does the configuration. `vpn-router-setup` opens the port on the internal interface in UFW and writes
not need the package's configuration code, which needs Jinja2. the features and the facts it resolves to `/run/vpn-router/web.json`.
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 and ADR-8 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 listens on plain HTTP on the
on plain HTTP on the internal network, and its configuration file is readable by root only. internal network, and its configuration file is readable by root only. The platform that deploys
the router generates one secret and gives it to the router's configuration and to the client.
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 a shared secret is what both sides have; RS256 or ES256 would need a key pair
token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for and its distribution. 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 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`,
@@ -130,62 +125,125 @@ checks certificates only; removing a user revokes the certificate for both. A us
certificate expires after the CA's default of a year and is renewed by removing and adding the certificate expires after the CA's default of a year and is renewed by removing and adding the
user again. user again.
## ADR-6: The web service manages users by running vpn-router-user ## ADR-6: The web service and vpn-router-user share one module for users
Status: Accepted Status: Accepted
Context: The web service has only the standard library (ADR-2), and managing users needs Context: Users are managed from the command line, by an operator at a terminal, and through
`python3-ldap` and `simple-ca`. Its unit is sandboxed with `ProtectSystem=strict`. `/users` of the web service, by the Router Manager. Both must apply the same rules to names,
passwords and person attributes, and both change the directory and the CA. `simple-ca` keeps
the CA's state in one file, so two changes must not run at the same time. The web unit is
sandboxed with `ProtectSystem=strict`.
Decision: `/users` runs `vpn-router-user` and returns its JSON answer, mapping its exit status Decision: `vpnrouter_users.py` holds the user operations, the checks and a session that takes a
to the HTTP status. Passwords go to the program on standard input, so they never appear in a lock and the connection of the user manager account. The web service imports it and maps a
command line. The unit allows writing the paths the program needs: `/run/vpn-router` for its `Failure` to the HTTP status; `vpn-router-user` imports it and adds the argument parsing and
lock, `/etc/vpn-router/pki` for the certificates and the CA's state, and `/etc/swanctl/x509crl` the output, text for people and JSON with `--json`. The web unit allows writing the paths the
for the published CRL. A lock serializes the program's runs, because `simple-ca` keeps its state operations need: `/run/vpn-router` for the lock, `/etc/vpn-router/pki` for the certificates and
in one file. the CA's state, and `/etc/swanctl/x509crl` for the published CRL.
Consequences: The web service still imports nothing outside the standard library, and the same Consequences: The rules exist once. A request starts no process, and a password never leaves
program serves the command line and the API. Each request starts a process, which is fine for the process that received it. The web service imports `python3-ldap` and the package's
user administration. The API is protected by the token of `/config`, so whoever has the secret configuration module. The API is protected by the token of `/config`, so whoever has the secret
can manage users. can 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
`general.mode` directs, with the discovery code in `vpnrouter_net.py`, which uses only the `general.mode` directs, with the discovery code in `vpnrouter_net.py`, which `vpnrouter.py` uses
standard library and which `vpnrouter.py` uses too, and `web.port` and `web.enabled` from the too, and `web.port` and `web.enabled` from the same file. It runs the HTTP server in a thread of
same file. It runs the HTTP server in a thread of its own at that address and port, stops it and its own at that address and port, stops it and starts a new one when they change, and runs none
starts a new one when they change, and runs none while the service is disabled, the address is while the service is disabled, the address is not known or it cannot be bound. Each request
not known or it cannot be bound. Each request reads the token secret from `vpn-router.json` and reads the token secret from `vpn-router.json` and the features and resolved facts from
the features and resolved facts from `web.json`, which setup still writes, because they need the `web.json`, which setup writes when it applies the configuration, so the service shows what setup
configuration code; while that file is missing the router shows no features. The unit is not applied; 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 ## ADR-8: /config leaves secret options out
Status: Accepted Status: Accepted
Context: `/config` showed the secret options of the configuration with their values replaced by Context: A secret has no use outside the router, so a client never needs it, and showing even
`********`. That still tells a reader which secrets are set, and a secret has no use outside the that a secret is set tells a reader more than it needs.
router, so a client never needs it.
Decision: The configuration that `vpn-router-setup` writes for the web service has no secret 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`, options at all. They are recognised by name: `psk`, and options with `secret`, `password`,
`password`, `token` or a `_key` ending in the name. `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 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
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.
## ADR-11: Groups are groupOfNames entries that never become empty
Status: Accepted
Context: Users are to be organised in groups. Groups record membership only; nothing on the
router reads them. LDAP clients that read groups, such as `openvpn-auth-ldap` and the LDAP
connectors of OIDC providers, expect `groupOfNames` with member DNs, and OpenLDAP's core schema
requires a `groupOfNames` to have at least one `member`.
Decision: A group is `cn=<name>,ou=groups,dc=vpn-router,dc=local`, a `groupOfNames` with the
DNs of its members and an optional `description`. A group is created with at least one member,
and taking out its last member is refused; such a group is removed instead. Removing a user
takes it out of its groups and is refused while the user is the last member of one. The group
operations are in `vpnrouter_users.py` with the user operations, so they share the session and
its lock; `vpn-router-group` is a tool of its own beside `vpn-router-user`, and the web service
serves them under `/groups`. Group names follow the rules of user names.
Consequences: A group always satisfies the schema without a placeholder member, and an empty
group cannot exist. Removing a user or a member can need another step first. A later use of
groups, such as allowing OpenVPN only to some, can read them as they are.
+112 -37
View File
@@ -85,8 +85,10 @@ that deploy a router on Azure or GCP.
## Configuration ## Configuration
`/etc/vpn-router/vpn-router.json` is JSON, mode 0600, and is created on first install. It `/etc/vpn-router/vpn-router.json` is JSON, mode 0600, and is created on first install. It
is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies what is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies the answers
changed and asks before touching anything you altered by hand. A complete example is given and asks before touching anything you altered by hand, and an upgrade leaves the file as
it is and asks nothing. Settings the file leaves out take their defaults when it is read, so a
file written by cloud-init or by hand needs only the settings that differ. A complete example is
installed at `/usr/share/doc/vpn-router/vpn-router.json.example`. installed at `/usr/share/doc/vpn-router/vpn-router.json.example`.
The file is an object of sections, each an object of settings. A section or setting that The file is an object of sections, each an object of settings. A section or setting that
@@ -122,7 +124,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 +274,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,32 +316,42 @@ 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
password; password;
- `ou=users` holds one `inetOrgPerson` per user, named by `uid`; - `ou=users` holds one `inetOrgPerson` per user, named by `uid`;
- `cn=user-manager,ou=services` is the account that changes users; it may write `ou=users` and - `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in
set passwords, never read them, and its password is in `member` and an optional `description`;
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write
`ou=users` and `ou=groups` and set passwords, never read them, and its password is in
`/etc/vpn-router/ldap/user-manager.secret` (root only); `/etc/vpn-router/ldap/user-manager.secret` (root only);
- passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy - passwords are stored as ARGON2 hashes, and a disabled user is locked with the password policy
(`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
`vpn-router-user` manages the users. A user is the directory entry and a client certificate `vpn-router-user` manages the users. A user is the directory entry, with a first name, a last
with the user name as its common name, in `/etc/vpn-router/pki/users`. Passwords are read from name and an e-mail address (`givenName`, `sn` and `mail`; `cn` is the first and the last name),
the first line of standard input. The answer is JSON. and a client certificate with the user name as its common name, in `/etc/vpn-router/pki/users`.
Passwords are read from the first line of standard input. The answer is text: a table of the
users, the client profile or a short message, and a failure is a message on standard error.
`--json`, before the command, gives the answer as JSON on standard output, and a failure as
`{"error": ...}` there too; the web service uses it. `-h` or `--help` shows the commands, and
after a command its options.
```sh ```sh
echo 's3cr3t-pass' | vpn-router-user add alice echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com
vpn-router-user update alice --email a.smith@example.com
vpn-router-user list vpn-router-user list
vpn-router-user profile alice vpn-router-user profile alice > alice.ovpn
vpn-router-user --json list
echo 'n3w-pass' | vpn-router-user passwd alice echo 'n3w-pass' | vpn-router-user passwd alice
vpn-router-user disable alice vpn-router-user disable alice
vpn-router-user enable alice vpn-router-user enable alice
@@ -348,20 +360,52 @@ vpn-router-user remove alice
| Command | Effect | | Command | Effect |
| --- | --- | | --- | --- |
| `list` | The users, whether each is enabled and has a certificate | | `list` | The users with their names and e-mail addresses, whether each is enabled and has a certificate |
| `add <name>` | Adds the user and issues the certificate | | `add <name> --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required |
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
| `passwd <name>` | Sets the password | | `passwd <name>` | Sets the password |
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN, and lets the user in again | | `disable <name>`, `enable <name>` | Locks the user out of OpenVPN, and lets the user in again |
| `remove <name>` | Revokes the certificate, renews the CRL and removes the user | | `remove <name>` | Takes the user out of its groups, revokes the certificate, renews the CRL and removes the user; refused while the user is the last member of a group |
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline | | `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
password has 8 to 128 characters. The exit status is 0 on success, 2 for an invalid request, password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
e-mail address has one `@`, no spaces and a dot in its domain. In the answers a user added
without the names or the e-mail address has them as `null`. The exit status is 0 on success, 2 for an invalid request,
3 when the user does not exist, 4 when the user exists and 1 for any other failure. 3 when the user does not exist, 4 when the user exists and 1 for any other failure.
The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks The certificate is also accepted by road-warrior (IKEv2 EAP-TLS) access, which checks
certificates only, so `disable` does not affect it; `remove` revokes the certificate for both. certificates only, so `disable` does not affect it; `remove` revokes the certificate for both.
### Managing groups
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
optional description of at most 256 printable characters, and at least one member, each a user
of the directory. Groups record membership only; nothing on the router reads them. The output,
`--json`, `-h` and `--help` work as for `vpn-router-user`.
```sh
vpn-router-group add ops --member alice --member bob --description 'Operations team'
vpn-router-group list
vpn-router-group update ops --name netops
vpn-router-group update netops --description ''
vpn-router-group member-add netops carol
vpn-router-group member-remove netops bob
vpn-router-group remove netops
```
| Command | Effect |
| --- | --- |
| `list` | The groups with their members and descriptions |
| `add <name> --member U [--member U ...] [--description D]` | Adds the group with its members |
| `update <name> [--name N] [--description D]` | Renames the group or sets its description; `''` removes it |
| `remove <name>` | Removes the group; its members are kept |
| `member-add <name> <user>` | Adds the user to the group |
| `member-remove <name> <user>` | Takes the user out of the group; refused for the last member |
The exit status is 0 on success, 2 for an invalid request, 3 when the group or the user does
not exist, 4 when the group exists and 1 for any other failure.
## Platform modules ## Platform modules
Platform-specific values do not belong in the core, so they are Python modules in Platform-specific values do not belong in the core, so they are Python modules in
@@ -406,8 +450,8 @@ 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). Its pages `/` and `/status` need no authentication and show no
authentication, and shows no configuration: no key, identity, address or subnet. configuration: no key, identity, address or subnet.
| Page | Content | | Page | Content |
| --- | --- | | --- | --- |
@@ -415,10 +459,11 @@ authentication, and shows no configuration: no key, identity, address or subnet.
| `/status` | The same status as JSON | | `/status` | The same status as JSON |
| `/config` | The configuration without its secret options, 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 |
| `/groups` | The groups of users; 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 +472,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
@@ -465,18 +510,20 @@ 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
`/users` manages users with the same token as `/config`, by running `vpn-router-user`. Request `/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses
too, `/usr/lib/vpn-router/vpnrouter_users.py`, so both apply the same rules. Request
and answer bodies are JSON; a request body is at most 4096 bytes. and answer bodies are JSON; a request body is at most 4096 bytes.
| Request | Body | Answer | | Request | Body | Answer |
| --- | --- | --- | | --- | --- | --- |
| `GET /users` | | `{"users": [{"name", "enabled", "certificate"}, ...]}` | | `GET /users` | | `{"users": [{"name", "enabled", "certificate", "first_name", "last_name", "email"}, ...]}` |
| `POST /users` | `{"name": ..., "password": ...}` | 201 and the user | | `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 201 and the user |
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}` | the user |
| `PUT /users/<name>/password` | `{"password": ...}` | the user | | `PUT /users/<name>/password` | `{"password": ...}` | the user |
| `POST /users/<name>/disable` | | the user | | `POST /users/<name>/disable` | | the user |
| `POST /users/<name>/enable` | | the user | | `POST /users/<name>/enable` | | the user |
@@ -484,15 +531,34 @@ 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", "first_name": "Alice", "last_name": "Smith", "email": "alice@example.com"}' \
http://10.0.0.4:7081/users
``` ```
### The /groups API
`/groups` manages groups with the same token and the same module as `/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /groups` | | `{"groups": [{"name", "description", "members": [...]}, ...]}` |
| `POST /groups` | `{"name", "members": [...]}`, and `"description"` | 201 and the group |
| `PUT /groups/<name>` | any of `{"name", "description"}`; `""` removes the description | the group |
| `DELETE /groups/<name>` | | `{"name", "removed": true}` |
| `POST /groups/<name>/members` | `{"user": ...}` | the group |
| `DELETE /groups/<name>/members/<user>` | | the group |
A failure is `{"error": ...}` with 400 for an invalid request, among them taking out the last
member, 404 for a group or user that does not exist, 409 for a group that exists and 500
otherwise. `DELETE /users/<name>` is refused with 400 while the user is the last member of a
group.
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
30 seconds it works out the internal address from `vpn-router.json` as `general.mode` directs, 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 and reads `web.port` and `web.enabled`; it listens there, moves when the address or the port
@@ -502,6 +568,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.
+82
View File
@@ -1,3 +1,85 @@
vpn-router (0.5.8-1) unstable; urgency=medium
fix: an upgrade keeps the configuration and asks nothing.
* postinst passes on only the debconf answers that were given. The
defaults of questions never asked, such as platform none, no longer
replace settings of vpn-router.json.
* Without an answer that changes it, the configuration ucf is given is
vpn-router.json byte for byte, so an upgrade has no conflict to ask
about; settings the file leaves out keep their defaults when it is read.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 20:00:00 +0200
vpn-router (0.5.7-1) unstable; urgency=medium
feature: users have a first name, a last name and an e-mail address.
* vpn-router-user add needs --first-name, --last-name and --email, kept as
givenName, sn and mail, with cn set to the first and the last name.
vpn-router-user update changes them, and list and every answer show
them as first_name, last_name and email.
* POST /users needs first_name, last_name and email, and PUT
/users/<name> changes them.
* vpn-router-user answers in text: a table of the users, the client
profile or a short message, with failures on standard error. --json
gives the JSON answers. -h and --help show the commands and their
options.
* The web service manages users with vpnrouter_users, the module
vpn-router-user uses, instead of running vpn-router-user for each
request.
* Groups of users, kept as groupOfNames entries in ou=groups with a
description and at least one member. vpn-router-group lists, adds,
renames, describes and removes them and adds and takes out members, and
the web service serves the same under /groups. Removing a user takes it
out of its groups and is refused while it is the last member of one.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sat, 24 Oct 2026 19:00:00 +0200
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 vpn-router (0.5.2-1) unstable; urgency=medium
fix: /config no longer shows secret options. fix: /config no longer shows secret options.
+2
View File
@@ -2,8 +2,10 @@ 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.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_ldap.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/vpnrouter_net.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/
src/usr/lib/vpn-router/openvpn-same-user 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/sbin/vpn-router-user usr/sbin/
src/usr/sbin/vpn-router-group usr/sbin/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
src/usr/lib/vpn-router/configure usr/lib/vpn-router/ src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/ src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
+30 -19
View File
@@ -2,28 +2,39 @@
set -e set -e
. /usr/share/debconf/confmodule . /usr/share/debconf/confmodule
# The answer to a question in ANSWER, or "" when the question has not been seen; the
# default debconf holds for an unseen question is not an answer.
answer() {
ANSWER=""
db_fget "$1" seen
if [ "$RET" = "true" ]; then
db_get "$1"
ANSWER="$RET"
fi
}
case "$1" in case "$1" in
configure) configure)
# --- Read the installer's answers --- # --- Read the installer's answers ---
db_get vpn-router/platform; VPN_ROUTER_PLATFORM="$RET" answer vpn-router/platform; VPN_ROUTER_PLATFORM="$ANSWER"
db_get vpn-router/mode; VPN_ROUTER_MODE="$RET" answer vpn-router/mode; VPN_ROUTER_MODE="$ANSWER"
db_get vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$RET" answer vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$ANSWER"
db_get vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$RET" answer vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$ANSWER"
db_get vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$RET" answer vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$ANSWER"
db_get vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$RET" answer vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$ANSWER"
db_get vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$RET" answer vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$ANSWER"
db_get vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$RET" answer vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$ANSWER"
db_get vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$RET" answer vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$ANSWER"
db_get vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$RET" answer vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$ANSWER"
db_get vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$RET" answer vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$ANSWER"
db_get vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$RET" answer vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$ANSWER"
db_get vpn-router/psk; VPN_ROUTER_PSK="$RET" answer vpn-router/psk; VPN_ROUTER_PSK="$ANSWER"
db_get vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$RET" answer vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$ANSWER"
db_get vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$RET" answer vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$ANSWER"
db_get vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$RET" answer vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$ANSWER"
db_get vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$RET" answer vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$ANSWER"
db_get vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$RET" answer vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$ANSWER"
db_get vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$RET" answer vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$ANSWER"
export VPN_ROUTER_PLATFORM VPN_ROUTER_MODE VPN_ROUTER_INT_ADDR \ export VPN_ROUTER_PLATFORM VPN_ROUTER_MODE VPN_ROUTER_INT_ADDR \
VPN_ROUTER_EXTERNAL_INTERFACE \ VPN_ROUTER_EXTERNAL_INTERFACE \
+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 }
@@ -6,18 +6,15 @@ Called from postinst, which hands the result to ucf(1). ucf compares it with
this script never writes to /etc and never has to worry about destroying an this script never writes to /etc and never has to worry about destroying an
administrator's edits. administrator's edits.
The candidate is built in three layers, each overriding the one before: The candidate is the current /etc/vpn-router/vpn-router.json as it is, or the
shipped defaults when there is none, with every debconf answer that was given
1. the shipped defaults written over it. Without an answer that changes it, the candidate is the current
2. the current contents of /etc/vpn-router/vpn-router.json, if it exists file byte for byte, so
3. any debconf answer that was actually given an upgrade or a reconfiguration that changes nothing gives ucf nothing to ask;
defaults the file leaves out are applied by vpnrouter.load_config() when the
Layer 2 is what makes reconfiguration safe: a setting the administrator edited file is read. An empty answer means "leave alone", which is why clearing the
by hand, and that debconf has nothing to say about, is carried through pre-shared key from the debconf database after installation does not blank it
unchanged, so ucf only sees a diff where something really changed. An empty on the next dpkg-reconfigure.
answer means "leave alone", which is why clearing the pre-shared key from the
debconf database after installation does not blank it on the next
dpkg-reconfigure.
Answers arrive as VPN_ROUTER_* environment variables and are converted to the Answers arrive as VPN_ROUTER_* environment variables and are converted to the
type of the setting they fill: comma-separated lists become arrays, "true" and type of the setting they fill: comma-separated lists become arrays, "true" and
@@ -62,8 +59,21 @@ def main():
return 2 return 2
output = sys.argv[1] output = sys.argv[1]
cfg = vpnrouter.load_config() try:
current = vpnrouter.CONFIG_FILE.read_text(encoding='utf-8')
except FileNotFoundError:
current = None
if current is None:
cfg = vpnrouter.load_config()
else:
try:
cfg = json.loads(current)
except ValueError as exc:
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}')
if not isinstance(cfg, dict):
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: the top level is not an object')
answered = False
for variable, (section, option) in ANSWERS.items(): for variable, (section, option) in ANSWERS.items():
value = os.environ.get(variable) value = os.environ.get(variable)
if not value: if not value:
@@ -76,11 +86,19 @@ def main():
value = int(value) if value.isdigit() else value value = int(value) if value.isdigit() else value
elif isinstance(default, list): elif isinstance(default, list):
value = [item.strip() for item in value.split(',') if item.strip()] value = [item.strip() for item in value.split(',') if item.strip()]
cfg[section][option] = value if not isinstance(cfg.get(section, {}), dict):
vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {section} is not an object')
if cfg.setdefault(section, {}).get(option) != value:
cfg[section][option] = value
answered = True
# Without a change the candidate is the current file byte for byte, as ucf compares checksums.
with open(output, 'w', encoding='utf-8') as handle: with open(output, 'w', encoding='utf-8') as handle:
json.dump(cfg, handle, indent=2) if current is not None and not answered:
handle.write('\n') handle.write(current)
else:
json.dump(cfg, handle, indent=2)
handle.write('\n')
os.chmod(output, 0o600) os.chmod(output, 0o600)
return 0 return 0
+27 -9
View File
@@ -31,6 +31,11 @@ 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 left out wherever the configuration is shown. #: Configuration options left out wherever the configuration is shown.
@@ -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:
@@ -398,7 +417,6 @@ 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': public,
'network': { 'network': {
'external_interface': ctx['wan_iface'], 'external_interface': ctx['wan_iface'],
@@ -445,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,8 +3,8 @@
The package keeps its own database, dc=vpn-router,dc=local in /var/lib/ldap/vpn-router, The package keeps its own database, dc=vpn-router,dc=local in /var/lib/ldap/vpn-router,
next to the one slapd's installation creates. Root manages it through the local socket next to the one slapd's installation creates. Root manages it through the local socket
with SASL EXTERNAL: the database's root DN is root's peer-credential DN, so there is no with SASL EXTERNAL: the database's root DN is root's peer-credential DN, so there is no
administrator password. Users are changed by the user-manager service account, which the administrator password. Users and groups are changed by the user-manager service account,
ACLs allow to write ou=users and to set, never read, passwords. which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords.
""" """
import os import os
@@ -20,6 +20,7 @@ import ldap.sasl
URI = 'ldapi:///' URI = 'ldapi:///'
BASE = 'dc=vpn-router,dc=local' BASE = 'dc=vpn-router,dc=local'
USERS = f'ou=users,{BASE}' USERS = f'ou=users,{BASE}'
GROUPS = f'ou=groups,{BASE}'
SERVICES = f'ou=services,{BASE}' SERVICES = f'ou=services,{BASE}'
POLICIES = f'ou=policies,{BASE}' POLICIES = f'ou=policies,{BASE}'
DEFAULT_POLICY = f'cn=default,{POLICIES}' DEFAULT_POLICY = f'cn=default,{POLICIES}'
@@ -39,6 +40,7 @@ PASSWORD_HASH = '{ARGON2}'
ACCESS = ( ACCESS = (
f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none', f'to attrs=userPassword by dn.exact="{MANAGER}" =wx by self =wx by anonymous auth by * none',
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read', f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read',
'to * by * read', 'to * by * read',
) )
@@ -168,7 +170,7 @@ def provision():
database = _ensure_database(connection) database = _ensure_database(connection)
_ensure_overlay(connection, database) _ensure_overlay(connection, database)
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']}) _ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
for unit in (USERS, SERVICES, POLICIES): for unit in (USERS, GROUPS, SERVICES, POLICIES):
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]}) _ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
_ensure_entry(connection, DEFAULT_POLICY, { _ensure_entry(connection, DEFAULT_POLICY, {
'objectClass': [b'organizationalRole', b'pwdPolicy'], 'objectClass': [b'organizationalRole', b'pwdPolicy'],
@@ -0,0 +1,410 @@
"""The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service.
A user is an entry in the router's directory, with a first name, a last name and an e-mail
address, whose password OpenVPN checks, and a client certificate of the router CA with the
user name as its common name, which OpenVPN and IKEv2 accept. Users exist whether or not a
P2S system is enabled.
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
optional description and at least one member; a group cannot be empty, so the last member
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
record membership; nothing on the router reads them.
Every operation runs in a session(), which holds the lock that serializes changes and the
connection of the user manager account. A request that cannot be done raises Failure, whose
status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 1
anything else. Answers are dicts with the keys of the JSON answers.
"""
import contextlib
import fcntl
import pathlib
import re
import subprocess
import sys
import ldap
import ldap.filter
import ldap.modlist
import vpnrouter
import vpnrouter_ldap
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
PASSWORD_MIN = 8
PASSWORD_MAX = 128
#: The longest first or last name, in characters.
PERSON_NAME_MAX = 64
#: An e-mail address: one @, no spaces, and a dot in the domain.
EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
EMAIL_MAX = 254
#: The keys of the person attributes and their LDAP attributes.
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
#: The attributes read for a user.
ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail']
#: The attributes read for a group.
GROUP_ATTRIBUTES = ['cn', 'description', 'member']
#: The longest group description, in characters.
DESCRIPTION_MAX = 256
#: The value of pwdAccountLockedTime that locks an account until it is unlocked.
LOCKED = b'000001010000Z'
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
@contextlib.contextmanager
def session():
"""The connection of the user manager account, used under the lock that serializes changes."""
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK_FILE, 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
connection = vpnrouter_ldap.manager()
try:
yield connection
finally:
connection.unbind_s()
def check_name(name):
if not isinstance(name, str) or not NAME.match(name):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
def check_password(value):
if not isinstance(value, str) or '\n' in value or '\r' in value:
raise Failure('the password must be a string of one line', 2)
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
def person(values, required):
"""The person attributes of values ({first_name, last_name, email}), checked, as {LDAP attribute: value}.
A key whose value is None is not given; with required, all three must be given.
"""
found = {}
for key, attribute in PERSON:
value = values.get(key)
if value is None:
if required:
raise Failure(f'{key} is required', 2)
continue
if not isinstance(value, str):
raise Failure(f'{key} must be a string', 2)
value = value.strip()
if key == 'email':
if len(value) > EMAIL_MAX or not EMAIL.match(value):
raise Failure(f'{value!r} is not an e-mail address', 2)
elif not value or len(value) > PERSON_NAME_MAX or any(not character.isprintable() for character in value):
raise Failure(f'the {key.replace("_", " ")} must have 1 to {PERSON_NAME_MAX} printable characters', 2)
found[attribute] = value
return found
def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}'
def certificate(name):
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem'
def entry(connection, name):
"""The attributes of the user's entry; Failure with status 3 when there is none."""
check_name(name)
try:
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=ATTRIBUTES)[0][1]
except ldap.NO_SUCH_OBJECT:
raise Failure(f'user {name} does not exist', 3)
def simple_ca(*args):
result = subprocess.run([sys.executable, str(vpnrouter.SIMPLE_CA), *args], capture_output=True, text=True)
if result.returncode != 0:
raise Failure(f'simple-ca {args[0]} failed: {result.stderr.strip() or result.stdout.strip()}')
def publish_crl():
"""Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection."""
if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644):
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
def summary(attributes):
"""A user as the answers show it; a person attribute the entry does not have is None."""
name = attributes['uid'][0].decode()
user = {
'name': name,
'enabled': 'pwdAccountLockedTime' not in attributes,
'certificate': certificate(name)[0].is_file(),
}
for key, attribute in PERSON:
values = attributes.get(attribute)
user[key] = values[0].decode() if values else None
return user
def common_name(attributes, name):
"""The cn of an entry: the first and the last name, or the user name without them."""
full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value)
return full or name
def list_users(connection):
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)', ATTRIBUTES)
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
def add(connection, name, password, values):
"""Add a user with the person attributes of values, all three required, and issue the certificate."""
check_name(name)
details = person(values, required=True)
check_password(password)
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file():
raise Failure('the router CA does not exist; vpn-router-setup creates it once a platform is chosen')
record = {'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()],
'cn': [common_name(details, name).encode()]}
record.update({attribute: [value.encode()] for attribute, value in details.items()})
try:
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS:
raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, password)
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
for path in certificate(name):
path.unlink(missing_ok=True)
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name)
return summary(entry(connection, name))
def update(connection, name, values):
"""Change the person attributes values gives; the cn follows the first and the last name."""
details = person(values, required=False)
if not details:
raise Failure('give at least one of first_name, last_name and email', 2)
current = entry(connection, name)
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
def passwd(connection, name, password):
check_password(password)
entry(connection, name)
connection.passwd_s(dn_of(name), None, password)
return summary(entry(connection, name))
def disable(connection, name):
if 'pwdAccountLockedTime' not in entry(connection, name):
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
return summary(entry(connection, name))
def enable(connection, name):
if 'pwdAccountLockedTime' in entry(connection, name):
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
return summary(entry(connection, name))
def remove(connection, name):
"""Remove the user from its groups and the directory, and revoke the certificate.
A user who is the last member of a group is not removed.
"""
entry(connection, name)
groups = groups_of(connection, name)
alone = sorted(group for group, members in groups.items() if members == [name])
if alone:
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
for group in groups:
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
cert, key = certificate(name)
if cert.is_file():
simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert))
simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS))
publish_crl()
cert.unlink(missing_ok=True)
key.unlink(missing_ok=True)
connection.delete_s(dn_of(name))
return {'name': name, 'removed': True}
def profile(connection, name):
"""The OpenVPN client profile of the user, with the CA, the certificate and the key inline."""
entry(connection, name)
cfg = vpnrouter.load_config()
if not cfg['openvpn']['enabled']:
raise Failure('OpenVPN is not enabled', 2)
cert, key = certificate(name)
if not cert.is_file() or not key.is_file():
raise Failure(f'user {name} has no certificate')
fqdn = cfg['wan']['local_fqdn'].strip()
text = '\n'.join([
'client',
'dev tun',
f'proto {cfg["openvpn"]["protocol"]}',
f'remote {fqdn} {cfg["openvpn"]["port"]}',
'nobind',
'persist-key',
'persist-tun',
'remote-cert-tls server',
f'verify-x509-name {fqdn} name',
'auth-user-pass',
'auth-nocache',
'verb 3',
'<ca>', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '</ca>',
'<cert>', extract_pem(cert.read_text(encoding='utf-8')), '</cert>',
'<key>', key.read_text(encoding='utf-8').strip(), '</key>',
'',
])
return {'name': name, 'profile': text}
def group_dn(name):
return f'cn={name},{vpnrouter_ldap.GROUPS}'
def check_group_name(name):
if not isinstance(name, str) or not NAME.match(name):
raise Failure('a group name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
def check_description(value):
"""The description, stripped; '' means none."""
if not isinstance(value, str):
raise Failure('the description must be a string', 2)
value = value.strip()
if len(value) > DESCRIPTION_MAX or any(not character.isprintable() for character in value):
raise Failure(f'the description must have at most {DESCRIPTION_MAX} printable characters', 2)
return value
def member_names(attributes):
"""The user names of a group's member DNs, sorted."""
names = []
for value in attributes.get('member', []):
dn = value.decode()
if dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'):
names.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')])
return sorted(names)
def group_summary(attributes):
"""A group as the answers show it."""
values = attributes.get('description')
return {
'name': attributes['cn'][0].decode(),
'description': values[0].decode() if values else '',
'members': member_names(attributes),
}
def group_entry(connection, name):
"""The attributes of the group's entry; Failure with status 3 when there is none."""
check_group_name(name)
try:
return connection.search_s(group_dn(name), ldap.SCOPE_BASE, attrlist=GROUP_ATTRIBUTES)[0][1]
except ldap.NO_SUCH_OBJECT:
raise Failure(f'group {name} does not exist', 3)
def groups_of(connection, name):
"""The groups the user is a member of, as {group name: its member names}."""
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL,
f'(&(objectClass=groupOfNames)(member={ldap.filter.escape_filter_chars(dn_of(name))}))',
GROUP_ATTRIBUTES)
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
def list_groups(connection):
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
def group_add(connection, name, description, members):
"""Add a group with a description ('' for none) and at least one member, each an existing user."""
check_group_name(name)
description = check_description(description if description is not None else '')
if not isinstance(members, list) or not members:
raise Failure('a group needs at least one member', 2)
for member in members:
entry(connection, member)
record = {'objectClass': [b'groupOfNames'], 'cn': [name.encode()],
'member': [dn_of(member).encode() for member in sorted(set(members))]}
if description:
record['description'] = [description.encode()]
try:
connection.add_s(group_dn(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS:
raise Failure(f'group {name} exists', 4)
return group_summary(group_entry(connection, name))
def group_update(connection, name, new_name=None, description=None):
"""Rename the group or change its description ('' removes it); at least one must be given."""
if new_name is None and description is None:
raise Failure('give a new name or a description', 2)
group_entry(connection, name)
if description is not None:
description = check_description(description)
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
(ldap.MOD_REPLACE, 'description', [])
connection.modify_s(group_dn(name), [change])
if new_name is not None and new_name != name:
check_group_name(new_name)
try:
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
except ldap.ALREADY_EXISTS:
raise Failure(f'group {new_name} exists', 4)
name = new_name
return group_summary(group_entry(connection, name))
def group_remove(connection, name):
group_entry(connection, name)
connection.delete_s(group_dn(name))
return {'name': name, 'removed': True}
def group_member_add(connection, name, user):
attributes = group_entry(connection, name)
entry(connection, user)
if user not in member_names(attributes):
connection.modify_s(group_dn(name), [(ldap.MOD_ADD, 'member', [dn_of(user).encode()])])
return group_summary(group_entry(connection, name))
def group_member_remove(connection, name, user):
"""Take the user out of the group; the last member cannot leave."""
check_name(user)
members = member_names(group_entry(connection, name))
if user not in members:
raise Failure(f'user {user} is not a member of group {name}', 3)
if members == [user]:
raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2)
connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])])
return group_summary(group_entry(connection, name))
def extract_pem(text):
"""The PEM blocks of the certificates in a file that also holds their text form."""
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
blocks = []
start = text.find(begin)
while start != -1:
stop = text.index(end, start) + len(end)
blocks.append(text[start:stop])
start = text.find(begin, stop)
return '\n'.join(blocks)
+83 -57
View File
@@ -1,8 +1,8 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Serve the status of the router on its internal address. """Serve the status of the router on its internal address.
Run by vpn-router-web.service. It needs nothing but the Python standard library; users are Run by vpn-router-web.service. Users are managed with vpnrouter_users, the module that
managed by running vpn-router-user. vpn-router-user uses too.
Pages: Pages:
/ the status for people: plain text for curl and wget, a styled page / the status for people: plain text for curl and wget, a styled page
@@ -10,10 +10,13 @@ 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,
remove, and the OpenVPN client profile (see users()) add, change, set the password, disable, enable, remove, and the OpenVPN client
profile (see users())
/groups the groups of the router's directory: list, add, rename or describe, remove, and
add and take out members (see groups())
/config and /users need an HS256 JWT in an Authorization: Bearer header that is signed /config, /users and /groups need an HS256 JWT in an Authorization: Bearer header that is signed
with web.jwt_secret and expires within ten minutes; they are not served (404) while with web.jwt_secret and expires within ten minutes; they are not served (404) while
web.jwt_secret is not set. web.jwt_secret is not set.
@@ -46,13 +49,16 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap
import vpnrouter_net import vpnrouter_net
import vpnrouter_users
STATE_FILE = '/run/vpn-router/web.json' 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
@@ -70,17 +76,13 @@ TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
#: Seconds after which the status page in a browser reloads itself. #: Seconds after which the status page in a browser reloads itself.
REFRESH_SECONDS = 60 REFRESH_SECONDS = 60
USER_TOOL = '/usr/sbin/vpn-router-user'
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log' OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
#: Seconds a user command may take; adding a user creates an RSA key.
USER_TIMEOUT = 120
#: The largest request body accepted, in bytes. #: The largest request body accepted, in bytes.
BODY_LIMIT = 4096 BODY_LIMIT = 4096
#: The HTTP status for each exit status of vpn-router-user; any other is 500. #: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {0: 200, 2: 400, 3: 404, 4: 409} USER_STATUS = {2: 400, 3: 404, 4: 409}
_cache = {'at': 0.0, 'data': None} _cache = {'at': 0.0, 'data': None}
_lock = threading.Lock() _lock = threading.Lock()
@@ -224,15 +226,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',
@@ -411,60 +411,85 @@ def html_of(status):
""" """
def user_tool(*args, password=None):
"""Run vpn-router-user; the HTTP status for its exit status and its JSON answer."""
try:
result = subprocess.run([USER_TOOL, *args], input='' if password is None else password + '\n',
capture_output=True, text=True, timeout=USER_TIMEOUT)
document = json.loads(result.stdout)
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
return 500, {'error': f'vpn-router-user failed: {exc}'}
if not isinstance(document, dict):
return 500, {'error': 'vpn-router-user gave no object'}
return USER_STATUS.get(result.returncode, 500), document
def users(state, method, path, body): def users(state, method, path, body):
"""Answer a /users request: the HTTP status and the JSON document. """Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.
GET /users the users GET /users the users
POST /users add a user: {"name": ..., "password": ...} POST /users add a user: {"name", "password", "first_name", "last_name", "email"}
PUT /users/<name> change any of {"first_name", "last_name", "email"}
PUT /users/<name>/password set the password: {"password": ...} PUT /users/<name>/password set the password: {"password": ...}
POST /users/<name>/disable lock the user out of OpenVPN POST /users/<name>/disable lock the user out of OpenVPN
POST /users/<name>/enable let the user in again POST /users/<name>/enable let the user in again
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 {}
password = body.get('password')
if password is not None and (not isinstance(password, str) or '\n' in password or '\r' in password):
return 400, {'error': 'the password must be a string of one line'}
parts = path.strip('/').split('/') parts = path.strip('/').split('/')
route = (method, len(parts), parts[2] if len(parts) == 3 else None) route = (method, len(parts), parts[2] if len(parts) == 3 else None)
if route == ('GET', 1, None): name = parts[1] if len(parts) > 1 else None
return user_tool('list') actions = {
if route == ('POST', 1, None): ('GET', 1, None): lambda connection: vpnrouter_users.list_users(connection),
name = body.get('name') ('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
if not isinstance(name, str) or password is None: ('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
return 400, {'error': 'name and password are required strings'} ('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
code, document = user_tool('add', name, password=password) ('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
return (201 if code == 200 else code), document ('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
if route == ('PUT', 3, 'password'): ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
if password is None: ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
return 400, {'error': 'password is a required string'} }
return user_tool('passwd', parts[1], password=password) if route not in actions:
if route in (('POST', 3, 'disable'), ('POST', 3, 'enable')): return 404, {'error': f'no route {method} {path}'}
return user_tool(parts[2], parts[1]) try:
if route == ('DELETE', 2, None): with vpnrouter_users.session() as connection:
return user_tool('remove', parts[1]) document = actions[route](connection)
if route == ('GET', 3, 'profile'): except vpnrouter_users.Failure as failure:
return user_tool('profile', parts[1]) return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
return 404, {'error': f'no route {method} {path}'} except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except SystemExit:
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason.
return 500, {'error': 'the router configuration cannot be read'}
return (201 if route == ('POST', 1, None) else 200), document
def groups(state, method, path, body):
"""Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document.
GET /groups the groups with their members
POST /groups add a group: {"name", "members": [...], "description"?}
PUT /groups/<name> rename it or change the description: {"name"?, "description"?}
DELETE /groups/<name> remove the group; its members are kept
POST /groups/<name>/members add a member: {"user": ...}
DELETE /groups/<name>/members/<user> take a member out; the last member cannot leave
"""
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 {}
parts = path.strip('/').split('/')
name = parts[1] if len(parts) > 1 else None
route = (method, len(parts), parts[2] if len(parts) > 2 else None)
actions = {
('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection),
('POST', 1, None): lambda connection: vpnrouter_users.group_add(
connection, body.get('name'), body.get('description'), body.get('members')),
('PUT', 2, None): lambda connection: vpnrouter_users.group_update(
connection, name, body.get('name'), body.get('description')),
('DELETE', 2, None): lambda connection: vpnrouter_users.group_remove(connection, name),
('POST', 3, 'members'): lambda connection: vpnrouter_users.group_member_add(connection, name, body.get('user')),
('DELETE', 4, 'members'): lambda connection: vpnrouter_users.group_member_remove(connection, name, parts[3]),
}
if route not in actions:
return 404, {'error': f'no route {method} {path}'}
try:
with vpnrouter_users.session() as connection:
document = actions[route](connection)
except vpnrouter_users.Failure as failure:
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
return (201 if route == ('POST', 1, None) else 200), document
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@@ -488,7 +513,8 @@ class Handler(BaseHTTPRequestHandler):
# A body that is not read must not be taken for the next request. # A body that is not read must not be taken for the next request.
if self.command not in ('GET', 'HEAD'): if self.command not in ('GET', 'HEAD'):
self.close_connection = True self.close_connection = True
protected = path == '/config' or path == '/users' or path.startswith('/users/') directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path == '/config' or directory is not None
state = current() if protected else {} state = current() if protected else {}
if protected and not state['jwt_secret']: if protected and not state['jwt_secret']:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404 body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
@@ -498,7 +524,7 @@ class Handler(BaseHTTPRequestHandler):
elif path == '/config' and self.command in ('GET', 'HEAD'): elif path == '/config' and self.command in ('GET', 'HEAD'):
body, kind, code = json.dumps(details(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': elif protected and path != '/config':
code, document = users(state, self.command, path, self.request_body()) code, document = (users if directory == 'users' else groups)(state, self.command, path, self.request_body())
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif self.command not in ('GET', 'HEAD'): elif self.command not in ('GET', 'HEAD'):
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405 body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/python3
"""Manage the groups of the router's directory from the command line, with vpnrouter_users.
A group has a name, an optional description and at least one member, each a user of the
directory. The answer is text for people on standard output, and a failure is a message on
standard error. With --json the answer is JSON on standard output, and a failure is
{"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the
group or the user does not exist, 4 the group exists, 1 anything else. -h or --help shows the
commands and their options.
vpn-router-group [--json] list
vpn-router-group [--json] add <name> --member U [--member U ...] [--description D]
vpn-router-group [--json] update <name> [--name N] [--description D]
vpn-router-group [--json] remove <name>
vpn-router-group [--json] member-add <name> <user>
vpn-router-group [--json] member-remove <name> <user>
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter_users
from vpnrouter_users import Failure
#: The help line of each command.
HELP = {
'list': 'list the groups with their members',
'add': 'add a group with at least one member',
'update': 'rename the group or change its description',
'remove': 'remove the group; its members are kept',
'member-add': 'add a user to the group',
'member-remove': 'take a user out of the group; the last member cannot leave',
}
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
def parse(argv):
parser = Parser(prog='vpn-router-group', description='Manage the groups of the router.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
for command, text in HELP.items():
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
if command != 'list':
sub.add_argument('name', help='the group name')
if command == 'add':
sub.add_argument('--member', dest='members', action='append', required=True, metavar='USER',
help='a member; give it once for each member')
sub.add_argument('--description', metavar='TEXT', help='the description')
if command == 'update':
sub.add_argument('--name', dest='new_name', metavar='NAME', help='the new group name')
sub.add_argument('--description', metavar='TEXT', help="the description; '' removes it")
if command in ('member-add', 'member-remove'):
sub.add_argument('user', help='the user name')
return parser.parse_args(argv)
def run(connection, options):
"""The answer of the command the options name."""
command = options.command
if command == 'list':
return vpnrouter_users.list_groups(connection)
if command == 'add':
return vpnrouter_users.group_add(connection, options.name, options.description, options.members)
if command == 'update':
return vpnrouter_users.group_update(connection, options.name, options.new_name, options.description)
if command == 'remove':
return vpnrouter_users.group_remove(connection, options.name)
if command == 'member-add':
return vpnrouter_users.group_member_add(connection, options.name, options.user)
return vpnrouter_users.group_member_remove(connection, options.name, options.user)
def text_of(answer):
"""The answer as text for people."""
if answer.get('removed'):
return f'group {answer["name"]} removed'
groups = answer['groups'] if 'groups' in answer else [answer]
if not groups:
return 'no groups'
head = ('GROUP', 'MEMBERS', 'DESCRIPTION')
rows = [(group['name'], ', '.join(group['members']), group['description'] or '-') for group in groups]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
def main(argv):
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
as_json = '--json' in argv
try:
options = parse(argv)
as_json = options.json
with vpnrouter_users.session() as connection:
answer = run(connection, options)
except Failure as failure:
message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1
else:
print(json.dumps(answer) if as_json else text_of(answer))
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-group: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+95 -189
View File
@@ -1,224 +1,130 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Manage the users of the router's P2S systems. """Manage the users of the router's directory from the command line, with vpnrouter_users.
A user is an entry in the router's directory, whose password OpenVPN checks, and a client Passwords are read from the first line of standard input, never from the command line. The
certificate of the router CA with the user name as its common name, which OpenVPN and answer is text for people on standard output, and a failure is a message on standard error.
IKEv2 accept. Passwords are read from the first line of standard input, never from the With --json the answer is JSON on standard output, and a failure is {"error": ...} there too.
command line. The answer is JSON on standard output; on failure it is {"error": ...} and The exit status says what failed: 2 the request is invalid, 3 the user does not exist, 4 the
the exit status says why: 2 the request is invalid, 3 the user does not exist, 4 the user user exists, 1 anything else. -h or --help shows the commands and their options.
exists, 1 anything else.
vpn-router-user list vpn-router-user [--json] list
vpn-router-user add <name> (password on stdin) vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
vpn-router-user passwd <name> (password on stdin) vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
vpn-router-user disable <name> vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user enable <name> vpn-router-user [--json] disable <name>
vpn-router-user remove <name> vpn-router-user [--json] enable <name>
vpn-router-user profile <name> vpn-router-user [--json] remove <name>
vpn-router-user [--json] profile <name>
""" """
import fcntl import argparse
import json import json
import pathlib
import re
import subprocess
import sys import sys
sys.path.insert(0, '/usr/lib/vpn-router') sys.path.insert(0, '/usr/lib/vpn-router')
import ldap import ldap
import ldap.modlist
import vpnrouter import vpnrouter_users
import vpnrouter_ldap from vpnrouter_users import Failure
#: A user name is one lowercase DNS label, as it is also the common name of the certificate. #: The help line of each command.
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$') HELP = {
PASSWORD_MIN = 8 'list': 'list the users',
PASSWORD_MAX = 128 'add': 'add a user and issue the certificate; the password is read from standard input',
#: The value of pwdAccountLockedTime that locks an account until it is unlocked. 'update': 'change the first name, the last name or the e-mail address',
LOCKED = b'000001010000Z' 'passwd': 'set the password, read from standard input',
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock') 'disable': 'lock the user out of OpenVPN',
'enable': 'let the user in again',
'remove': 'revoke the certificate and remove the user',
'profile': 'print the OpenVPN client profile',
}
#: The help of the person options.
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address'}
class Failure(Exception): class Parser(argparse.ArgumentParser):
"""A request that cannot be done; status is the exit status.""" """An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
def __init__(self, message, status=1): def error(self, message):
super().__init__(message) raise Failure(f'{message}; see {self.prog} --help', 2)
self.status = status
def dn_of(name): def parse(argv):
return f'uid={name},{vpnrouter_ldap.USERS}' parser = Parser(prog='vpn-router-user', description='Manage the users of the router.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
def certificate(name): for command, text in HELP.items():
return vpnrouter.USER_CERT_DIR / f'{name}_cert.pem', vpnrouter.USER_CERT_DIR / f'{name}_key.pem' sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
if command != 'list':
sub.add_argument('name', help='the user name')
def entry(connection, name): if command in ('add', 'update'):
"""The attributes of the user's entry; Failure with status 3 when there is none.""" for key, _ in vpnrouter_users.PERSON:
try: sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add',
return connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['uid', 'pwdAccountLockedTime'])[0][1] help=PERSON_HELP[key])
except ldap.NO_SUCH_OBJECT: return parser.parse_args(argv)
raise Failure(f'user {name} does not exist', 3)
def password(): def password():
"""The password from the first line of standard input.""" """The password from the first line of standard input."""
value = sys.stdin.readline().rstrip('\n') return sys.stdin.readline().rstrip('\n')
if not PASSWORD_MIN <= len(value) <= PASSWORD_MAX:
raise Failure(f'the password must have {PASSWORD_MIN} to {PASSWORD_MAX} characters', 2)
return value
def simple_ca(*args): def run(connection, options, secret):
result = subprocess.run([sys.executable, str(vpnrouter.SIMPLE_CA), *args], capture_output=True, text=True) """The answer of the command the options name; secret is the password of add and passwd."""
if result.returncode != 0: command = options.command
raise Failure(f'simple-ca {args[0]} failed: {result.stderr.strip() or result.stdout.strip()}') if command == 'list':
return vpnrouter_users.list_users(connection)
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON}
if command == 'add':
return vpnrouter_users.add(connection, options.name, secret, values)
if command == 'update':
return vpnrouter_users.update(connection, options.name, values)
if command == 'passwd':
return vpnrouter_users.passwd(connection, options.name, secret)
return getattr(vpnrouter_users, command)(connection, options.name)
def publish_crl(): def text_of(answer):
"""Give strongSwan the CRL after a revocation; OpenVPN reads it at every connection.""" """The answer as text for people."""
if vpnrouter.install_file(vpnrouter.PKI_DIR / 'crl.pem', vpnrouter.CRL_DEST, 0o644): if 'profile' in answer:
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True) return answer['profile'].rstrip('\n')
if answer.get('removed'):
return f'user {answer["name"]} removed'
def summary(attributes): users = answer['users'] if 'users' in answer else [answer]
name = attributes['uid'][0].decode() if not users:
return { return 'no users'
'name': name, head = ('USER', 'STATE', 'CERTIFICATE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
'enabled': 'pwdAccountLockedTime' not in attributes, rows = [(user['name'], 'enabled' if user['enabled'] else 'disabled', 'issued' if user['certificate'] else 'missing',
'certificate': certificate(name)[0].is_file(), user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') for user in users]
} widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
def list_users(connection):
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)',
['uid', 'pwdAccountLockedTime'])
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
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')
try:
connection.add_s(dn_of(name), ldap.modlist.addModlist({
'objectClass': [b'inetOrgPerson'], 'uid': [name.encode()], 'cn': [name.encode()], 'sn': [name.encode()],
}))
except ldap.ALREADY_EXISTS:
raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, secret)
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
for path in certificate(name):
path.unlink(missing_ok=True)
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), name)
return summary(entry(connection, name))
def passwd(connection, name):
secret = password()
entry(connection, name)
connection.passwd_s(dn_of(name), None, secret)
return summary(entry(connection, name))
def disable(connection, name):
if 'pwdAccountLockedTime' not in entry(connection, name):
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
return summary(entry(connection, name))
def enable(connection, name):
if 'pwdAccountLockedTime' in entry(connection, name):
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
return summary(entry(connection, name))
def remove(connection, name):
entry(connection, name)
cert, key = certificate(name)
if cert.is_file():
simple_ca('revoke-cert', '--ca-dir', str(vpnrouter.PKI_DIR), str(cert))
simple_ca('make-crl', '--ca-dir', str(vpnrouter.PKI_DIR), '--days', str(vpnrouter.CRL_DAYS))
publish_crl()
cert.unlink(missing_ok=True)
key.unlink(missing_ok=True)
connection.delete_s(dn_of(name))
return {'name': name, 'removed': True}
def profile(connection, name):
"""The OpenVPN client profile of the user, with the CA, the certificate and the key inline."""
entry(connection, name)
cfg = vpnrouter.load_config()
if not cfg['openvpn']['enabled']:
raise Failure('OpenVPN is not enabled', 2)
cert, key = certificate(name)
if not cert.is_file() or not key.is_file():
raise Failure(f'user {name} has no certificate')
fqdn = cfg['wan']['local_fqdn'].strip()
text = '\n'.join([
'client',
'dev tun',
f'proto {cfg["openvpn"]["protocol"]}',
f'remote {fqdn} {cfg["openvpn"]["port"]}',
'nobind',
'persist-key',
'persist-tun',
'remote-cert-tls server',
f'verify-x509-name {fqdn} name',
'auth-user-pass',
'auth-nocache',
'verb 3',
'<ca>', extract_pem(vpnrouter.CA_CERT_DEST.read_text(encoding='utf-8')), '</ca>',
'<cert>', extract_pem(cert.read_text(encoding='utf-8')), '</cert>',
'<key>', key.read_text(encoding='utf-8').strip(), '</key>',
'',
])
return {'name': name, 'profile': text}
def extract_pem(text):
"""The PEM blocks of the certificates in a file that also holds their text form."""
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
blocks = []
start = text.find(begin)
while start != -1:
stop = text.index(end, start) + len(end)
blocks.append(text[start:stop])
start = text.find(begin, stop)
return '\n'.join(blocks)
COMMANDS = {'list': list_users, 'add': add, 'passwd': passwd, 'disable': disable, 'enable': enable,
'remove': remove, 'profile': profile}
def main(argv): def main(argv):
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
as_json = '--json' in argv
try: try:
if not argv or argv[0] not in COMMANDS or len(argv) != (1 if argv[0] == 'list' else 2): options = parse(argv)
raise Failure('usage: vpn-router-user list | add|passwd|disable|enable|remove|profile <name>', 2) as_json = options.json
if len(argv) == 2 and not NAME.match(argv[1]): if options.command != 'list':
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2) vpnrouter_users.check_name(options.name)
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True) secret = password() if options.command in ('add', 'passwd') else None
with open(LOCK_FILE, 'w') as lock: with vpnrouter_users.session() as connection:
fcntl.flock(lock, fcntl.LOCK_EX) answer = run(connection, options, secret)
connection = vpnrouter_ldap.manager()
try:
answer = COMMANDS[argv[0]](connection, *argv[1:])
finally:
connection.unbind_s()
except Failure as failure: except Failure as failure:
print(json.dumps({'error': str(failure)})) message, status = str(failure), failure.status
return failure.status
except (ldap.LDAPError, OSError) as exc: except (ldap.LDAPError, OSError) as exc:
print(json.dumps({'error': f'the user directory failed: {exc}'})) message, status = f'the user directory failed: {exc}', 1
return 1 else:
print(json.dumps(answer)) print(json.dumps(answer) if as_json else text_of(answer))
return 0 return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-user: {message}', file=sys.stderr)
return status
if __name__ == '__main__': if __name__ == '__main__':
@@ -45,7 +45,7 @@
}, },
"web": { "web": {
"enabled": true, "enabled": true,
"port": 8080, "port": 7081,
"jwt_secret": "" "jwt_secret": ""
} }
} }
View File
File renamed without changes.