Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8782edff0f | ||
|
|
fe17e83b49 | ||
|
|
17331eea37 | ||
|
|
af3127d1e8 | ||
|
|
b1e6d9cd0b | ||
|
|
732cc8cb54 |
No files matched your search
@@ -359,11 +359,22 @@ 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,
|
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
|
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
|
creates them. Each service does it before it listens, retrying every 15 seconds and logging each
|
||||||
failure, then listens.
|
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.
|
||||||
|
|
||||||
Consequences: The order of installation and of the start of PostgreSQL does not matter; a
|
psycopg (Ubuntu ships only its pure-Python implementation) finds libpq through the dynamic linker
|
||||||
database that cannot be created is created once the cause is gone, and the reason is in the
|
cache, which dpkg refreshes with its deferred `ldconfig` only at the end of an installation, so a
|
||||||
service's log. The web unit may write `/etc/vpn-router` for the key and the database password.
|
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
|
## ADR-17: Bootstrap diagnostics run only until the first setup succeeds
|
||||||
|
|
||||||
@@ -390,3 +401,80 @@ a `SystemExit` carrying its message, so setup records it.
|
|||||||
Consequences: The diagnostics need nothing the setup provides and cannot collide with the real
|
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.
|
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.
|
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.
|
||||||
@@ -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.
|
||||||
@@ -502,11 +525,15 @@ curl http://10.0.0.4:17081/
|
|||||||
| `/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
|
||||||
@@ -520,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 the web service makes on its first run, `/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.
|
||||||
|
|
||||||
@@ -590,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 |
|
||||||
@@ -614,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`.
|
||||||
|
|||||||
@@ -1,3 +1,79 @@
|
|||||||
|
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
|
vpn-router (0.9.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: bootstrap diagnostics on port 17081 during the first setup.
|
feature: bootstrap diagnostics on port 17081 during the first setup.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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/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/
|
||||||
|
|||||||
@@ -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,5 +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
|
dh_installsystemd --name=vpn-router-bootstrap --no-enable --no-start
|
||||||
@@ -6,7 +6,9 @@ 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
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ 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 vpnrouter
|
import vpnrouter
|
||||||
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')
|
||||||
@@ -49,6 +52,10 @@ 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'
|
||||||
@@ -384,6 +391,31 @@ def manage_directory():
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def manage_admin():
|
||||||
|
"""Create the Router Manager's default admin from MANAGER_ADMIN, then remove the file; the problem, or ''.
|
||||||
|
|
||||||
|
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:
|
||||||
|
answers = json.loads(MANAGER_ADMIN.read_text(encoding='utf-8'))
|
||||||
|
name, password = answers['username'], answers['password']
|
||||||
|
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):
|
||||||
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
|
||||||
unit = 'openvpn-server@vpn-router'
|
unit = 'openvpn-server@vpn-router'
|
||||||
@@ -532,9 +564,14 @@ def main():
|
|||||||
progress('running', 'WireGuard')
|
progress('running', 'WireGuard')
|
||||||
manage_wireguard(ctx, changed)
|
manage_wireguard(ctx, changed)
|
||||||
|
|
||||||
# The tunnels are up before the directory, whose problem ends setup only after every other step.
|
# The tunnels are up before the directory and the admin, whose problem ends setup only after every other step.
|
||||||
progress('running', 'user directory')
|
failed_step = 'user directory'
|
||||||
|
progress('running', failed_step)
|
||||||
problem = manage_directory()
|
problem = manage_directory()
|
||||||
|
if not problem:
|
||||||
|
failed_step = 'Router Manager admin'
|
||||||
|
progress('running', failed_step)
|
||||||
|
problem = manage_admin()
|
||||||
progress('running', 'OpenVPN')
|
progress('running', 'OpenVPN')
|
||||||
manage_openvpn(ctx, changed)
|
manage_openvpn(ctx, changed)
|
||||||
|
|
||||||
@@ -549,7 +586,7 @@ def main():
|
|||||||
progress('running', 'web service state')
|
progress('running', 'web service state')
|
||||||
refresh_web(cfg, ctx)
|
refresh_web(cfg, ctx)
|
||||||
if problem:
|
if problem:
|
||||||
progress('failed', 'user directory', problem)
|
progress('failed', failed_step, problem)
|
||||||
print(f'vpn-router: error: {problem}', file=sys.stderr)
|
print(f'vpn-router: error: {problem}', file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
progress('done', 'done')
|
progress('done', 'done')
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ router and that never leaves it. /auth issues an access token naming the user in
|
|||||||
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 on the
|
web.database, by default the vpn-router database on this host that provision() creates on the
|
||||||
web service's first run, with 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,6 +46,10 @@ 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 the web service has not made it."""
|
"""The signing key, or '' while the web service has not made it."""
|
||||||
try:
|
try:
|
||||||
@@ -112,19 +121,23 @@ def admin_token(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,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
@@ -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
|
||||||
@@ -15,24 +15,29 @@ 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 first initializes, without listening: it makes the signing key and the refresh
|
The service first initializes, without listening: it makes the signing key and the refresh
|
||||||
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
|
tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until both are ready and
|
||||||
logging each failure. Then it listens.
|
logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found libpq
|
||||||
|
in the linker cache, where psycopg looks it up.
|
||||||
|
|
||||||
The service needs nothing else to start and does not end on its own. Every DISCOVERY_SECONDS it
|
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,
|
works out the internal address from /etc/vpn-router/vpn-router.json as general.mode directs,
|
||||||
@@ -58,7 +63,6 @@ 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
|
||||||
@@ -143,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():
|
||||||
@@ -239,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'):
|
||||||
@@ -252,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(),
|
||||||
@@ -327,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']
|
||||||
@@ -399,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
|
||||||
@@ -431,17 +453,22 @@ 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 as exc:
|
except SystemExit as exc:
|
||||||
# vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
|
# vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
|
||||||
return 500, {'error': str(exc.code)}
|
return 500, {'error': str(exc.code)}
|
||||||
@@ -486,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
|
||||||
@@ -502,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
|
||||||
@@ -544,8 +621,8 @@ 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 as exc:
|
except SystemExit as exc:
|
||||||
return 500, {'error': str(exc.code)}
|
return 500, {'error': str(exc.code)}
|
||||||
|
|
||||||
@@ -604,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 {}
|
||||||
@@ -632,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'
|
||||||
@@ -707,8 +787,9 @@ def initialize():
|
|||||||
phase = 'creating the token database'
|
phase = 'creating the token database'
|
||||||
vpnrouter_auth.provision(cfg)
|
vpnrouter_auth.provision(cfg)
|
||||||
phase = 'connecting to the token database'
|
phase = 'connecting to the token database'
|
||||||
vpnrouter_auth.connect(cfg).close()
|
with vpnrouter_auth.database(cfg):
|
||||||
except (OSError, psycopg.Error, SystemExit) as exc:
|
pass
|
||||||
|
except (OSError, SystemExit, vpnrouter_auth.DatabaseError) as exc:
|
||||||
error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}'
|
error = f'{phase} failed: {exc.code if isinstance(exc, SystemExit) else exc}'
|
||||||
if error != reported:
|
if error != reported:
|
||||||
log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds')
|
log(f'{error}; retrying every {INIT_RETRY_SECONDS} seconds')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user