Compare commits

...
14 Commits
Author SHA1 Message Date
slawek d1d565b1d9 fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
Release / release (push) Successful in 14s
2026-10-26 09:11:13 +01:00
slawek e675540ce2 fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
Release / release (push) Successful in 20s
2026-10-26 08:40:24 +01:00
slawek 90db6d46a2 feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
Release / release (push) Successful in 14s
2026-10-26 08:35:10 +01:00
slawek 91cc64d6a6 fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
Release / release (push) Successful in 13s
2026-10-26 07:56:23 +01:00
slawek a1f314d8c1 fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
Release / release (push) Failing after 17m18s
2026-10-26 07:18:39 +01:00
slawek 7d8892b8c4 fix: wireguard.address must be the router's own address in its network, not the network or broadcast address, from which the peers' addresses are told apart.
Release / release (push) Failing after 16m1s
2026-10-26 02:59:56 +01:00
slawek 4491377d9b fix: /remote-access gives the router's WireGuard public key, endpoint and allowed IPs, which a peer's configuration needs.
Release / release (push) Successful in 13s
2026-10-26 02:50:39 +01:00
slawek f1d9c3bc36 feature: a VPN user has WireGuard peers, each a device with its public key and IPs, kept in the directory and written into wg0.conf by setup.
Release / release (push) Successful in 13s
2026-10-26 02:33:58 +01:00
slawek 015fba50ca fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute.
Release / release (push) Successful in 22s
2026-10-26 02:10:38 +01:00
slawek 9255d56276 feature: /remote-access of the web service shows and changes the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for the Router Manager's Remote access page.
Release / release (push) Successful in 14s
2026-10-26 00:57:22 +01:00
slawek 07dbe91d0c fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason.
Release / release (push) Successful in 20s
2026-10-26 00:43:55 +01:00
slawek ec93bc6a2d fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
Release / release (push) Successful in 28s
2026-10-26 00:21:13 +01:00
slawek 2e1eb89f52 feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
Release / release (push) Successful in 15s
2026-10-26 00:05:14 +01:00
slawek 1825e1efb1 fix: the status gives the number of site-to-site connections set up and established, not each connection by name.
Release / release (push) Successful in 28s
2026-10-25 23:44:05 +01:00
17 changed files with 1602 additions and 96 deletions

No files matched your search

+109
View File
@@ -895,3 +895,112 @@ Consequences: `slapcat` writes the users and the PKI into every backup in one co
restore brings both back together. The PKI depends on slapd, as the users already do, and not on
PostgreSQL. The directory holds the CA keys, so it is as sensitive as the CA itself. The schema
grows by the package's PKI attributes and classes, which setup adds to slapd's configuration.
## ADR-33: The web service opens a shell for the Router Manager over a WebSocket
Context: The Router Manager's Shell page gives an administrator a terminal on the router in the
browser, with xterm.js, the terminal of VS Code. A terminal needs a full-duplex channel, which
xterm.js expects as a WebSocket. The web service runs in a sandbox (`NoNewPrivileges`,
`ProtectSystem=strict`, `ProtectHome`) that a shell must not inherit, and as root, which a shell
in a browser should not be. A stolen session cookie or access token alone must not give a shell.
Decision: `/shell` of the web service is off until `web.shell` is set on the router; the Router
Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so
it is never root and each administrator's shell is the administrator's own; a user without
console login gets none. `POST /shell` signs the user in again with the password
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
so within `web.shell_auth_timeout` minutes of the user's last sign-in or keystroke a ticket is given
without them; the times are kept in the web service's memory only, so a restart asks again, and 0
keeps the sign-in at every opening. The WebSocket of RFC 6455 is implemented on the standard library inside the
web service's `http.server`, server side only, without extensions. `systemd-run --pty` starts the
shell in a transient unit with a PAM login session, outside the sandbox, and `setsid --ctty`
makes the terminal its controlling terminal so window sizes reach the shell. A shell idle for
`web.shell_idle_minutes` is closed, and the journal records every opening, end and refusal. The Router
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
both handshakes.
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
can get a shell when the router allows one and the user's console login is on; a session alone
cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
show up as `vpn-router-shell-<id>` units and logind sessions of the console account. The shell's
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
network to the web service.
## ADR-34: Console login maps an administrator to a POSIX account
Context: A shell, SSH and sudo need a POSIX account. One shared system account for every
administrator hides who did what and shares one set of rights. The administrators are users of
the router's directory, with their passwords, TOTP tokens and lockout there; a second set of
local accounts would duplicate them. slapd listens on its local socket only. SSSD authenticates
only over TLS or LDAPS and has no `ldapi://`; nslcd talks `ldapi://` and checks a password by
binding as the user.
Decision: Console login is a property of a member of `admins`, `vpnRouterConsoleLogin`, that
only root on the router sets, as for the group `admins`. It runs the console as an existing local
account (`vpnRouterConsoleAccount`), or as the user's own POSIX account: the user's entry becomes a
`posixAccount` with a uid from 20000 up and the local group `vpn-router-console` as its group.
nslcd serves the POSIX accounts with console login on, and PAM checks their passwords with a bind,
so slapd's otp overlay and password policy apply as for the Router Manager: the password followed
by the code, and the lockout. Members of `vpn-router-console` get sudo with that password. SSH
accepts it only while `ssh.ldap_login` is set, in a `Match Group` drop-in of sshd. Switching console
login off keeps the `posixAccount`, hidden by nslcd's filter, so the uid and the home stay the
user's; leaving `admins` switches it off. A console reaches root through sudo, so a password alone
must not open one: console login needs a TOTP token. Switching it on is refused without one, and
the token is checked at use rather than only when it is switched on, because an administrator can
remove it later: the shell's ticket and every minute of an open shell check it, nslcd's
`pam_authz_search` requires `oathTOTPUser`, and removing the token ends the sessions of the user's
own POSIX account.
Consequences: Each administrator's shell, SSH session and sudo run as the administrator, logged
under the administrator's name. The directory becomes part of the system's name service through
nslcd, and the system knows only the accounts with console login on. The user types the code
after the password at the SSH and sudo prompts. An administrator whose console login was switched
on before the token was required is refused until enrolling. Homes are created at the first login and
are not removed with the user. A password is changed through the directory, not with `passwd`.
## ADR-35: WireGuard peers are the devices of the VPN users, kept in the directory
Context: The WireGuard endpoint could be switched on from the Router Manager, but its peers were
written by hand into `wg0.conf`, so nobody could connect without SSH. A peer is a P2S client like
an OpenVPN or IKEv2 user, and one person may have several devices.
Decision: A VPN user's entry holds the user's WireGuard peers in the multi-valued attribute
`vpnRouterWireGuardPeer`, one value `<public key> <IP>[,<IP>...]` per device; the router never
sees a private key. `PUT /users/<name>` replaces a user's peers after checking the keys, which
are unique, and the IPs, which do not overlap another peer's or cover the router's address. Setup
reads the peers of the users who are not disabled from the directory and writes them as a marked
block of `[Peer]` sections into `wg0.conf`; sections outside the block, added by hand, stay. A
change of the peers, and disabling, enabling or removing a user with peers, restart setup. The
Router Manager adds and removes peers on the WireGuard tab of Remote access management, where the
endpoint is, choosing the user for each.
Consequences: Peers follow their users: disabling a user drops the user's devices, and the
directory's backup holds them. A peer cannot exist without a VPN user. A change restarts
`wg-quick@wg0` through setup, which briefly interrupts the other peers.
## ADR-36: Passkeys sign in to the Router Manager only, verified by the router with python3-fido2
Context: Typing the password and a TOTP code at every sign-in to the Router Manager is tedious.
WebAuthn needs a browser, HTTPS and a domain name; OpenVPN, IKEv2, WireGuard, SSH and sudo have no
browser, and the sudo PAM module for security keys needs the key at the router. The router signs
users in (`/auth`) and keeps them in its directory. The standard library cannot read CBOR or COSE
keys; Ubuntu packages Yubico's python-fido2 in noble (1.1) and resolute (2.0), whose server API is
the same once the JSON mapping of WebAuthn Level 3 is switched on, which 2.0 always uses.
Decision: A passkey is a discoverable credential with user verification of the relying party
`wan.local_fqdn`, with the user name as its user handle, so the sign-in needs no name. The router
registers and verifies passkeys with python3-fido2 in `vpnrouter_passkeys`; the origin must be
HTTPS on that name, which python-fido2 checks. Each passkey is a JSON value of the multi-valued
`vpnRouterPasskey` on the user's entry, with the public key only. Registration needs the current
password and code, so a session alone cannot add a lasting way in; removal needs neither. A
passkey sign-in gives the same tokens as `/auth`; as it binds no password, the router checks that
the user is enabled, not locked and not a service account. The begun steps are kept in the web
service's memory for 2 minutes. The browser converts the options and answers with
`PublicKeyCredential.parseCreationOptionsFromJSON`, `parseRequestOptionsFromJSON` and `toJSON`.
Consequences: Passkeys are a convenience for the Router Manager; the password and code stay for
everything else, the shell included. A changed `wan.local_fqdn` orphans every passkey. The
Router Manager on another name, or in development on `localhost`, cannot use them. Failed passkey
sign-ins do not count towards the directory's lockout, but the sign-in calls are rate limited.
Browsers without the JSON methods of WebAuthn Level 3 cannot add or use passkeys.
+145 -18
View File
@@ -114,7 +114,7 @@ must have the type shown below; an unknown section or setting is an error.
| `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` |
| `wireguard` | `address` | string | | The router's own address on `wg0` with the prefix of the peers' network, for example `192.168.200.1/24`; the network and the broadcast address of a network larger than /31 are refused |
| `wireguard` | `listen_port` | integer | `51820` | UDP port |
| `openvpn` | `enabled` | boolean | `false` | OpenVPN access for users |
| `openvpn` | `address_pool` | string | | IPv4 CIDR assigned to OpenVPN clients |
@@ -128,6 +128,10 @@ must have the type shown below; an unknown section or setting is an error.
| `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 address the call came in on | Addresses whose `X-Forwarded-For` header names the client for the `/auth` rate limit and the Client IP of the status |
| `web` | `status_content` | string | `basic` | What `/` and `/status` show: `basic`, `standard` or `full` (see Status web service) |
| `web` | `shell` | boolean | `false` | `/shell` opens a login shell for a member of `admins` with console login (see The shell) |
| `web` | `shell_idle_minutes` | integer | `15` | Minutes without a keystroke after which a shell is closed, 1 to 1440 |
| `web` | `shell_auth_timeout` | integer | `15` | Minutes after the last use of a shell in which a new shell opens without signing in again, 0 (always sign in) to 1440 |
| `ssh` | `ldap_login` | boolean | `false` | sshd accepts the directory password of the console accounts in the directory (see Console login and SSH) |
| `https` | `enabled` | boolean | `true` | Caddy serves the status web service and the Router Manager over HTTPS for `wan.local_fqdn` |
| `https` | `email` | string | | The e-mail address of the Let's Encrypt account |
| `https` | `web_port` | integer | `7444` | HTTPS port of the status web service |
@@ -234,9 +238,17 @@ an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configura
strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed
and is refused. Road-warrior and OpenVPN access need a name in `wan.local_fqdn`.
WireGuard peers are added by hand in `/etc/wireguard/wg0.conf`. The package owns the
`[Interface]` section of that file and rewrites it when the settings above change, but it
carries every `[Peer]` section across untouched.
WireGuard peers are P2S users' devices: a VPN user of the router's directory may have up to 16
peers, each with its public key and its IPs, IPv4 addresses or networks, kept in the user's
entry (`vpnRouterWireGuardPeer`) and set with `PUT /users/<name>` (`wireguard_peers`) or on the
WireGuard tab of the Router Manager's Remote access management. A key is used by one peer only,
the IPs of two peers may not overlap, and no peer's IPs cover the router's own address of
`wireguard.address`. The package owns the `[Interface]` section of `/etc/wireguard/wg0.conf` and,
between the lines `# BEGIN` and `# END WireGuard peers of the users of the router's directory`, a
`[Peer]` section for each peer of a user who is not disabled; setup rewrites them when the
settings or the peers change, and a change of a user's peers, disabling, enabling or removing a
user with peers restarts setup. Every other `[Peer]` section, added by hand, is carried across
untouched. While the directory cannot be reached, the users' peers in the file stay as they are.
Every feature is independent. Leaving a group empty simply means that feature is not
configured, and its generated files are removed.
@@ -688,7 +700,7 @@ vpn-router-user otp-remove alice
| Command | Effect |
| --- | --- |
| `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 |
| `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, the number of passkeys and the console login |
| `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 |
| `add <name> --service [--display-name N] [--description D] [--email E]` | Adds a service account, with its display name, description and e-mail address when given; the names are refused |
@@ -701,9 +713,13 @@ vpn-router-user otp-remove alice
| `profile <name>` | The OpenVPN client profile, with the CA, the certificate and the key inline |
| `otp-new <name>` | A new TOTP secret and its `otpauth://` URI, issued by `wan.local_fqdn`, for an authenticator app; nothing is stored |
| `otp-enrol <name> <code>` | Stores the secret read from standard input when the code is a current code of it, replacing a secret the user has |
| `otp-remove <name>` | Removes the user's TOTP secret |
| `otp-remove <name>` | Removes the user's TOTP secret and ends the sessions of the user's own POSIX account |
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` enrolled for TOTP on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
| `passkeys-remove <name>` | Removes all the user's passkeys (see Passkeys) |
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter; a
A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter, and a
service account may instead be named by a GUID in lowercase, such as
`3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b`; a
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
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
@@ -722,6 +738,60 @@ the file.
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.
### Console login and SSH
A member of `admins` enrolled for TOTP may have console login: the Router Manager's Shell page
(see The shell), and, for the user's own POSIX account, SSH and sudo. Only root on the router
switches it, with `vpn-router-user console` or a token of `vpn-router-token`, as for the group
`admins`. Switching it on is refused without the enrolment, and a user whose enrolment is removed
is refused the shell, SSH and sudo until enrolling again; the user's open shells end within a
minute, and removing the enrolment ends the sessions of the user's own POSIX account:
```sh
vpn-router-user console alice --posix
vpn-router-user console bob --account azureuser
vpn-router-user console alice --off
vpn-config-edit set ssh.ldap_login true
```
- **Own POSIX account** (`--posix`): the user's entry becomes a `posixAccount` of the directory
with the user's name, a uid from 20000 up, the local group `vpn-router-console` as its group,
`/home/<name>` and `/bin/bash`. `nslcd` makes it known to the system over slapd's local socket
and checks its password by binding as the user, so the user types the password followed by a
current code, failed logins count towards the lockout, and a disabled user cannot sign in. The home is created at the first login and kept when the user is removed. Members of
`vpn-router-console` may run any command with sudo, with the same password. A local account of
the same name is refused.
- **Local account** (`--account`): the console runs as an existing local account with a uid from
1000 to 19999, such as the VM's admin user; its SSH keys and sudo rights stay as they are.
- **Off** (`--off`): the system no longer knows the user's own POSIX account, and its sessions
end; its uid is kept for the next time. Leaving `admins`, or removing or renaming the group,
switches the console login of the members off.
While `ssh.ldap_login` is `true`, setup lets sshd accept the password of the members of
`vpn-router-console` (`/etc/ssh/sshd_config.d/vpn-router.conf`); other accounts keep sshd's
settings. A password change is made in the Router Manager or with `vpn-router-user passwd`, not
with `passwd`.
### Passkeys
A user who is not a service account may sign in to the Router Manager with a passkey instead of
the password and code. Passkeys are for that sign-in only: OpenVPN, the shell, SSH and sudo keep
the password and code. A passkey belongs to `wan.local_fqdn`, so the browser offers it on the
Router Manager's HTTPS address on that name, and passkeys need the name set; a passkey made
before the name changes is no longer offered. The authenticator checks the user (a PIN or a
fingerprint), and the passkey is kept by it or by the user's password manager.
A user adds a passkey on the Router Manager's Account page with the current password and code,
and removes it there. Each passkey is a value of `vpnRouterPasskey` on the user's entry, with its
public key, label and the time it was added; a user has at most 10. An administrator removes all
passkeys of a user on the Users page, through `/users`, or with:
```sh
vpn-router-user passkeys-remove alice
```
A disabled or locked user is refused a passkey sign-in too.
### Managing groups
`vpn-router-group` manages the groups. A group has a name, with the rules of a user name, an
@@ -804,7 +874,7 @@ configuration: no key, identity, subnet, or address but the client's. They show
|---|---|
| `basic` (default) | The overall status (`ok` or `degraded`), the time and the Client IP |
| `standard` | Also the `vpn-router` version, the `vpn-router-manager` version or that it is not installed, and the number of active services and of services in any other state, without their names |
| `full` | Also each service and its state, Docker, and the state of each site-to-site connection, road-warrior, OpenVPN and WireGuard |
| `full` | Also each service and its state, Docker, how many site-to-site connections are set up and how many of them are established (`site_to_site`: `connections` and `established`, `null` when swanctl cannot be asked), road-warrior, OpenVPN and WireGuard |
The Client IP is the address the request came from: the last entry of `X-Forwarded-For` for a
request from one of `web.trusted_proxies`, by default the address the request came in on, so
@@ -849,11 +919,12 @@ The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cl
`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
`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 and,
OpenVPN clients, from the server's status file, which OpenVPN rewrites every 5 seconds; 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`
(the Docker rules), which do not change `status`; as `site_to_site.connections`, whether each
site-to-site connection that is set up is established (`null` when swanctl cannot be asked); the number of connected road-warrior (IPsec P2S) clients; and
(the Docker rules), which do not change `status`; as `site_to_site`, the number of site-to-site
connections that are set up (`connections`) and of those established (`established`, `null` when
swanctl cannot be asked), without their names; the number of connected road-warrior (IPsec P2S) clients; and
the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when
all these services are active, and `degraded` otherwise.
@@ -864,7 +935,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/shell` 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).
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
@@ -891,7 +962,9 @@ vpn-router-token --json --days 30
| Request | Body | Answer |
| --- | --- | --- |
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens |
| `POST /auth` | `{"name", "password", "code"}`; `code` only for a user enrolled for TOTP | the tokens and `"name"` |
| `POST /auth/passkey` | `{}` | `{"id", "options"}`: a pending sign-in, valid 2 minutes, and the options of `navigator.credentials.get` in their JSON form |
| `POST /auth/passkey/complete` | `{"id", "credential"}`, `credential` the browser's answer as its `toJSON()` gives it | the tokens and `"name"`, the user whose passkey signed |
| `POST /auth/refresh` | `{"refresh_token"}` | new tokens; the refresh token given is used up |
| `POST /auth/logout` | `{"refresh_token"}` | `{"revoked": true}`, or `false` for a token not known |
@@ -900,7 +973,9 @@ The tokens are `{"access_token", "token_type": "Bearer", "expires_in": 300, "ref
get 401 with the same message; a refresh token is refused once its user is disabled, locked or
removed. Changing a user's password, disabling, unlocking or removing the user revokes the
user's refresh tokens. Five failed logins lock the account for 15 minutes (see the user
directory). `/auth` and `/auth/refresh` take at most 10 calls a minute from one client address,
directory). A passkey that does not sign, is not the user's, or of a disabled, locked or service
account gets 401 too (see Passkeys). `/auth`, `/auth/passkey/complete` and `/auth/refresh` take
at most 10 calls a minute from one client address,
and answer 429 with `Retry-After` beyond that; for a call from one of `web.trusted_proxies`, by
default the address the call came in on, the client address is the last entry of
`X-Forwarded-For`.
@@ -922,6 +997,36 @@ curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:7081/config
does with `web.status_content` `full`, whatever that setting says, so the Router Manager shows
every service and tunnel while the public status page shows less.
### The shell
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's console account, for the
Router Manager's Shell page. It is off by default and switched on and off on the router only:
```sh
vpn-router-user console alice --posix
vpn-config-edit set web.shell true
```
| Request | Body | Answer |
| --- | --- | --- |
| `POST /shell` | `{"password", "code"}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
`web.shell` is `false`, the user's console login is off or the user is not enrolled for TOTP. Without a password it gives a ticket
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
the router are the terminal's output. The shell is a PAM login session of the console account,
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
exits, when the connection closes, or within a minute of the user's console login being switched
off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
when it ended and why, and each refused ticket or shell with the reason.
### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
@@ -934,7 +1039,7 @@ every service and tunnel while the public status page shows less.
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
| `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails |
| `caddy` | The installed Caddy: `version`, and `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise; `null` when Caddy is not installed |
| `caddy` | The installed Caddy: `version`, `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise, and `service`, the state of `caddy.service`; `null` when Caddy is not installed |
### The /postgresql page
@@ -953,9 +1058,9 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "console", "wireguard_peers", "passkeys", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, `passkeys` a list of `{"id", "label", "created"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from |
| `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"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email", "wireguard_peers"}`; `wireguard_peers`, `[{"public_key", "ips"}]`, replaces a VPN user's WireGuard peers (see Configuration); for a Manager-only account only `{"email"}`, `""` removing it | the user |
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
| `POST /users/<name>/disable` | | the user |
| `POST /users/<name>/enable` | | the user |
@@ -965,6 +1070,8 @@ and answer bodies are JSON; a request body is at most 65536 bytes.
| `POST /users/<name>/otp` | | `{"name", "secret", "uri"}`, a new TOTP secret; nothing is stored |
| `PUT /users/<name>/otp` | `{"secret", "code"}` | the user, once the code matches the secret |
| `DELETE /users/<name>/otp` | | the user |
| `DELETE /users/<name>/passkeys` | | the user, without passkeys |
| `PUT /users/<name>/console` | `{"enabled", "account"?}`; only with a token of `vpn-router-token` | the user |
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not
@@ -1010,8 +1117,11 @@ router manages them, their display name and description included. A failure is o
| `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 |
| `POST /account/passkeys` | `{"current_password", "current_code"}` | `{"id", "options"}`: a pending registration, valid 2 minutes, and the options of `navigator.credentials.create` in their JSON form |
| `PUT /account/passkeys` | `{"id", "credential", "label"?}`, `credential` the browser's answer as its `toJSON()` gives it; `label` at most 64 characters, `Passkey` without one | the user |
| `DELETE /account/passkeys/<id>` | | the user |
`current_code` is needed only for a user enrolled for TOTP. A wrong current password or code gets
`current_code` is needed only for a user enrolled for TOTP; removing a passkey needs neither. 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
@@ -1077,6 +1187,23 @@ The answer of `GET /backup` holds:
A change is made, checked and applied as for `/site-to-site`.
### The /remote-access API
`/remote-access` shows and changes the remote access systems, IKEv2 road-warrior (`p2s`),
OpenVPN (`openvpn`) and WireGuard (`wireguard`), with the same token as `/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /remote-access` | | `{"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}`; `wireguard_server` is `{"public_key", "endpoint", "allowed_ips"}`: the router's WireGuard public key (`null` before setup made it), `<wan.local_fqdn>:<wireguard.listen_port>` (`null` without a name) and the networks a peer reaches through the tunnel, the router's local CIDRs and the WireGuard network |
| `PUT /remote-access/<system>` | any of the settings of the section `p2s`, `openvpn` or `wireguard` | the section |
`running` tells for each system whether setup runs it. IKEv2 and OpenVPN run once enabled with an
address pool, `wan.local_fqdn` set and the router's VPN server certificate issued by the P2S CA;
WireGuard once enabled with an address. A system enabled without its address pool or address is
refused. A change is made, checked and applied as for `/site-to-site`. The router's firewall
follows the settings; a cloud firewall in front of the router, such as an Azure network security
group, must allow the OpenVPN and WireGuard ports itself.
### The /ca API
`/ca` manages the PKI (see Certificates) with the same token as `/users`, with the module
+167
View File
@@ -1,3 +1,170 @@
vpn-router (0.26.2-1) unstable; urgency=medium
fix: a service account may be named by a GUID in lowercase, as the Router Manager proposes for a new one.
* vpn-router-user add --service and POST /service-accounts take a GUID
in lowercase as the name, besides a user name; users and groups keep
the rules of user names.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 10:00:00 +0100
vpn-router (0.26.1-1) unstable; urgency=medium
fix: the package installs vpnrouter_passkeys, without which the web service and vpn-router-user do not start.
* /usr/lib/vpn-router/vpnrouter_passkeys.py is part of the package.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 08:45:00 +0100
vpn-router (0.26.0-1) unstable; urgency=medium
feature: users sign in to the Router Manager with passkeys, and console login needs a TOTP token, so a user without one is refused the shell, SSH and sudo.
* Console login is switched on only for a user enrolled for TOTP. A user
whose console login is on without a token is refused POST /shell, the
shell's WebSocket, SSH and sudo; an open shell ends within a minute of
the token's removal or of console login being switched off, and
removing the token ends the sessions of the user's own POSIX account.
* POST /auth/passkey and /auth/passkey/complete sign a user in with a
passkey of wan.local_fqdn, giving the tokens of /auth; POST /auth also
answers the user's name.
* POST, PUT /account/passkeys and DELETE /account/passkeys/<id> add, with
the current password and code, and remove the user's passkeys, at most
10, kept in the new attribute vpnRouterPasskey; DELETE
/users/<name>/passkeys and vpn-router-user passkeys-remove remove all
passkeys of a user.
* Users have passkeys, [{"id", "label", "created"}], in /users and
/account; vpn-router-user list shows their number and a console login
refused for want of a TOTP token.
* The package depends on python3-fido2.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 08:00:00 +0100
vpn-router (0.25.4-1) unstable; urgency=medium
fix: a shell opens without the password and code again while the user's last use of a shell is within web.shell_auth_timeout minutes.
* New setting web.shell_auth_timeout, 15 minutes by default, 0 to 1440:
POST /shell without a password gives a ticket while the user's last
sign-in of POST /shell or keystroke in a shell is within it, and a
null ticket otherwise; 0 asks for the password and code every time.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 06:00:00 +0100
vpn-router (0.25.3-1) unstable; urgency=medium
fix: /config gives the state of caddy.service with the installed Caddy, for the Router Manager's Web page.
* caddy of /config is {"version", "source", "service"}.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 05:00:00 +0100
vpn-router (0.25.2-1) unstable; urgency=medium
fix: wireguard.address must be the router's own address in its network, not the network or broadcast address, from which the peers' addresses are told apart.
* A wireguard.address that is the network or the broadcast address of a
network larger than /31, such as 192.168.232.0/24, is refused with the
first address of the network as the example.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 02:30:00 +0100
vpn-router (0.25.1-1) unstable; urgency=medium
fix: /remote-access gives the router's WireGuard public key, endpoint and allowed IPs, which a peer's configuration needs.
* GET /remote-access answers wireguard_server: {"public_key",
"endpoint", "allowed_ips"}.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 01:30:00 +0100
vpn-router (0.25.0-1) unstable; urgency=medium
feature: a VPN user has WireGuard peers, each a device with its public key and IPs, kept in the directory and written into wg0.conf by setup.
* PUT /users/<name> takes wireguard_peers, [{"public_key", "ips"}],
which replaces the user's peers; keys are unique and IPs may not
overlap another peer's or cover the router's WireGuard address.
* GET /users answers each user's wireguard_peers and the WireGuard
endpoint's enabled and address.
* Setup writes a [Peer] section into wg0.conf for each peer of a user
who is not disabled, in a marked block, and keeps the peers added by
hand; changing a user's peers, disabling, enabling or removing a user
with peers applies at once.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 03 Nov 2026 00:00:00 +0100
vpn-router (0.24.1-1) unstable; urgency=medium
fix: the status gives the number of connected OpenVPN clients at most 5 seconds old instead of up to a minute.
* The OpenVPN server writes its status file every 5 seconds, so a
refresh of the status shows clients that connected or left since.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 23:30:00 +0100
vpn-router (0.24.0-1) unstable; urgency=medium
feature: /remote-access of the web service shows and changes the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each runs, for the Router Manager's Remote access page.
* GET /remote-access answers the p2s, openvpn and wireguard sections,
which of them setup runs, and wan.local_fqdn; PUT
/remote-access/<system> changes one section and restarts setup.
* p2s.enabled and openvpn.enabled need their address_pool, and
wireguard.enabled its address.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 23:00:00 +0100
vpn-router (0.23.2-1) unstable; urgency=medium
fix: the shell of a user's own POSIX account opens on the first setup that sets up console login, and the journal records each refused shell with the reason.
* Setup restarts vpn-router-web when it adds ldap to the name service
switch, so the web service finds the POSIX accounts of the directory.
* The journal of vpn-router-web records each refused ticket and shell,
with the user, the address and the reason.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 22:00:00 +0100
vpn-router (0.23.1-1) unstable; urgency=medium
fix: the shell runs as the console account of each member of admins, a local account or the user's own POSIX account in the directory, which also signs in with SSH and sudo, instead of one web.shell_user.
* vpn-router-user console <name> --posix | --account LOCAL | --off, and
PUT /users/<name>/console with a token of vpn-router-token, switch a
member of admins' console login; leaving admins switches it off.
* The user's own POSIX account is served by nslcd from the directory,
with a uid from 20000, the group vpn-router-console, which may use
sudo, and a home created at the first login; its password is the
directory password, followed by the code once enrolled for TOTP.
* ssh.ldap_login (false) lets sshd accept that password.
* POST /shell and the shell need the user's console login; web.shell_user
is gone.
* Depends on nslcd, libnss-ldapd, libpam-ldapd and sudo.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 21:00:00 +0100
vpn-router (0.23.0-1) unstable; urgency=medium
feature: web.shell lets /shell of the web service open a login shell of web.shell_user over a WebSocket for the Router Manager's Shell page, after the user signs in again.
* web.shell (false), web.shell_user and web.shell_idle_minutes (15).
* POST /shell takes the password and the TOTP code and answers a
one-time ticket; GET /shell with it is a WebSocket carrying the
terminal, closed after web.shell_idle_minutes without a keystroke.
* The journal of vpn-router-web records each shell opened and ended.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 20:00:00 +0100
vpn-router (0.22.1-1) unstable; urgency=medium
fix: the status gives the number of site-to-site connections set up and established, not each connection by name.
* site_to_site of the status document is {"connections", "established"},
and the status page shows "Site to site: N of M established".
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 02 Nov 2026 18:00:00 +0100
vpn-router (0.22.0-1) unstable; urgency=medium
feature: pki.extended_key_usages adds custom extended key usages by their OIDs, which templates give by name, and a template's policies put certificate policy OIDs into its certificates.
+5
View File
@@ -22,11 +22,16 @@ Depends: ${misc:Depends},
python3-jinja2,
python3-ldap,
python3-pyotp,
python3-fido2,
python3-psycopg,
python3-psutil,
python3-prometheus-client,
postgresql,
slapd,
nslcd,
libnss-ldapd,
libpam-ldapd,
sudo,
openvpn,
openvpn-auth-ldap,
iproute2,
+1
View File
@@ -4,6 +4,7 @@ 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_serve.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_users.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_passkeys.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_auth.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_metrics.py usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_status.py usr/lib/vpn-router/
+4
View File
@@ -54,9 +54,13 @@ case "$1" in
/etc/vpn-router/web.key \
/etc/vpn-router/metrics.secret \
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
/etc/sudoers.d/vpn-router \
/etc/ssh/sshd_config.d/vpn-router.conf \
/var/lib/vpn-router/setup.done \
/var/lib/vpn-router/backup.json
rmdir --ignore-fail-on-non-empty /etc/systemd/system/vpn-router-backup.timer.d 2>/dev/null || true
# The name service no longer looks up the console accounts of the router's directory.
sed -i '/^passwd:/s/ ldap\b//' /etc/nsswitch.conf
rm -rf /var/lib/vpn-router/backup/staging /var/lib/vpn-router/backup/restore \
/var/lib/vpn-router/pki-publish /etc/caddy/vpn-router-pki \
/etc/openvpn/server/vpn-router-ca.pem /etc/openvpn/server/vpn-router-crl.pem
+100
View File
@@ -83,6 +83,15 @@ CADDY_PKI_DIR = pathlib.Path('/etc/caddy/vpn-router-pki')
#: The timer of vpn-router-ca maintain, which keeps the CRLs fresh.
PKI_TIMER = 'vpn-router-pki.timer'
#: The system's side of console login (vpnrouter_users.console): nslcd's configuration, which serves the posixAccounts
#: of the directory with console login on, the name service switch, the sudo rule of their group, and the drop-in of
#: sshd that lets them sign in with their password while ssh.ldap_login is true.
NSLCD_CONF = pathlib.Path('/etc/nslcd.conf')
NSSWITCH = pathlib.Path('/etc/nsswitch.conf')
SUDOERS = pathlib.Path('/etc/sudoers.d/vpn-router')
SSHD_DROPIN = pathlib.Path('/etc/ssh/sshd_config.d/vpn-router.conf')
PAM_SESSION = pathlib.Path('/etc/pam.d/common-session')
FILTER_ANCHOR = '# End required lines'
#: Configuration options left out wherever the configuration is shown.
@@ -529,6 +538,93 @@ def manage_metrics_account(ctx):
return ''
def write_if_changed(path, text, mode=0o644):
"""Write the file when its content differs; whether it was written."""
try:
if path.read_text(encoding='utf-8') == text:
return False
except OSError:
pass
path.write_text(text, encoding='utf-8')
path.chmod(mode)
return True
def manage_console(cfg):
"""Make the system serve console login; the problem, or ''.
The local group vpnrouter_ldap.CONSOLE_GROUP is created when missing, and sudo lets its members run any command
with their password. nslcd serves the directory's posixAccounts with console login on through the local socket,
and checks their passwords by binding as the user; the passwd line of the name service switch gains ldap, which
restarts the web service, and PAM creates a home at the first login. While ssh.ldap_login is true, sshd accepts the password of the group's members;
a drop-in sshd -t refuses is removed again.
"""
group = vpnrouter_ldap.CONSOLE_GROUP
try:
grp.getgrnam(group)
except KeyError:
result = run(['groupadd', '--system', group], check=False)
if result.returncode != 0:
return f'the group {group} cannot be created: {result.stderr.strip()}'
rule = f'%{group} ALL=(ALL:ALL) ALL\n'
try:
if not SUDOERS.is_file() or SUDOERS.read_text(encoding='utf-8') != rule:
staged = SUDOERS.with_name('.vpn-router.new')
staged.write_text(rule, encoding='utf-8')
staged.chmod(0o440)
checked = run(['visudo', '-cf', str(staged)], check=False)
if checked.returncode != 0:
staged.unlink()
return f'the sudo rule of {group} is refused: {checked.stdout.strip()} {checked.stderr.strip()}'
staged.replace(SUDOERS)
uri = 'ldapi://' + vpnrouter_ldap.SLAPD_SOCKET.replace('/', '%2f') + '/'
nslcd = '\n'.join([
'uid nslcd',
'gid nslcd',
f'uri {uri}',
f'base {vpnrouter_ldap.BASE}',
'filter passwd (&(objectClass=posixAccount)(vpnRouterConsoleLogin=TRUE))',
f'nss_min_uid {vpnrouter_ldap.CONSOLE_UID_MIN}',
'pam_authz_search (&(objectClass=posixAccount)(objectClass=oathTOTPUser)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
'pam_password_prohibit_message The password is changed in the Router Manager or with vpn-router-user.',
'',
])
if write_if_changed(NSLCD_CONF, nslcd, 0o640) or not unit_is_active('nslcd'):
os.chown(NSLCD_CONF, 0, grp.getgrnam('nslcd').gr_gid)
run(['systemctl', 'enable', 'nslcd'], check=False)
run(['systemctl', 'restart', 'nslcd'], check=False)
lines = NSSWITCH.read_text(encoding='utf-8').splitlines(keepends=True)
switched = [line.rstrip('\n') + ' ldap\n' if line.startswith('passwd:') and 'ldap' not in line.split() else line
for line in lines]
if switched != lines:
NSSWITCH.write_text(''.join(switched), encoding='utf-8')
# A running process keeps the name service switch it read first, so the web service's shell would not find
# the console accounts of the directory until it restarts.
run(['systemctl', 'try-restart', 'vpn-router-web.service'], check=False)
if 'pam_mkhomedir' not in PAM_SESSION.read_text(encoding='utf-8'):
run(['pam-auth-update', '--enable', 'mkhomedir'], check=False)
if cfg['ssh']['ldap_login']:
changed = write_if_changed(SSHD_DROPIN, f'Match Group {group}\n PasswordAuthentication yes\n')
else:
changed = SSHD_DROPIN.exists()
SSHD_DROPIN.unlink(missing_ok=True)
if changed:
checked = run(['sshd', '-t'], check=False)
if checked.returncode != 0:
SSHD_DROPIN.unlink(missing_ok=True)
return f'sshd refuses {SSHD_DROPIN}: {checked.stderr.strip()}'
if unit_is_active('ssh'):
run(['systemctl', 'reload', 'ssh'], check=False)
except (OSError, KeyError) as exc:
return f'console login cannot be set up: {exc}'
return ''
def manage_openvpn(ctx, changed):
"""Run the OpenVPN server when it is configured, and stop it when it is not."""
unit = 'openvpn-server@vpn-router'
@@ -796,6 +892,10 @@ def main():
failed_step = 'metrics service account'
progress('running', failed_step)
problem = manage_metrics_account(ctx)
if not problem:
failed_step = 'console login'
progress('running', failed_step)
problem = manage_console(cfg)
progress('running', 'OpenVPN')
manage_openvpn(ctx, changed)
progress('running', 'HTTPS proxy')
@@ -26,6 +26,7 @@ if str(LIB_DIR) not in sys.path:
import vpnrouter_ldap
import vpnrouter_pki
import vpnrouter_platforms
import vpnrouter_status
from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
@@ -37,6 +38,11 @@ SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-site.conf'
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
#: The router's WireGuard public key, which setup writes with its private key.
WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub')
#: The lines around the [Peer] sections of the users' WireGuard peers in WG_CONF, which setup writes from the directory.
WG_PEERS_BEGIN = "# BEGIN WireGuard peers of the users of the router's directory"
WG_PEERS_END = "# END WireGuard peers of the users of the router's directory"
OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf')
OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf')
@@ -52,7 +58,8 @@ SCHEMA = {
'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'},
'docker': {'mode': 'auto', 'exposed_ports': []},
'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [],
'status_content': 'basic'},
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15},
'ssh': {'ldap_login': False},
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096',
'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA',
@@ -194,6 +201,9 @@ CA_NAME_MAX = 64
#: What / and /status of the web service show, web.status_content, from the least to the most.
STATUS_CONTENTS = ('basic', 'standard', 'full')
#: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day.
SHELL_IDLE_MAX = 1440
DOCKER_MODES = ('auto', 'on', 'off')
OPENVPN_PROTOCOLS = ('udp', 'tcp')
@@ -431,6 +441,9 @@ def validate(cfg):
raise ValueError
except ValueError:
raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network')
for system, option in (('p2s', 'address_pool'), ('openvpn', 'address_pool'), ('wireguard', 'address')):
if _get(cfg, system, 'enabled') and not _get(cfg, system, option):
raise ConfigError(f'{system}.enabled needs {system}.{option}')
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
@@ -445,10 +458,16 @@ def validate(cfg):
wg_address = _get(cfg, 'wireguard', 'address')
if wg_address:
try:
ipaddress.ip_interface(wg_address)
interface = ipaddress.ip_interface(wg_address)
except ValueError:
raise ConfigError(
f'wireguard.address: {wg_address!r} is not an address with prefix')
# The router's own address on wg0, from which the peers' addresses of the same network are told apart.
network = interface.network
if network.num_addresses > 2 and interface.ip in (network.network_address, network.broadcast_address):
raise ConfigError(
f'wireguard.address: {wg_address} is the network or broadcast address of {network}; give the '
f'router\'s own address in it, such as {next(network.hosts())}/{network.prefixlen}')
port = _get(cfg, 'wireguard', 'listen_port')
if not 1 <= port <= 65535:
@@ -477,6 +496,13 @@ def validate(cfg):
raise ConfigError(
f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}')
shell_idle = _get(cfg, 'web', 'shell_idle_minutes')
if not 1 <= shell_idle <= SHELL_IDLE_MAX:
raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}')
shell_auth = _get(cfg, 'web', 'shell_auth_timeout')
if not 0 <= shell_auth <= SHELL_IDLE_MAX:
raise ConfigError(f'web.shell_auth_timeout: {shell_auth} is not between 0 and {SHELL_IDLE_MAX}')
https_ports = []
for option in ('web_port', 'manager_port'):
port = _get(cfg, 'https', option)
@@ -875,6 +901,8 @@ def build_context(cfg, public_ip=None):
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'),
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
'wg_peers_begin': WG_PEERS_BEGIN,
'wg_peers_end': WG_PEERS_END,
'openvpn_enabled': _get(cfg, 'openvpn', 'enabled'),
'openvpn_pool': openvpn_pool,
'openvpn_network': str(openvpn_network.network_address) if openvpn_network else '',
@@ -885,6 +913,8 @@ def build_context(cfg, public_ip=None):
'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'),
'openvpn_device': OPENVPN_DEVICE,
'openvpn_ldap_conf': str(OPENVPN_LDAP),
'openvpn_status': vpnrouter_status.OPENVPN_STATUS,
'openvpn_status_seconds': vpnrouter_status.OPENVPN_STATUS_SECONDS,
'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))),
'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA),
'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL),
@@ -967,27 +997,38 @@ def render(env, ctx, template_name, dest, mode):
def existing_peers(path):
"""Return the [Peer] sections of an existing WireGuard config.
"""The block of the users' peers and the [Peer] sections added by hand of an existing WireGuard config, each ''
without one.
Peers are added by the operator by hand, so re-rendering the [Interface]
section must not discard them.
The block lies between WG_PEERS_BEGIN and WG_PEERS_END; the [Peer] sections outside it are the operator's, which
re-rendering the file keeps.
"""
path = pathlib.Path(path)
if not path.is_file():
return ''
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
return '', ''
text = path.read_text(encoding='utf-8')
block = ''
begin, end = text.find(WG_PEERS_BEGIN), text.find(WG_PEERS_END)
if begin != -1 and end > begin:
block = text[begin:end + len(WG_PEERS_END)]
text = text[:begin] + text[end + len(WG_PEERS_END):]
lines = text.splitlines(keepends=True)
for index, line in enumerate(lines):
if line.strip().lower() == '[peer]':
return ''.join(lines[index:])
return ''
return block, ''.join(lines[index:])
return block, ''
def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600):
"""Render wg0.conf, carrying any operator-defined peers across."""
content = env.get_template('wg0.conf.j2').render(ctx)
peers = existing_peers(dest)
if peers:
content = content.rstrip('\n') + '\n\n' + peers
"""Render wg0.conf: the [Interface], the block of the users' peers of ctx's wg_peers, the block the file holds while
wg_peers is None, and the peers the operator added by hand, carried across."""
content = env.get_template('wg0.conf.j2').render(ctx).rstrip('\n') + '\n'
block, own = existing_peers(dest)
if ctx['wg_peers'] is not None:
block = env.get_template('wg0-peers.conf.j2').render(ctx).strip('\n') if ctx['wg_peers'] else ''
for part in (block, own):
if part.strip():
content += '\n' + part.strip('\n') + '\n'
dest = pathlib.Path(dest)
if dest.exists() and dest.read_text(encoding='utf-8') == content:
@@ -1056,6 +1097,13 @@ def configure(cfg=None, public_ip=None):
notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}']
for notice in notices:
warn(notice)
# The WireGuard peers of the users; None keeps those of wg0.conf while the directory cannot be reached.
ctx['wg_peers'] = None
if wg_ready(ctx):
try:
ctx['wg_peers'] = vpnrouter_ldap.wireguard_peers()
except ldap.LDAPError as exc:
warn(f'the WireGuard peers of the users are left as they are: the directory cannot be reached: {exc}')
# What needs the router's own certificates.
ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file()
for connection in ctx['connections']:
@@ -12,6 +12,10 @@ a successful bind clears the count. slapd's otp overlay is configured on the dat
bind of a user with a TOTP token takes the password followed by a current code.
cn=totp,ou=policies holds the TOTP parameters the token of every user points to.
A user with console login on its own account is also a posixAccount, with a uidNumber from
CONSOLE_UID_MIN and the gidNumber of the local group CONSOLE_GROUP; nslcd serves those with
vpnRouterConsoleLogin TRUE to the system over the local socket.
ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per
certificate the CA issued. Private keys are readable by root alone, the database's root DN.
"""
@@ -27,6 +31,8 @@ import ldap.modlist
import ldap.sasl
URI = 'ldapi:///'
#: The local socket ldapi:/// names, which slapd listens on.
SLAPD_SOCKET = '/var/run/slapd/ldapi'
BASE = 'dc=vpn-router,dc=local'
USERS = f'ou=users,{BASE}'
GROUPS = f'ou=groups,{BASE}'
@@ -57,8 +63,12 @@ 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, vpnRouterScope,
#: authorization scopes, of which service marks a service account, and displayName and
#: description, the labels of a service account. vpnRouterAuthority is a CA of the PKI: its
#: authorization scopes, of which service marks a service account, displayName and
#: description, the labels of a service account, and vpnRouterConsoleLogin and
#: vpnRouterConsoleAccount, whether the user may use a console and the local account it runs as,
#: without which it runs as the user's own posixAccount, and vpnRouterWireGuardPeer, each a
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
#: passkey of the user as JSON (vpnrouter_passkeys). vpnRouterAuthority is a CA of the PKI: its
#: certificate and CRL (cACertificate and certificateRevocationList of RFC 4523), its key, the
#: CSR it waits with for the offline root, the number of its last CRL, where the CRL came from,
#: and when it was retired. vpnRouterCertificate is a certificate a CA issued, named by its
@@ -85,10 +95,19 @@ SCHEMA_ATTRIBUTE_TYPES = (
f"( {SCHEMA_OID}.1.10 NAME 'vpnRouterNotAfter' DESC 'VPN router: when a certificate expires' {_TIME} )",
f"( {SCHEMA_OID}.1.11 NAME 'vpnRouterRevoked' DESC 'VPN router: when a certificate was revoked' {_TIME} )",
f"( {SCHEMA_OID}.1.12 NAME 'vpnRouterRevocationReason' DESC 'VPN router: why a certificate was revoked' {_STRING} )",
f"( {SCHEMA_OID}.1.13 NAME 'vpnRouterConsoleLogin' DESC 'VPN router: whether the user may use a console' "
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
f"( {SCHEMA_OID}.1.14 NAME 'vpnRouterConsoleAccount' DESC 'VPN router: the local account the console of the user runs as' "
f"{_STRING} )",
f"( {SCHEMA_OID}.1.15 NAME 'vpnRouterWireGuardPeer' DESC 'VPN router: a WireGuard peer of the user, its public key and IPs' "
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
f"( {SCHEMA_OID}.1.16 NAME 'vpnRouterPasskey' DESC 'VPN router: a passkey of the user, its credential as JSON' "
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{4096} )",
)
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 $ displayName $ description ) )",
"SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description $ vpnRouterConsoleLogin $ "
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey ) )",
f"( {SCHEMA_OID}.2.2 NAME 'vpnRouterAuthority' DESC 'VPN router: a CA of the PKI' SUP top STRUCTURAL MUST cn "
"MAY ( cACertificate $ certificateRevocationList $ vpnRouterPrivateKey $ vpnRouterCertificateRequest $ "
"vpnRouterCRLNumber $ vpnRouterCRLSource $ vpnRouterRetired ) )",
@@ -103,6 +122,19 @@ TOTP_ALGORITHM = '1.2.840.113549.2.7'
TOTP_PERIOD = 30
TOTP_WINDOW = 1
#: The value of pwdAccountLockedTime that disables a user until it is enabled again.
DISABLED = '000001010000Z'
#: The indexes of the database.
INDEXES = ('objectClass eq', 'uid eq', 'uidNumber eq', 'vpnRouterConsoleLogin eq')
#: The POSIX accounts of console login (vpnrouter_users.console): the lowest uidNumber, which nslcd's nss_min_uid
#: matches, the local group that is their primary group and grants sudo, the shell and the parent of their homes.
CONSOLE_UID_MIN = 20000
CONSOLE_GROUP = 'vpn-router-console'
CONSOLE_SHELL = '/bin/bash'
CONSOLE_HOMES = '/home'
#: The ACLs of the database, in order.
ACCESS = (
'to attrs=vpnRouterPrivateKey by * none',
@@ -189,8 +221,9 @@ def _ensure_password_hash(connection):
def _ensure_database(connection):
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS."""
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
"""The DN of the package's database in cn=config, created when missing; its ACLs are kept as ACCESS, and the
indexes of INDEXES it lacks are added, which slapd builds online."""
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
if not found:
DB_DIRECTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
openldap = pwd.getpwnam('openldap')
@@ -201,13 +234,17 @@ def _ensure_database(connection):
'olcSuffix': [BASE.encode()],
'olcDbDirectory': [str(DB_DIRECTORY).encode()],
'olcRootDN': [ROOT.encode()],
'olcDbIndex': [b'objectClass eq', b'uid eq'],
'olcDbIndex': [index.encode() for index in INDEXES],
'olcAccess': [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)],
}))
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess'])
found = connection.search_s('cn=config', ldap.SCOPE_ONELEVEL, f'(olcSuffix={BASE})', ['olcAccess', 'olcDbIndex'])
dn, attributes = found[0]
if unordered(attributes.get('olcAccess', [])) != list(ACCESS):
connection.modify_s(dn, [(ldap.MOD_REPLACE, 'olcAccess', [f'{{{index}}}{rule}'.encode() for index, rule in enumerate(ACCESS)])])
present = [value.decode() for value in attributes.get('olcDbIndex', [])]
missing = [index for index in INDEXES if index not in present]
if missing:
connection.modify_s(dn, [(ldap.MOD_ADD, 'olcDbIndex', [index.encode() for index in missing])])
return dn
@@ -259,6 +296,23 @@ def _ensure_manager(connection):
MANAGER_SECRET.write_text(password, encoding='utf-8')
def wireguard_peers():
"""The WireGuard peers of the users who are not disabled, read as root: [(user, public key, [IP, ...])], sorted."""
connection = root()
try:
found = connection.search_s(USERS, ldap.SCOPE_ONELEVEL,
f'(&(vpnRouterWireGuardPeer=*)(!(pwdAccountLockedTime={DISABLED})))',
['uid', 'vpnRouterWireGuardPeer'])
finally:
connection.unbind_s()
peers = []
for _, attributes in found:
for value in attributes['vpnRouterWireGuardPeer']:
key, _, ips = value.decode().partition(' ')
peers.append((attributes['uid'][0].decode(), key, ips.split(',')))
return sorted(peers)
def provision():
"""Create or bring up to date the modules, the package's schema, database, ACLs, password policy, entries and service account."""
connection = root()
@@ -0,0 +1,173 @@
"""The passkeys of the router's users, for signing in to the Router Manager, with python3-fido2.
A passkey is a discoverable WebAuthn credential with user verification, of the relying party
wan.local_fqdn, so the browser offers it at the Router Manager's address on that name over HTTPS.
Its user handle is the user name. Each passkey of a user is a value of vpnRouterPasskey on the
user entry: a JSON object {"id", "credential", "label", "created"}, id the credential ID and
credential the attested credential data (with the public key), both base64url. A user has at
most PASSKEYS_MAX passkeys; service accounts have none.
Registration and sign-in are two steps each: begin answers the options for the browser and a
pending id, whose state the web service keeps in memory for PENDING_SECONDS; complete takes the
id and the browser's answer. A request that cannot be done raises vpnrouter_users.Failure, with
the statuses of that module; a sign-in that fails gives status 5 whatever the reason.
"""
import json
import secrets
import threading
import time
import fido2.features
import ldap
from fido2.server import Fido2Server
from fido2.utils import websafe_decode, websafe_encode
from fido2.webauthn import (AttestedCredentialData, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity,
ResidentKeyRequirement, UserVerificationRequirement)
import vpnrouter
import vpnrouter_users
# The options and the browser's answers are in the JSON mapping of WebAuthn Level 3; python3-fido2 2.0 always uses it.
_json_mapping = getattr(fido2.features, 'webauthn_json_mapping', None)
if _json_mapping is not None:
_json_mapping.enabled = True
#: The most passkeys of one user.
PASSKEYS_MAX = 10
#: The longest label of a passkey, in characters.
LABEL_MAX = 64
#: Seconds a begun registration or sign-in may be completed.
PENDING_SECONDS = 120
#: The most begun registrations and sign-ins kept at once.
PENDING_MAX = 1000
#: The begun registrations and sign-ins: pending id -> (kind, user name or None, state, monotonic time of expiry).
_pending = {}
_lock = threading.Lock()
def server():
"""The Fido2Server of the relying party wan.local_fqdn; passkeys need the name."""
fqdn = vpnrouter.load_config()['wan']['local_fqdn'].strip()
if not fqdn:
raise vpnrouter_users.Failure('passkeys need wan.local_fqdn, the name the Router Manager is reached at', 2)
return Fido2Server(rp=PublicKeyCredentialRpEntity(name=fqdn, id=fqdn))
def keep(kind, name, state):
"""Keep the state of a begun step and answer its pending id."""
pending = secrets.token_urlsafe(24)
now = time.monotonic()
with _lock:
for old in [key for key, (*_, expires) in _pending.items() if expires <= now]:
del _pending[old]
if len(_pending) >= PENDING_MAX:
raise vpnrouter_users.Failure('too many passkey requests at once; retry in a moment')
_pending[pending] = (kind, name, state, now + PENDING_SECONDS)
return pending
def take(kind, name, pending):
"""The state of a begun step of the kind and user, used up; None when it is unknown or expired."""
with _lock:
found = _pending.pop(pending, None) if isinstance(pending, str) else None
if found is None or found[0] != kind or found[1] != name or found[3] <= time.monotonic():
return None
return found[2]
def credentials(attributes):
"""The attested credential data of the passkeys of an entry."""
return [AttestedCredentialData(websafe_decode(passkey['credential']))
for _, passkey in vpnrouter_users.passkeys_of(attributes)]
def register_begin(connection, name):
"""Begin the registration of a passkey of the user: {"id", "options"}, options those of navigator.credentials.create."""
current = vpnrouter_users.entry(connection, name)
if vpnrouter_users.service(current):
raise vpnrouter_users.Failure(f'{name} is a service account, which has no passkeys', 2)
if len(vpnrouter_users.passkeys_of(current)) >= PASSKEYS_MAX:
raise vpnrouter_users.Failure(f'user {name} has {PASSKEYS_MAX} passkeys, the most; remove one first', 2)
options, state = server().register_begin(
PublicKeyCredentialUserEntity(id=name.encode(), name=name, display_name=name),
credentials(current),
resident_key_requirement=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
)
return {'id': keep('register', name, state), 'options': dict(options)}
def register_complete(connection, name, pending, response, label):
"""Store the passkey the browser created for a begun registration, with its label; the user (vpnrouter_users.summary)."""
state = take('register', name, pending)
if state is None:
raise vpnrouter_users.Failure('the registration is unknown or expired; begin it again', 2)
if label is None or (isinstance(label, str) and not label.strip()):
label = 'Passkey'
if not isinstance(label, str) or len(label.strip()) > LABEL_MAX:
raise vpnrouter_users.Failure(f'the label must be text of at most {LABEL_MAX} characters', 2)
if not isinstance(response, dict):
raise vpnrouter_users.Failure('credential must be the answer of navigator.credentials.create as JSON', 2)
try:
data = server().register_complete(state, response).credential_data
except (ValueError, KeyError, TypeError) as exc:
raise vpnrouter_users.Failure(f'the passkey is refused: {exc}', 2)
current = vpnrouter_users.entry(connection, name)
value = json.dumps({
'id': websafe_encode(data.credential_id),
'credential': websafe_encode(bytes(data)),
'label': label.strip(),
'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}, separators=(',', ':'))
changes = [] if 'vpnrouteraccount' in vpnrouter_users.classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
changes.append((ldap.MOD_ADD, 'vpnRouterPasskey', [value.encode()]))
connection.modify_s(vpnrouter_users.dn_of(name), changes)
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
def remove(connection, name, passkey_id=None):
"""Remove the user's passkey of the id, or all its passkeys without one; the user (vpnrouter_users.summary)."""
current = vpnrouter_users.entry(connection, name)
values = current.get('vpnRouterPasskey', [])
kept = [value for value, passkey in vpnrouter_users.passkeys_of(current)
if passkey_id is not None and passkey['id'] != passkey_id]
if passkey_id is not None and len(kept) == len(vpnrouter_users.passkeys_of(current)):
raise vpnrouter_users.Failure(f'user {name} has no passkey {passkey_id}', 3)
if len(kept) != len(values):
connection.modify_s(vpnrouter_users.dn_of(name), [(ldap.MOD_REPLACE, 'vpnRouterPasskey', kept)])
return vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
def login_begin():
"""Begin a sign-in with a passkey: {"id", "options"}, options those of navigator.credentials.get, for any user."""
options, state = server().authenticate_begin(user_verification=UserVerificationRequirement.REQUIRED)
return {'id': keep('login', None, state), 'options': dict(options)}
def login_complete(connection, pending, response):
"""The name of the user whose passkey signed a begun sign-in, when the user is enabled, not locked and not a
service account."""
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
state = take('login', None, pending)
if state is None or not isinstance(response, dict):
raise refused
try:
name = websafe_decode(response['response']['userHandle']).decode()
except (KeyError, TypeError, ValueError):
raise refused
if not vpnrouter_users.NAME.match(name):
raise refused
try:
current = vpnrouter_users.entry(connection, name)
except vpnrouter_users.Failure:
raise refused
user = vpnrouter_users.summary(current)
if user['service'] or not user['enabled'] or user['locked']:
raise refused
try:
server().authenticate_complete(state, credentials(current), response)
except (ValueError, KeyError, TypeError):
raise refused
return name
@@ -12,6 +12,9 @@ COMMAND_TIMEOUT = 5
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
#: Seconds between two writes of OPENVPN_STATUS by the OpenVPN server, whose configuration sets it (OpenVPN's default is 60).
OPENVPN_STATUS_SECONDS = 5
#: Seconds since its last handshake within which a WireGuard peer counts as connected.
WIREGUARD_CONNECTED_SECONDS = 180
@@ -20,16 +20,29 @@ optional description and at least one member; a group cannot be empty, so the la
cannot leave it and a user who is the last member of a group cannot be removed. Groups only
record membership; nothing on the router reads them.
A VPN user may have WireGuard peers, each a device with its public key and its IPs, in
vpnRouterWireGuardPeer; vpn-router-setup writes a [Peer] of wg0.conf for each peer of a user who
is not disabled.
A user may have a TOTP token: the user entry itself is an oathTOTPUser and oathTOTPToken whose
oathTOTPToken points to itself, with the secret in oathSecret and the parameters of
vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new secret is
verified: otp_new makes a secret and its otpauth URI, and otp_enrol stores the secret given a
code of it. slapd's otp overlay then takes the password followed by a code at every bind.
A user who is not a service account may have passkeys for signing in to the Router Manager, in
vpnRouterPasskey (vpnrouter_passkeys registers and checks them).
A user is disabled when pwdAccountLockedTime holds LOCKED, and locked when the password
policy has set it after failed binds, for vpnrouter_ldap.LOCKOUT_SECONDS; unlock ends that.
login() checks a user's password and code with a bind. Members of ADMIN_GROUP manage the
router through the web service. The module knows only the directory; the callers revoke a
router through the web service.
A member of ADMIN_GROUP with a TOTP token may have console login (console): the Shell page of the
Router Manager, and SSH and sudo with the directory's password followed by the code; without the
token the console is refused, also for one whose console login was switched on before. The
console runs as a local account, or as the user's own posixAccount, which the user entry becomes
and nslcd serves to the system. Leaving ADMIN_GROUP switches it off. The module knows only the directory; the callers revoke a
user's refresh tokens after the operations of vpnrouter_auth.REVOKING.
Every operation runs in a session(), which holds the lock that serializes changes, the PKI's
@@ -42,7 +55,12 @@ import base64
import calendar
import contextlib
import fcntl
import grp
import ipaddress
import json
import pwd
import re
import subprocess
import time
import ldap
@@ -56,6 +74,8 @@ import vpnrouter_pki
#: A user name is one lowercase DNS label, as it is also the common name of the certificate.
NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}[a-z0-9]$|^[a-z]$')
#: The other name of a service account: a GUID in lowercase hexadecimal, as the Router Manager proposes one.
GUID = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
PASSWORD_MIN = 8
PASSWORD_MAX = 128
#: The longest first or last name, in characters.
@@ -69,7 +89,10 @@ PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
LABELS = (('display_name', 'displayName'), ('description', 'description'))
#: The attributes read for a user.
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
'displayName', 'description', 'userCertificate;binary']
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey']
#: The most WireGuard peers of one user.
WIREGUARD_PEERS_MAX = 16
#: The vpnRouterScope of a service account.
SERVICE_SCOPE = 'service'
#: The object classes of a VPN user and of a Manager-only account.
@@ -85,7 +108,7 @@ GROUP_ATTRIBUTES = ['cn', 'description', 'member']
#: The longest group description, in characters.
DESCRIPTION_MAX = 256
#: The value of pwdAccountLockedTime that disables an account until it is enabled.
LOCKED = b'000001010000Z'
LOCKED = vpnrouter_ldap.DISABLED.encode()
#: The group whose members manage the router through the web service.
ADMIN_GROUP = 'admins'
#: The digits of a TOTP code given to login().
@@ -114,9 +137,11 @@ def session():
connection.unbind_s()
def check_name(name):
if not isinstance(name, str) or not NAME.match(name):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter', 2)
def check_name(name, service=True):
"""Refuse a name that is not a user name (NAME), or, with service, the GUID of a service account either."""
if not isinstance(name, str) or not (NAME.match(name) or service and GUID.match(name)):
raise Failure('a user name is 1 to 32 lowercase letters, digits and hyphens, starting with a letter'
+ (', or, for a service account, a GUID in lowercase' if service else ''), 2)
def check_password(value):
@@ -234,6 +259,9 @@ def summary(attributes):
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
'certificate': 'userCertificate;binary' in attributes,
'otp': 'oathTOTPToken' in attributes,
'console': console_of(attributes),
'wireguard_peers': peers_of(attributes),
'passkeys': [{key: passkey[key] for key in ('id', 'label', 'created')} for _, passkey in passkeys_of(attributes)],
}
for key, attribute in PERSON + LABELS:
values = attributes.get(attribute)
@@ -241,6 +269,188 @@ def summary(attributes):
return user
def console_of(attributes):
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
user's own posixAccount, and uid the uidNumber of that posixAccount, None without one."""
if 'vpnRouterConsoleLogin' not in attributes:
return None
local = attributes.get('vpnRouterConsoleAccount')
number = attributes.get('uidNumber')
return {
'enabled': attributes['vpnRouterConsoleLogin'] == [b'TRUE'],
'account': local[0].decode() if local else attributes['uid'][0].decode(),
'local': bool(local),
'uid': int(number[0]) if number else None,
}
def peers_of(attributes):
"""The WireGuard peers of an entry as the answers show them: [{public_key, ips}], in the order they are stored."""
peers = []
for value in attributes.get('vpnRouterWireGuardPeer', []):
key, _, ips = value.decode().partition(' ')
peers.append({'public_key': key, 'ips': ips.split(',')})
return peers
def passkeys_of(attributes):
"""The passkeys of an entry (vpnrouter_passkeys) with their stored values: [(value, {id, credential, label,
created})]; values that cannot be read are left out."""
passkeys = []
for value in attributes.get('vpnRouterPasskey', []):
try:
passkey = json.loads(value)
except ValueError:
continue
if isinstance(passkey, dict) and all(isinstance(passkey.get(key), str) for key in ('id', 'credential', 'label', 'created')):
passkeys.append((value, passkey))
return passkeys
def checked_peers(connection, name, peers):
"""The values of vpnRouterWireGuardPeer for the user's WireGuard peers, [{public_key, ips}], when they are valid.
A public key is 32 bytes in base64, used by no other peer of any user; each peer has at least one IP, an IPv4
address or network, which no other peer of any user covers and which does not cover the router's own address of
wireguard.address. A user has at most WIREGUARD_PEERS_MAX peers.
"""
if not isinstance(peers, list) or len(peers) > WIREGUARD_PEERS_MAX:
raise Failure(f'wireguard_peers must be a list of at most {WIREGUARD_PEERS_MAX} peers', 2)
router = vpnrouter.load_config()['wireguard']['address'].strip()
taken = [(other['name'], key, network)
for other in list_users(connection)['users'] if other['name'] != name
for peer in other['wireguard_peers'] for key in [peer['public_key']]
for network in (ipaddress.ip_network(ip) for ip in peer['ips'])]
values = []
for peer in peers:
key = peer.get('public_key') if isinstance(peer, dict) else None
ips = peer.get('ips') if isinstance(peer, dict) else None
try:
if not isinstance(key, str) or len(base64.b64decode(key.strip(), validate=True)) != 32:
raise ValueError
except ValueError:
raise Failure(f'{key!r} is not a WireGuard public key: 32 bytes in base64', 2)
key = key.strip()
if not isinstance(ips, list) or not ips or not all(isinstance(ip, str) for ip in ips):
raise Failure(f'the peer {key} needs at least one IP', 2)
networks = []
for ip in ips:
try:
network = ipaddress.ip_network(ip.strip(), strict=True)
except ValueError:
raise Failure(f'{ip!r} is not an IPv4 address or network of the peer {key}', 2)
if network.version != 4:
raise Failure(f'{ip!r} of the peer {key} is not IPv4', 2)
if router and ipaddress.ip_interface(router).ip in network:
raise Failure(f'{network} of the peer {key} covers the router\'s own address {router}', 2)
for owner, other_key, other in taken:
if other_key == key:
raise Failure(f'the key {key} is a peer of {owner}', 4)
if network.overlaps(other):
raise Failure(f'{network} of the peer {key} overlaps {other} of {owner}', 4)
networks.append(network)
taken += [(name, key, network) for network in networks]
values.append(f'{key} {",".join(str(network) for network in networks)}'.encode())
return values
def classes(attributes):
"""The object classes of an entry, in lower case."""
return {value.decode().lower() for value in attributes.get('objectClass', [])}
def free_uid(connection):
"""The lowest uidNumber from vpnrouter_ldap.CONSOLE_UID_MIN above every posixAccount's that no local account has."""
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, '(uidNumber=*)', ['uidNumber'])
number = max([vpnrouter_ldap.CONSOLE_UID_MIN - 1] + [int(attributes['uidNumber'][0]) for _, attributes in found]) + 1
while True:
try:
pwd.getpwuid(number)
except KeyError:
return number
number += 1
def console(connection, name, enabled, account=None):
"""Switch the console login of a member of ADMIN_GROUP with a TOTP token on, as the local account given or, without
one, as the user's own posixAccount, or off.
The posixAccount is added the first time and kept when the console login is switched off, so its uidNumber and the
home it owns stay the user's. A local account must exist in the system's passwd with a uid from 1000 to below
vpnrouter_ldap.CONSOLE_UID_MIN, and not be nobody; the user's own account needs a name no local account has.
Switching off ends the sessions of the user's own account.
"""
current = entry(connection, name)
if not isinstance(enabled, bool):
raise Failure('enabled must be true or false', 2)
if account is not None and (not isinstance(account, str) or not account):
raise Failure('account must be the name of a local account', 2)
changes = [] if 'vpnrouteraccount' in classes(current) else [(ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount'])]
if not enabled:
if 'vpnRouterConsoleLogin' not in current:
return summary(current)
connection.modify_s(dn_of(name), changes + [(ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'FALSE'])])
end_sessions(current)
return summary(entry(connection, name))
if service(current):
raise Failure(f'{name} is a service account, which has no console', 2)
if ADMIN_GROUP not in groups_of(connection, name):
raise Failure(f'user {name} is not a member of {ADMIN_GROUP}', 2)
if 'oathTOTPToken' not in current:
raise Failure(f'user {name} has no TOTP token; console login needs one', 2)
if account is not None:
try:
local = pwd.getpwnam(account)
except KeyError:
raise Failure(f'there is no local account {account}', 2)
if not 1000 <= local.pw_uid < vpnrouter_ldap.CONSOLE_UID_MIN or account == 'nobody':
raise Failure(f'{account} is not a local user account with a uid from 1000 to {vpnrouter_ldap.CONSOLE_UID_MIN - 1}', 2)
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', [account.encode()]))
else:
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleAccount', []))
if 'posixaccount' not in classes(current):
try:
pwd.getpwnam(name)
except KeyError:
pass
else:
raise Failure(f'a local account {name} exists; give it as the account, or use another user', 4)
try:
group = grp.getgrnam(vpnrouter_ldap.CONSOLE_GROUP).gr_gid
except KeyError:
raise Failure(f'the group {vpnrouter_ldap.CONSOLE_GROUP} does not exist yet; vpn-router-setup creates it')
changes += [
(ldap.MOD_ADD, 'objectClass', [b'posixAccount']),
(ldap.MOD_REPLACE, 'uidNumber', [str(free_uid(connection)).encode()]),
(ldap.MOD_REPLACE, 'gidNumber', [str(group).encode()]),
(ldap.MOD_REPLACE, 'homeDirectory', [f'{vpnrouter_ldap.CONSOLE_HOMES}/{name}'.encode()]),
(ldap.MOD_REPLACE, 'loginShell', [vpnrouter_ldap.CONSOLE_SHELL.encode()]),
]
if manager_only(current):
changes.append((ldap.MOD_REPLACE, 'cn', [name.encode()]))
changes.append((ldap.MOD_REPLACE, 'vpnRouterConsoleLogin', [b'TRUE']))
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
def end_sessions(attributes):
"""End the login sessions of the user's own posixAccount, when the entry has one; those of a local account stay."""
if 'posixaccount' in classes(attributes) and not attributes.get('vpnRouterConsoleAccount'):
subprocess.run(['loginctl', 'terminate-user', attributes['uid'][0].decode()], capture_output=True)
def console_off(connection, names):
"""Switch the console login off for each of the users that has it on; for those who leave ADMIN_GROUP."""
for name in names:
try:
current = entry(connection, name)
except Failure:
continue
if current.get('vpnRouterConsoleLogin') == [b'TRUE']:
console(connection, name, False)
def common_name(attributes, name):
"""The cn of an entry: the first and the last name, or the user name without them."""
full = ' '.join(value for value in (attributes.get('givenName'), attributes.get('sn')) if value)
@@ -256,8 +466,8 @@ def list_users(connection):
def add(connection, name, password, values, service=False):
"""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, and, with service, a service account, with at most the
e-mail address and its labels (labels)."""
check_name(name)
e-mail address and its labels (labels); only a service account may be named by a GUID."""
check_name(name, service)
details = person(values, required=False)
vpn_user = 'givenName' in details or 'sn' in details
if service and vpn_user:
@@ -292,7 +502,8 @@ def add(connection, name, password, values, service=False):
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 following the first and the last name, and of a VPN user the
WireGuard peers, wireguard_peers, which replace the user's peers (checked_peers).
A Manager-only account has only the e-mail address, which '' removes; a service account also its labels
(update_service).
@@ -301,6 +512,8 @@ def update(connection, name, values):
if service(current):
return update_service(connection, name, values)
if manager_only(current):
if values.get('wireguard_peers') is not None:
raise Failure(f'user {name} is a Manager-only account, which has no VPN access and so no WireGuard peers', 2)
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')
@@ -313,16 +526,30 @@ def update(connection, name, values):
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)
if not details:
raise Failure('give at least one of first_name, last_name and email', 2)
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
peers = values.get('wireguard_peers')
if not details and peers is None:
raise Failure('give at least one of first_name, last_name, email and wireguard_peers', 2)
changes = []
if details:
names = {attribute: current[attribute][0].decode() for attribute in ('givenName', 'sn') if current.get(attribute)}
names.update({attribute: details[attribute] for attribute in ('givenName', 'sn') if attribute in details})
changes += [(ldap.MOD_REPLACE, attribute, [value.encode()]) for attribute, value in details.items()]
changes.append((ldap.MOD_REPLACE, 'cn', [common_name(names, name).encode()]))
if peers is not None:
if 'vpnrouteraccount' not in classes(current):
changes.append((ldap.MOD_ADD, 'objectClass', [b'vpnRouterAccount']))
changes.append((ldap.MOD_REPLACE, 'vpnRouterWireGuardPeer', checked_peers(connection, name, peers)))
connection.modify_s(dn_of(name), changes)
if peers is not None:
apply_wireguard()
return summary(entry(connection, name))
def apply_wireguard():
"""Have vpn-router-setup write the WireGuard peers of the users into wg0.conf, without waiting for it."""
subprocess.run(['systemctl', 'restart', '--no-block', 'vpn-router-setup.service'], capture_output=True)
def update_service(connection, name, values):
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
if not service(entry(connection, name)):
@@ -346,14 +573,22 @@ def passwd(connection, name, password):
def disable(connection, name):
if entry(connection, name).get('pwdAccountLockedTime') != [LOCKED]:
"""Lock the user out; the WireGuard peers of a disabled user are left out of wg0.conf."""
current = entry(connection, name)
if current.get('pwdAccountLockedTime') != [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_REPLACE, 'pwdAccountLockedTime', [LOCKED])])
if 'vpnRouterWireGuardPeer' in current:
apply_wireguard()
return summary(entry(connection, name))
def enable(connection, name):
if entry(connection, name).get('pwdAccountLockedTime') == [LOCKED]:
"""Let the user in again, with the WireGuard peers."""
current = entry(connection, name)
if current.get('pwdAccountLockedTime') == [LOCKED]:
connection.modify_s(dn_of(name), [(ldap.MOD_DELETE, 'pwdAccountLockedTime', None)])
if 'vpnRouterWireGuardPeer' in current:
apply_wireguard()
return summary(entry(connection, name))
@@ -430,22 +665,25 @@ def otp_enrol(connection, name, secret, code):
def otp_remove(connection, name):
"""Remove the user's TOTP token, when the user has one."""
if 'oathTOTPToken' in entry(connection, name):
"""Remove the user's TOTP token, when the user has one, and end the console sessions it needed (end_sessions)."""
current = entry(connection, name)
if 'oathTOTPToken' in current:
token = connection.search_s(dn_of(name), ldap.SCOPE_BASE, attrlist=TOKEN_ATTRIBUTES)[0][1]
changes = [(ldap.MOD_DELETE, 'objectClass', [b'oathTOTPUser', b'oathTOTPToken']),
(ldap.MOD_DELETE, 'oathSecret', None)]
changes += [(ldap.MOD_DELETE, attribute, None) for attribute in token]
connection.modify_s(dn_of(name), changes)
end_sessions(current)
return summary(entry(connection, name))
def remove(connection, name):
"""Remove the user from its groups and the directory, and revoke the certificate.
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
posixAccount.
A user who is the last member of a group is not removed.
"""
entry(connection, name)
current = entry(connection, name)
groups = groups_of(connection, name)
alone = sorted(group for group, members in groups.items() if members == [name])
if alone:
@@ -457,6 +695,9 @@ def remove(connection, name):
except vpnrouter_pki.Failure as failure:
raise Failure(f'the certificate cannot be revoked: {failure}', failure.status)
connection.delete_s(dn_of(name))
end_sessions(current)
if 'vpnRouterWireGuardPeer' in current:
apply_wireguard()
return {'name': name, 'removed': True}
@@ -573,10 +814,11 @@ def group_add(connection, name, description, members):
def group_update(connection, name, new_name=None, description=None):
"""Rename the group or change its description ('' removes it); at least one must be given."""
"""Rename the group or change its description ('' removes it); at least one must be given. Renaming ADMIN_GROUP
switches the console login of its members off."""
if new_name is None and description is None:
raise Failure('give a new name or a description', 2)
group_entry(connection, name)
members = member_names(group_entry(connection, name))
if description is not None:
description = check_description(description)
change = (ldap.MOD_REPLACE, 'description', [description.encode()]) if description else \
@@ -588,13 +830,18 @@ def group_update(connection, name, new_name=None, description=None):
connection.rename_s(group_dn(name), f'cn={new_name}', delold=1)
except ldap.ALREADY_EXISTS:
raise Failure(f'group {new_name} exists', 4)
if name == ADMIN_GROUP:
console_off(connection, members)
name = new_name
return group_summary(group_entry(connection, name))
def group_remove(connection, name):
group_entry(connection, name)
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
members = member_names(group_entry(connection, name))
connection.delete_s(group_dn(name))
if name == ADMIN_GROUP:
console_off(connection, members)
return {'name': name, 'removed': True}
@@ -607,7 +854,8 @@ def group_member_add(connection, name, user):
def group_member_remove(connection, name, user):
"""Take the user out of the group; the last member cannot leave."""
"""Take the user out of the group; the last member cannot leave. Leaving ADMIN_GROUP switches the user's console
login off."""
check_name(user)
members = member_names(group_entry(connection, name))
if user not in members:
@@ -615,6 +863,8 @@ def group_member_remove(connection, name, user):
if members == [user]:
raise Failure(f'user {user} is the last member of group {name}; remove the group instead', 2)
connection.modify_s(group_dn(name), [(ldap.MOD_DELETE, 'member', [dn_of(user).encode()])])
if name == ADMIN_GROUP:
console_off(connection, [user])
return group_summary(group_entry(connection, name))
+451 -29
View File
@@ -21,20 +21,29 @@ Pages:
service_accounts())
/groups the groups of the router's directory: list, add, rename or describe, remove, and
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())
/account the account of the signed-in user: its details, its password, its TOTP
enrolment and its passkeys (see account())
/site-to-site
the site-to-site connections of the configuration: list, add, change and remove,
applied by vpn-router-setup (see site_to_site())
/backup the backup settings, the last run and the local archives: show, change and run
now (see backup())
/remote-access
the settings of IKEv2 road-warrior, OpenVPN and WireGuard access and whether each
runs: show and change, applied by vpn-router-setup (see remote_access())
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
settings, issue and revoke certificates, and take the certificate of an issuing CA
and a root CRL signed offline (see ca())
/pki/<name>.crt, /pki/<name>.crl
the DER certificate and CRL of the root (root) and of each issuing CA, which the
certificates name in their AIA and CRL distribution points; no authentication
/auth sign-in with a user's password and TOTP code, refresh and sign-out (see auth())
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
a member of admins whose console login is on and who has a TOTP token: POST signs the user in again, unless
the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
(see shell_ticket()), GET with it is a WebSocket
that carries the terminal (see Handler.shell())
/auth sign-in with a user's password and TOTP code or with a passkey, refresh and sign-out
(see auth())
/metrics/history
the metrics the metrics service keeps, for a view: hour, day, month or year (see
metrics_history())
@@ -42,13 +51,15 @@ Pages:
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /ca and /account need an HS256 JWT in an Authorization: Bearer header signed
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /shell and /account need an HS256 JWT in an Authorization: Bearer header signed
with vpnrouter_auth.WEB_KEY (see vpnrouter_auth.verify). A token of /auth names a user in sub;
/account serves that user, and the other paths need a member of vpnrouter_users.ADMIN_GROUP. A
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
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
add or take out its members, and remove one of its members, and switch a user's console login.
These paths and /auth are not
served (404) while the key does not exist. /auth but its sign-out and the beginning of a passkey
sign-in is limited to AUTH_LIMIT calls per client
address in AUTH_WINDOW seconds; the address of a call from one of web.trusted_proxies, by
default the address the call came in on, is the last entry of its X-Forwarded-For header.
@@ -56,9 +67,9 @@ default the address the call came in on, is the last entry of its X-Forwarded-Fo
default, the overall status, the time and the client's address (its address as for the rate limit
below); standard also the package version, the installed version of vpn-router-manager
(manager_version, null when it is not installed) and the number of active and other services; full
whether each service runs, whether Docker is installed, and the state of each site-to-site connection, road-warrior
(IPsec P2S), OpenVPN and WireGuard. No configuration data or key is shown, and no address but the
client's.
whether each service runs, whether Docker is installed, how many of the site-to-site connections are established
(site_to_site: connections and established, null when swanctl cannot be asked), and road-warrior (IPsec P2S), OpenVPN
and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
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
@@ -76,13 +87,24 @@ key, the configuration, and the features of the router and the facts shown by /c
the router has no features, and /config shows no configuration or interfaces.
"""
import base64
import collections
import configparser
import fcntl
import hashlib
import ipaddress
import json
import os
import pwd
import secrets
import select
import shutil
import signal
import socket
import struct
import subprocess
import sys
import termios
import threading
import time
import urllib.parse
@@ -98,6 +120,7 @@ import vpnrouter_auth
import vpnrouter_backup
import vpnrouter_metrics
import vpnrouter_net
import vpnrouter_passkeys
import vpnrouter_pki
import vpnrouter_serve
import vpnrouter_users
@@ -135,6 +158,9 @@ BODY_LIMIT = 65536
#: The settings of a site_to_site connection that /site-to-site adds and changes.
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
#: The sections of the remote access systems /remote-access changes: IKEv2 road-warrior, OpenVPN and WireGuard.
REMOTE_ACCESS = ('p2s', 'openvpn', 'wireguard')
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
@@ -144,6 +170,21 @@ PKI_STATUS = {2: 400, 3: 404, 4: 409}
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
#: Seconds a ticket of POST /shell may open a shell.
SHELL_TICKET_SECONDS = 30
#: Seconds between the checks of an open shell's user, which end the shell once its console is refused.
SHELL_CHECK_SECONDS = 60
#: The largest WebSocket message accepted from a client, in bytes.
MESSAGE_LIMIT = 65536
#: The GUID of RFC 6455 that the Sec-WebSocket-Accept of a handshake is made with.
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
#: WebSocket opcodes.
TEXT, BINARY, CLOSE, PING, PONG = 1, 2, 8, 9, 10
_cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()
@@ -151,6 +192,13 @@ _lock = threading.Lock()
_auth_calls = collections.defaultdict(collections.deque)
_auth_lock = threading.Lock()
#: The unused tickets of POST /shell: ticket -> (user, monotonic time of expiry).
_shell_tickets = {}
_shell_lock = threading.Lock()
#: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time.
_shell_used = {}
def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object."""
@@ -234,8 +282,9 @@ def collect(state):
}
names = state.get('site_to_site')
if isinstance(names, list) and names:
status['site_to_site'] = {'connections': {name: None if count is None else count > 0
for name, count in established_each(names).items()}}
counts = established_each(names).values()
status['site_to_site'] = {'connections': len(names),
'established': None if None in counts else sum(1 for count in counts if count)}
if state.get('p2s'):
status['p2s'] = {'clients': established('road-warrior')}
if state.get('wireguard'):
@@ -364,7 +413,7 @@ def postgresql_databases():
def details(state):
"""What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
installed Caddy, {version, source}, null when it is not installed."""
installed Caddy, {version, source, service}, service the state of caddy.service, null when it is not installed."""
configuration = state.get('configuration', {})
caddy = package_version('caddy')
return {
@@ -381,8 +430,8 @@ def details(state):
},
'postgresql': postgresql_configuration(),
# Caddy is upstream's once its repository is configured (vpn-router-caddy).
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu'}
if caddy else None,
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu',
'service': unit_state('caddy.service')} if caddy else None,
}
@@ -401,8 +450,9 @@ def text_of(status):
docker = status['docker']
lines.append('Docker: not installed' if not docker['installed']
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
for name, value in status.get('site_to_site', {}).get('connections', {}).items():
lines.append(f'Site to site {name}: {"unknown" if value is None else "established" if value else "not established"}')
if 'site_to_site' in status:
established, connections = status['site_to_site']['established'], status['site_to_site']['connections']
lines.append(f'Site to site: {"unknown" if established is None else established} of {connections} established')
if 'p2s' in status:
clients = status['p2s']['clients']
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
@@ -468,10 +518,12 @@ def html_of(status):
def users(state, method, path, body):
"""Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.
GET /users the users
GET /users the users, and {"enabled", "address"} of wireguard, whose network their
WireGuard peers take their addresses from
POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
without the names 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", "wireguard_peers"};
wireguard_peers, [{"public_key", "ips"}], replaces the user's peers
PUT /users/<name>/password set the password: {"password": ...}
POST /users/<name>/disable lock the user out of OpenVPN
POST /users/<name>/enable let the user in again
@@ -481,6 +533,9 @@ def users(state, method, path, body):
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
DELETE /users/<name>/otp remove the TOTP token
DELETE /users/<name>/passkeys remove all the user's passkeys
PUT /users/<name>/console switch console login on or off: {"enabled", "account"?}, account a local account,
without which the user's own posixAccount; only a token of vpn-router-token
Service accounts are served by /service-accounts: they are not listed, and a request naming one is 404.
"""
@@ -492,7 +547,8 @@ def users(state, method, path, body):
name = parts[1] if len(parts) > 1 else None
actions = {
('GET', 1, None): lambda connection: {
'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']]},
'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']],
'wireguard': {option: vpnrouter.load_config()['wireguard'][option] for option in ('enabled', 'address')}},
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
@@ -504,6 +560,8 @@ def users(state, method, path, body):
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
('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, 'passkeys'): lambda connection: vpnrouter_passkeys.remove(connection, name),
('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')),
}
# 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',
@@ -778,6 +836,68 @@ def backup(method, path, body):
return 200, {'settings': backup_settings(cfg['backup'])}
def wireguard_server(cfg, state):
"""What a WireGuard peer's configuration needs of the router: {public_key, endpoint, allowed_ips}; public_key is
null before setup made the key pair, endpoint, <wan.local_fqdn>:<wireguard.listen_port>, null without a name, and
allowed_ips the networks reached through the tunnel, the router's local CIDRs and the WireGuard network."""
try:
public_key = vpnrouter.WG_PUBLIC_KEY.read_text(encoding='utf-8').strip() or None
except OSError:
public_key = None
fqdn = cfg['wan']['local_fqdn'].strip()
address = cfg['wireguard']['address'].strip()
networks = section(state, 'network').get('local_cidrs')
allowed = [cidr for cidr in networks if isinstance(cidr, str)] if isinstance(networks, list) else []
if address:
allowed.append(str(ipaddress.ip_interface(address).network))
return {'public_key': public_key, 'endpoint': f'{fqdn}:{cfg["wireguard"]["listen_port"]}' if fqdn else None,
'allowed_ips': list(dict.fromkeys(allowed))}
def remote_access(state, method, path, body):
"""Answer a /remote-access request: the HTTP status and the JSON document.
GET /remote-access {"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}: the
settings of the three remote access systems (REMOTE_ACCESS), whether setup runs
each (running: {p2s, openvpn, wireguard}), wan.local_fqdn, which IKEv2 and OpenVPN
need with the router's VPN server certificate, and what a WireGuard peer's
configuration needs of the router (wireguard_server)
PUT /remote-access/<system> change any of the settings of the system's section; answers the section
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
try:
if (method, len(parts)) == ('GET', 1):
cfg = vpnrouter.load_config()
return 200, {**{system: cfg[system] for system in REMOTE_ACCESS},
'running': {system: bool(state.get(system)) for system in REMOTE_ACCESS},
'local_fqdn': cfg['wan']['local_fqdn'],
'wireguard_server': wireguard_server(cfg, state)}
if method != 'PUT' or len(parts) != 2 or parts[1] not in REMOTE_ACCESS:
return 404, {'error': f'no route {method} {path}'}
system = parts[1]
settings = {option: body[option] for option in vpnrouter.SCHEMA[system] if option in body}
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get(system)
data[system] = {**(current if isinstance(current, dict) else {}), **settings}
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except OSError as exc:
return 500, {'error': f'the configuration cannot be written: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, cfg[system]
def ca(method, path, body):
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
@@ -864,20 +984,27 @@ def account(name, method, path, body):
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
POST /account/passkeys begin the registration of a passkey: {"current_password", "current_code"?};
{"id", "options"} for navigator.credentials.create
PUT /account/passkeys store the passkey: {"id", "credential", "label"?}, credential the browser's answer
DELETE /account/passkeys/<id>
remove a passkey
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.
A change but the removal of a passkey 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')):
removing = method == 'DELETE' and path.startswith('/account/passkeys/') and len(path.strip('/').split('/')) == 3
if not removing and route not in (('GET', '/account'), ('PUT', '/account/password'), ('POST', '/account/otp'),
('PUT', '/account/otp'), ('POST', '/account/passkeys'), ('PUT', '/account/passkeys')):
return 404, {'error': f'no route {method} {path}'}
try:
if route == ('PUT', '/account/password'):
vpnrouter_users.check_password(body.get('password'))
if method == 'PUT':
if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys')):
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
with vpnrouter_users.session() as connection:
if route == ('GET', '/account'):
@@ -887,6 +1014,13 @@ def account(name, method, path, body):
document = vpnrouter_users.passwd(connection, name, body['password'])
elif route == ('POST', '/account/otp'):
document = vpnrouter_users.otp_new(connection, name)
elif route == ('POST', '/account/passkeys'):
document = vpnrouter_passkeys.register_begin(connection, name)
elif route == ('PUT', '/account/passkeys'):
document = vpnrouter_passkeys.register_complete(connection, name, body.get('id'), body.get('credential'),
body.get('label'))
elif removing:
document = vpnrouter_passkeys.remove(connection, name, urllib.parse.unquote(path.strip('/').split('/')[2]))
else:
document = vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code'))
if route == ('PUT', '/account/password'):
@@ -948,15 +1082,21 @@ def refusal(claims, method, path, body):
with vpnrouter_users.session() as connection:
if not vpnrouter_users.is_admin(connection, name):
return f'user {name} is not a member of {admins}'
if changes_admins(connection, method, path.strip('/').split('/'), body if isinstance(body, dict) else {}):
parts = path.strip('/').split('/')
if changes_admins(connection, method, parts, body if isinstance(body, dict) else {}):
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
if parts[0] == 'users' and len(parts) == 3 and parts[2] == 'console' and method != 'GET':
return 'only a token of vpn-router-token may change console login'
return ''
def auth(path, body):
"""Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.
POST /auth sign in: {"name", "password", "code"?}; the tokens
POST /auth sign in: {"name", "password", "code"?}; the tokens and the user's name
POST /auth/passkey begin a sign-in with a passkey: {"id", "options"} for navigator.credentials.get
POST /auth/passkey/complete
{"id", "credential"}, credential the browser's answer; the tokens and the user's name
POST /auth/refresh {"refresh_token"}; new tokens, the refresh token used up
POST /auth/logout {"refresh_token"}; the refresh token revoked
"""
@@ -969,7 +1109,13 @@ def auth(path, body):
with vpnrouter_users.session() as connection:
if vpnrouter_users.service(vpnrouter_users.entry(connection, body['name'])):
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
return 200, vpnrouter_auth.issue(cfg, body['name'])
return 200, {**vpnrouter_auth.issue(cfg, body['name']), 'name': body['name']}
if path == '/auth/passkey':
return 200, vpnrouter_passkeys.login_begin()
if path == '/auth/passkey/complete':
with vpnrouter_users.session() as connection:
name = vpnrouter_passkeys.login_complete(connection, body.get('id'), body.get('credential'))
return 200, {**vpnrouter_auth.issue(cfg, name), 'name': name}
name = vpnrouter_auth.take(cfg, body.get('refresh_token'))
if path == '/auth/logout':
return 200, {'revoked': name is not None}
@@ -990,6 +1136,93 @@ def auth(path, body):
return 500, {'error': str(exc.code)}
def shell_ticket(name, body):
"""Answer POST /shell: the HTTP status and the JSON document.
The user named by the token signs in again with {"password", "code"?}; a wrong password or code is 403 and counts
as a failed login. Without a password, the user is not asked to sign in while its last use of a shell (see
_shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket", "expires_in"}: a ticket that
opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null ticket when the user must
sign in. 403 while web.shell is false, the user's console login is not on or the user has no TOTP token.
"""
if not isinstance(name, str):
return 403, {'error': 'the shell needs a token of a signed-in user'}
if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'}
try:
options = vpnrouter.load_config()['web']
if not options['shell']:
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
refusal = console_account(name)[1]
if refusal:
return 403, {'error': refusal}
if 'password' not in body:
with _shell_lock:
used = _shell_used.get(name)
if used is None or time.monotonic() - used >= options['shell_auth_timeout'] * 60:
return 200, {'ticket': None, 'expires_in': 0}
else:
vpnrouter_users.login(name, body.get('password'), body.get('code'))
except vpnrouter_users.Failure as failure:
if failure.status == 5:
return 403, {'error': 'the password or the 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 SystemExit as exc:
return 500, {'error': str(exc.code)}
ticket = secrets.token_urlsafe(32)
now = time.monotonic()
with _shell_lock:
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
del _shell_tickets[unused]
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
_shell_used[name] = now
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
def console_account(name):
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
console: its console login is off, or it has no TOTP token."""
with vpnrouter_users.session() as connection:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
if not user['console'] or not user['console']['enabled']:
return None, f'console login is not enabled for {name}; vpn-router-user console sets it'
if not user['otp']:
return None, f'{name} has no TOTP token; the shell needs one, enrolled on the Account page'
return user['console']['account'], None
def take_ticket(name, ticket):
"""Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way."""
with _shell_lock:
holder, expires = _shell_tickets.pop(ticket, (None, 0.0))
return holder == name and expires > time.monotonic()
def shell_command(account, unit):
"""The command that runs the login shell of the system user account in the transient unit, on its terminal.
setsid makes the terminal the controlling one of systemd-run, which passes the window size on; systemd-run starts
the shell outside the web service's sandbox, in a PAM login session of the user.
"""
return ['setsid', '--ctty', 'systemd-run', '--quiet', '--collect', '--pty', '--send-sighup', f'--unit={unit}',
f'--uid={account.pw_name}', '--property=PAMName=login', f'--working-directory={account.pw_dir}',
'--setenv=TERM=xterm-256color', account.pw_shell or '/bin/sh', '-l']
def frame(opcode, payload):
"""A final, unmasked WebSocket frame of a server."""
size = len(payload)
if size < 126:
length = bytes([size])
elif size < 65536:
length = bytes([126]) + size.to_bytes(2, 'big')
else:
length = bytes([127]) + size.to_bytes(8, 'big')
return bytes([0x80 | opcode]) + length + payload
def auth_wait(address):
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
now = time.monotonic()
@@ -1037,6 +1270,184 @@ class Handler(BaseHTTPRequestHandler):
return 500, f'the user directory failed: {exc}'
return (403, reason) if reason else None
def exact(self, size):
"""The next size bytes from the client; ConnectionError when it closes first."""
data = self.rfile.read(size)
if len(data) != size:
raise ConnectionError('the client closed the connection')
return data
def message(self, send):
"""The opcode and the payload of the client's next WebSocket data message, or CLOSE and its payload.
Fragments are joined and unmasked; a ping is answered with send, a pong ignored. ValueError for an unmasked
frame or a message over MESSAGE_LIMIT.
"""
opcode, payload = None, b''
while True:
head = self.exact(2)
code, size = head[0] & 0x0F, head[1] & 0x7F
if not head[1] & 0x80:
raise ValueError('a frame of the client is not masked')
if size == 126:
size = int.from_bytes(self.exact(2), 'big')
elif size == 127:
size = int.from_bytes(self.exact(8), 'big')
if len(payload) + size > MESSAGE_LIMIT:
raise ValueError(f'a message is larger than {MESSAGE_LIMIT} bytes')
mask = self.exact(4) * (size // 4 + 1)
data = bytes(byte ^ key for byte, key in zip(self.exact(size), mask))
if code == PING:
send(PONG, data)
continue
if code == PONG:
continue
if code == CLOSE:
return CLOSE, data
opcode = code if opcode is None else opcode
payload += data
if head[0] & 0x80:
return opcode, payload
def shell(self, name):
"""Answer GET /shell?ticket=<ticket>, a WebSocket handshake, with a login shell of the user's console account
(vpnrouter_users.console) on a terminal.
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
terminal's output. The shell ends after web.shell_idle_minutes without a keystroke, when it exits, when the
client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or
its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged
with its reason.
"""
def refuse(code, message):
log(f'shell of {name} from {self.client()} refused: {message}')
body = json.dumps({'error': message}).encode() + b'\n'
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
key = self.headers.get('Sec-WebSocket-Key', '').strip()
if self.headers.get('Upgrade', '').lower() != 'websocket' or 'upgrade' not in self.headers.get('Connection', '').lower() \
or self.headers.get('Sec-WebSocket-Version') != '13' or not key:
return refuse(400, 'GET /shell must be a WebSocket handshake')
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
if not take_ticket(name, query.get('ticket', [''])[0]):
return refuse(403, 'the ticket is not valid; POST /shell gives one')
try:
options = vpnrouter.load_config()['web']
except SystemExit as exc:
return refuse(500, str(exc.code))
if not options['shell']:
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
try:
login, refusal = console_account(name)
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
return refuse(500, f'the user directory failed: {exc}')
if refusal:
return refuse(403, refusal)
try:
account = pwd.getpwnam(login)
except KeyError:
return refuse(500, f'the system does not know the account {login}; is nslcd running?')
unit = f'vpn-router-shell-{secrets.token_hex(4)}'
master, terminal = os.openpty()
try:
process = subprocess.Popen(shell_command(account, unit), stdin=terminal, stdout=terminal, stderr=terminal,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'})
except OSError as exc:
os.close(master)
return refuse(500, f'the shell cannot be started: {exc}')
finally:
os.close(terminal)
accept = base64.b64encode(hashlib.sha1((key + WEBSOCKET_GUID).encode()).digest()).decode()
self.send_response(101, 'Switching Protocols')
self.send_header('Upgrade', 'websocket')
self.send_header('Connection', 'Upgrade')
self.send_header('Sec-WebSocket-Accept', accept)
self.end_headers()
self.close_connection = True
client, started, idle = self.client(), time.monotonic(), options['shell_idle_minutes'] * 60
log(f'shell of {name} from {client} opened as {account.pw_name} in {unit}')
lock, ended, typed = threading.Lock(), threading.Event(), [started]
def send(opcode, payload):
with lock:
self.wfile.write(frame(opcode, payload))
def receive():
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
try:
while True:
opcode, payload = self.message(send)
if opcode == CLOSE:
break
if opcode == BINARY:
os.write(master, payload)
typed[0] = time.monotonic()
with _shell_lock:
_shell_used[name] = typed[0]
elif opcode == TEXT:
size = json.loads(payload)
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
except (OSError, ValueError, KeyError, TypeError, struct.error):
pass
finally:
ended.set()
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
reader.start()
reason = 'the shell ended'
checked = started
try:
while not ended.is_set():
left = typed[0] + idle - time.monotonic()
if left <= 0:
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
break
# The user's console login and TOTP token are checked again every SHELL_CHECK_SECONDS.
if time.monotonic() - checked >= SHELL_CHECK_SECONDS:
checked = time.monotonic()
try:
refusal = console_account(name)[1]
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
refusal = f'the user directory failed: {exc}'
if refusal:
reason = refusal
break
if select.select([master], [], [], min(left, 1))[0]:
try:
output = os.read(master, 65536)
except OSError:
output = b''
if not output:
break
send(BINARY, output)
else:
reason = 'the client closed the connection'
except OSError:
reason = 'the connection failed'
run('systemctl', 'stop', unit)
try:
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
except OSError:
pass
try:
self.connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
reader.join()
os.close(master)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
log(f'shell of {name} from {client} in {unit} ended after {int(time.monotonic() - started)} seconds: {reason}')
def answer(self, send_body):
path = self.path.split('?', 1)[0]
extra = []
@@ -1045,10 +1456,10 @@ class Handler(BaseHTTPRequestHandler):
if self.command not in ('GET', 'HEAD'):
self.close_connection = True
directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site', 'backup',
'ca')
'ca', 'shell', 'remote-access')
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/status/full', '/config', '/metrics/history', '/postgresql') or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout')
signing = path in ('/auth', '/auth/passkey', '/auth/passkey/complete', '/auth/refresh', '/auth/logout')
state = current() if protected or signing else {}
claims = self.claims(state['key']) if protected and state['key'] else None
payload = self.request_body() if (protected or signing) and self.command not in ('GET', 'HEAD') else None
@@ -1059,7 +1470,7 @@ class Handler(BaseHTTPRequestHandler):
elif signing and self.command != 'POST':
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
elif signing:
wait = auth_wait(self.client()) if path != '/auth/logout' else 0
wait = auth_wait(self.client()) if path not in ('/auth/logout', '/auth/passkey') else 0
if wait:
code, document = 429, {'error': f'too many sign-in calls; retry in {wait} seconds'}
extra.append(('Retry-After', str(wait)))
@@ -1083,12 +1494,23 @@ class Handler(BaseHTTPRequestHandler):
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
code, document = metrics_history(query.get('range', ['hour'])[0])
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
return self.shell(claims.get('sub'))
elif directory == 'shell':
code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \
else (404, {'error': f'no route {self.command} {path}'})
if code != 200:
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
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 directory == 'site-to-site':
code, document = site_to_site(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'remote-access':
code, document = remote_access(state, self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'backup':
code, document = backup(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
+28 -2
View File
@@ -24,6 +24,8 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] otp-new <name>
vpn-router-user [--json] otp-enrol <name> <code> (secret on stdin)
vpn-router-user [--json] otp-remove <name>
vpn-router-user [--json] console <name> (--posix | --account LOCAL | --off)
vpn-router-user [--json] passkeys-remove <name>
"""
import argparse
@@ -36,6 +38,7 @@ import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_passkeys
import vpnrouter_users
from vpnrouter_users import Failure
@@ -56,6 +59,9 @@ HELP = {
'otp-new': 'make a TOTP secret and its otpauth URI; nothing is stored',
'otp-enrol': 'store the TOTP secret read from standard input, when the code is a code of it',
'otp-remove': 'remove the TOTP token',
'console': "switch the console login of a member of admins with a TOTP token on, as the user's own POSIX account "
'in the directory or as a local account, or off',
'passkeys-remove': "remove all the user's passkeys",
}
#: The help of the person options.
@@ -86,6 +92,11 @@ def parse(argv):
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
if command == 'otp-enrol':
sub.add_argument('code', help='a code of the secret from the authenticator')
if command == 'console':
choice = sub.add_mutually_exclusive_group(required=True)
choice.add_argument('--posix', action='store_true', help="run the console as the user's own POSIX account")
choice.add_argument('--account', metavar='LOCAL', help='run the console as this local account')
choice.add_argument('--off', action='store_true', help='switch console login off')
return parser.parse_args(argv)
@@ -108,9 +119,23 @@ def run(connection, options, secret):
return vpnrouter_users.passwd(connection, options.name, secret)
if command == 'otp-enrol':
return vpnrouter_users.otp_enrol(connection, options.name, secret, options.code)
if command == 'console':
return vpnrouter_users.console(connection, options.name, not options.off, options.account)
if command == 'passkeys-remove':
return vpnrouter_passkeys.remove(connection, options.name)
return getattr(vpnrouter_users, command.replace('-', '_'))(connection, options.name)
def console_text(user):
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account, refused without a TOTP
token."""
console = user['console']
if console is None or not console['enabled']:
return '-' if console is None else 'off'
text = f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
return text if user['otp'] else f'{text}, refused: no OTP'
def text_of(answer):
"""The answer as text for people."""
if 'profile' in answer:
@@ -122,11 +147,12 @@ def text_of(answer):
users = answer['users'] if 'users' in answer else [answer]
if not users:
return 'no users'
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else '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',
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
'enrolled' if user['otp'] else '-', str(len(user['passkeys'])) if user['passkeys'] else '-', console_text(user),
user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
for user in users]
widths = [max(len(row[column]) for row in (head, *rows)) for column in range(len(head))]
return '\n'.join(' '.join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip() for row in (head, *rows))
@@ -61,7 +61,13 @@
"port": 7081,
"database": "",
"trusted_proxies": [],
"status_content": "basic"
"status_content": "basic",
"shell": false,
"shell_idle_minutes": 15,
"shell_auth_timeout": 15
},
"ssh": {
"ldap_login": false
},
"https": {
"enabled": true,
@@ -24,6 +24,8 @@ push "route {{ network }} {{ netmask }}"
push "dhcp-option DNS {{ address }}"
{% endfor -%}
status {{ openvpn_status }} {{ openvpn_status_seconds }}
keepalive 10 120
{% if openvpn_protocol == 'udp' -%}
explicit-exit-notify 1
@@ -0,0 +1,9 @@
{{ wg_peers_begin }}
{% for user, key, ips in wg_peers -%}
[Peer]
# {{ user }}
PublicKey = {{ key }}
AllowedIPs = {{ ips | join(', ') }}
{% endfor -%}
{{ wg_peers_end }}