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

This commit is contained in:
slawek committed 2026-10-26 08:35:10 +01:00
1 parent 91cc64d6a6
commit 90db6d46a2
10 files changed
+408 -62

No files matched your search

+50 -18
View File
@@ -700,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 |
@@ -713,8 +713,9 @@ 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 |
| `console <name> (--posix \| --account LOCAL \| --off)` | Switches the console login of a member of `admins` on, as the user's own POSIX account or a local account, or off (see Console login and SSH) |
| `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
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
@@ -737,9 +738,12 @@ certificates only, so `disable` does not affect it; `remove` revokes the certifi
### Console login and SSH
A member of `admins` 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`:
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
@@ -751,9 +755,8 @@ 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 a user enrolled for TOTP 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
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
@@ -767,6 +770,26 @@ While `ssh.ldap_login` is `true`, setup lets sshd accept the password of the mem
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
@@ -937,7 +960,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 |
@@ -946,7 +971,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`.
@@ -970,8 +997,8 @@ 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 (see
Console login and SSH) a login shell on the router, as the user's console account, for the
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
@@ -981,19 +1008,20 @@ vpn-config-edit set web.shell true
| Request | Body | Answer |
| --- | --- | --- |
| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
| `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` or the user's console login is off. Without a password it gives a ticket
`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, or when the connection closes; the router then sends a close frame with the reason. The
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.
@@ -1028,7 +1056,7 @@ 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", "console", "wireguard_peers", "first_name", "last_name", "email"}, ...], "wireguard": {"enabled", "address"}}`; `console` is `null` or `{"enabled", "account", "local", "uid"}`, `wireguard_peers` a list of `{"public_key", "ips"}`, and `wireguard` the endpoint's settings, whose network the peers' addresses come from |
| `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", "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 |
@@ -1040,6 +1068,7 @@ 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
@@ -1086,8 +1115,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