feature: users sign in to the web service, and only members of admins manage the router.
Release / release (push) Failing after 16m57s

This commit is contained in:
slawek committed 2026-10-24 04:19:01 +02:00
1 parent 791fc7fddd
commit caf0e334f0
17 files changed
+792 -177

No files matched your search

+122 -46
View File
@@ -52,27 +52,31 @@ configuration. It runs as root, because `swanctl` and `wg` need it, with `NoNewP
`ProtectSystem=strict`, `ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a `ProtectSystem=strict`, `ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a
browser on a network without internet shows the unstyled page, and `curl` is unaffected. browser on a network without internet shows the unstyled page, and `curl` is unaffected.
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret ## ADR-3: The protected paths take JWTs that only the router signs
Status: Accepted Status: Accepted
Context: A client such as the Router Manager needs the configuration and the resolved state of Context: Clients such as the Router Manager, and people with `curl`, need the configuration,
the router, which `/` and `/status` must not show. The service listens on plain HTTP on the the users and the groups of the router, which `/` and `/status` must not show. Both people and
internal network, and its configuration file is readable by root only. The platform that deploys programs sign in with a user of the directory. A secret shared with the platform that deploys
the router generates one secret and gives it to the router's configuration and to the client. the router would let whoever holds it, and every copy of the deployment's state, make tokens.
The service listens on plain HTTP on the internal network.
Decision: `/config` needs a JWT in an `Authorization: Bearer` header. The service verifies HS256 Decision: `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header,
tokens with `hmac` and `hashlib`, with the secret `web.jwt_secret`, and accepts no other HS256, verified with `hmac` and `hashlib`; no other algorithm is accepted. The key is
algorithm, because a shared secret is what both sides have; RS256 or ES256 would need a key pair `/etc/vpn-router/web.key`, random, made by `vpn-router-setup` on the router and readable by root
and its distribution. The token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for only; it is never configured or output, and `web.jwt_secret` is ignored. The router itself issues
each use and a leaked one expires. Without a secret the page is not served. The configuration the tokens: `POST /auth` checks a user's password and TOTP code with a bind (ADR-14) and answers
shown has no secret options (ADR-8). an access token naming the user in `sub`, valid for 5 minutes, and a refresh token valid for 8
hours (ADR-15); `vpn-router-token` makes tokens with the `admin` claim (ADR-13). A token without
`admin` must expire within ten minutes, so a leaked one is of little use. Without the key the
paths and `/auth` are not served. The configuration shown has no secret options (ADR-8).
Consequences: The client and the router share one secret, and whoever holds it can make tokens, Consequences: Nothing outside the router can make a token, so a token is either issued to a
so it is long and random; it is checked to have at least 32 characters. The secret is only in the user who signed in or made by root on the router. A client keeps the refresh token to stay
configuration file, readable by root only. The token and the answer cross the network signed in. A token of `vpn-router-token` can be revoked only by replacing the key, which ends
unencrypted, so the page is for a trusted internal network; HTTPS would need a certificate that every token. Tokens, passwords and answers cross the network unencrypted, so the API is for a
the package does not have. trusted internal network; HTTPS would need a certificate that the package does not have.
## ADR-4: Users are kept in a local OpenLDAP directory ## ADR-4: Users are kept in a local OpenLDAP directory
@@ -144,8 +148,8 @@ the CA's state, and `/etc/swanctl/x509crl` for the published CRL.
Consequences: The rules exist once. A request starts no process, and a password never leaves Consequences: The rules exist once. A request starts no process, and a password never leaves
the process that received it. The web service imports `python3-ldap` and the package's the process that received it. The web service imports `python3-ldap` and the package's
configuration module. The API is protected by the token of `/config`, so whoever has the secret configuration module. The API is protected by the tokens of ADR-3, so only members of `admins`
can manage users. and the holders of an admin token 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
@@ -161,14 +165,14 @@ thread works out, every 30 seconds, the internal address from `vpn-router.json`
too, and `web.port` and `web.enabled` from the same file. It runs the HTTP server in a thread of too, and `web.port` and `web.enabled` from the same file. It runs the HTTP server in a thread of
its own at that address and port, stops it and starts a new one when they change, and runs none its own at that address and port, stops it and starts a new one when they change, and runs none
while the service is disabled, the address is not known or it cannot be bound. Each request while the service is disabled, the address is not known or it cannot be bound. Each request
reads the token secret from `vpn-router.json` and the features and resolved facts from reads the signing key and `vpn-router.json`, and the features and resolved facts from
`web.json`, which setup writes when it applies the configuration, so the service shows what setup `web.json`, which setup writes when it applies the configuration, so the service shows what setup
applied; 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. A change of the secret applies to the next request. The interfaces are read every 30 restart. A new signing key applies to the next request. The interfaces are read every 30
seconds, which costs a few `ip` commands. seconds, which costs a few `ip` commands.
## ADR-8: /config leaves secret options out ## ADR-8: /config leaves secret options out
@@ -179,8 +183,8 @@ Context: A secret has no use outside the router, so a client never needs it, and
that a secret is set tells a reader more than it needs. that a secret is set tells a reader more than it needs.
Decision: The configuration that `vpn-router-setup` writes for the web service has no secret Decision: The configuration that `vpn-router-setup` writes for the web service has no secret
options at all. They are recognised by name: `psk`, and options with `secret`, `password`, options at all. They are recognised by name: `psk`, `database`, whose connection string may hold
`token` or a `_key` ending in the name. a password, and options with `secret`, `password`, `token` or a `_key` ending in the name.
Consequences: No secret reaches the web service's state file or any answer of the API. A client Consequences: No secret reaches the web service's state file or any answer of the API. A client
cannot tell whether a secret is set. A new secret option needs a name that matches. cannot tell whether a secret is set. A new secret option needs a name that matches.
@@ -190,26 +194,28 @@ cannot tell whether a secret is set. A new secret option needs a name that match
Status: Accepted Status: Accepted
Context: The router needs a web application to manage it, which may run on the router or on 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 another Linux system. The router's web service issues tokens through `/auth` (ADR-3) and sends
`/config` and `/users` and sends no CORS headers, so a browser cannot call it with the secret no CORS headers, so a browser cannot call it directly, and tokens kept in the page could be read
kept away from the page. by its scripts.
Decision: `vpn-router-manager` is a Debian package of its own, built in the repository 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 `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 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>` Ubuntu packages `python3-requests` and `python3-psycopg`, serves them, signs users in through the
call to the router's `/<path>`, signing a short-lived token with the router's secret; it holds router's `/auth`, keeps their tokens in a server-side session in PostgreSQL behind an
no logic of the router's own. It reads its settings from `/etc/vpn-router-manager/manager.json` `HttpOnly` cookie, and passes every other `/api/<path>` call to the router's `/<path>` with the
at each request; on the router the settings not given come from `vpn-router.json`, and it session's access token, refreshing it as needed; it holds no logic of the router's own and no
listens on the internal address like the status web service, with the same discovery thread. key of it. It reads its settings from `/etc/vpn-router-manager/manager.json` at each request; on
`vpn-router-setup` opens the manager's port on the internal interface while the manager is the router the settings not given come from `vpn-router.json`, and it listens on the internal
installed, so the firewall stays in one place. The manager asks for no authentication. 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.
Consequences: The router needs no Node.js; the package needs only `python3` and packages of the 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 Ubuntu archive. Tokens never reach the browser, and sessions survive a restart of the manager.
answer of the router. Anyone who reaches the manager's port manages the router, so on another The router decides what a user may do; the manager only signs in. The application checks the
system it listens on `127.0.0.1` unless told otherwise. The router's firewall depends on whether shape of every answer of the router. The router's firewall depends on whether the manager is
the manager is installed, and a changed manager port needs `vpn-router-setup` to run again. 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 ## ADR-10: Users and their certificates do not depend on a P2S system being enabled
@@ -252,13 +258,15 @@ groups, such as allowing OpenVPN only to some, can read them as they are.
Status: Accepted Status: Accepted
Context: Users are to be enrolled for TOTP one-time passwords, with nothing checking the codes Context: Users are enrolled for TOTP one-time passwords, and enrolled users must give a code
yet. Ubuntu's slapd ships the `otp` module, which holds the OATH-LDAP schema and an overlay that whenever they log in, to OpenVPN and to `/auth`. Ubuntu's slapd ships the `otp` module, which
checks a code appended to the password at bind time. Ubuntu packages `python3-pyotp`. An holds the OATH-LDAP schema and an overlay that checks a code appended to the password at bind
authenticator app is enrolled from a QR code of an `otpauth://` URI. time; both OpenVPN (`openvpn-auth-ldap`) and `/auth` check passwords with a bind. Ubuntu
packages `python3-pyotp`. An authenticator app is enrolled from a QR code of an `otpauth://` URI.
Decision: The `otp` module is loaded for its schema; its overlay is not configured. The TOTP Decision: The `otp` module is loaded and its overlay is configured on the package's database,
token is the user entry itself: enrolment adds `oathTOTPUser` and `oathTOTPToken` to it, with after ppolicy, so every bind of an enrolled user takes the password followed by a current code
and slapd records the time step used. The TOTP token is the user entry itself: enrolment adds `oathTOTPUser` and `oathTOTPToken` to it, with
`oathTOTPToken` naming the entry, the secret in `oathSecret` and `oathTOTPParams` naming one `oathTOTPToken` naming the entry, the secret in `oathSecret` and `oathTOTPParams` naming one
shared parameter entry, `cn=totp,ou=policies`, with the parameters authenticator apps assume shared parameter entry, `cn=totp,ou=policies`, with the parameters authenticator apps assume
(6 digits, HMAC-SHA1, 30 seconds). An ACL lets the user manager account set `oathSecret` and (6 digits, HMAC-SHA1, 30 seconds). An ACL lets the user manager account set `oathSecret` and
@@ -267,7 +275,75 @@ with its URI, storing nothing, and `otp_enrol` stores the secret it is given bac
code of it verifies, so a secret is never stored that the user's app does not produce. The code of it verifies, so a secret is never stored that the user's app does not produce. The
router answers the URI; the client renders the QR code. router answers the URI; the client renders the QR code.
Consequences: Removing a user removes its token. Configuring the overlay later makes slapd Consequences: Removing a user removes its token. One check covers OpenVPN and `/auth`, and
check the stored tokens without moving data. The secret passes through the client between an enrolled user types the code after the password in the OpenVPN client. The secret passes
`otp_new` and `otp_enrol`, so the router keeps no pending state; enrolling again replaces the through the client between `otp_new` and `otp_enrol`, so the router keeps no pending state;
secret only once a code of the new one verifies. enrolling again replaces the secret only once a code of the new one verifies.
## ADR-13: Members of admins manage the router; only CLI tokens change admins
Status: Accepted
Context: Signing in through `/auth` (ADR-3) is open to every user of the directory, most of
whom only use OpenVPN. Finer permissions per operation are not needed. Somebody must be able to
manage the router before any administrator exists and after every administrator lost access,
and administrators must not be able to grant themselves or others the administrator role over
the API.
Decision: A token naming a user in `sub` may call the protected paths only while the user is a
member of the group `admins`, read at each request; other users get 403. `vpn-router-token`, run
as root on the router, makes tokens with the claim `admin: true` and a lifetime given at
creation, without a maximum, signed with the router's key; `/auth` never issues the claim. An
admin token may call everything, and only an admin token may change `admins`: create it, rename a
group to it, change or remove it, add or take out its members, or remove a user who is a member
of it. The first member is added with `vpn-router-group` on the router, which acts as root.
Consequences: Removing a user from `admins` takes effect at the next request, without waiting
for the user's tokens to expire. Membership of `admins` changes only through root on the router.
An admin token is as powerful as root over the API and is revoked only by replacing the key, so
it is given a short lifetime unless it is meant for automation.
## ADR-14: Failed logins lock the account in slapd; /auth is rate limited per client
Status: Accepted
Context: `/auth` is open to the internal network and checks passwords, so it invites guessing;
OpenVPN checks passwords from the internet. Both check with a bind, so slapd sees every attempt.
ppolicy locks an account after a number of failed binds for a fixed time; it has no growing
lockout. A Router Manager signs all its users in from one address.
Decision: The default password policy locks an account for 15 minutes after 5 failed binds
within 15 minutes; a successful bind clears the count; `vpn-router-user unlock` and
`POST /users/<name>/unlock` end a lockout and clear the count, and leave a disabled user disabled.
`/auth` answers the same 401 for a wrong name, password or code and for a locked or disabled
user. `/auth` and `/auth/refresh` allow 10 calls a minute per client address, counted in memory,
and answer 429 with `Retry-After` beyond that. The client address is the peer, or the last
`X-Forwarded-For` entry for a peer in `web.trusted_proxies`, by default the address the service
listens on, where a manager on the router calls from.
Consequences: One lockout covers OpenVPN and `/auth`, kept by slapd alone. Someone who knows a
user name can lock the user out for 15 minutes at a time. The rate limit restarts with the
service. A manager elsewhere than on the router has its address added to `web.trusted_proxies`,
or its users share one limit.
## ADR-15: Refresh tokens are kept in PostgreSQL
Status: Accepted
Context: An access token lives 5 minutes, and a client stays signed in with a refresh token for
8 hours; refresh tokens must be revocable when a user signs out, is disabled, changes the
password or is removed, and must survive a restart of the service. The Router Manager keeps its
sessions in PostgreSQL too.
Decision: Refresh tokens are random strings, stored as their SHA-256 with the user name and the
expiry in the table `refresh_tokens`, through psycopg 3 (`python3-psycopg`). The database is
`web.database`, or by default the database and role `vpn-router` on the router, which
`vpn-router-setup` creates with `runuser -u postgres -- psql` and a random password in
`/etc/vpn-router/database.secret`, reached over TCP on `localhost`, as Ubuntu's `pg_hba.conf`
takes peer authentication on the local socket. A refresh token is used once: refreshing deletes
it and issues a new one, after checking that the user is neither disabled nor locked.
Consequences: The package depends on `postgresql` and `python3-psycopg`. A stolen database holds
no usable token. Changing a user's password, disabling, unlocking or removing the user, and
signing out, end the user's sessions at once; an access token already issued lives out its
5 minutes.
+74 -38
View File
@@ -125,7 +125,8 @@ must have the type shown below; an unknown section or setting is an error.
| `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 | `7081` | 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` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that `vpn-router-setup` creates |
| `web` | `trusted_proxies` | list | the listening address | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit |
A minimal site-to-site configuration in `auto` mode: A minimal site-to-site configuration in `auto` mode:
@@ -310,7 +311,7 @@ certificate names:
- the certificate is checked against the CA and the CRL (`crl-verify`, read at every - the certificate is checked against the CA and the CRL (`crl-verify`, read at every
connection); connection);
- the password is checked by `openvpn-auth-ldap`, which binds to the user directory as the - the password is checked by `openvpn-auth-ldap`, which binds to the user directory as the
user; user; a user enrolled for TOTP types the password followed by the current 6-digit code;
- `/usr/lib/vpn-router/openvpn-same-user` refuses a login whose user name is not the common - `/usr/lib/vpn-router/openvpn-same-user` refuses a login whose user name is not the common
name of the certificate. name of the certificate.
@@ -331,11 +332,14 @@ is switched on:
`/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's `otp` module is loaded for the OATH-LDAP schema; a user enrolled for TOTP is also an - the password policy locks an account for 15 minutes after 5 failed binds within 15 minutes,
whether they come from OpenVPN or from `/auth`; a successful bind clears the count, and
`vpn-router-user unlock` ends a lockout;
- slapd's `otp` overlay is configured on the database; a user enrolled for TOTP is also an
`oathTOTPUser` and `oathTOTPToken` whose `oathTOTPToken` names the entry itself, with the `oathTOTPUser` and `oathTOTPToken` whose `oathTOTPToken` names the entry itself, with the
secret in `oathSecret`, which the user manager account may set and no one can read, and secret in `oathSecret`, which the user manager account may set and no one can read, and
`oathTOTPParams` naming `cn=totp,ou=policies` (6 digits, HMAC-SHA1, 30 second steps, one step `oathTOTPParams` naming `cn=totp,ou=policies` (6 digits, HMAC-SHA1, 30 second steps, one step
of tolerance). The module's overlay is not configured, so binds take the password alone; of tolerance). A bind of an enrolled user takes the password followed by a current code;
- 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 on purge. The directory is kept on purge.
@@ -361,6 +365,7 @@ 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
vpn-router-user unlock alice
vpn-router-user remove alice vpn-router-user remove alice
vpn-router-user otp-new alice vpn-router-user otp-new alice
echo 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' | vpn-router-user otp-enrol alice 123456 echo 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' | vpn-router-user otp-enrol alice 123456
@@ -369,11 +374,12 @@ vpn-router-user otp-remove alice
| Command | Effect | | Command | Effect |
| --- | --- | | --- | --- |
| `list` | The users with their names and e-mail addresses, whether each is enabled, has a certificate and is enrolled for TOTP | | `list` | The users with their names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
| `add <name> --first-name F --last-name L --email E` | Adds the user and issues the certificate; all three are required | | `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 | | `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 `/auth`, and lets the user in again |
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
| `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 | | `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 |
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored | | `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
@@ -383,8 +389,8 @@ vpn-router-user otp-remove alice
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; a first or last name has 1 to 64 printable characters; an 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; a TOTP secret is 32 base32 e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
characters, as `otp-new` makes it, and a code 6 digits. Enrolment does not change how users log characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
in. In the answers a user added and `/auth` with the password followed by a current code. 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, 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.
@@ -471,6 +477,7 @@ configuration: no key, identity, address or subnet.
| --- | --- | | --- | --- |
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) | | `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
| `/status` | The same status as JSON | | `/status` | The same status as JSON |
| `/auth` | Signing in with a user's password and TOTP code, refreshing and signing out |
| `/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 | | `/groups` | The groups of users; needs a token |
@@ -490,43 +497,70 @@ curl http://10.0.0.4:7081/
curl http://10.0.0.4:7081/status curl http://10.0.0.4:7081/status
``` ```
### Tokens and signing in
`/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header, signed with
HS256 by the key `vpn-router-setup` makes on the router, `/etc/vpn-router/web.key` (root only).
The key never leaves the router; while it does not exist these paths and `/auth` are not served
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
`WWW-Authenticate: Bearer`. There are two kinds of tokens:
- an access token of `/auth` names a user in `sub` and is valid for 5 minutes; the user must
be a member of the group `admins`, otherwise the request gets 403;
- a token of `vpn-router-token`, run as root on the router, has the claim `admin` and is valid
for the lifetime given, without a maximum. It may call everything, and only such a token may
change the group `admins`: create it, rename a group to it, change or remove it, add or take
out its members, or remove a user who is a member of it.
The first member of `admins` is added on the router:
```sh
vpn-router-group add admins --member alice --description 'Router administrators'
vpn-router-token --hours 1
vpn-router-token --json --days 30
```
`POST /auth` signs a user in. Request and answer bodies are JSON:
| Request | Body | Answer |
| --- | --- | --- |
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens |
| `POST /auth/refresh` | `{"refresh_token"}` | new tokens; the refresh token given is used up |
| `POST /auth/logout` | `{"refresh_token"}` | `{"revoked": true}`, or `false` for a token not known |
The tokens are `{"access_token", "token_type": "Bearer", "expires_in": 300, "refresh_token",
"refresh_expires_in": 28800}`. A wrong name, password or code, and a locked or disabled user,
get 401 with the same message; a refresh token is refused once its user is disabled, locked or
removed. Changing a user's password, disabling, unlocking or removing the user revokes the
user's refresh tokens. Five failed logins lock the account for 15 minutes (see the user
directory). `/auth` and `/auth/refresh` take at most 10 calls a minute from one client address,
and answer 429 with `Retry-After` beyond that; for a call from one of `web.trusted_proxies`, by
default the address the service listens on, the client address is the last entry of
`X-Forwarded-For`.
Refresh tokens are kept, as their SHA-256, in PostgreSQL: by default in the database and role
`vpn-router` on the router, which `vpn-router-setup` creates with a password in
`/etc/vpn-router/database.secret` (root only), reached over TCP on `localhost`; `web.database`
names another database. Tokens and passwords travel over plain HTTP on the internal network.
```bash
curl -H 'Content-Type: application/json' -d '{"name": "alice", "password": "s3cr3t-pass", "code": "123456"}' \
http://10.0.0.4:7081/auth
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
```
### The /config page ### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui: `/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
| Key | Content | | Key | Content |
| --- | --- | | --- | --- |
| `configuration` | `vpn-router.json` with the defaults applied, without its secret options (`psk`, `jwt_secret`, and any option named like a password, token or key) | | `configuration` | `vpn-router.json` with the defaults applied, without its secret options (`psk`, `database`, and any option named like a secret, password, token or key) |
| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them | | `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them |
| `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values | | `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values |
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key | | `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain | | `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
The page needs a JWT in an `Authorization: Bearer` header, signed with HS256 by `web.jwt_secret`.
The token must have an `exp` claim, not more than ten minutes from now (a minute of clock
difference is allowed); `nbf` is checked when present. Other algorithms are refused, with 401 and
`WWW-Authenticate: Bearer`. While `web.jwt_secret` is not set the page is not served (404). The
token and the page travel over plain HTTP on the internal network, and the token can be used
until it expires, so keep the lifetime short. A client makes a token from the secret with only
HMAC-SHA256; with Python:
```python
import base64, hashlib, hmac, json, time
def b64(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
secret = 'the web.jwt_secret value'
head = b64(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
body = b64(json.dumps({'exp': int(time.time()) + 300}).encode())
sign = b64(hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest())
print(f'{head}.{body}.{sign}')
```
```bash
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
```
### The /users API ### The /users API
`/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses `/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses
@@ -535,12 +569,13 @@ 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", "otp", "first_name", "last_name", "email"}, ...]}` | | `GET /users` | | `{"users": [{"name", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}`, all required | 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>` | 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 |
| `POST /users/<name>/unlock` | | the user |
| `DELETE /users/<name>` | | `{"name", "removed": true}` | | `DELETE /users/<name>` | | `{"name", "removed": true}` |
| `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile | | `GET /users/<name>/profile` | | `{"name", "profile"}`, the OpenVPN client profile |
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored | | `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
@@ -580,7 +615,7 @@ The service starts on its own, without waiting for `vpn-router-setup`, and keeps
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
changes, and stops listening while `web.enabled` is `false` or the address is not known. Each changes, and stops listening while `web.enabled` is `false` or the address is not known. Each
request reads the token secret from `vpn-router.json`, and the features and the facts for request reads the signing key and `vpn-router.json`, and the features and the facts for
`/config` from `/run/vpn-router/web.json` (readable by root only), which `vpn-router-setup` writes; `/config` from `/run/vpn-router/web.json` (readable by root only), which `vpn-router-setup` writes;
before that file exists the page shows no features. `vpn-router-setup` opens `web.port` on the 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.
@@ -603,8 +638,9 @@ may be in use by something else.
`apt-get purge` additionally removes the generated files and the firewall rules, restoring `apt-get purge` additionally removes the generated files and the firewall rules, restoring
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf` `/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
before deleting it so a later reinstall starts clean. Key material and users are never before deleting it so a later reinstall starts clean. It also removes the web service's
deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the signing key, and the refresh tokens' database, its role and their password file when the package
created them. Key material and users are never deleted: `/etc/vpn-router/pki`, `/etc/vpn-router/ldap`, the user directory in slapd, the
WireGuard key pair and everything under `/etc/swanctl` are left alone. WireGuard key pair and everything under `/etc/swanctl` are left alone.
## Building ## Building
+26
View File
@@ -1,3 +1,29 @@
vpn-router (0.7.0-1) unstable; urgency=medium
feature: users sign in to the web service, and only members of admins manage the router.
* POST /auth signs a user in with the password, and the TOTP code for an
enrolled user, and answers a 5 minute access token and an 8 hour refresh
token; /auth/refresh renews them and /auth/logout revokes the refresh
token.
* /config, /users and /groups take tokens of users in the group admins,
and tokens of the new vpn-router-token, which has an admin claim and a
chosen lifetime. Only these may change the group admins.
* The tokens are signed with /etc/vpn-router/web.key, which
vpn-router-setup makes on the router; web.jwt_secret is ignored.
* Five failed logins lock an account for 15 minutes, for OpenVPN and
/auth. vpn-router-user unlock and POST /users/<name>/unlock end the
lockout, and the users show locked.
* /auth takes at most 10 calls a minute per client address; for calls from
web.trusted_proxies the address is read from X-Forwarded-For.
* Users enrolled for TOTP log in to OpenVPN with the password followed by
the code.
* Refresh tokens are kept in PostgreSQL, in the database web.database
names or in the vpn-router database vpn-router-setup creates. The
package depends on postgresql and python3-psycopg.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 01:00:00 +0200
vpn-router (0.6.0-1) unstable; urgency=medium vpn-router (0.6.0-1) unstable; urgency=medium
feature: users can be enrolled for TOTP one-time passwords. feature: users can be enrolled for TOTP one-time passwords.
+2
View File
@@ -22,6 +22,8 @@ Depends: ${misc:Depends},
python3-jinja2, python3-jinja2,
python3-ldap, python3-ldap,
python3-pyotp, python3-pyotp,
python3-psycopg,
postgresql,
slapd, slapd,
openvpn, openvpn,
openvpn-auth-ldap, openvpn-auth-ldap,
+2
View File
@@ -3,9 +3,11 @@ 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/vpnrouter_users.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_auth.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/sbin/vpn-router-group usr/sbin/
src/usr/sbin/vpn-router-token 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/
+9 -1
View File
@@ -49,7 +49,15 @@ case "$1" in
/etc/wireguard/wg0.conf \ /etc/wireguard/wg0.conf \
/etc/openvpn/server/vpn-router.conf \ /etc/openvpn/server/vpn-router.conf \
/etc/openvpn/server/vpn-router-ldap.conf \ /etc/openvpn/server/vpn-router-ldap.conf \
/etc/vpn-router/vpn-router.json /etc/vpn-router/vpn-router.json \
/etc/vpn-router/web.key
# The refresh tokens' database and role, when the package created them.
if [ -f /etc/vpn-router/database.secret ] && command -v psql >/dev/null 2>&1; then
runuser -u postgres -- psql -X -q -c 'DROP DATABASE IF EXISTS "vpn-router"' || true
runuser -u postgres -- psql -X -q -c 'DROP ROLE IF EXISTS "vpn-router"' || true
fi
rm -f /etc/vpn-router/database.secret
# Key material and users are not package state: /etc/vpn-router/pki, # Key material and users are not package state: /etc/vpn-router/pki,
# /etc/vpn-router/ldap, the user directory in slapd, the WireGuard key # /etc/vpn-router/ldap, the user directory in slapd, the WireGuard key
@@ -1,8 +1,8 @@
[Unit] [Unit]
Description=VPN Router Setup Description=VPN Router Setup
Documentation=file:/usr/share/doc/vpn-router/README.md Documentation=file:/usr/share/doc/vpn-router/README.md
Wants=network-online.target Wants=network-online.target postgresql.service
After=network-online.target docker.service After=network-online.target docker.service postgresql.service
[Service] [Service]
Type=oneshot Type=oneshot
@@ -1,7 +1,7 @@
[Unit] [Unit]
Description=VPN Router status web service Description=VPN Router status web service
Documentation=file:/usr/share/doc/vpn-router/README.md Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target After=network-online.target postgresql.service
Wants=network-online.target Wants=network-online.target
[Service] [Service]
+12 -1
View File
@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ldap import ldap
import vpnrouter import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap import vpnrouter_ldap
from vpnrouter import fail from vpnrouter import fail
@@ -39,7 +40,7 @@ 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.
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$') SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$|^database$')
BLOCKS = ( BLOCKS = (
('# IPSEC RULES START', '# IPSEC RULES END'), ('# IPSEC RULES START', '# IPSEC RULES END'),
@@ -357,6 +358,15 @@ def manage_directory():
fail(f'the user directory cannot be provisioned: {exc}') fail(f'the user directory cannot be provisioned: {exc}')
def manage_tokens(cfg):
"""Make the web service's signing key, and the database of its refresh tokens unless web.database names one."""
vpnrouter_auth.ensure_key()
try:
vpnrouter_auth.provision(cfg)
except OSError as exc:
fail(f'the token database cannot be provisioned: {exc}')
def manage_openvpn(ctx, changed): def manage_openvpn(ctx, changed):
"""Run the OpenVPN server when it is configured, and stop it when it is not.""" """Run the OpenVPN server when it is configured, and stop it when it is not."""
unit = 'openvpn-server@vpn-router' unit = 'openvpn-server@vpn-router'
@@ -464,6 +474,7 @@ def main():
run(['ufw', 'reload'], check=False) run(['ufw', 'reload'], check=False)
manage_directory() manage_directory()
manage_tokens(cfg)
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,12 +54,10 @@ 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': 7081, 'jwt_secret': ''}, # web.jwt_secret is read and ignored; the tokens are signed with vpnrouter_auth.WEB_KEY.
'web': {'enabled': True, 'port': 7081, 'jwt_secret': '', 'database': '', 'trusted_proxies': []},
} }
#: The shortest secret that signs the tokens of the status web service.
WEB_SECRET_MIN = 32
DOCKER_MODES = ('auto', 'on', 'off') DOCKER_MODES = ('auto', 'on', 'off')
OPENVPN_PROTOCOLS = ('udp', 'tcp') OPENVPN_PROTOCOLS = ('udp', 'tcp')
@@ -262,9 +260,8 @@ def validate(cfg):
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535: if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
raise ConfigError(f'web.port: {web_port!r} is not a valid port') raise ConfigError(f'web.port: {web_port!r} is not a valid port')
web_secret = _get(cfg, 'web', 'jwt_secret') for item in _get_list(cfg, 'web', 'trusted_proxies'):
if web_secret and len(web_secret) < WEB_SECRET_MIN: _check_address(item, 'web.trusted_proxies')
raise ConfigError(f'web.jwt_secret: use at least {WEB_SECRET_MIN} characters')
docker_mode = _get(cfg, 'docker', 'mode') docker_mode = _get(cfg, 'docker', 'mode')
if docker_mode not in DOCKER_MODES: if docker_mode not in DOCKER_MODES:
@@ -0,0 +1,194 @@
"""The tokens of the router's web service, for the web service and vpn-router-token.
Tokens are HS256 JWTs signed with WEB_KEY, a key vpn-router-setup makes on the router and that
never leaves it. /auth issues an access token naming the user in sub, valid ACCESS_SECONDS, and
a refresh token valid REFRESH_SECONDS; vpn-router-token issues tokens with the admin claim and
any lifetime. A token without the admin claim must expire within MAX_LIFETIME seconds.
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of
web.database, by default the vpn-router database on this host that provision() creates, with
the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
"""
import base64
import hashlib
import hmac
import json
import pathlib
import secrets
import subprocess
import time
import psycopg
from psycopg.conninfo import make_conninfo
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
#: The role and the database provision() creates when web.database is not set.
DATABASE = 'vpn-router'
#: Seconds an access token of /auth and a refresh token are valid.
ACCESS_SECONDS = 300
REFRESH_SECONDS = 8 * 3600
#: The longest a token without the admin claim may be valid, from now, in seconds.
MAX_LIFETIME = 600
#: Seconds of clock difference accepted between a token and this router.
LEEWAY = 60
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
def key():
"""The signing key, or '' while vpn-router-setup has not made it."""
try:
return WEB_KEY.read_text(encoding='utf-8').strip()
except OSError:
return ''
def ensure_key():
"""Make the signing key when it is missing."""
if not key():
WEB_KEY.parent.mkdir(parents=True, exist_ok=True)
WEB_KEY.touch(mode=0o600)
WEB_KEY.chmod(0o600)
WEB_KEY.write_text(secrets.token_urlsafe(36) + '\n', encoding='utf-8')
def b64encode(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def b64decode(part):
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
def sign(claims, secret):
head = b64encode(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
body = b64encode(json.dumps(claims).encode())
signature = hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest()
return f'{head}.{body}.{b64encode(signature)}'
def verify(token, secret):
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token must expire
(exp); without the admin claim not later than MAX_LIFETIME seconds from now, so that a stolen
token is of little use. LEEWAY seconds of clock difference are allowed.
"""
try:
header, payload, signature = token.split('.')
if json.loads(b64decode(header)).get('alg') != 'HS256':
return None
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64decode(signature)):
return None
claims = json.loads(b64decode(payload))
now = time.time()
expires = claims['exp']
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or expires < now - LEEWAY:
return None
if claims.get('admin') is not True and expires > now + MAX_LIFETIME + LEEWAY:
return None
not_before = claims.get('nbf')
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
return None
except (ValueError, KeyError, TypeError, AttributeError):
return None
return claims
def admin_token(seconds):
"""A token with the admin claim, valid for the seconds given."""
secret = key()
if not secret:
raise OSError(f'{WEB_KEY} does not exist; vpn-router-setup makes it once a platform is chosen')
now = int(time.time())
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
def connection_string(cfg):
"""web.database, or the database provision() creates."""
configured = cfg['web']['database'].strip()
if configured:
return configured
password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
return make_conninfo(host='localhost', dbname=DATABASE, user=DATABASE, password=password)
def connect(cfg):
"""A connection to the database of the refresh tokens, with their table."""
connection = psycopg.connect(connection_string(cfg), autocommit=True)
connection.execute(SCHEMA)
return connection
def digest(token):
return hashlib.sha256(token.encode()).hexdigest()
def issue(cfg, name):
"""An access token and a refresh token for the user, as /auth answers them."""
secret = key()
now = int(time.time())
refresh = secrets.token_urlsafe(32)
with connect(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE expires < now()')
connection.execute("INSERT INTO refresh_tokens VALUES (%s, %s, now() + make_interval(secs => %s))",
(digest(refresh), name, REFRESH_SECONDS))
return {
'access_token': sign({'sub': name, 'iat': now, 'exp': now + ACCESS_SECONDS}, secret),
'token_type': 'Bearer',
'expires_in': ACCESS_SECONDS,
'refresh_token': refresh,
'refresh_expires_in': REFRESH_SECONDS,
}
def take(cfg, token):
"""Remove an unexpired refresh token and return the user it was issued to, or None."""
if not isinstance(token, str) or not token:
return None
with connect(cfg) as connection:
row = connection.execute('DELETE FROM refresh_tokens WHERE hash = %s RETURNING name, expires > now()',
(digest(token),)).fetchone()
return row[0] if row and row[1] else None
def revoke(cfg, name):
"""Remove all refresh tokens of the user."""
with connect(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
def psql(sql, name):
"""The output of an SQL statement, read from standard input, run by psql as postgres with name as the variable v1."""
args = ['runuser', '-u', 'postgres', '--', 'psql', '-X', '-q', '-t', '-A', '-v', 'ON_ERROR_STOP=1', '-v', f'v1={name}']
result = subprocess.run(args, input=sql, capture_output=True, text=True)
if result.returncode != 0:
raise OSError(f'psql failed: {result.stderr.strip()}')
return result.stdout.strip()
def provision(cfg):
"""Create the role, its password file and the database when web.database is not set and they are missing.
The password, of URL-safe characters, reaches psql on standard input, not on its command line.
"""
if cfg['web']['database'].strip():
return
if not psql("SELECT 1 FROM pg_roles WHERE rolname = :'v1'", DATABASE):
DATABASE_SECRET.unlink(missing_ok=True)
psql("CREATE ROLE :\"v1\" LOGIN", DATABASE)
if not DATABASE_SECRET.is_file():
password = secrets.token_urlsafe(32)
psql(f"ALTER ROLE :\"v1\" PASSWORD '{password}'", DATABASE)
DATABASE_SECRET.touch(mode=0o600)
DATABASE_SECRET.chmod(0o600)
DATABASE_SECRET.write_text(password + '\n', encoding='utf-8')
if not psql("SELECT 1 FROM pg_database WHERE datname = :'v1'", DATABASE):
psql("CREATE DATABASE :\"v1\" OWNER :\"v1\"", DATABASE)
@@ -7,9 +7,10 @@ administrator password. Users and groups are changed by the user-manager service
which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords and which the ACLs allow to write ou=users and ou=groups and to set, never read, passwords and
TOTP secrets. TOTP secrets.
slapd's otp module is loaded for the OATH-LDAP schema; its overlay is not configured on the The password policy locks an account for LOCKOUT_SECONDS after LOCKOUT_FAILURES failed binds;
database, so binds do not check one-time passwords. cn=totp,ou=policies holds the TOTP a successful bind clears the count. slapd's otp overlay is configured on the database, so a
parameters the token of every user points to. bind of a user with a TOTP token takes the password followed by a current code.
cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
""" """
import os import os
@@ -42,6 +43,13 @@ SLAPD_SERVICES = 'SLAPD_SERVICES="ldapi:///"'
MODULES = ('argon2', 'ppolicy', 'otp') MODULES = ('argon2', 'ppolicy', 'otp')
PASSWORD_HASH = '{ARGON2}' PASSWORD_HASH = '{ARGON2}'
#: Failed binds that lock an account, within LOCKOUT_SECONDS, and the seconds it stays locked.
LOCKOUT_FAILURES = 5
LOCKOUT_SECONDS = 900
#: The overlays of the database, in the order they are configured.
OVERLAYS = ('ppolicy', 'otp')
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now. #: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
TOTP_DIGITS = 6 TOTP_DIGITS = 6
TOTP_ALGORITHM = '1.2.840.113549.2.7' TOTP_ALGORITHM = '1.2.840.113549.2.7'
@@ -131,13 +139,18 @@ def _ensure_database(connection):
return dn return dn
def _ensure_overlay(connection, database): def _ensure_overlays(connection, database):
if not connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcPPolicyConfig)', ['1.1']): """Add the overlays of OVERLAYS the database lacks; the otp overlay has no settings of its own."""
connection.add_s(f'olcOverlay=ppolicy,{database}', ldap.modlist.addModlist({ found = connection.search_s(database, ldap.SCOPE_ONELEVEL, '(objectClass=olcOverlayConfig)', ['olcOverlay'])
'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'], present = {ORDER.sub(b'', attributes['olcOverlay'][0]).decode() for _, attributes in found}
'olcOverlay': [b'ppolicy'], for name in OVERLAYS:
'olcPPolicyDefault': [DEFAULT_POLICY.encode()], if name in present:
})) continue
record = {'objectClass': [b'olcOverlayConfig'], 'olcOverlay': [name.encode()]}
if name == 'ppolicy':
record = {'objectClass': [b'olcOverlayConfig', b'olcPPolicyConfig'], 'olcOverlay': [b'ppolicy'],
'olcPPolicyDefault': [DEFAULT_POLICY.encode()]}
connection.add_s(f'olcOverlay={name},{database}', ldap.modlist.addModlist(record))
def _ensure_entry(connection, dn, attributes): def _ensure_entry(connection, dn, attributes):
@@ -181,7 +194,7 @@ def provision():
_ensure_modules(connection) _ensure_modules(connection)
_ensure_password_hash(connection) _ensure_password_hash(connection)
database = _ensure_database(connection) database = _ensure_database(connection)
_ensure_overlay(connection, database) _ensure_overlays(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, GROUPS, 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()]})
@@ -190,7 +203,9 @@ def provision():
'cn': [b'default'], 'cn': [b'default'],
'pwdAttribute': [b'userPassword'], 'pwdAttribute': [b'userPassword'],
'pwdLockout': [b'TRUE'], 'pwdLockout': [b'TRUE'],
'pwdMaxFailure': [b'0'], 'pwdMaxFailure': [str(LOCKOUT_FAILURES).encode()],
'pwdLockoutDuration': [str(LOCKOUT_SECONDS).encode()],
'pwdFailureCountInterval': [str(LOCKOUT_SECONDS).encode()],
}) })
_ensure_entry(connection, TOTP_PARAMS, { _ensure_entry(connection, TOTP_PARAMS, {
'objectClass': [b'organizationalRole', b'oathTOTPParams'], 'objectClass': [b'organizationalRole', b'oathTOTPParams'],
@@ -14,15 +14,22 @@ A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathT
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
code of it. Nothing on the router checks the codes at login. code of it. slapd's otp overlay then takes the password followed by a code at every bind.
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
router through the web service. Changing the password, disabling, unlocking and removing a
user revoke its refresh tokens.
Every operation runs in a session(), which holds the lock that serializes changes and the 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 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 status says why: 2 the request is invalid, 3 the user does not exist, 4 the user exists, 5 a
anything else. Answers are dicts with the keys of the JSON answers. login failed, 1 anything else. Answers are dicts with the keys of the JSON answers.
""" """
import base64 import base64
import calendar
import contextlib import contextlib
import fcntl import fcntl
import pathlib import pathlib
@@ -37,6 +44,7 @@ import ldap.modlist
import pyotp import pyotp
import vpnrouter import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap import vpnrouter_ldap
#: A user name is one lowercase DNS label, as it is also the common name of the certificate. #: A user name is one lowercase DNS label, as it is also the common name of the certificate.
@@ -61,13 +69,17 @@ TOTP_SECRET = re.compile(r'^[A-Z2-7]{32}$')
GROUP_ATTRIBUTES = ['cn', 'description', 'member'] GROUP_ATTRIBUTES = ['cn', 'description', 'member']
#: The longest group description, in characters. #: The longest group description, in characters.
DESCRIPTION_MAX = 256 DESCRIPTION_MAX = 256
#: The value of pwdAccountLockedTime that locks an account until it is unlocked. #: The value of pwdAccountLockedTime that disables an account until it is enabled.
LOCKED = b'000001010000Z' LOCKED = b'000001010000Z'
#: The group whose members manage the router through the web service.
ADMIN_GROUP = 'admins'
#: The digits of a TOTP code given to login().
CODE = re.compile(r'^\d{6}$')
LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock') LOCK_FILE = pathlib.Path('/run/vpn-router/users.lock')
class Failure(Exception): class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 1 other.""" """A request that cannot be done; status is 2 invalid, 3 no such user, 4 the user exists, 5 failed login, 1 other."""
def __init__(self, message, status=1): def __init__(self, message, status=1):
super().__init__(message) super().__init__(message)
@@ -152,12 +164,22 @@ def publish_crl():
subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True) subprocess.run(['swanctl', '--load-creds'], capture_output=True, text=True)
def locked_since(value):
"""The time a pwdAccountLockedTime value names, in seconds since the epoch, or 0 when it names none."""
try:
return calendar.timegm(time.strptime(value[:14].decode(), '%Y%m%d%H%M%S'))
except ValueError:
return 0
def summary(attributes): def summary(attributes):
"""A user as the answers show it; a person attribute the entry does not have is None.""" """A user as the answers show it; a person attribute the entry does not have is None."""
name = attributes['uid'][0].decode() name = attributes['uid'][0].decode()
lock = attributes.get('pwdAccountLockedTime', [None])[0]
user = { user = {
'name': name, 'name': name,
'enabled': 'pwdAccountLockedTime' not in attributes, 'enabled': lock != LOCKED,
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
'certificate': certificate(name)[0].is_file(), 'certificate': certificate(name)[0].is_file(),
'otp': 'oathTOTPToken' in attributes, 'otp': 'oathTOTPToken' in attributes,
} }
@@ -218,21 +240,64 @@ def passwd(connection, name, password):
check_password(password) check_password(password)
entry(connection, name) entry(connection, name)
connection.passwd_s(dn_of(name), None, password) connection.passwd_s(dn_of(name), None, password)
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
return summary(entry(connection, name)) return summary(entry(connection, name))
def disable(connection, name): def disable(connection, name):
if 'pwdAccountLockedTime' not in entry(connection, name): if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])]) connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
return summary(entry(connection, name)) return summary(entry(connection, name))
def enable(connection, name): def enable(connection, name):
if 'pwdAccountLockedTime' in entry(connection, name): if entry(connection, name).get('pwdAccountLockedTime') == [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)]) connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
return summary(entry(connection, name)) return summary(entry(connection, name))
def unlock(connection, name):
"""End a lockout after failed binds and clear the failure count; a disabled user stays disabled."""
entry(connection, name)
current = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=['pwdAccountLockedTime', 'pwdFailureTime'])[0][1]
changes = [(ldap.MOD_DELETE, 'pwdFailureTime', None)] if 'pwdFailureTime' in current else []
if 'pwdAccountLockedTime' in current and current['pwdAccountLockedTime'] != [LOCKED]:
changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None))
if changes:
connection.modify_s(dn_of(name), changes)
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
return summary(entry(connection, name))
def login(name, password, code):
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user.
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
"""
refused = Failure('the name, the password or the code is wrong', 5)
if not isinstance(name, str) or not NAME.match(name) or not isinstance(password, str) or not password:
raise refused
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
raise refused
connection = ldap.initialize(vpnrouter_ldap.URI)
try:
connection.simple_bind_s(dn_of(name), password + (code or ''))
except ldap.INVALID_CREDENTIALS:
raise refused
finally:
connection.unbind_s()
def is_admin(connection, name):
"""Whether the user exists and is a member of ADMIN_GROUP."""
try:
entry(connection, name)
except Failure:
return False
return ADMIN_GROUP in groups_of(connection, name)
def totp(secret): def totp(secret):
return pyotp.TOTP(secret, digits=vpnrouter_ldap.TOTP_DIGITS, interval=vpnrouter_ldap.TOTP_PERIOD) return pyotp.TOTP(secret, digits=vpnrouter_ldap.TOTP_DIGITS, interval=vpnrouter_ldap.TOTP_PERIOD)
@@ -296,6 +361,7 @@ def remove(connection, name):
cert.unlink(missing_ok=True) cert.unlink(missing_ok=True)
key.unlink(missing_ok=True) key.unlink(missing_ok=True)
connection.delete_s(dn_of(name)) connection.delete_s(dn_of(name))
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
return {'name': name, 'removed': True} return {'name': name, 'removed': True}
+155 -59
View File
@@ -15,10 +15,16 @@ Pages:
profile, and TOTP enrolment (see users()) profile, and TOTP enrolment (see users())
/groups the groups of the router's directory: list, add, rename or describe, remove, and /groups the groups of the router's directory: list, add, rename or describe, remove, and
add and take out members (see groups()) add and take out members (see groups())
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/config, /users and /groups 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 signed with
with web.jwt_secret and expires within ten minutes; they are not served (404) while vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub, who
web.jwt_secret is not set. must be a member of vpnrouter_users.ADMIN_GROUP; a token of vpn-router-token has the admin
claim, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it,
add or take out its members, and remove one of its members. These paths and /auth are not
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
default the address the service listens on, is the last entry of its X-Forwarded-For header.
/ and /status show only the status: whether the services run, the package version, whether / and /status show only the status: whether the services run, the package version, whether
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S), OpenVPN and
@@ -28,15 +34,13 @@ The service needs nothing else to start and does not end on its own. A discovery
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts
it, moves it when the address or the port changes, and stops it while the service is disabled it, moves it when the address or the port changes, and stops it while the service is disabled
or the address is not known. Every request reads what it needs when it is made: the token secret or the address is not known. Every request reads what it needs when it is made: the signing
from the configuration, and the features of the router and the facts shown by /config from key, the configuration, and the features of the router and the facts shown by /config from
/run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file /run/vpn-router/web.json, which vpn-router-setup writes. Before setup has written that file
the router has no features, and /config shows no configuration or interfaces. the router has no features, and /config shows no configuration or interfaces.
""" """
import base64 import collections
import hashlib
import hmac
import json import json
import os import os
import signal import signal
@@ -50,7 +54,10 @@ 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 ldap
import psycopg
import vpnrouter
import vpnrouter_auth
import vpnrouter_net import vpnrouter_net
import vpnrouter_users import vpnrouter_users
@@ -65,11 +72,9 @@ DISCOVERY_SECONDS = 30
COMMAND_TIMEOUT = 5 COMMAND_TIMEOUT = 5
CACHE_SECONDS = 2 CACHE_SECONDS = 2
#: Seconds of clock difference accepted between a token and this router. #: The calls of /auth allowed per client address in AUTH_WINDOW seconds.
LEEWAY = 60 AUTH_LIMIT = 10
AUTH_WINDOW = 60
#: The longest a token may be valid, from now, in seconds.
MAX_LIFETIME = 600
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4' TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
@@ -82,11 +87,15 @@ OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
BODY_LIMIT = 4096 BODY_LIMIT = 4096
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500. #: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {2: 400, 3: 404, 4: 409} USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
_cache = {'at': 0.0, 'data': None} _cache = {'at': 0.0, 'data': None}
_lock = threading.Lock() _lock = threading.Lock()
#: The times of the recent /auth calls of each client address.
_auth_calls = collections.defaultdict(collections.deque)
_auth_lock = threading.Lock()
def read_json(path): def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object.""" """The object in a JSON file, or {} when the file is missing or holds no object."""
@@ -105,14 +114,12 @@ def section(configuration, name):
def current(): def current():
"""What a request needs, read now: the features and facts setup resolved, and the token secret. """What a request needs, read now: the features and facts setup resolved, and the signing key.
Before setup has run the router has no features, and without a secret /config and /users are Before setup has run the router has no features, and without a key the protected paths and
not served. /auth are not served.
""" """
state = read_json(STATE_FILE) return dict(read_json(STATE_FILE), key=vpnrouter_auth.key())
secret = section(read_json(CONFIG_FILE), 'web').get('jwt_secret')
return dict(state, jwt_secret=secret if isinstance(secret, str) else '')
def run(*args): def run(*args):
@@ -253,37 +260,6 @@ def collect(state):
return status return status
def b64decode(part):
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
def verify(token, secret):
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token
must expire (exp), and not later than MAX_LIFETIME seconds from now, so that a
stolen token is of little use; LEEWAY seconds of clock difference are allowed.
"""
try:
header, payload, signature = token.split('.')
if json.loads(b64decode(header)).get('alg') != 'HS256':
return None
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64decode(signature)):
return None
claims = json.loads(b64decode(payload))
now = time.time()
expires = claims['exp']
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not now - LEEWAY <= expires <= now + MAX_LIFETIME + LEEWAY:
return None
not_before = claims.get('nbf')
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
return None
except (ValueError, KeyError, TypeError, AttributeError):
return None
return claims
def lines_of(*args): def lines_of(*args):
"""The output lines of a command, or None when it fails.""" """The output lines of a command, or None when it fails."""
result = run(*args) result = run(*args)
@@ -420,6 +396,7 @@ def users(state, method, path, body):
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
POST /users/<name>/unlock end a lockout after failed logins
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
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
@@ -439,6 +416,7 @@ def users(state, method, path, body):
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')), ('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name), ('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name), ('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name), ('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name), ('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name), ('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
@@ -454,6 +432,8 @@ def users(state, method, path, body):
return USER_STATUS.get(failure.status, 500), {'error': str(failure)} return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc: except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'} return 500, {'error': f'the user directory failed: {exc}'}
except psycopg.Error as exc:
return 500, {'error': f'the token database failed: {exc}'}
except SystemExit: except SystemExit:
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason. # 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 500, {'error': 'the router configuration cannot be read'}
@@ -498,6 +478,85 @@ def groups(state, method, path, body):
return (201 if route == ('POST', 1, None) else 200), document return (201 if route == ('POST', 1, None) else 200), document
def changes_admins(connection, method, parts, body):
"""Whether a /users or /groups request creates, renames to, changes or removes ADMIN_GROUP, or removes a member of it."""
admins = vpnrouter_users.ADMIN_GROUP
if parts[0] == 'groups':
if method == 'POST' and len(parts) == 1:
return body.get('name') == admins
if method != 'GET' and len(parts) > 1 and parts[1] == admins:
return True
return method == 'PUT' and len(parts) == 2 and body.get('name') == admins
if parts[0] == 'users' and method == 'DELETE' and len(parts) == 2:
return admins in vpnrouter_users.groups_of(connection, parts[1])
return False
def refusal(claims, method, path, body):
"""Why the holder of the claims may not make the request to a protected path, or '' when it may."""
if claims.get('admin') is True:
return ''
name = claims.get('sub')
if not isinstance(name, str):
return 'the token names no user'
admins = vpnrouter_users.ADMIN_GROUP
with vpnrouter_users.session() as connection:
if not vpnrouter_users.is_admin(connection, name):
return f'user {name} is not a member of {admins}'
if changes_admins(connection, method, path.strip('/').split('/'), body if isinstance(body, dict) else {}):
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
return ''
def auth(path, body):
"""Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.
POST /auth sign in: {"name", "password", "code"?}; the tokens
POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up
POST /auth/logout {"refresh_token"}; the refresh token revoked
"""
if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'}
try:
cfg = vpnrouter.load_config()
if path == '/auth':
vpnrouter_users.login(body.get('name'), body.get('password'), body.get('code'))
return 200, vpnrouter_auth.issue(cfg, body['name'])
name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
if path == '/auth/logout':
return 200, {'revoked': name is not None}
if name is None:
return 401, {'error': 'the refresh token is not valid'}
with vpnrouter_users.session() as connection:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
if not user['enabled'] or user['locked']:
return 401, {'error': f'user {name} is disabled or locked'}
return 200, vpnrouter_auth.issue(cfg, name)
except vpnrouter_users.Failure as failure:
return (401 if failure.status == 3 else USER_STATUS.get(failure.status, 500)), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except psycopg.Error as exc:
return 500, {'error': f'the token database failed: {exc}'}
except SystemExit:
return 500, {'error': 'the router configuration cannot be read'}
def auth_wait(address):
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
now = time.monotonic()
with _auth_lock:
for idle in [key for key, times in _auth_calls.items() if times[-1] <= now - AUTH_WINDOW]:
del _auth_calls[idle]
calls = _auth_calls[address]
while calls and calls[0] <= now - AUTH_WINDOW:
calls.popleft()
if len(calls) >= AUTH_LIMIT:
return int(calls[0] + AUTH_WINDOW - now) + 1
calls.append(now)
return 0
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1' protocol_version = 'HTTP/1.1'
@@ -508,29 +567,65 @@ class Handler(BaseHTTPRequestHandler):
_cache['at'] = time.monotonic() _cache['at'] = time.monotonic()
return _cache['data'] return _cache['data']
def authorized(self, secret): def claims(self, secret):
"""Whether the request carries a token signed with the secret; False also while no secret is set.""" """The claims of the request's token signed with the secret, or None without an acceptable one."""
header = self.headers.get('Authorization', '') header = self.headers.get('Authorization', '')
return bool(secret) and header.startswith('Bearer ') and verify(header[7:].strip(), secret) is not None return vpnrouter_auth.verify(header[7:].strip(), secret) if header.startswith('Bearer ') else None
def client(self):
"""The client address: the last X-Forwarded-For entry when the peer is a trusted proxy, else the peer."""
peer = self.client_address[0]
trusted = section(read_json(CONFIG_FILE), 'web').get('trusted_proxies') or [self.server.server_address[0]]
forwarded = self.headers.get('X-Forwarded-For', '')
if isinstance(trusted, list) and peer in trusted and forwarded.strip():
return forwarded.split(',')[-1].strip()
return peer
def refusal(self, claims, path, payload):
"""The HTTP status and the message that refuse a request to a protected path, or None when it may be made."""
try:
reason = refusal(claims, self.command, path, payload)
except (ldap.LDAPError, OSError) as exc:
return 500, f'the user directory failed: {exc}'
return (403, reason) if reason else None
def answer(self, send_body): def answer(self, send_body):
path = self.path.split('?', 1)[0] path = self.path.split('?', 1)[0]
extra = [] extra = []
self.principal = None
# 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
directory = next((name for name in ('users', 'groups') if path == f'/{name}' or path.startswith(f'/{name}/')), None) 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 protected = path == '/config' or directory is not None
state = current() if protected else {} signing = path in ('/auth', '/auth/refresh', '/auth/logout')
if protected and not state['jwt_secret']: state = current() if protected or signing else {}
claims = self.claims(state['key']) if protected and state['key'] else None
payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None
if claims is not None:
self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
if (protected or signing) and not state['key']:
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
elif protected and not self.authorized(state['jwt_secret']): elif signing and self.command != 'POST':
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
elif signing:
wait = auth_wait(self.client()) if path != '/auth/logout' else 0
if wait:
code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
extra.append(('Retry-After', str(wait)))
else:
code, document = auth(path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif protected and claims is None:
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401 body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
extra.append(('WWW-Authenticate', 'Bearer')) extra.append(('WWW-Authenticate', 'Bearer'))
elif protected and (refused := self.refusal(claims, path, payload)):
code, kind = refused[0], 'application/json'
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
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 if directory == 'users' else groups)(state, self.command, path, self.request_body()) code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
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
@@ -584,7 +679,8 @@ class Handler(BaseHTTPRequestHandler):
self.answer(True) self.answer(True)
def log_message(self, format, *args): def log_message(self, format, *args):
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args)) principal = getattr(self, 'principal', None) or '-'
sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))
def wanted(): def wanted():
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/python3
"""Make a token with the admin claim for the router's web service, with vpnrouter_auth.
The token is signed with the router's key and is valid for the lifetime given, without a
maximum. It may call every protected path, and it is the only token that may change the
admins group. The answer is the token on standard output, and a failure is a message on
standard error. With --json the answer is {"token", "expires"} on standard output, and a
failure is {"error": ...} there too. The exit status is 2 for an invalid request and 1 for
any other failure. -h or --help shows the options.
vpn-router-token [--json] (--minutes N | --hours N | --days N)
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import vpnrouter_auth
#: The seconds of each unit of the lifetime.
UNITS = {'minutes': 60, 'hours': 3600, 'days': 86400}
class Failure(Exception):
"""A request that cannot be done; status is 2 invalid, 1 other."""
def __init__(self, message, status=1):
super().__init__(message)
self.status = status
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 positive(value):
try:
number = int(value)
except ValueError:
number = 0
if number < 1:
raise argparse.ArgumentTypeError(f'{value!r} is not a positive whole number')
return number
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:
parser = Parser(prog='vpn-router-token', description='Make a token with the admin claim for the web service.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
lifetime = parser.add_mutually_exclusive_group(required=True)
for unit in UNITS:
lifetime.add_argument(f'--{unit}', type=positive, metavar='N', help=f'valid for N {unit}')
options = parser.parse_args(argv)
as_json = options.json
seconds = next(getattr(options, unit) * factor for unit, factor in UNITS.items() if getattr(options, unit))
answer = vpnrouter_auth.admin_token(seconds)
except Failure as failure:
message, status = str(failure), failure.status
except OSError as exc:
message, status = str(exc), 1
else:
print(json.dumps(answer) if as_json else answer['token'])
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-token: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+7 -1
View File
@@ -14,6 +14,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] passwd <name> (password on stdin) vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name> vpn-router-user [--json] disable <name>
vpn-router-user [--json] enable <name> vpn-router-user [--json] enable <name>
vpn-router-user [--json] unlock <name>
vpn-router-user [--json] remove <name> vpn-router-user [--json] remove <name>
vpn-router-user [--json] profile <name> vpn-router-user [--json] profile <name>
vpn-router-user [--json] otp-new <name> vpn-router-user [--json] otp-new <name>
@@ -28,6 +29,7 @@ import sys
sys.path.insert(0, '/usr/lib/vpn-router') sys.path.insert(0, '/usr/lib/vpn-router')
import ldap import ldap
import psycopg
import vpnrouter_users import vpnrouter_users
from vpnrouter_users import Failure from vpnrouter_users import Failure
@@ -40,6 +42,7 @@ HELP = {
'passwd': 'set the password, read from standard input', 'passwd': 'set the password, read from standard input',
'disable': 'lock the user out of OpenVPN', 'disable': 'lock the user out of OpenVPN',
'enable': 'let the user in again', 'enable': 'let the user in again',
'unlock': 'end a lockout after failed logins',
'remove': 'revoke the certificate and remove the user', 'remove': 'revoke the certificate and remove the user',
'profile': 'print the OpenVPN client profile', 'profile': 'print the OpenVPN client profile',
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored', 'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
@@ -109,7 +112,8 @@ def text_of(answer):
if not users: if not users:
return 'no users' return 'no users'
head = ('USER', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL') head = ('USER', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
rows = [(user['name'], 'enabled' if user['enabled'] else 'disabled', 'issued' if user['certificate'] else 'missing', rows = [(user['name'], 'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
'issued' if user['certificate'] else 'missing',
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-') 'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
for user in users] for user in users]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))] widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
@@ -131,6 +135,8 @@ def main(argv):
message, status = str(failure), failure.status message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc: except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1 message, status = f'the user directory failed: {exc}', 1
except psycopg.Error as exc:
message, status = f'the token database failed: {exc}', 1
else: else:
print(json.dumps(answer) if as_json else text_of(answer)) print(json.dumps(answer) if as_json else text_of(answer))
return 0 return 0
@@ -46,6 +46,7 @@
"web": { "web": {
"enabled": true, "enabled": true,
"port": 7081, "port": 7081,
"jwt_secret": "" "database": "",
"trusted_proxies": []
} }
} }