Compare commits

...
10 Commits
Author SHA1 Message Date
slawek 581b53d447 fix: the web service manages users also when it started before setup.
Release / release (push) Successful in 19s
2026-10-24 21:32:00 +02:00
slawek 8782edff0f fix: the status page shows whether the Router Manager runs.
Release / release (push) Successful in 19s
2026-10-24 12:50:49 +02:00
slawek fe17e83b49 feature: every user manages their own account through /account.
Release / release (push) Successful in 11s
2026-10-24 12:44:49 +02:00
slawek 17331eea37 feature: the status page shows the Router Manager's version.
Release / release (push) Successful in 12s
2026-10-24 12:16:02 +02:00
slawek af3127d1e8 feature: Manager-only accounts, and the Router Manager's first administrator.
Release / release (push) Successful in 20s
2026-10-24 11:56:58 +02:00
slawek b1e6d9cd0b fix: the web service starts once libpq can be found.
Release / release (push) Successful in 12s
2026-10-24 11:07:45 +02:00
slawek 732cc8cb54 fix: the web service waits for the PostgreSQL driver instead of failing at start.
Release / release (push) Successful in 25s
2026-10-24 10:43:51 +02:00
slawek c0cd323a0c feature: bootstrap diagnostics on port 17081 during the first setup.
Release / release (push) Successful in 13s
2026-10-24 10:07:22 +02:00
slawek 2e2bacee7d feature: the web service creates its key and database itself and shows its starting phase.
Release / release (push) Successful in 29s
2026-10-24 09:07:28 +02:00
slawek 4f1aa282f6 fix: a failing user directory or token database no longer leaves the tunnels down.
Release / release (push) Failing after 15m34s
2026-10-24 08:55:25 +02:00
20 changed files with 942 additions and 177 deletions

No files matched your search

+139 -6
View File
@@ -64,8 +64,8 @@ The service listens on plain HTTP on the internal network.
Decision: `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header, Decision: `/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header,
HS256, verified with `hmac` and `hashlib`; no other algorithm is accepted. The key is HS256, verified with `hmac` and `hashlib`; no other algorithm is accepted. The key is
`/etc/vpn-router/web.key`, random, made by `vpn-router-setup` on the router and readable by root `/etc/vpn-router/web.key`, random, made by the web service on its first run and readable by root
only; it is never configured or output, and `web.jwt_secret` is ignored. The router itself issues only; it is never configured or output. The router itself issues
the tokens: `POST /auth` checks a user's password and TOTP code with a bind (ADR-14) and answers the tokens: `POST /auth` checks a user's password and TOTP code with a bind (ADR-14) and answers
an access token naming the user in `sub`, valid for 5 minutes, and a refresh token valid for 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 hours (ADR-15); `vpn-router-token` makes tokens with the `admin` claim (ADR-13). A token without
@@ -143,8 +143,10 @@ Decision: `vpnrouter_users.py` holds the user operations, the checks and a sessi
lock and the connection of the user manager account. The web service imports it and maps a lock and the connection of the user manager account. The web service imports it and maps a
`Failure` to the HTTP status; `vpn-router-user` imports it and adds the argument parsing and `Failure` to the HTTP status; `vpn-router-user` imports it and adds the argument parsing and
the output, text for people and JSON with `--json`. The web unit allows writing the paths the the output, text for people and JSON with `--json`. The web unit allows writing the paths the
operations need: `/run/vpn-router` for the lock, `/etc/vpn-router/pki` for the certificates and operations need: `/run/vpn-router` for the lock, its `RuntimeDirectory`, which systemd creates
the CA's state, and `/etc/swanctl/x509crl` for the published CRL. before the service starts, also before setup has run, and keeps when it stops, as setup's state
files are there too; `/etc/vpn-router/pki` for the certificates and 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
@@ -337,8 +339,8 @@ sessions in PostgreSQL too.
Decision: Refresh tokens are random strings, stored as their SHA-256 with the user name and the 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 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 `web.database`, or by default the database and role `vpn-router` on the router, which the web
`vpn-router-setup` creates with `runuser -u postgres -- psql` and a random password in service creates on its first run (ADR-16) 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` `/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 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. it and issues a new one, after checking that the user is neither disabled nor locked.
@@ -347,3 +349,134 @@ Consequences: The package depends on `postgresql` and `python3-psycopg`. A stole
no usable token. Changing a user's password, disabling, unlocking or removing the user, and 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 signing out, end the user's sessions at once; an access token already issued lives out its
5 minutes. 5 minutes.
## ADR-16: Each service creates what it needs on its first run
Status: Accepted
Context: The package manager configures packages in an order of its own; on a new VM the Router
Manager was configured before the PostgreSQL server, so a database created at installation was
missing. PostgreSQL may also start after the services at boot.
Decision: The web service makes its signing key and creates the refresh tokens' database itself,
and the Router Manager its sessions database; neither `vpn-router-setup` nor a maintainer script
creates them. Each service does it before it listens, retrying every 15 seconds and logging each
failure, then listens. `vpnrouter_auth` turns every failure of the database into
`DatabaseError`, the only database exception the web service and `vpn-router-user` handle.
psycopg (Ubuntu ships only its pure-Python implementation) finds libpq through the dynamic linker
cache, which dpkg refreshes with its deferred `ldconfig` only at the end of an installation, so a
service started during the installation cannot import it yet; a failed import cannot be repeated
in the same process either, as psycopg caches the lookup. The services therefore import psycopg
plainly, and their units wait instead: `ExecStartPre=wait-for-libpq`, a shell script of each
package, waits until `ldconfig -p` lists `libpq.so.5`, within `TimeoutStartSec=5min`. The
maintainer scripts start the units with `systemctl --no-block` (debhelper's start is turned off),
because a start that waited would hold dpkg before its `ldconfig`.
Consequences: The order of installation and of the start of PostgreSQL and of the linker cache
does not matter; a database that cannot be created is retried until the cause is gone, and the
reason is in the service's log. Outside an installation the wait ends at once. The web unit may
write `/etc/vpn-router` for the key and the database password.
## ADR-17: Bootstrap diagnostics run only until the first setup succeeds
Status: Accepted
Context: A new VM is reachable only once its setup has worked, and the reason of a failure must
be readable from the internal network before an SSH session is possible. A service that reports
the setup cannot be one of the services being set up: those import packages that may not load yet
(psycopg could not find libpq before `ldconfig` ran), and they would fail before reporting
anything. Once the router has been set up, SSH is the way to diagnose it. The more a diagnostic
page does, the more ways it has to fail itself.
Decision: Each package ships a bootstrap service of the Python standard library alone, a script
that answers every request with the plain-text output of a fixed list of commands: the setup
state, the services, the failed units, the addresses and the end of the logs. The router's
listens on 17081 and the Router Manager's on 17080, beside the ports of the services, 7081 and
7080, on every address. The units are never enabled and not started at installation;
`vpn-router-setup` starts them while `/var/lib/vpn-router/setup.done` does not exist, and opens
their ports on the internal interface. The first successful run writes that file, closes the ports
and stops them; a failed run leaves them running, so the reason stays readable. Setup records its
state, step and failure in `/run/vpn-router/setup.json` at every step; `vpnrouter.fail` ends with
a `SystemExit` carrying its message, so setup records it.
Consequences: The diagnostics need nothing the setup provides and cannot collide with the real
services. After the first success they never run again, also not after an upgrade or a reboot.
The page shows log lines to whoever reaches the internal network during the first setup.
## ADR-18: Manager-only accounts are a second kind of user; the package can create the first admin
Status: Accepted
Context: The Router Manager needs a first administrator before anyone has added users, as other
packages create a default user from their debconf answers. Such an account, and others that only
manage the router, have no person behind them and need no VPN access. A VPN user is an
`inetOrgPerson`, which requires a last name and a common name, and its certificate needs the
names. The Manager is configured before the router's directory exists during an installation.
Decision: A user is one of two kinds in `ou=users`, fixed when it is added: a VPN user, an
`inetOrgPerson` with the three person attributes and a certificate, or a Manager-only account,
an `account`, `simpleSecurityObject` and `vpnRouterAccount` (ADR-19) with the name, the password,
an optional e-mail address for notifications, and no certificate. `add` with all three person
attributes adds a VPN user and without the first and the last name a Manager-only account; one
name without the rest is refused. The CLI and the API add both kinds; the Manager's dialog
requires the names unless Manager access only is chosen, which offers the e-mail address only.
Answers carry `manager_only`, and `update` changes only the e-mail address of a Manager-only
account. The Manager's package asks `admin_username` and `admin_password`; with both
it writes them to a root-only file, clears the answers, and `vpn-router-setup` creates the account
after it provisions the directory, adds it to `admins` (creating the group when missing) and
removes the file. Without a name nothing is created. `vpnrouter_users` knows only the directory,
so setup can use it before PostgreSQL's driver can be loaded; `web` and `vpn-router-user` revoke
refresh tokens after the operations of `vpnrouter_auth.REVOKING`.
Consequences: A Manager-only account cannot become a VPN user, as LDAP cannot change an entry's
structural class and its password hash cannot be copied; it is removed and added again instead.
It has no certificate, so OpenVPN and road-warrior access refuse it. The administrator's password
is on disk only until setup has run.
## ADR-19: The package has an LDAP schema of its own under a UUID OID
Status: Accepted
Context: A Manager-only account (ADR-18) is an `account`, which allows no `mail`, yet notifications
need an address for it; no standard auxiliary class allows `mail` except `extensibleObject`, which
allows every attribute. Authorization scopes for users of both kinds may follow. An attribute or
class of its own needs an OID, and the package has no registered arc.
Decision: The package defines its schema, `cn=vpn-router,cn=schema,cn=config`, which
`vpnrouter_ldap.provision` adds when missing and keeps as `SCHEMA_ATTRIBUTE_TYPES` and
`SCHEMA_OBJECT_CLASSES`. Its OID is `2.25.98477352595735834532404256531620013001`, made from a
random UUID under the arc 2.25 (ITU-T X.667), which needs no registration; attribute types are
under `.1` and object classes under `.2`. The first version has the attribute `vpnRouterScope`
(multi-valued, case-insensitive strings) and the auxiliary class `vpnRouterAccount`,
`MAY ( mail $ vpnRouterScope )`. Manager-only accounts have the class from their creation, so an
e-mail address can be added later without changing their classes; VPN users get it once scopes are
used.
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
OIDs continue the arcs. Nothing reads `vpnRouterScope` yet. The OID must never be reused for
anything else.
## ADR-20: Users manage their own account through /account, proving who they are again
Status: Accepted
Context: Users who are not members of `admins` can sign in but get 403 on every path, so they
cannot change their password or enrol an authenticator without an administrator. A session
token alone is a weak proof for such changes: whoever holds an open session could lock the user
out. A wrong current password must not look like an ended session to the Manager, which signs a
user out on 401.
Decision: `/account` serves the user named in the token's `sub`, admin or not: `GET` the user
and whether they are a member of `admins`, `PUT /account/password`, and `POST` and
`PUT /account/otp` to enrol or replace the TOTP enrolment. The routes do not take a user name.
Each change sends the current password and, for an enrolled user, a current code, checked with a
bind as the user like `/auth`, so it counts toward the lockout (ADR-14); a wrong one is 403. A
new password is checked before the bind, so an invalid one does not use up a code. A user cannot
remove their own enrolment; `/users` and `vpn-router-user` do. Tokens of `vpn-router-token` name
no user and get 403 there.
Consequences: slapd's `otp` overlay accepts each code once, so a change right after signing in
needs the next code. Changing the password revokes the user's refresh tokens, so the user's
Manager session ends with its access token. An enrolled user who has lost the authenticator
needs an administrator.
+89 -21
View File
@@ -125,7 +125,7 @@ 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` | `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` | `database` | string | | A PostgreSQL connection string for the refresh tokens; without it, the `vpn-router` database on this host that the web service creates |
| `web` | `trusted_proxies` | list | the listening address | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit | | `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:
@@ -324,7 +324,12 @@ 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 entry per user, named by `uid`: an `inetOrgPerson` for a VPN user, an
`account` for a Manager-only account (see Managing users);
- the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID
`2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class
`vpnRouterAccount` with `MAY ( mail $ vpnRouterScope )`, which Manager-only accounts have, and
the attribute `vpnRouterScope`, authorization scopes that nothing reads yet;
- `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in - `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in
`member` and an optional `description`; `member` and an optional `description`;
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write - `cn=user-manager,ou=services` is the account that changes users and groups; it may write
@@ -346,9 +351,17 @@ The directory is kept on purge.
### Managing users ### Managing users
`vpn-router-user` manages the users. A user is the directory entry, with a first name, a last `vpn-router-user` manages the users. A user is a directory entry of one of two kinds, fixed when it
name and an e-mail address (`givenName`, `sn` and `mail`; `cn` is the first and the last name), is added:
and a client certificate with the user name as its common name, in `/etc/vpn-router/pki/users`.
- a VPN user, an `inetOrgPerson` with a first name, a last name and an e-mail address
(`givenName`, `sn` and `mail`; `cn` is the first and the last name), and a client certificate
with the user name as its common name, in `/etc/vpn-router/pki/users`;
- a Manager-only account, an `account`, `simpleSecurityObject` and `vpnRouterAccount` with the
name, the password and an optional e-mail address, for notifications: no names and no
certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage
the router. `add` without the first and the last name adds one; its names cannot be set later.
Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a
table of the users, the client profile, a TOTP secret or a short message, and a failure is a table of the users, the client profile, a TOTP secret or a short message, and a failure is a
message on standard error. message on standard error.
@@ -358,6 +371,7 @@ after a command its options.
```sh ```sh
echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com
echo 'm4nager-pass' | vpn-router-user add ops
vpn-router-user update alice --email a.smith@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 > alice.ovpn vpn-router-user profile alice > alice.ovpn
@@ -374,8 +388,9 @@ vpn-router-user otp-remove alice
| Command | Effect | | Command | Effect |
| --- | --- | | --- | --- |
| `list` | The users with their names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP | | `list` | The users with their kind, 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 a VPN user and issues the certificate |
| `add <name> [--email E]` | Adds a Manager-only account, with the e-mail address when given; one name without the other two options is refused |
| `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 `/auth`, and lets the user in again | | `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
@@ -390,9 +405,17 @@ A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a le
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. Once enrolled, a user logs in to OpenVPN characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
and `/auth` with the password followed by a current code. In the answers a user added and `/auth` with the password followed by a current code. In the answers `manager_only` tells the
without the names or the e-mail address has them as `null`. The exit status is 0 on success, 2 for an invalid request, kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
3 when the user does not exist, 4 when the user exists and 1 for any other failure. address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its
refresh tokens of `/auth`. 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.
When the Router Manager is installed with an administrator (its debconf questions
`vpn-router-manager/admin_username` and `admin_password`), its package leaves the answers in
`/etc/vpn-router-manager/admin.json` (root only). `vpn-router-setup` then creates that user as a
Manager-only account, adds it to the group `admins`, which it creates when missing, and removes
the file.
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.
@@ -473,6 +496,27 @@ The `vpn-router-web` service shows the state of the router on its internal addre
`web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no `web.port` (7081 by default). Its pages `/` and `/status` need no authentication and show no
configuration: no key, identity, address or subnet. configuration: no key, identity, address or subnet.
The service first makes its signing key and the refresh tokens' database, retrying every 15
seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens.
### Bootstrap diagnostics
Until a run of `vpn-router-setup` has succeeded, every run starts `vpn-router-bootstrap`, which
serves diagnostics as plain text on port 17081 of every address, and the firewall opens 17081 and
17080 (the Router Manager's bootstrap) on the internal interface. The first successful run stops
the bootstrap services and closes their ports, and writes `/var/lib/vpn-router/setup.done`, after
which they never run again; a failed run leaves them running, so the reason stays readable until
a later run succeeds. The unit is never enabled.
The page, read again every 10 seconds, shows the state, the step and the failure of the last
setup run (`/run/vpn-router/setup.json`, which setup writes at every step), the state of the
router's services, the failed units, the addresses and routes, `cloud-init status`, and the end of
the logs of `vpn-router-setup` and `vpn-router-web`:
```bash
curl http://10.0.0.4:17081/
```
| Page | Content | | Page | Content |
| --- | --- | | --- | --- |
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) | | `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser, in the browser's light or dark scheme, that reloads itself every minute (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
@@ -481,11 +525,15 @@ configuration: no key, identity, address or subnet.
| `/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 |
| `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token |
The status holds the state of `vpn-router-setup` and `slapd`, of `strongswan` when site-to-site or The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
`vpn-router-manager` when that package is installed, of `strongswan` when site-to-site or
road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of road-warrior access is configured, of `wg-quick@wg0` when WireGuard is, and of
`openvpn-server@vpn-router` when OpenVPN is; the number of connected `openvpn-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 and,
as `manager_version`, the installed version of `vpn-router-manager` (`null`, shown as not installed, without it); 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
site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and
@@ -499,16 +547,18 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in ### Tokens and signing in
`/config`, `/users` and `/groups` need a JWT in an `Authorization: Bearer` header, signed with `/config`, `/users`, `/groups` and `/account` 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). HS256 by the key the web service makes on its first run, `/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 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 (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: `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 - an access token of `/auth` names a user in `sub` and is valid for 5 minutes; every user may
be a member of the group `admins`, otherwise the request gets 403; call `/account`, and for the other paths 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 - 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 for the lifetime given, without a maximum. It names no user, so `/account` refuses it with 403;
it may call everything else, and only such a token may
change the group `admins`: create it, rename a group to it, change or remove it, add or take 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. out its members, or remove a user who is a member of it.
@@ -539,7 +589,7 @@ default the address the service listens on, the client address is the last entry
`X-Forwarded-For`. `X-Forwarded-For`.
Refresh tokens are kept, as their SHA-256, in PostgreSQL: by default in the database and role 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 `vpn-router` on the router, which the web service creates on its first run with a password in
`/etc/vpn-router/database.secret` (root only), reached over TCP on `localhost`; `web.database` `/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. names another database. Tokens and passwords travel over plain HTTP on the internal network.
@@ -569,9 +619,9 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
| Request | Body | Answer | | Request | Body | Answer |
| --- | --- | --- | | --- | --- | --- |
| `GET /users` | | `{"users": [{"name", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` | | `GET /users` | | `{"users": [{"name", "manager_only", "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"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 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"}`; for a Manager-only account only `{"email"}`, `""` removing it | 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 |
@@ -593,6 +643,24 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
http://10.0.0.4:7081/users http://10.0.0.4:7081/users
``` ```
### The /account API
`/account` serves the user the access token names, whether or not the user is a member of
`admins`, with the same module as `/users`:
| Request | Body | Answer |
| --- | --- | --- |
| `GET /account` | | the user as `/users` shows it, and `"admin"`, whether the user is a member of `admins` |
| `PUT /account/password` | `{"current_password", "current_code", "password"}` | the user |
| `POST /account/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
| `PUT /account/otp` | `{"current_password", "current_code", "secret", "code"}` | the user, once the code matches the secret |
`current_code` is needed only for a user enrolled for TOTP. A wrong current password or code gets
403 and counts as a failed login; slapd accepts each code once, so a code used to sign in is not
accepted again, and a change right after signing in waits for the next code. A user enrols or
replaces the enrolment; removing it is left to `/users` and `vpn-router-user`. Changing the
password revokes the user's refresh tokens. Other failures are those of `/users`.
### The /groups API ### The /groups API
`/groups` manages groups with the same token and the same module as `/users`. `/groups` manages groups with the same token and the same module as `/users`.
+128
View File
@@ -1,3 +1,131 @@
vpn-router (0.12.2-1) unstable; urgency=medium
fix: the web service manages users also when it started before setup.
* vpn-router-web creates /run/vpn-router when it starts, so the user
operations take their lock there; started before vpn-router-setup had
made the directory, they failed with "Read-only file system".
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 12:00:00 +0200
vpn-router (0.12.1-1) unstable; urgency=medium
fix: the status page shows whether the Router Manager runs.
* When vpn-router-manager is installed, the services show the state of
vpn-router-manager, and status is degraded while it is not active.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 11:00:00 +0200
vpn-router (0.12.0-1) unstable; urgency=medium
feature: every user manages their own account through /account.
* /account serves the signed-in user, admin or not: GET shows the user
and whether they are a member of admins; PUT /account/password sets
the password and POST and PUT /account/otp enrol or replace the TOTP
enrolment, given the current password and, when enrolled, a current
code. A wrong one is 403 and counts as a failed login. Tokens of
vpn-router-token get 403 there.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 10:00:00 +0200
vpn-router (0.11.0-1) unstable; urgency=medium
feature: the status page shows the Router Manager's version.
* / and /status show the installed version of vpn-router-manager, as
manager_version in JSON, or that it is not installed.
* The services show the state of each PostgreSQL cluster, as
postgresql@<version>-<cluster>, or postgresql: no cluster without one.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 09:00:00 +0200
vpn-router (0.10.0-1) unstable; urgency=medium
feature: Manager-only accounts, and the Router Manager's first administrator.
* vpn-router-user add without the person options, and POST /users without
them, add a Manager-only account: a name and a password, no names, no
e-mail address and no certificate, so no VPN access. Giving only some of
the three is refused. The answers show the kind as manager_only, list
shows a KIND column, and update refuses a Manager-only account.
* vpn-router-setup creates the administrator the Router Manager's package
leaves in /etc/vpn-router-manager/admin.json as a Manager-only account in
the group admins, which it creates when missing, and removes the file.
* The directory has the package's own schema, with the auxiliary class
vpnRouterAccount and the attribute vpnRouterScope. A Manager-only account
can have an e-mail address: add --email, update --email ('' removes it),
and email in POST and PUT /users.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 08:00:00 +0200
vpn-router (0.9.2-1) unstable; urgency=medium
fix: the web service starts once libpq can be found.
* The web service unit waits, with wait-for-libpq, until libpq is in the
linker cache before it starts the service, for at most 5 minutes; the
service imports psycopg at start again.
* The package starts the web service without waiting for it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 06:00:00 +0200
vpn-router (0.9.1-1) unstable; urgency=medium
fix: the web service waits for the PostgreSQL driver instead of failing at start.
* The web service imports psycopg as the first step of its
initialization, retrying every 15 seconds and logging the failure while
libpq cannot be found yet during an installation.
* vpn-router-user and vpn-router-token load without psycopg; a command
that needs the token database then reports that the driver cannot be
loaded, as it reports every failure of that database.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 05:00:00 +0200
vpn-router (0.9.0-1) unstable; urgency=medium
feature: bootstrap diagnostics on port 17081 during the first setup.
* vpn-router-bootstrap serves, on port 17081, the setup state, the
services, the failed units, the addresses, cloud-init status and the end
of the logs of setup and the web service, as plain text.
* vpn-router-setup starts it, and the Router Manager's bootstrap on port
17080, until a run has succeeded, and opens both ports on the internal
interface; the first successful run writes
/var/lib/vpn-router/setup.done, closes the ports and stops them. A
failed run leaves them running.
* The web service no longer has a starting phase: it creates its key and
database, then listens.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 04:00:00 +0200
vpn-router (0.8.0-1) unstable; urgency=medium
feature: the web service creates its key and database itself and shows its starting phase.
* vpn-router-web makes its signing key and creates the refresh tokens'
database on its first run, retrying every 15 seconds until PostgreSQL
answers; vpn-router-setup no longer creates them.
* Until then / and /status show the web service's phase and its failure,
with the state, the step and the failure of the last run of
vpn-router-setup, and every page answers 503.
* vpn-router-setup records its state, current step and failure in
/run/vpn-router/setup.json.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 03:00:00 +0200
vpn-router (0.7.1-1) unstable; urgency=medium
fix: a failing user directory or token database no longer leaves the tunnels down.
* vpn-router-setup brings up strongSwan and WireGuard before it provisions
the user directory and the token database. A failure of either no
longer stops the other steps; setup reports it and fails at the end.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Sun, 25 Oct 2026 02:00:00 +0200
vpn-router (0.7.0-1) unstable; urgency=medium 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. feature: users sign in to the web service, and only members of admins manage the router.
+3
View File
@@ -2,6 +2,7 @@ src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter.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_serve.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/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/
@@ -13,6 +14,8 @@ 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/
src/usr/lib/vpn-router/setup usr/lib/vpn-router/ src/usr/lib/vpn-router/setup usr/lib/vpn-router/
src/usr/lib/vpn-router/web usr/lib/vpn-router/ src/usr/lib/vpn-router/web usr/lib/vpn-router/
src/usr/lib/vpn-router/bootstrap usr/lib/vpn-router/
src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/ src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/ src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/ src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
+6
View File
@@ -70,6 +70,12 @@ case "$1" in
# The firewall is not touched here. Enabling it, allowing SSH and # The firewall is not touched here. Enabling it, allowing SSH and
# setting the forward policy all belong to vpn-router-setup, which does # setting the forward policy all belong to vpn-router-setup, which does
# them only once the configuration says this host is a router. # them only once the configuration says this host is a router.
# The web service waits for libpq in the linker cache, which dpkg
# refreshes after this script, so it is started without waiting for it.
if [ -d /run/systemd/system ]; then
systemctl --no-block restart vpn-router-web.service >/dev/null 2>&1 || true
fi
;; ;;
esac esac
+5 -3
View File
@@ -11,10 +11,10 @@ strip_ufw_blocks() {
tmp="$(mktemp)" tmp="$(mktemp)"
awk ' awk '
/^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ { /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|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|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ { /^# (IPSEC|WIREGUARD|OPENVPN|P2S DNS|WEB|BOOTSTRAP|MANAGER|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
skip = 0; next skip = 0; next
} }
skip { next } skip { next }
@@ -50,7 +50,9 @@ case "$1" in
/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 /etc/vpn-router/web.key \
/var/lib/vpn-router/setup.done
rmdir --ignore-fail-on-non-empty /var/lib/vpn-router 2>/dev/null || true
# The refresh tokens' database and role, when the package created them. # 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 if [ -f /etc/vpn-router/database.secret ] && command -v psql >/dev/null 2>&1; then
+2 -1
View File
@@ -5,4 +5,5 @@
override_dh_installsystemd: override_dh_installsystemd:
dh_installsystemd --name=vpn-router-setup dh_installsystemd --name=vpn-router-setup
dh_installsystemd --name=vpn-router-docker dh_installsystemd --name=vpn-router-docker
dh_installsystemd --name=vpn-router-web dh_installsystemd --name=vpn-router-web --no-start
dh_installsystemd --name=vpn-router-bootstrap --no-enable --no-start
@@ -0,0 +1,14 @@
[Unit]
Description=VPN Router bootstrap diagnostics page
Documentation=file:/usr/share/doc/vpn-router/README.md
# Never enabled: vpn-router-setup starts it until a setup has succeeded, and stops it after that.
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/bootstrap
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
@@ -6,12 +6,16 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStartPre=/usr/lib/vpn-router/wait-for-libpq
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web
TimeoutStartSec=5min
Restart=on-failure Restart=on-failure
RestartSec=15 RestartSec=15
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectSystem=strict ProtectSystem=strict
ReadWritePaths=-/run/vpn-router -/etc/vpn-router/pki -/etc/swanctl/x509crl RuntimeDirectory=vpn-router
RuntimeDirectoryPreserve=yes
ReadWritePaths=-/etc/vpn-router -/etc/swanctl/x509crl
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/python3
"""Serve diagnostics of the router's first setup on PORT, with the Python standard library alone.
Run by vpn-router-bootstrap.service, which vpn-router-setup starts until a setup has succeeded and
then stops. Every request on any address answers, as plain text, the output of COMMANDS run at that
moment: the state of the last setup run, the state of the services, the addresses, and the end of
the logs.
"""
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = 17081
#: The title of each section and its command.
COMMANDS = (
('Setup state', ['cat', '/run/vpn-router/setup.json']),
('Services', ['systemctl', 'is-active', 'vpn-router-setup', 'vpn-router-web', 'slapd', 'postgresql',
'strongswan', 'openvpn-server@vpn-router', 'wg-quick@wg0']),
('Failed units', ['systemctl', '--failed', '--no-legend', '--plain']),
('Addresses', ['ip', '-brief', 'address']),
('Routes', ['ip', 'route']),
('cloud-init', ['cloud-init', 'status', '--long']),
('vpn-router-setup log', ['journalctl', '--unit', 'vpn-router-setup', '--lines', '60', '--no-pager', '--output', 'short-iso']),
('vpn-router-web log', ['journalctl', '--unit', 'vpn-router-web', '--lines', '30', '--no-pager', '--output', 'short-iso']),
)
def report():
"""The output of every command of COMMANDS under its title."""
sections = []
for title, command in COMMANDS:
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=10)
text = (result.stdout + result.stderr).rstrip()
except (OSError, subprocess.TimeoutExpired) as exc:
text = f'cannot run {command[0]}: {exc}'
sections.append(f'== {title} ==\n{text}\n')
return '\n'.join(sections)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = report().encode()
self.send_response(200)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('Refresh', '10')
self.end_headers()
self.wfile.write(body)
if __name__ == '__main__':
ThreadingHTTPServer(('', PORT), Handler).serve_forever()
+138 -14
View File
@@ -5,6 +5,12 @@ Regenerates the configuration files first, then does everything that needs
the live system: routes, DNS-derived identity, WireGuard keys, firewall rules the live system: routes, DNS-derived identity, WireGuard keys, firewall rules
and service reloads. Idempotent - a second run changes nothing. and service reloads. Idempotent - a second run changes nothing.
Until a run has succeeded (SETUP_DONE), every run starts the bootstrap
services, BOOTSTRAP_UNITS, which show the progress and the failure recorded in
SETUP_STATE on BOOTSTRAP_PORTS, and the firewall opens those ports on the
internal interface. The first successful run writes SETUP_DONE, closes the
ports and stops the bootstrap services; a failed run leaves them running.
Interface names come from the configuration and are never guessed. The Interface names come from the configuration and are never guessed. The
firewall and NAT rules are written against them, so choosing the wrong one firewall and NAT rules are written against them, so choosing the wrong one
would be a silent and security-relevant mistake. would be a silent and security-relevant mistake.
@@ -17,14 +23,15 @@ import re
import socket import socket
import subprocess import subprocess
import sys import sys
import time
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 vpnrouter import vpnrouter
import vpnrouter_auth
import vpnrouter_ldap import vpnrouter_ldap
import vpnrouter_users
from vpnrouter import fail from vpnrouter import fail
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules') BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
@@ -32,9 +39,23 @@ 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 state, the current step and the failure of the last run, which the bootstrap services show.
SETUP_STATE = pathlib.Path('/run/vpn-router/setup.json')
#: Written once a run has succeeded; until then setup runs the bootstrap services and opens their ports.
SETUP_DONE = pathlib.Path('/var/lib/vpn-router/setup.done')
#: The units of the bootstrap services, and their ports: the Router Manager's and the router's.
BOOTSTRAP_UNITS = ('vpn-router-bootstrap.service', 'vpn-router-manager-bootstrap.service')
BOOTSTRAP_PORTS = (17080, 17081)
#: The Router Manager, vpn-router-manager, when it is installed, and its settings. #: The Router Manager, vpn-router-manager, when it is installed, and its settings.
MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager') MANAGER = pathlib.Path('/usr/lib/vpn-router-manager/manager')
MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json') MANAGER_CONFIG = pathlib.Path('/etc/vpn-router-manager/manager.json')
#: The default admin of the Router Manager, {"username", "password"}, which its package writes from
#: its debconf answers; setup creates the account and removes the file.
MANAGER_ADMIN = pathlib.Path('/etc/vpn-router-manager/admin.json')
MANAGER_PORT = 7080 MANAGER_PORT = 7080
FILTER_ANCHOR = '# End required lines' FILTER_ANCHOR = '# End required lines'
@@ -48,6 +69,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'),
('# BOOTSTRAP RULES START', '# BOOTSTRAP RULES END'),
('# MANAGER RULES START', '# MANAGER 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'),
@@ -201,6 +223,14 @@ def filter_block(ctx):
'# WEB RULES END', '# WEB RULES END',
] ]
if not SETUP_DONE.exists() and ctx['int_iface']:
lines += [
'',
'# BOOTSTRAP RULES START',
*(f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {port} -j ACCEPT' for port in BOOTSTRAP_PORTS),
'# BOOTSTRAP RULES END',
]
if MANAGER.exists() and ctx['int_iface']: if MANAGER.exists() and ctx['int_iface']:
port = manager_port() port = manager_port()
lines += [ lines += [
@@ -349,22 +379,41 @@ def manage_wireguard(ctx, changed):
def manage_directory(): def manage_directory():
"""Provision the user directory in slapd, whether or not a P2S system is configured.""" """Provision the user directory in slapd, whether or not a P2S system is configured; the problem, or ''."""
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']) result = run(['systemctl', 'restart', 'slapd'], check=False)
if result.returncode != 0:
return f'slapd cannot be restarted: {result.stderr.strip()}'
try: try:
vpnrouter_ldap.provision() vpnrouter_ldap.provision()
except ldap.LDAPError as exc: except Exception as exc:
fail(f'the user directory cannot be provisioned: {exc}') return f'the user directory cannot be provisioned: {exc!r}'
return ''
def manage_tokens(cfg): def manage_admin():
"""Make the web service's signing key, and the database of its refresh tokens unless web.database names one.""" """Create the Router Manager's default admin from MANAGER_ADMIN, then remove the file; the problem, or ''.
vpnrouter_auth.ensure_key()
The admin is a Manager-only account and a member of the admins group, which is created with it
when it does not exist.
"""
if not MANAGER_ADMIN.exists():
return ''
try: try:
vpnrouter_auth.provision(cfg) answers = json.loads(MANAGER_ADMIN.read_text(encoding='utf-8'))
except OSError as exc: name, password = answers['username'], answers['password']
fail(f'the token database cannot be provisioned: {exc}') with vpnrouter_users.session() as connection:
vpnrouter_users.add(connection, name, password, {})
try:
vpnrouter_users.group_member_add(connection, vpnrouter_users.ADMIN_GROUP, name)
except vpnrouter_users.Failure as failure:
if failure.status != 3:
raise
vpnrouter_users.group_add(connection, vpnrouter_users.ADMIN_GROUP, 'Router administrators', [name])
MANAGER_ADMIN.unlink()
except (OSError, ValueError, KeyError, TypeError, ldap.LDAPError, vpnrouter_users.Failure) as exc:
return f'the Router Manager admin cannot be created from {MANAGER_ADMIN}: {exc}'
return ''
def manage_openvpn(ctx, changed): def manage_openvpn(ctx, changed):
@@ -442,25 +491,62 @@ def refresh_web(cfg, ctx):
}) + '\n', encoding='utf-8') }) + '\n', encoding='utf-8')
def progress(state, step, error=None):
"""Record where setup is in SETUP_STATE: running, deferred, done or failed, the step, and the error of a failure."""
SETUP_STATE.parent.mkdir(parents=True, exist_ok=True)
SETUP_STATE.write_text(json.dumps({
'state': state,
'step': step,
'error': error,
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
}) + '\n', encoding='utf-8')
def start_bootstrap():
"""Start the bootstrap services while no run has succeeded; a unit that is not installed is skipped."""
if not SETUP_DONE.exists():
for unit in BOOTSTRAP_UNITS:
run(['systemctl', 'start', '--no-block', unit], check=False)
def end_bootstrap(ctx):
"""After the first successful run: write SETUP_DONE, close the bootstrap ports and stop the bootstrap services."""
if SETUP_DONE.exists():
return
SETUP_DONE.parent.mkdir(parents=True, exist_ok=True)
SETUP_DONE.touch()
if setup_ufw(ctx):
run(['ufw', 'reload'], check=False)
for unit in BOOTSTRAP_UNITS:
run(['systemctl', 'stop', unit], check=False)
def main(): def main():
start_bootstrap()
progress('running', 'configuration')
cfg = vpnrouter.load_config() cfg = vpnrouter.load_config()
ctx, changed = vpnrouter.configure(cfg) ctx, changed = vpnrouter.configure(cfg)
if ctx is None: if ctx is None:
print('vpn-router: platform is none, configuration deferred; ' print('vpn-router: platform is none, configuration deferred; '
f'set general.platform in {vpnrouter.CONFIG_FILE}') f'set general.platform in {vpnrouter.CONFIG_FILE}')
progress('deferred', 'configuration', f'general.platform is none; set it in {vpnrouter.CONFIG_FILE}')
return 0 return 0
progress('running', 'routes')
if vpnrouter.routing_ready(ctx): if vpnrouter.routing_ready(ctx):
apply_routes(ctx) apply_routes(ctx)
if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']: if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']:
progress('running', 'public address')
address = resolve_public_ip(ctx['local_fqdn']) address = resolve_public_ip(ctx['local_fqdn'])
ctx, more = vpnrouter.configure(cfg, local_id_override=address) ctx, more = vpnrouter.configure(cfg, local_id_override=address)
changed |= more changed |= more
progress('running', 'WireGuard keys')
setup_wireguard(ctx) setup_wireguard(ctx)
progress('running', 'firewall')
rules_changed = setup_ufw(ctx) rules_changed = setup_ufw(ctx)
if is_configured(ctx): if is_configured(ctx):
status = run(['ufw', 'status'], check=False) status = run(['ufw', 'status'], check=False)
@@ -473,20 +559,58 @@ def main():
elif rules_changed: elif rules_changed:
run(['ufw', 'reload'], check=False) run(['ufw', 'reload'], check=False)
manage_directory() progress('running', 'strongSwan')
manage_tokens(cfg)
manage_strongswan(ctx, changed) manage_strongswan(ctx, changed)
progress('running', 'WireGuard')
manage_wireguard(ctx, changed) manage_wireguard(ctx, changed)
# The tunnels are up before the directory and the admin, whose problem ends setup only after every other step.
failed_step = 'user directory'
progress('running', failed_step)
problem = manage_directory()
if not problem:
failed_step = 'Router Manager admin'
progress('running', failed_step)
problem = manage_admin()
progress('running', 'OpenVPN')
manage_openvpn(ctx, changed) manage_openvpn(ctx, changed)
if 'resolved' in changed: if 'resolved' in changed:
progress('running', 'resolver')
run(['systemctl', 'restart', 'systemd-resolved'], check=False) run(['systemctl', 'restart', 'systemd-resolved'], check=False)
progress('running', 'platform module')
run_platform_setup(cfg, ctx) run_platform_setup(cfg, ctx)
progress('running', 'Docker rules')
refresh_docker_rules() refresh_docker_rules()
progress('running', 'web service state')
refresh_web(cfg, ctx) refresh_web(cfg, ctx)
if problem:
progress('failed', failed_step, problem)
print(f'vpn-router: error: {problem}', file=sys.stderr)
return 1
progress('done', 'done')
end_bootstrap(ctx)
return 0 return 0
def record_failure(error):
"""Mark the run failed in the step SETUP_STATE holds."""
try:
step = json.loads(SETUP_STATE.read_text(encoding='utf-8')).get('step')
except (OSError, ValueError):
step = None
progress('failed', step, error)
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main()) # A failing step ends with SystemExit carrying its message (vpnrouter.fail).
try:
code = main()
except SystemExit as exc:
record_failure(str(exc.code))
raise
except Exception as exc:
record_failure(repr(exc))
raise
sys.exit(code)
@@ -54,8 +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.jwt_secret is read and ignored; the tokens are signed with vpnrouter_auth.WEB_KEY. 'web': {'enabled': True, 'port': 7081, 'database': '', 'trusted_proxies': []},
'web': {'enabled': True, 'port': 7081, 'jwt_secret': '', 'database': '', 'trusted_proxies': []},
} }
DOCKER_MODES = ('auto', 'on', 'off') DOCKER_MODES = ('auto', 'on', 'off')
@@ -96,8 +95,8 @@ def warn(message):
def fail(message): def fail(message):
print(f'vpn-router: error: {message}', file=sys.stderr) """End with status 1; SystemExit carries the message, which Python prints to standard error."""
sys.exit(1) sys.exit(f'vpn-router: error: {message}')
def load_config(path=None): def load_config(path=None):
@@ -1,16 +1,19 @@
"""The tokens of the router's web service, for the web service and vpn-router-token. """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 Tokens are HS256 JWTs signed with WEB_KEY, a key the web service makes on its first run on the
never leaves it. /auth issues an access token naming the user in sub, valid ACCESS_SECONDS, and 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 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. any lifetime. A token without the admin claim must expire within MAX_LIFETIME seconds.
Every failure of the database raises DatabaseError, the only database exception callers see.
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of 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 web.database, by default the vpn-router database on this host that provision() creates on the
the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it. web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
""" """
import base64 import base64
import contextlib
import hashlib import hashlib
import hmac import hmac
import json import json
@@ -20,7 +23,6 @@ import subprocess
import time import time
import psycopg import psycopg
from psycopg.conninfo import make_conninfo
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key') WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret') DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
@@ -28,6 +30,9 @@ DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
#: The role and the database provision() creates when web.database is not set. #: The role and the database provision() creates when web.database is not set.
DATABASE = 'vpn-router' DATABASE = 'vpn-router'
#: The user operations of vpnrouter_users after which the user's refresh tokens are revoked.
REVOKING = ('passwd', 'disable', 'unlock', 'remove')
#: Seconds an access token of /auth and a refresh token are valid. #: Seconds an access token of /auth and a refresh token are valid.
ACCESS_SECONDS = 300 ACCESS_SECONDS = 300
REFRESH_SECONDS = 8 * 3600 REFRESH_SECONDS = 8 * 3600
@@ -41,8 +46,12 @@ LEEWAY = 60
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)' SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
class DatabaseError(Exception):
"""The database of the refresh tokens failed."""
def key(): def key():
"""The signing key, or '' while vpn-router-setup has not made it.""" """The signing key, or '' while the web service has not made it."""
try: try:
return WEB_KEY.read_text(encoding='utf-8').strip() return WEB_KEY.read_text(encoding='utf-8').strip()
except OSError: except OSError:
@@ -106,25 +115,29 @@ def admin_token(seconds):
"""A token with the admin claim, valid for the seconds given.""" """A token with the admin claim, valid for the seconds given."""
secret = key() secret = key()
if not secret: if not secret:
raise OSError(f'{WEB_KEY} does not exist; vpn-router-setup makes it once a platform is chosen') raise OSError(f'{WEB_KEY} does not exist; vpn-router-web makes it when it starts')
now = int(time.time()) now = int(time.time())
return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds} return {'token': sign({'admin': True, 'iat': now, 'exp': now + seconds}, secret), 'expires': now + seconds}
def connection_string(cfg): def connection_string(cfg):
"""web.database, or the database provision() creates.""" """web.database, or the database provision() creates; its password, of URL-safe characters, is written unquoted."""
configured = cfg['web']['database'].strip() configured = cfg['web']['database'].strip()
if configured: if configured:
return configured return configured
password = DATABASE_SECRET.read_text(encoding='utf-8').strip() password = DATABASE_SECRET.read_text(encoding='utf-8').strip()
return make_conninfo(host='localhost', dbname=DATABASE, user=DATABASE, password=password) return f'host=localhost dbname={DATABASE} user={DATABASE} password={password}'
def connect(cfg): @contextlib.contextmanager
"""A connection to the database of the refresh tokens, with their table.""" def database(cfg):
connection = psycopg.connect(connection_string(cfg), autocommit=True) """A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure."""
connection.execute(SCHEMA) try:
return connection with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
connection.execute(SCHEMA)
yield connection
except psycopg.Error as exc:
raise DatabaseError(f'the token database failed: {exc}') from exc
def digest(token): def digest(token):
@@ -136,7 +149,7 @@ def issue(cfg, name):
secret = key() secret = key()
now = int(time.time()) now = int(time.time())
refresh = secrets.token_urlsafe(32) refresh = secrets.token_urlsafe(32)
with connect(cfg) as connection: with database(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE expires < now()') connection.execute('DELETE FROM refresh_tokens WHERE expires < now()')
connection.execute("INSERT INTO refresh_tokens VALUES (%s, %s, now() + make_interval(secs => %s))", connection.execute("INSERT INTO refresh_tokens VALUES (%s, %s, now() + make_interval(secs => %s))",
(digest(refresh), name, REFRESH_SECONDS)) (digest(refresh), name, REFRESH_SECONDS))
@@ -153,7 +166,7 @@ def take(cfg, token):
"""Remove an unexpired refresh token and return the user it was issued to, or None.""" """Remove an unexpired refresh token and return the user it was issued to, or None."""
if not isinstance(token, str) or not token: if not isinstance(token, str) or not token:
return None return None
with connect(cfg) as connection: with database(cfg) as connection:
row = connection.execute('DELETE FROM refresh_tokens WHERE hash = %s RETURNING name, expires > now()', row = connection.execute('DELETE FROM refresh_tokens WHERE hash = %s RETURNING name, expires > now()',
(digest(token),)).fetchone() (digest(token),)).fetchone()
return row[0] if row and row[1] else None return row[0] if row and row[1] else None
@@ -161,7 +174,7 @@ def take(cfg, token):
def revoke(cfg, name): def revoke(cfg, name):
"""Remove all refresh tokens of the user.""" """Remove all refresh tokens of the user."""
with connect(cfg) as connection: with database(cfg) as connection:
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,)) connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
@@ -50,6 +50,21 @@ LOCKOUT_SECONDS = 900
#: The overlays of the database, in the order they are configured. #: The overlays of the database, in the order they are configured.
OVERLAYS = ('ppolicy', 'otp') OVERLAYS = ('ppolicy', 'otp')
#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
#: package to a user of either kind: mail, for a Manager-only account, and vpnRouterScope,
#: authorization scopes that nothing reads yet.
SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
SCHEMA_NAME = 'vpn-router'
SCHEMA_ATTRIBUTE_TYPES = (
f"( {SCHEMA_OID}.1.1 NAME 'vpnRouterScope' DESC 'VPN router: an authorization scope of the account' "
"EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )",
)
SCHEMA_OBJECT_CLASSES = (
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' "
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope ) )",
)
#: 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'
@@ -109,6 +124,23 @@ def _ensure_modules(connection):
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])]) connection.modify_s(dn, [(ldap.MOD_ADD, 'olcModuleLoad', [name.encode() for name in missing])])
def _ensure_schema(connection):
"""Add the package's schema to cn=config when missing, else set its definitions that differ."""
found = connection.search_s('cn=schema,cn=config', ldap.SCOPE_ONELEVEL, f'(cn=*{SCHEMA_NAME})',
['olcAttributeTypes', 'olcObjectClasses'])
attributes = {'olcAttributeTypes': [value.encode() for value in SCHEMA_ATTRIBUTE_TYPES],
'olcObjectClasses': [value.encode() for value in SCHEMA_OBJECT_CLASSES]}
if not found:
connection.add_s(f'cn={SCHEMA_NAME},cn=schema,cn=config', ldap.modlist.addModlist(
dict(attributes, objectClass=[b'olcSchemaConfig'], cn=[SCHEMA_NAME.encode()])))
return
dn, current = found[0]
changes = [(ldap.MOD_REPLACE, name, values) for name, values in attributes.items()
if unordered(current.get(name, [])) != [value.decode() for value in values]]
if changes:
connection.modify_s(dn, changes)
def _ensure_password_hash(connection): def _ensure_password_hash(connection):
dn = 'olcDatabase={-1}frontend,cn=config' dn = 'olcDatabase={-1}frontend,cn=config'
current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', []) current = connection.search_s(dn, ldap.SCOPE_BASE, attrlist=['olcPasswordHash'])[0][1].get('olcPasswordHash', [])
@@ -188,10 +220,11 @@ def _ensure_manager(connection):
def provision(): def provision():
"""Create or bring up to date the modules, database, ACLs, password policy, entries and service account.""" """Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account."""
connection = root() connection = root()
try: try:
_ensure_modules(connection) _ensure_modules(connection)
_ensure_schema(connection)
_ensure_password_hash(connection) _ensure_password_hash(connection)
database = _ensure_database(connection) database = _ensure_database(connection)
_ensure_overlays(connection, database) _ensure_overlays(connection, database)
@@ -1,6 +1,7 @@
"""Interfaces and addresses of the router, read from the system. """Interfaces and addresses of the router, read from the system.
Only the Python standard library is used, so the web service can use this module too. Only the Python standard library is used, so the web service and its bootstrap server can use
this module too.
""" """
import pathlib import pathlib
@@ -107,3 +108,28 @@ def internal_address(mode, internal, int_addr):
except ConfigError: except ConfigError:
return '' return ''
return addresses[0] if addresses else '' return addresses[0] if addresses else ''
def web_listen(configuration, default_port):
"""Where the web service listens, from vpn-router.json read as configuration, as ((address, port), '').
The address is the internal address as general.mode directs, the port web.port, else
default_port. While web.enabled is false or the address is not known, the answer is (None,
the reason).
"""
def section(name):
value = configuration.get(name)
return value if isinstance(value, dict) else {}
web = section('web')
if web.get('enabled', True) is False:
return None, 'the web service is disabled in the configuration'
port = web.get('port', default_port)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
port = default_port
general, interfaces, local = section('general'), section('interfaces'), section('local')
address = internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
str(local.get('int_addr') or ''))
if not address:
return None, 'the internal address is not known yet'
return (address, port), ''
@@ -0,0 +1,40 @@
"""The listening loop of the web service and of its bootstrap server.
Only the Python standard library is used, so the bootstrap server, which must run whatever else
is broken, can use it.
"""
import threading
import time
from http.server import ThreadingHTTPServer
def serve(wanted, handler, log, interval):
"""Work out where to listen every interval seconds with wanted, and run an HTTP server of handler there.
wanted answers ((address, port), '') or (None, the reason not to listen). When the address or
the port changes, the server is stopped and a new one listens at the new place; while there is
no place or it cannot be bound, no server runs and the next round tries again. Every new
reason is logged with log. Does not return.
"""
server, thread, listening, reported = None, None, None, None
while True:
target, reason = wanted()
if target != listening and server is not None:
server.shutdown()
server.server_close()
thread.join()
server, listening = None, None
if target is not None and server is None:
try:
server = ThreadingHTTPServer(target, handler)
except OSError as exc:
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}'
else:
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True)
thread.start()
listening, reason = target, f'serving on http://{target[0]}:{target[1]}'
if reason and reason != reported:
log(reason)
reported = reason
time.sleep(interval)
@@ -1,9 +1,13 @@
"""The users and groups of the router's directory, for vpn-router-user, vpn-router-group and the web service. """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 A user is an entry in ou=users of the router's directory, of one of two kinds, fixed when it is
address, whose password OpenVPN checks, and a client certificate of the router CA with the added. A VPN user is an inetOrgPerson with a first name, a last name and an e-mail address, whose
user name as its common name, which OpenVPN and IKEv2 accept. Users exist whether or not a password OpenVPN checks, and a client certificate of the router CA with the user name as its
P2S system is enabled. common name, which OpenVPN and IKEv2 accept. A Manager-only account is an account,
simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and
optionally an e-mail address, no names, no certificate and so no VPN access; it is added when
neither the first nor the last name is given, and can sign in to the web service. 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 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 optional description and at least one member; a group cannot be empty, so the last member
@@ -19,8 +23,8 @@ code of it. slapd's otp overlay then takes the password followed by a code at ev
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password 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. 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 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 router through the web service. The module knows only the directory; the callers revoke a
user revoke its refresh tokens. user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
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
@@ -44,7 +48,6 @@ 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.
@@ -59,7 +62,10 @@ EMAIL_MAX = 254
#: The keys of the person attributes and their LDAP attributes. #: The keys of the person attributes and their LDAP attributes.
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail')) PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
#: The attributes read for a user. #: The attributes read for a user.
ATTRIBUTES = ['uid', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken'] ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken']
#: The object classes of a VPN user and of a Manager-only account.
VPN_USER_CLASSES = [b'inetOrgPerson']
MANAGER_ONLY_CLASSES = [b'account', b'simpleSecurityObject', b'vpnRouterAccount']
#: The readable attributes of a TOTP token; oathSecret is never readable. #: The readable attributes of a TOTP token; oathSecret is never readable.
TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift', TOKEN_ATTRIBUTES = ['oathTOTPToken', 'oathTOTPParams', 'oathSecretTime', 'oathTOTPLastTimeStep', 'oathTOTPTimeStepDrift',
'oathLastLogin', 'oathLastFailure', 'oathFailureCount'] 'oathLastLogin', 'oathLastFailure', 'oathFailureCount']
@@ -172,12 +178,18 @@ def locked_since(value):
return 0 return 0
def manager_only(attributes):
"""Whether the entry is a Manager-only account."""
return b'account' in (value.lower() for value in attributes.get('objectClass', []))
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] lock = attributes.get('pwdAccountLockedTime', [None])[0]
user = { user = {
'name': name, 'name': name,
'manager_only': manager_only(attributes),
'enabled': lock != LOCKED, 'enabled': lock != LOCKED,
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS, '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(),
@@ -196,38 +208,62 @@ def common_name(attributes, name):
def list_users(connection): def list_users(connection):
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(objectClass=inetOrgPerson)', ATTRIBUTES) found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(|(objectClass=inetOrgPerson)(objectClass=account))',
ATTRIBUTES)
return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])} return {'users': sorted((summary(attributes) for _, attributes in found), key=lambda user: user['name'])}
def add(connection, name, password, values): def add(connection, name, password, values):
"""Add a user with the person attributes of values, all three required, and issue the certificate.""" """Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
with at most the e-mail address when it gives neither name."""
check_name(name) check_name(name)
details = person(values, required=True) details = person(values, required=False)
vpn_user = 'givenName' in details or 'sn' in details
if vpn_user and len(details) != len(PERSON):
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
check_password(password) check_password(password)
if not (vpnrouter.PKI_DIR / 'ca_key.pem').is_file(): if vpn_user and 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') 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()], if vpn_user:
'cn': [common_name(details, name).encode()]} record = {'objectClass': VPN_USER_CLASSES, 'uid': [name.encode()], 'cn': [common_name(details, name).encode()]}
else:
# simpleSecurityObject requires a userPassword; passwd_s sets the real one at once.
record = {'objectClass': MANAGER_ONLY_CLASSES, 'uid': [name.encode()], 'userPassword': [b'{CRYPT}!']}
record.update({attribute: [value.encode()] for attribute, value in details.items()}) record.update({attribute: [value.encode()] for attribute, value in details.items()})
try: try:
connection.add_s(dn_of(name), ldap.modlist.addModlist(record)) connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS: except ldap.ALREADY_EXISTS:
raise Failure(f'user {name} exists', 4) raise Failure(f'user {name} exists', 4)
connection.passwd_s(dn_of(name), None, password) connection.passwd_s(dn_of(name), None, password)
vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) if vpn_user:
for path in certificate(name): vpnrouter.USER_CERT_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
path.unlink(missing_ok=True) for path in certificate(name):
simple_ca('make-cert', '--ca-dir', str(vpnrouter.PKI_DIR), '--cert-dir', str(vpnrouter.USER_CERT_DIR), 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)) return summary(entry(connection, name))
def update(connection, name, values): def update(connection, name, values):
"""Change the person attributes values gives; the cn follows the first and the last name.""" """Change the person attributes values gives; the cn follows the first and the last name.
A Manager-only account has only the e-mail address, which '' removes.
"""
current = entry(connection, name)
if manager_only(current):
if values.get('first_name') is not None or values.get('last_name') is not None:
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
email = values.get('email')
if email is None:
raise Failure('give email', 2)
if isinstance(email, str) and email.strip() == '':
if 'mail' in current:
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'mail', None)])
else:
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'mail', [person({'email': email}, required=False)['mail'].encode()])])
return summary(entry(connection, name))
details = person(values, required=False) details = person(values, required=False)
if not details: if not details:
raise Failure('give at least one of first_name, last_name and email', 2) 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 = {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}) 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 = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
@@ -240,14 +276,12 @@ 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 entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]: 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))
@@ -266,7 +300,6 @@ def unlock(connection, name):
changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None)) changes.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None))
if changes: if changes:
connection.modify_s(dn_of(name), changes) connection.modify_s(dn_of(name), changes)
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
return summary(entry(connection, name)) return summary(entry(connection, name))
@@ -361,7 +394,6 @@ 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}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Wait until libpq is in the dynamic linker cache, where psycopg looks it up.
# ExecStartPre= of vpn-router-web.service; TimeoutStartSec= of the unit bounds the wait.
reported=
until ldconfig -p | grep -q '^[[:space:]]*libpq\.so\.5 '; do
if [ -z "$reported" ]; then
echo 'waiting for libpq.so.5 in the linker cache'
reported=yes
fi
sleep 2
done
+141 -75
View File
@@ -15,26 +15,35 @@ 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())
/account the account of the signed-in user: its details, its password and its TOTP
enrolment (see account())
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth()) /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 signed with /config, /users, /groups and /account need an HS256 JWT in an Authorization: Bearer header signed
vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub, who with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
must be a member of vpnrouter_users.ADMIN_GROUP; a token of vpn-router-token has the admin /account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
claim, and only such a token may change ADMIN_GROUP: create, rename to it, change, remove it, token of vpn-router-token has the admin claim and names no user; it may call every path but
/account, 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 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 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 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. 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 and the
installed version of vpn-router-manager (manager_version, null when it is not installed), 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
WireGuard. No configuration data, key or address is shown. WireGuard. No configuration data, key or address is shown.
The service needs nothing else to start and does not end on its own. A discovery thread works The service first initializes, without listening: it makes the signing key and the refresh
out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs, with tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
web.port and web.enabled, every DISCOVERY_SECONDS, and runs the server thread there: it starts logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found libpq
it, moves it when the address or the port changes, and stops it while the service is disabled in the linker cache, where psycopg looks it up.
or the address is not known. Every request reads what it needs when it is made: the signing
The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it
works out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs,
with web.port and web.enabled (vpnrouter_net.web_listen), and runs the server there
(vpnrouter_serve.serve): it starts it, moves it when the address or the port changes, and stops
it while the service is disabled or the address is not known. Every request reads what it needs when it is made: the signing
key, 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.
@@ -49,21 +58,24 @@ import sys
import threading import threading
import time import time
from html import escape from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler
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
import vpnrouter_auth import vpnrouter_auth
import vpnrouter_net import vpnrouter_net
import vpnrouter_serve
import vpnrouter_users 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'
#: Seconds between two attempts of the initialization.
INIT_RETRY_SECONDS = 15
#: The port when web.port is not set. #: The port when web.port is not set.
DEFAULT_PORT = 7081 DEFAULT_PORT = 7081
@@ -135,9 +147,10 @@ def unit_state(unit):
return result.stdout.strip() if result and result.stdout.strip() else 'unknown' return result.stdout.strip() if result and result.stdout.strip() else 'unknown'
def package_version(): def package_version(package='vpn-router'):
result = run('dpkg-query', '-W', '-f=${Version}', 'vpn-router') """The installed version of the package, or None when it is not installed."""
return result.stdout.strip() if result and result.returncode == 0 else None result = run('dpkg-query', '-W', '-f=${Version}', package)
return (result.stdout.strip() or None) if result and result.returncode == 0 else None
def docker_status(): def docker_status():
@@ -231,9 +244,23 @@ def openvpn_clients():
return None return None
def postgresql_clusters():
"""The state of each PostgreSQL cluster unit (postgresql@<version>-<name>), or {'postgresql': 'no cluster'} without one.
postgresql.service, which only runs /bin/true, is not reported.
"""
lines = lines_of('systemctl', 'list-units', 'postgresql@*.service', '--all', '--plain', '--no-legend') or []
names = [line.split()[0].removesuffix('.service') for line in lines if line.strip()]
return {name: unit_state(f'{name}.service') for name in names} or {'postgresql': 'no cluster'}
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'), 'slapd': unit_state('slapd.service')} units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service'),
**postgresql_clusters()}
manager_version = package_version('vpn-router-manager')
if manager_version:
units['vpn-router-manager'] = unit_state('vpn-router-manager.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'):
@@ -244,6 +271,7 @@ def collect(state):
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',
'version': package_version(), 'version': package_version(),
'manager_version': manager_version,
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
'units': units, 'units': units,
'docker': docker_status(), 'docker': docker_status(),
@@ -319,6 +347,7 @@ def details(state):
def text_of(status): def text_of(status):
"""The status for people, as plain text.""" """The status for people, as plain text."""
lines = ['VPN Router', f'Status: {status["status"]}', f'Version: {status["version"] or "unknown"}', ''] lines = ['VPN Router', f'Status: {status["status"]}', f'Version: {status["version"] or "unknown"}', '']
lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
lines += [f'{name}: {value}' for name, value in status['units'].items()] lines += [f'{name}: {value}' for name, value in status['units'].items()]
docker = status['docker'] docker = status['docker']
lines.append('Docker: not installed' if not docker['installed'] lines.append('Docker: not installed' if not docker['installed']
@@ -391,7 +420,8 @@ def users(state, method, path, body):
"""Answer a /users request with vpnrouter_users: 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", "first_name", "last_name", "email"} POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
without the last three a Manager-only account
PUT /users/<name> change any of {"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
@@ -423,20 +453,25 @@ def users(state, method, path, body):
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')), ('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name), ('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
} }
# The vpnrouter_users operation of each route that may revoke refresh tokens.
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
('DELETE', 2, None): 'remove'}
if route not in actions: if route not in actions:
return 404, {'error': f'no route {method} {path}'} return 404, {'error': f'no route {method} {path}'}
try: try:
with vpnrouter_users.session() as connection: with vpnrouter_users.session() as connection:
document = actions[route](connection) document = actions[route](connection)
if operations.get(route) in vpnrouter_auth.REVOKING:
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
except vpnrouter_users.Failure as failure: except vpnrouter_users.Failure as failure:
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: except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': f'the token database failed: {exc}'} return 500, {'error': str(exc)}
except SystemExit: except SystemExit as exc:
# vpnrouter ends with SystemExit on a configuration it cannot read; it has printed the reason. # vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
return 500, {'error': 'the router configuration cannot be read'} return 500, {'error': str(exc.code)}
return (201 if route == ('POST', 1, None) else 200), document return (201 if route == ('POST', 1, None) else 200), document
@@ -478,6 +513,54 @@ 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 account(name, method, path, body):
"""Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.
GET /account the user, with admin, whether the user is a member of ADMIN_GROUP
PUT /account/password set the password: {"current_password", "current_code"?, "password"}
POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"},
code a code of the secret
A change needs the current password and, for an enrolled user, a current code; a wrong one is 403 and counts as
a failed login. A password change revokes the user's refresh tokens.
"""
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 {}
route = (method, path.rstrip('/'))
if route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'), ('PUT', '/account/otp')):
return 404, {'error': f'no route {method} {path}'}
try:
if route == ('PUT', '/account/password'):
vpnrouter_users.check_password(body.get('password'))
if method == 'PUT':
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
with vpnrouter_users.session() as connection:
if route == ('GET', '/account'):
document = {**vpnrouter_users.summary(vpnrouter_users.entry(connection, name)),
'admin': vpnrouter_users.is_admin(connection, name)}
elif route == ('PUT', '/account/password'):
document = vpnrouter_users.passwd(connection, name, body['password'])
elif route == ('POST', '/account/otp'):
document = vpnrouter_users.otp_new(connection, name)
else:
document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code'))
if route == ('PUT', '/account/password'):
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
except vpnrouter_users.Failure as failure:
if failure.status == 5:
return 403, {'error': 'the current password or code is wrong'}
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}'}
except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': str(exc)}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
return 200, document
def changes_admins(connection, method, parts, body): 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.""" """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 admins = vpnrouter_users.ADMIN_GROUP
@@ -494,9 +577,11 @@ def changes_admins(connection, method, parts, body):
def refusal(claims, method, path, body): 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.""" """Why the holder of the claims may not make the request to a protected path, or '' when it may."""
name = claims.get('sub')
if path == '/account' or path.startswith('/account/'):
return '' if isinstance(name, str) else 'the token names no user'
if claims.get('admin') is True: if claims.get('admin') is True:
return '' return ''
name = claims.get('sub')
if not isinstance(name, str): if not isinstance(name, str):
return 'the token names no user' return 'the token names no user'
admins = vpnrouter_users.ADMIN_GROUP admins = vpnrouter_users.ADMIN_GROUP
@@ -536,10 +621,10 @@ def auth(path, body):
return (401 if failure.status == 3 else USER_STATUS.get(failure.status, 500)), {'error': str(failure)} return (401 if failure.status == 3 else 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: except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': f'the token database failed: {exc}'} return 500, {'error': str(exc)}
except SystemExit: except SystemExit as exc:
return 500, {'error': 'the router configuration cannot be read'} return 500, {'error': str(exc.code)}
def auth_wait(address): def auth_wait(address):
@@ -596,7 +681,7 @@ 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
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', 'account') 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
signing = path in ('/auth', '/auth/refresh', '/auth/logout') signing = path in ('/auth', '/auth/refresh', '/auth/logout')
state = current() if protected or signing else {} state = current() if protected or signing else {}
@@ -624,6 +709,9 @@ class Handler(BaseHTTPRequestHandler):
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n' 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 directory == 'account':
code, document = account(claims['sub'], self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif protected and path != '/config': elif protected and path != '/config':
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload) 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'
@@ -683,63 +771,41 @@ class Handler(BaseHTTPRequestHandler):
sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args)) sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))
def wanted():
"""Where the service should listen now, as (address, port), or the reason it should not listen."""
configuration = read_json(CONFIG_FILE)
web = section(configuration, 'web')
if web.get('enabled', True) is False:
return None, 'the web service is disabled in the configuration'
port = web.get('port', DEFAULT_PORT)
if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
port = DEFAULT_PORT
general, interfaces, local = (section(configuration, name) for name in ('general', 'interfaces', 'local'))
address = vpnrouter_net.internal_address(str(general.get('mode') or 'manual'), str(interfaces.get('internal') or ''),
str(local.get('int_addr') or ''))
if not address:
return None, 'the internal address is not known yet'
return (address, port), ''
def log(message): def log(message):
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True) print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
def discover(): def initialize():
"""Work out where to listen every DISCOVERY_SECONDS, and run the server thread there. """Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready."""
reported = None
When the address or the port changes, the server thread is stopped and a new one listens at
the new place; while the service is disabled, the address is not known or it cannot be bound,
no server runs and the next round tries again.
"""
server, thread, listening, reported = None, None, None, None
while True: while True:
target, reason = wanted() phase = 'reading the configuration'
if target != listening and server is not None: try:
server.shutdown() cfg = vpnrouter.load_config()
server.server_close() phase = 'making the signing key'
thread.join() vpnrouter_auth.ensure_key()
server, listening = None, None phase = 'creating the token database'
if target is not None and server is None: vpnrouter_auth.provision(cfg)
try: phase = 'connecting to the token database'
server = ThreadingHTTPServer(target, Handler) with vpnrouter_auth.database(cfg):
except OSError as exc: pass
reason = f'cannot listen on {target[0]}:{target[1]}: {exc}' except (OSError, SystemExit, vpnrouter_auth.DatabaseError) as exc:
else: error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}'
thread = threading.Thread(target=server.serve_forever, name='server', daemon=True) if error != reported:
thread.start() log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds')
listening, reason = target, f'serving on http://{target[0]}:{target[1]}' reported = error
if reason and reason != reported: time.sleep(INIT_RETRY_SECONDS)
log(reason) continue
reported = reason log('ready')
time.sleep(DISCOVERY_SECONDS) return
def main(): def main():
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
discovery = threading.Thread(target=discover, name='discovery', daemon=True) initialize()
discovery.start()
try: try:
discovery.join() vpnrouter_serve.serve(lambda: vpnrouter_net.web_listen(read_json(CONFIG_FILE), DEFAULT_PORT), Handler, log,
DISCOVERY_SECONDS)
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
return 0 return 0
+15 -9
View File
@@ -10,6 +10,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] list vpn-router-user [--json] list
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin) vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E] vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
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>
@@ -29,16 +30,19 @@ 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
import vpnrouter_auth
import vpnrouter_users import vpnrouter_users
from vpnrouter_users import Failure from vpnrouter_users import Failure
#: The help line of each command. #: The help line of each command.
HELP = { HELP = {
'list': 'list the users', 'list': 'list the users',
'add': 'add a user and issue the certificate; the password is read from standard input', 'add': 'add a VPN user with the three person options and issue the certificate, or without the names a '
'update': 'change the first name, the last name or the e-mail address', 'Manager-only account, with an optional e-mail address; the password is read from standard input',
'update': "change the first name, the last name or the e-mail address; a Manager-only account has only the "
"e-mail address, which '' removes",
'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',
@@ -71,8 +75,7 @@ def parse(argv):
sub.add_argument('name', help='the user name') sub.add_argument('name', help='the user name')
if command in ('add', 'update'): if command in ('add', 'update'):
for key, _ in vpnrouter_users.PERSON: for key, _ in vpnrouter_users.PERSON:
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', required=command == 'add', sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
help=PERSON_HELP[key])
if command == 'otp-enrol': if command == 'otp-enrol':
sub.add_argument('code', help='a code of the secret from the authenticator') sub.add_argument('code', help='a code of the secret from the authenticator')
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -111,8 +114,9 @@ def text_of(answer):
users = answer['users'] if 'users' in answer else [answer] users = answer['users'] if 'users' in answer else [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', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
rows = [(user['name'], 'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled', rows = [(user['name'], 'manager' if user['manager_only'] else 'vpn',
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
'issued' if user['certificate'] else 'missing', '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]
@@ -131,12 +135,14 @@ def main(argv):
secret = password() if options.command in ('add', 'passwd', 'otp-enrol') else None secret = password() if options.command in ('add', 'passwd', 'otp-enrol') else None
with vpnrouter_users.session() as connection: with vpnrouter_users.session() as connection:
answer = run(connection, options, secret) answer = run(connection, options, secret)
if options.command in vpnrouter_auth.REVOKING:
vpnrouter_auth.revoke(vpnrouter.load_config(), options.name)
except Failure as failure: except Failure as failure:
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: except vpnrouter_auth.DatabaseError as exc:
message, status = f'the token database failed: {exc}', 1 message, status = str(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