Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77bdbcb058 | ||
|
|
cb33b3bfa1 | ||
|
|
81b7f4cac6 | ||
|
|
8cac21845a | ||
|
|
d1d565b1d9 | ||
|
|
e675540ce2 | ||
|
|
90db6d46a2 | ||
|
|
91cc64d6a6 |
No files matched your search
@@ -98,8 +98,8 @@ Context: OpenVPN needs user accounts with passwords, and the users are meant to
|
||||
system of the router, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
|
||||
authenticate. Everything the router runs comes from the Ubuntu archive. slapd, its Python module
|
||||
`python3-ldap` and the OpenVPN plugin `openvpn-auth-ldap` are all in the archive; the plugin
|
||||
checks a password by binding as the user, so the hash scheme is slapd's alone. An OIDC provider
|
||||
can read the directory through its LDAP connector.
|
||||
checks a password by binding as the user, so the hash scheme is slapd's alone. The router's own
|
||||
OpenID Provider (ADR-37) signs the directory's users in to other systems.
|
||||
|
||||
Decision: The users are entries in slapd. The package keeps its own database,
|
||||
`dc=vpn-router,dc=local` in `/var/lib/ldap/vpn-router` (the path the slapd AppArmor profile
|
||||
@@ -909,7 +909,10 @@ Manager cannot set it. The shell runs as the console account of the signed-in us
|
||||
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. The WebSocket of RFC 6455 is implemented on the standard library inside the
|
||||
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
|
||||
@@ -942,12 +945,18 @@ so slapd's otp overlay and password policy apply as for the Router Manager: the
|
||||
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.
|
||||
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. A user enrolled for TOTP types
|
||||
the code after the password at the SSH and sudo prompts. Homes are created at the first login and
|
||||
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
|
||||
@@ -969,3 +978,107 @@ 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 and the OpenID Provider, 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`.
|
||||
The OpenID Provider's sign-in page (ADR-37) takes a passkey the same way.
|
||||
|
||||
Consequences: Passkeys are a convenience for the Router Manager and the OpenID Provider; 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.
|
||||
|
||||
## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients are service accounts
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: Other systems, such as dashboards, should sign the router's users in with the directory's
|
||||
passwords, TOTP codes and passkeys, without a second user store. The Ubuntu archive has no
|
||||
packaged identity provider. `python3-oauthlib` and `python3-jwt` are in main for noble and
|
||||
resolute; oauthlib implements the OpenID Connect flows and leaves the checks, the storage and the
|
||||
ID token's signature to the application. `python3-authlib` and `python3-jwcrypto` are in universe
|
||||
only. The web service already checks passwords and passkeys, rate limits sign-ins and keeps
|
||||
tokens in PostgreSQL, and Caddy serves it over HTTPS on `wan.local_fqdn`. ADR-3's HS256 key must
|
||||
not leave the router, but a relying party verifies an ID token itself.
|
||||
|
||||
Decision: The web service is the OpenID Provider, in `vpnrouter_oidc`, while `oidc.enabled` is
|
||||
true; its issuer is its HTTPS site, `https://<wan.local_fqdn>:<https.web_port>`, without the port
|
||||
on 443, and it answers the provider's paths only on `127.0.0.1`, where Caddy passes them. Only
|
||||
the authorization code flow with PKCE S256 and the refresh token grant are allowed. A client is a
|
||||
service account with redirect URIs, kept with the account in the new attributes
|
||||
`vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and `vpnRouterAllowedGroup` of
|
||||
`vpnRouterAccount`: its name is the client ID and its password the secret, checked with a bind.
|
||||
Allowed groups limit who may sign in; without them every user may. A registered client is
|
||||
trusted, so there is no consent page. The sign-in page is the web service's own, a Jinja2
|
||||
template with the password, the code and a passkey, so the provider works without the Router
|
||||
Manager. ID tokens are RS256, signed with an RSA key the web service makes on its first run
|
||||
(`/etc/vpn-router/oidc.key`), published as a JWK named by its RFC 7638 thumbprint; `sub` is the
|
||||
entry's `entryUUID`. Codes, access and refresh tokens and sessions are kept as their SHA-256 in
|
||||
the database of the refresh tokens, defined in `/usr/share/vpn-router/sql/oidc.sql` with
|
||||
procedures and functions the module calls; a process loads the file at its first connection. The
|
||||
operations that revoke the refresh tokens of `/auth` also revoke them, and every use of a code or
|
||||
token checks the user and the client's allowed groups again.
|
||||
|
||||
Consequences: The package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
|
||||
HTTPS, a public name and the web service. Clients are managed like service accounts, by the CLI,
|
||||
`/service-accounts` and the Router Manager; a group a client allows cannot be removed, and renaming
|
||||
it renames it in the clients. The signing key does not rotate; replacing it invalidates every ID
|
||||
token issued. Wrong client secrets lock the client like any account. The implicit and hybrid
|
||||
flows, dynamic client registration and public clients without a secret are not offered.
|
||||
|
||||
## ADR-38: Bash completions are files per command in bash-completion's completions directory
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The `vpn-*` commands have many subcommands, options and names (users, groups, issuing
|
||||
CAs, templates, settings) that are hard to remember. bash-completion's README recommends its
|
||||
`completionsdir`, `/usr/share/bash-completion/completions` on Ubuntu, whose files it loads on the
|
||||
first Tab of their command, and calls the eagerly loaded `/etc/bash_completion.d` not
|
||||
recommended; it prefers the name `<command>.bash`, which noble's 2.11 and resolute's 2.16 both
|
||||
look up.
|
||||
|
||||
Decision: The package installs `<command>.bash` for each `vpn-*` command into
|
||||
`/usr/share/bash-completion/completions` and recommends `bash-completion`. The completions read
|
||||
the names they offer from the commands themselves (`--json list`) or the package's Python
|
||||
modules (the schema, the configuration, the PKI's templates and reasons), not from copies, and
|
||||
send errors to `/dev/null`, so a user who may not read them gets the fixed words only.
|
||||
|
||||
Consequences: Completions follow the code without being edited for each new setting or name. A
|
||||
Tab that offers names runs a command or Python, which takes a fraction of a second. The files
|
||||
are not conffiles; an administrator overrides one in `/etc/bash_completion.d` or the user's own
|
||||
completions directory.
|
||||
|
||||
## ADR-39: /logs shows the last lines of listed units and files, capped per request
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: The Router Manager should show the router's logs without SSH. The web service runs as
|
||||
root, so a path or a unit taken from a request could expose any file or journal on the router,
|
||||
and a log of gigabytes cannot be sent whole to a browser.
|
||||
|
||||
Decision: `/logs` serves the journal of a fixed list of units, the package's own and the services
|
||||
it runs, or the whole journal, read with `journalctl`, and the files `web.log_files` lists,
|
||||
which `vpn-config-edit` sets; a unit or a path outside the lists is refused. A request answers
|
||||
at most 1000 lines, 200 by default, and a file is read from its last MiB only. The answer is
|
||||
read at each request; nothing is followed or kept.
|
||||
|
||||
Consequences: Only administrators read logs, and only those listed. Showing a new file needs a
|
||||
configuration change, not code. Long histories stay with SSH and `journalctl`.
|
||||
@@ -130,12 +130,15 @@ must have the type shown below; an unknown section or setting is an error.
|
||||
| `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` | `log_files` | list | | Absolute paths of the log files `/logs` shows, at most 32 (see The /logs API) |
|
||||
| `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 |
|
||||
| `https` | `manager_port` | integer | `7443` | HTTPS port of the Router Manager |
|
||||
| `https` | `caddy_source` | string | `ubuntu` | Where Caddy is installed from: `ubuntu`, the Ubuntu archive, or `upstream`, Caddy's own repository (see HTTPS) |
|
||||
| `oidc` | `enabled` | boolean | `false` | The OpenID Provider at the status web service's HTTPS site, which needs `web.enabled`, `https.enabled` and `wan.local_fqdn` (see OpenID Provider) |
|
||||
| `pki` | `root_name` | string | `VPN Router Root CA` | Common name of the root CA, when the router creates it |
|
||||
| `pki` | `root_days` | integer | `7300` | Days the root CA's certificate is valid |
|
||||
| `pki` | `ca_days` | integer | `3650` | Days an issuing CA's certificate is valid |
|
||||
@@ -341,6 +344,7 @@ vpn-config-edit set web.port 7082
|
||||
vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4
|
||||
vpn-config-edit set site_to_site.branch.addrs branch.example.net
|
||||
vpn-config-edit remove site_to_site.branch
|
||||
vpn-config-edit set --restart oidc.enabled true
|
||||
```
|
||||
|
||||
`show` prints the file as indented JSON and does not change it. `set` takes a setting as
|
||||
@@ -349,10 +353,29 @@ creates with the defaults when it does not exist, and converts the value to the
|
||||
a whole number, `true` or `false`, a list separated by commas (an empty value is an empty list),
|
||||
or a string; an unknown setting is refused. `remove site_to_site.NAME` removes a connection. It
|
||||
checks the whole configuration as `vpn-router-setup` does, and only then writes the file,
|
||||
indented, with mode 0600, and reminds that `vpn-router-setup` applies it. The status web service
|
||||
indented, with mode 0600, and reminds that `vpn-router-setup` applies it; with `--restart`, `set`
|
||||
and `remove` restart `vpn-router-setup` and wait until it has applied the change, and exit with
|
||||
status 1 when it fails. The status web service
|
||||
writes the file the same way, and a lock keeps the two from overwriting each other's change. A
|
||||
refused request exits with status 2, any other failure with 1.
|
||||
|
||||
## Bash completion
|
||||
|
||||
Bash completes the commands, options and arguments of `vpn-router-user`, `vpn-router-group`,
|
||||
`vpn-router-token`, `vpn-config-edit`, `vpn-router-backup`, `vpn-router-ca` and
|
||||
`vpn-router-caddy` with the Tab key. The package installs a file per command in
|
||||
`/usr/share/bash-completion/completions`, which the `bash-completion` package, recommended by
|
||||
`vpn-router`, loads on the first Tab after the command. For root, who can read them, the
|
||||
completions also offer the users and groups of the directory, the settings and site-to-site
|
||||
connections of `vpn-router.json` with `true` and `false` for a boolean setting, and the issuing CAs and
|
||||
certificate templates; for other users only the fixed words.
|
||||
|
||||
```sh
|
||||
sudo -i
|
||||
vpn-config-edit set oidc.<Tab>
|
||||
vpn-router-user passwd <Tab>
|
||||
```
|
||||
|
||||
## Applying changes
|
||||
|
||||
```sh
|
||||
@@ -626,10 +649,12 @@ is switched on:
|
||||
`account` for a Manager-only account (see Managing users);
|
||||
- the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID
|
||||
`2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class
|
||||
`vpnRouterAccount` with `MAY ( mail $ vpnRouterScope $ displayName $ description )`, which
|
||||
Manager-only and service accounts have, and the attribute `vpnRouterScope`, authorization
|
||||
scopes, of which `service` marks a service account; `displayName` and `description` are the
|
||||
display name and the description of a service account;
|
||||
`vpnRouterAccount`, which Manager-only and service accounts have, and the attribute
|
||||
`vpnRouterScope`, authorization scopes, of which `service` marks a service account;
|
||||
`displayName` and `description` are the display name and the description of a service
|
||||
account, and `vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri` and
|
||||
`vpnRouterAllowedGroup` the settings of a service account that is a client of the OpenID
|
||||
Provider;
|
||||
- `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in
|
||||
`member` and an optional `description`;
|
||||
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write
|
||||
@@ -664,7 +689,8 @@ it is added:
|
||||
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
|
||||
system signs in with to use a router service, such as an OpenTelemetry Collector reading
|
||||
`/metrics`; it cannot sign in to `/auth`. Besides its name, it has a display name and a
|
||||
description. `add --service` adds one, and the web service manages them at `/service-accounts`,
|
||||
description, and a service account with redirect URIs is a client of the OpenID Provider (see
|
||||
OpenID Provider). `add --service` adds one, and the web service manages them at `/service-accounts`,
|
||||
not at `/users`. Internal service accounts are those the router creates and manages for its own
|
||||
services, display name and description included; at present that is the metrics service's, the
|
||||
account `metrics.service_account` names while metrics are enabled (see Metrics), which
|
||||
@@ -699,12 +725,12 @@ 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 |
|
||||
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider client settings when given; the names are refused |
|
||||
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
|
||||
| `update <name> [--display-name N] [--description D] [--email E]` | Changes the display name, the description or the e-mail address of a service account; `''` removes one |
|
||||
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider client settings of a service account; `''` removes one; a repeated client option gives all values of its kind |
|
||||
| `passwd <name>` | Sets the password |
|
||||
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
|
||||
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
|
||||
@@ -712,17 +738,20 @@ 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
|
||||
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
|
||||
and `/auth` with the password followed by a current code. In the answers `manager_only`, `service`
|
||||
and `internal` tell the kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
|
||||
address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its
|
||||
refresh tokens of `/auth`. The exit status is 0 on success, 2 for an invalid request, 3 when the
|
||||
refresh tokens of `/auth` and its codes, tokens and sessions of the OpenID Provider. The exit status is 0 on success, 2 for an invalid request, 3 when the
|
||||
user does not exist, 4 when the user exists and 1 for any other failure.
|
||||
|
||||
When the Router Manager is installed with an administrator (its debconf questions
|
||||
@@ -736,9 +765,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
|
||||
@@ -750,9 +782,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
|
||||
@@ -766,12 +797,34 @@ 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 and to the OpenID Provider
|
||||
with a passkey instead of the password and code. Passkeys are for those sign-ins 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 HTTPS addresses 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
|
||||
optional description of at most 256 printable characters, and at least one member, each a user
|
||||
of the directory. Groups record membership only; nothing on the router reads them. The output,
|
||||
`--json`, `-h` and `--help` work as for `vpn-router-user`.
|
||||
of the directory. The group `admins` grants the management of the router, and the allowed groups
|
||||
of a client of the OpenID Provider admit their members to it; other groups record membership
|
||||
only. Renaming a group renames it in the clients that allow it, and a group a client allows
|
||||
cannot be removed. The output, `--json`, `-h` and `--help` work as for `vpn-router-user`.
|
||||
|
||||
```sh
|
||||
vpn-router-group add ops --member alice --member bob --description 'Operations team'
|
||||
@@ -788,7 +841,7 @@ vpn-router-group remove netops
|
||||
| `list` | The groups with their members and descriptions |
|
||||
| `add <name> --member U [--member U ...] [--description D]` | Adds the group with its members |
|
||||
| `update <name> [--name N] [--description D]` | Renames the group or sets its description; `''` removes it |
|
||||
| `remove <name>` | Removes the group; its members are kept |
|
||||
| `remove <name>` | Removes the group; its members are kept; refused while a client of the OpenID Provider allows it |
|
||||
| `member-add <name> <user>` | Adds the user to the group |
|
||||
| `member-remove <name> <user>` | Takes the user out of the group; refused for the last member |
|
||||
|
||||
@@ -855,8 +908,9 @@ request from one of `web.trusted_proxies`, by default the address the request ca
|
||||
behind Caddy or a Router Manager on the router it is the browser's. `/status` names the content in
|
||||
`content` and the Client IP in `client_ip`; `/` keeps the time and the link to `/status`.
|
||||
|
||||
The service first makes its signing key and the refresh tokens' database, retrying every 15
|
||||
seconds, for example while PostgreSQL is not running yet, and logs each failure; then it listens.
|
||||
The service first makes its signing key, the OpenID Provider's key and the refresh tokens'
|
||||
database, retrying every 15 seconds, for example while PostgreSQL is not running yet, and logs
|
||||
each failure; then it listens.
|
||||
|
||||
### Bootstrap diagnostics
|
||||
|
||||
@@ -887,6 +941,8 @@ curl http://10.0.0.4:17081/
|
||||
| `/groups` | The groups of users; needs a token |
|
||||
| `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token |
|
||||
| `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token |
|
||||
| `/logs` | The last lines of the journal of the router's units and of the files of `web.log_files`; needs a token |
|
||||
| `/.well-known/openid-configuration`, `/oidc/...` | The OpenID Provider, on `127.0.0.1` only, where Caddy passes the requests (see OpenID Provider) |
|
||||
|
||||
The status holds the state of `vpn-router-setup`, `slapd` and each PostgreSQL cluster unit
|
||||
(`postgresql@<version>-<cluster>`, or `postgresql` as `no cluster` without one), of
|
||||
@@ -909,7 +965,7 @@ curl http://10.0.0.4:7081/status
|
||||
|
||||
### Tokens and signing in
|
||||
|
||||
`/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
|
||||
`/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site`, `/backup`, `/remote-access`, `/ca`, `/logs`, `/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
|
||||
@@ -936,7 +992,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 |
|
||||
|
||||
@@ -945,7 +1003,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`.
|
||||
@@ -969,8 +1029,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
|
||||
@@ -980,17 +1040,20 @@ vpn-config-edit set web.shell true
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `POST /shell` | `{"password", "code"}`; `code` only for a user enrolled for TOTP | `{"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. The ticket opens one
|
||||
`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.
|
||||
|
||||
@@ -1017,6 +1080,27 @@ router:
|
||||
- **A cluster that is not online:** `databases` and `size` are `null`.
|
||||
- **Collection:** the web service reads it with `psql` as the `postgres` user at each request.
|
||||
|
||||
### The /logs API
|
||||
|
||||
`/logs`, with the same token as `/config`, shows the last lines of the journal and of log files,
|
||||
read at each request:
|
||||
|
||||
| Request | Answer |
|
||||
| --- | --- |
|
||||
| `GET /logs` | `{"units", "files", "max_lines"}`: the units whose journal it shows, the files of `web.log_files` and the most lines one request answers, 1000 |
|
||||
| `GET /logs/journal?unit=<unit>&lines=<n>` | `{"lines": [...]}`: the last lines of the unit's journal, or of the whole journal without `unit`, with ISO times |
|
||||
| `GET /logs/file?path=<path>&lines=<n>` | `{"lines": [...]}`: the last lines of a file `web.log_files` lists |
|
||||
|
||||
The units are the package's own (`vpn-router-setup`, `-web`, `-metrics`, `-pki`, `-backup`,
|
||||
`-caddy`), `vpn-router-manager`, `caddy`, `slapd`, `nslcd`, `postgresql`, `strongswan`,
|
||||
`wg-quick@wg0`, `openvpn-server@vpn-router` and `ssh`. `lines` is 1 to 1000, 200 without it. A
|
||||
file is read from at most its last MiB, so very long lines give fewer lines. A unit or a file not
|
||||
in the lists is 404, as is a listed file that does not exist.
|
||||
|
||||
```sh
|
||||
vpn-config-edit set web.log_files /var/log/syslog,/var/log/auth.log
|
||||
```
|
||||
|
||||
### The /users API
|
||||
|
||||
`/users` manages users with the same token as `/config`, with the module `vpn-router-user` uses
|
||||
@@ -1025,7 +1109,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 |
|
||||
@@ -1037,6 +1121,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
|
||||
@@ -1058,16 +1143,19 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
|
||||
| Request | Body | Answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", ...}, ...]}`, each as `/users` shows a user |
|
||||
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?}` | 201 and the account |
|
||||
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email"}`; `""` removes one | the account |
|
||||
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", ...}, ...], "oidc": {"enabled", "issuer"}}`, each as `/users` shows a user, and the issuer of the OpenID Provider, `null` while it is disabled |
|
||||
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?, "redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}` | 201 and the account |
|
||||
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups"}`; `""` removes one of the first three, `[]` the values of the others | the account |
|
||||
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
|
||||
| `POST /service-accounts/<name>/disable` | | the account |
|
||||
| `POST /service-accounts/<name>/enable` | | the account |
|
||||
| `POST /service-accounts/<name>/unlock` | | the account |
|
||||
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
|
||||
|
||||
A display name has at most 64 printable characters and a description at most 256. The internal
|
||||
A display name has at most 64 printable characters and a description at most 256. The client
|
||||
settings are lists of at most 16 values: URIs of `https`, or of `http` for `localhost`,
|
||||
`127.0.0.1` or `::1`, without a fragment and of at most 1024 characters, and names of existing
|
||||
groups (see OpenID Provider). The internal
|
||||
service accounts, `internal` true, are only listed: every other request on one gets 403, as the
|
||||
router manages them, their display name and description included. A failure is otherwise as for
|
||||
`/users`, with 404 also for a name that is not a service account.
|
||||
@@ -1083,8 +1171,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
|
||||
@@ -1265,6 +1356,75 @@ does on its own: remove the source and the key, run `apt-get update`, and instal
|
||||
with `apt-get install --allow-downgrades caddy=<version>`, the version `apt-cache policy caddy`
|
||||
lists for the archive.
|
||||
|
||||
## OpenID Provider
|
||||
|
||||
While `oidc.enabled` is `true`, the status web service is an OpenID Provider (OpenID Connect
|
||||
Core 1.0) for the users of the directory. Its issuer is the web service's HTTPS site,
|
||||
`https://<wan.local_fqdn>:<https.web_port>`, without the port when it is 443; the web service
|
||||
answers it only on `127.0.0.1`, where Caddy passes the requests, so the provider is never reached
|
||||
over plain HTTP. The setting needs `web.enabled`, `https.enabled` and `wan.local_fqdn`.
|
||||
|
||||
```sh
|
||||
vpn-config-edit set --restart oidc.enabled true
|
||||
curl https://router.example.com:7444/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /.well-known/openid-configuration` | The discovery document |
|
||||
| `GET /oidc/jwks` | The public key ID tokens are signed with |
|
||||
| `GET`, `POST /oidc/authorize` | The authorization code flow; the sign-in page while the browser has no session |
|
||||
| `POST /oidc/token` | Exchanges a code or a refresh token for tokens |
|
||||
| `GET`, `POST /oidc/userinfo` | The claims of the user of an access token |
|
||||
| `GET /oidc/logout` | Ends the session (RP-initiated logout) |
|
||||
|
||||
A client is a service account with at least one redirect URI: its name is the `client_id` and its
|
||||
password the `client_secret`, sent with HTTP Basic authentication (`client_secret_basic`) or in
|
||||
the body of the token request (`client_secret_post`). The password is checked with a bind, so 5
|
||||
wrong secrets lock the client for 15 minutes. Its settings are:
|
||||
|
||||
- redirect URIs, where the provider sends the browser back with a code, matched exactly; a URI
|
||||
uses `https`, or `http` for `localhost`, `127.0.0.1` or `::1`;
|
||||
- post-logout redirect URIs, where `/oidc/logout` may send the browser afterwards;
|
||||
- allowed groups: only their members may sign in to the client; without any, every user who is
|
||||
not a service account may.
|
||||
|
||||
```sh
|
||||
echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --service \
|
||||
--display-name Grafana --redirect-uri https://grafana.example.com/login/generic_oauth \
|
||||
--post-logout-redirect-uri https://grafana.example.com/login --allowed-group ops
|
||||
vpn-router-user update 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --allowed-group ''
|
||||
```
|
||||
|
||||
The flow is the authorization code flow with PKCE, whose `code_challenge_method` must be `S256`;
|
||||
the implicit and hybrid flows, and the password and client credentials grants, are refused. A
|
||||
registered client is trusted, so a user is not asked for consent. A user signs in on the
|
||||
provider's page with the name, the password and, when enrolled, the TOTP code, or with a passkey
|
||||
(see Passkeys); failed sign-ins count towards the lockout, and `POST /oidc/login` takes at most
|
||||
10 calls a minute from one client address, as `/auth` does. A
|
||||
disabled, locked or removed user, a service account, and a user outside the client's allowed
|
||||
groups cannot sign in, and the codes and tokens already issued to such a user stop working. The
|
||||
sign-in starts a session, a cookie of the issuer's `/oidc/` path valid for 8 hours, so later
|
||||
requests of any client go on without the page; `prompt=login` and `max_age` ask for a new sign-in,
|
||||
and `prompt=none` answers `login_required` without a session.
|
||||
|
||||
| Scope | Claims |
|
||||
| --- | --- |
|
||||
| `openid` (required) | `sub`, the `entryUUID` of the user's entry |
|
||||
| `profile` | `preferred_username` (the user name), `name`, `given_name`, `family_name` |
|
||||
| `email` | `email` |
|
||||
| `groups` | `groups`, the names of the user's groups |
|
||||
|
||||
The claims of the scopes granted are in the ID token and in the answer of `/oidc/userinfo`. ID
|
||||
tokens are RS256 JWTs, valid for 5 minutes, with `auth_time` and `amr` (`pwd`, `pwd` and `otp`, or
|
||||
`hwk` and `user` for a passkey), signed with an RSA key of 3072 bits that the web service makes on
|
||||
its first run, `/etc/vpn-router/oidc.key` (root only), and named in the JWKS by its RFC 7638
|
||||
thumbprint. A code is valid for 1 minute and used once, an access token for 5 minutes, and a
|
||||
refresh token for 8 hours and used once: each refresh answers a new one with a new ID token. The
|
||||
codes, tokens and sessions are kept, as their SHA-256, in the database of the refresh tokens of
|
||||
`/auth`. Changing a user's password, disabling, unlocking or removing the user ends its sessions
|
||||
and revokes its codes and tokens.
|
||||
|
||||
## Metrics
|
||||
|
||||
While `metrics.enabled` is `true`, `vpn-router-setup` runs `vpn-router-metrics`, which every minute
|
||||
@@ -1487,7 +1647,7 @@ may be in use by something else.
|
||||
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
||||
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
|
||||
before deleting it so a later reinstall starts clean. It also removes the web service's
|
||||
signing key, the password file of the metrics service account, and the database of the refresh
|
||||
signing key and the OpenID Provider's, the password file of the metrics service account, and the database of the refresh
|
||||
tokens and the metrics, its role and their password
|
||||
file when the package created them, and Caddy's configuration `/etc/caddy/vpn-router.Caddyfile`; the certificate in
|
||||
`/var/lib/caddy` belongs to the `caddy` package. It removes the backup timer's schedule, the
|
||||
|
||||
@@ -1,3 +1,122 @@
|
||||
vpn-router (0.30.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service serves the last lines of the journal of the router's units and of the log files web.log_files lists, at /logs.
|
||||
|
||||
* GET /logs lists the units whose journal and the files whose lines it
|
||||
serves; GET /logs/journal?unit=&lines= and /logs/file?path=&lines=
|
||||
answer the last lines, 200 by default and at most 1000, a file read
|
||||
from its last MiB. Units and files outside the lists are refused.
|
||||
* The new setting web.log_files lists the absolute paths of the log
|
||||
files, at most 32.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 19:00:00 +0100
|
||||
|
||||
vpn-router (0.29.0-1) unstable; urgency=medium
|
||||
|
||||
feature: vpn-config-edit set and remove take --restart, which applies the change with vpn-router-setup and waits for it.
|
||||
|
||||
* vpn-config-edit set --restart and remove --restart restart
|
||||
vpn-router-setup after writing the configuration and wait until it has
|
||||
applied the change; a failed run exits with status 1 and points to its
|
||||
journal. Bash completes --restart.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 17:00:00 +0100
|
||||
|
||||
vpn-router (0.28.0-1) unstable; urgency=medium
|
||||
|
||||
feature: bash completes the vpn-* commands, their options and the names they take.
|
||||
|
||||
* Bash completes the commands, options and arguments of vpn-router-user,
|
||||
vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup,
|
||||
vpn-router-ca and vpn-router-caddy, from
|
||||
/usr/share/bash-completion/completions; for root also the users,
|
||||
groups, issuing CAs, templates, settings and site-to-site connections.
|
||||
* The package recommends bash-completion.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 16:00:00 +0100
|
||||
|
||||
vpn-router (0.27.0-1) unstable; urgency=medium
|
||||
|
||||
feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs.
|
||||
|
||||
* oidc.enabled turns on the OpenID Provider at the issuer
|
||||
https://<wan.local_fqdn>:<https.web_port>, with discovery, JWKS,
|
||||
authorize, token, userinfo and RP-initiated logout under /oidc/,
|
||||
answered only through Caddy. The authorization code flow with PKCE
|
||||
S256 and refresh tokens are offered; ID tokens are RS256, signed with
|
||||
/etc/vpn-router/oidc.key, which the web service makes on its first run.
|
||||
* Users sign in on the provider's page with the password and the TOTP
|
||||
code or with a passkey; the session lasts 8 hours. The scopes openid,
|
||||
profile, email and groups give sub (the entry's entryUUID), the names,
|
||||
the e-mail address and the user's groups.
|
||||
* A service account with redirect URIs is a client: its name is the
|
||||
client ID and its password the secret. Its redirect URIs,
|
||||
post-logout redirect URIs and allowed groups are set with
|
||||
vpn-router-user add/update --redirect-uri, --post-logout-redirect-uri
|
||||
and --allowed-group, and in /service-accounts as redirect_uris,
|
||||
post_logout_redirect_uris and allowed_groups; GET /service-accounts
|
||||
also answers the issuer. vpn-router-user list shows them.
|
||||
* Changing a user's password, disabling, unlocking or removing the user
|
||||
also revokes the user's codes, tokens and sessions of the provider.
|
||||
* Renaming a group renames it in the clients that allow it, and a group
|
||||
a client allows cannot be removed.
|
||||
* The package depends on python3-oauthlib and python3-jwt.
|
||||
|
||||
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 14:00:00 +0100
|
||||
|
||||
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.
|
||||
|
||||
@@ -22,6 +22,9 @@ Depends: ${misc:Depends},
|
||||
python3-jinja2,
|
||||
python3-ldap,
|
||||
python3-pyotp,
|
||||
python3-fido2,
|
||||
python3-jwt,
|
||||
python3-oauthlib,
|
||||
python3-psycopg,
|
||||
python3-psutil,
|
||||
python3-prometheus-client,
|
||||
@@ -36,6 +39,7 @@ Depends: ${misc:Depends},
|
||||
iproute2,
|
||||
systemd-resolved,
|
||||
zstd
|
||||
Recommends: bash-completion
|
||||
Suggests: caddy,
|
||||
rclone,
|
||||
proxmox-backup-client-static
|
||||
@@ -48,7 +52,8 @@ Description: Linux VPN router with IPSec, road-warrior, OpenVPN and WireGuard su
|
||||
needs. Its two-level CA, whose root may be kept offline, has issuing CAs for
|
||||
the VPN clients, for site-to-site and for general use, and publishes their
|
||||
certificates and CRLs. Caddy, installed by the package from the Ubuntu
|
||||
archive or from Caddy's repository, serves HTTPS. It keeps metrics of the
|
||||
archive or from Caddy's repository, serves HTTPS, and an optional OpenID
|
||||
Provider signs the directory's users in to other systems. It keeps metrics of the
|
||||
router in PostgreSQL and serves them for an OpenTelemetry Collector, and
|
||||
makes scheduled backups to a local folder, copied to the cloud with rclone,
|
||||
or to a Proxmox Backup Server. Nothing in the package is specific to a cloud
|
||||
|
||||
@@ -4,7 +4,9 @@ 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_oidc.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/
|
||||
src/usr/lib/vpn-router/vpnrouter_backup.py usr/lib/vpn-router/
|
||||
@@ -28,5 +30,7 @@ src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
|
||||
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
|
||||
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
|
||||
src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/
|
||||
src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/
|
||||
src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/
|
||||
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
|
||||
@@ -52,6 +52,7 @@ case "$1" in
|
||||
/etc/caddy/vpn-router.Caddyfile \
|
||||
/etc/vpn-router/vpn-router.json \
|
||||
/etc/vpn-router/web.key \
|
||||
/etc/vpn-router/oidc.key \
|
||||
/etc/vpn-router/metrics.secret \
|
||||
/etc/systemd/system/vpn-router-backup.timer.d/schedule.conf \
|
||||
/etc/sudoers.d/vpn-router \
|
||||
|
||||
@@ -587,7 +587,7 @@ def manage_console(cfg):
|
||||
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)(uid=$username)(vpnRouterConsoleLogin=TRUE))',
|
||||
'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.',
|
||||
'',
|
||||
])
|
||||
|
||||
@@ -58,9 +58,11 @@ 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', 'shell': False, 'shell_idle_minutes': 15},
|
||||
'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15,
|
||||
'log_files': []},
|
||||
'ssh': {'ldap_login': False},
|
||||
'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'},
|
||||
'oidc': {'enabled': False},
|
||||
'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',
|
||||
's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {},
|
||||
@@ -125,6 +127,9 @@ ACME_HTTP_PORT = 80
|
||||
#: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default.
|
||||
HTTPS_DEFAULT_PORT = 443
|
||||
|
||||
#: The most files web.log_files lists.
|
||||
LOG_FILES_MAX = 32
|
||||
|
||||
#: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository.
|
||||
CADDY_SOURCES = ('ubuntu', 'upstream')
|
||||
|
||||
@@ -201,7 +206,7 @@ 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, a day.
|
||||
#: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day.
|
||||
SHELL_IDLE_MAX = 1440
|
||||
|
||||
DOCKER_MODES = ('auto', 'on', 'off')
|
||||
@@ -499,6 +504,9 @@ def validate(cfg):
|
||||
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'):
|
||||
@@ -514,10 +522,23 @@ def validate(cfg):
|
||||
for item in _get_list(cfg, 'web', 'trusted_proxies'):
|
||||
_check_address(item, 'web.trusted_proxies')
|
||||
|
||||
log_files = _get_list(cfg, 'web', 'log_files')
|
||||
if len(log_files) > LOG_FILES_MAX:
|
||||
raise ConfigError(f'web.log_files: at most {LOG_FILES_MAX} files')
|
||||
for item in log_files:
|
||||
if not item.startswith('/') or '/../' in f'{item}/':
|
||||
raise ConfigError(f'web.log_files: {item!r} is not an absolute path')
|
||||
|
||||
caddy_source = _get(cfg, 'https', 'caddy_source')
|
||||
if caddy_source not in CADDY_SOURCES:
|
||||
raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}')
|
||||
|
||||
if _get(cfg, 'oidc', 'enabled') and not (_get(cfg, 'web', 'enabled') and _get(cfg, 'https', 'enabled')
|
||||
and _get(cfg, 'wan', 'local_fqdn')):
|
||||
raise ConfigError('oidc.enabled: the OpenID Provider is the web service served by Caddy at '
|
||||
'https://<wan.local_fqdn>:<https.web_port>, which needs web.enabled, https.enabled and '
|
||||
'wan.local_fqdn')
|
||||
|
||||
for option in ('root_name', 'p2s_name', 's2s_name'):
|
||||
value = _get(cfg, 'pki', option)
|
||||
if not value or len(value) > CA_NAME_MAX or not value.isprintable():
|
||||
@@ -743,6 +764,12 @@ def pki_base_urls(cfg):
|
||||
return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else []
|
||||
|
||||
|
||||
def oidc_issuer(cfg):
|
||||
"""The issuer of the OpenID Provider: the web service's HTTPS site, without the port when it is HTTPS_DEFAULT_PORT."""
|
||||
port = _get(cfg, 'https', 'web_port')
|
||||
return f'https://{_get(cfg, "wan", "local_fqdn")}' + ('' if port == HTTPS_DEFAULT_PORT else f':{port}')
|
||||
|
||||
|
||||
def resolve_psk(connection, name):
|
||||
"""Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured."""
|
||||
psk_file = connection['psk_file'].strip()
|
||||
|
||||
@@ -10,6 +10,9 @@ Every failure of the database raises DatabaseError, the only database exception
|
||||
Refresh tokens are random strings kept in PostgreSQL as their SHA-256, in the database of
|
||||
web.database, by default the vpn-router database on this host that provision() creates on the
|
||||
web service's first run, with the password in DATABASE_SECRET. A refresh token is used once: refreshing replaces it.
|
||||
The same database holds the state of the OpenID Provider (vpnrouter_oidc), whose tables and procedures OIDC_SQL
|
||||
defines; the first connection of a process loads it. revoke() removes a user's refresh tokens and OpenID Provider
|
||||
codes, tokens and sessions.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -26,6 +29,7 @@ import psycopg
|
||||
|
||||
WEB_KEY = pathlib.Path('/etc/vpn-router/web.key')
|
||||
DATABASE_SECRET = pathlib.Path('/etc/vpn-router/database.secret')
|
||||
OIDC_SQL = pathlib.Path('/usr/share/vpn-router/sql/oidc.sql')
|
||||
|
||||
#: The role and the database provision() creates when web.database is not set.
|
||||
DATABASE = 'vpn-router'
|
||||
@@ -45,6 +49,9 @@ LEEWAY = 60
|
||||
|
||||
SCHEMA = 'CREATE TABLE IF NOT EXISTS refresh_tokens (hash text PRIMARY KEY, name text NOT NULL, expires timestamptz NOT NULL)'
|
||||
|
||||
#: Whether this process has loaded OIDC_SQL.
|
||||
_oidc_loaded = False
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""The database of the refresh tokens failed."""
|
||||
@@ -131,10 +138,19 @@ def connection_string(cfg):
|
||||
|
||||
@contextlib.contextmanager
|
||||
def database(cfg):
|
||||
"""A connection to the database of the refresh tokens, with their table, closed at the end; DatabaseError on any failure."""
|
||||
"""A connection to the database of the refresh tokens, with their table and the OpenID Provider's, closed at the end;
|
||||
DatabaseError on any failure."""
|
||||
global _oidc_loaded
|
||||
try:
|
||||
oidc = None if _oidc_loaded else OIDC_SQL.read_text(encoding='utf-8')
|
||||
except OSError as exc:
|
||||
raise DatabaseError(f'{OIDC_SQL} cannot be read: {exc}') from exc
|
||||
try:
|
||||
with psycopg.connect(connection_string(cfg), autocommit=True) as connection:
|
||||
connection.execute(SCHEMA)
|
||||
if oidc is not None:
|
||||
connection.execute(oidc)
|
||||
_oidc_loaded = True
|
||||
yield connection
|
||||
except psycopg.Error as exc:
|
||||
raise DatabaseError(f'the token database failed: {exc}') from exc
|
||||
@@ -173,9 +189,10 @@ def take(cfg, token):
|
||||
|
||||
|
||||
def revoke(cfg, name):
|
||||
"""Remove all refresh tokens of the user."""
|
||||
"""Remove all refresh tokens of the user, and its codes, tokens and sessions of the OpenID Provider."""
|
||||
with database(cfg) as connection:
|
||||
connection.execute('DELETE FROM refresh_tokens WHERE name = %s', (name,))
|
||||
connection.execute('CALL oidc_revoke(%s)', (name,))
|
||||
|
||||
|
||||
def psql(sql, name):
|
||||
|
||||
@@ -67,7 +67,11 @@ OVERLAYS = ('ppolicy', 'otp')
|
||||
#: 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>...]". vpnRouterAuthority is a CA of the PKI: its
|
||||
#: WireGuard peer of the user as "<public key> <IP>[,<IP>...]", and vpnRouterPasskey, each a
|
||||
#: passkey of the user as JSON (vpnrouter_passkeys). A service account with vpnRouterRedirectUri
|
||||
#: is a client of the OpenID Provider (vpnrouter_oidc): the URIs it is sent back to after a sign-in
|
||||
#: and, vpnRouterPostLogoutRedirectUri, after a sign-out, and vpnRouterAllowedGroup, the DNs of the
|
||||
#: groups whose members may sign in to it. 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
|
||||
@@ -100,11 +104,20 @@ SCHEMA_ATTRIBUTE_TYPES = (
|
||||
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} )",
|
||||
f"( {SCHEMA_OID}.1.17 NAME 'vpnRouterRedirectUri' DESC 'VPN router: a redirect URI of an OpenID Connect client' "
|
||||
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.18 NAME 'vpnRouterPostLogoutRedirectUri' DESC 'VPN router: a post-logout redirect URI of an "
|
||||
"OpenID Connect client' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )",
|
||||
f"( {SCHEMA_OID}.1.19 NAME 'vpnRouterAllowedGroup' DESC 'VPN router: a group whose members may sign in to an "
|
||||
"OpenID Connect client' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )",
|
||||
)
|
||||
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 $ vpnRouterConsoleLogin $ "
|
||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer ) )",
|
||||
"vpnRouterConsoleAccount $ vpnRouterWireGuardPeer $ vpnRouterPasskey $ vpnRouterRedirectUri $ "
|
||||
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup ) )",
|
||||
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 ) )",
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""The router's OpenID Provider, for the web service, with python3-oauthlib and python3-jwt.
|
||||
|
||||
While oidc.enabled is true, the web service answers OpenID Connect at the issuer vpnrouter.oidc_issuer, its HTTPS site
|
||||
that Caddy serves; the web service answers it only on the loopback address, where Caddy passes the requests. The users
|
||||
of the router's directory sign in, with the password and the TOTP code or with a passkey (vpnrouter_passkeys), to its
|
||||
clients, the service accounts with redirect URIs (vpnrouter_users.client_settings): a client's ID is its name and its
|
||||
secret its password, sent with HTTP Basic authentication or in the body of the token request. A client with allowed
|
||||
groups admits only their members; a service account never signs in as a user.
|
||||
|
||||
GET /.well-known/openid-configuration the discovery document
|
||||
GET /oidc/jwks the public key the ID tokens are signed with
|
||||
GET, POST /oidc/authorize the authorization code flow, PKCE with S256 required; the sign-in page while
|
||||
the browser has no session, or with prompt=login or an older one than max_age
|
||||
POST /oidc/login the sign-in page's form: the name, the password and the code, or a passkey
|
||||
POST /oidc/passkey begin a sign-in with a passkey: {"id", "options"}
|
||||
POST /oidc/token exchange a code or a refresh token for tokens
|
||||
GET, POST /oidc/userinfo the claims of the user an access token was issued for
|
||||
GET /oidc/logout end the session, then go to a post-logout redirect URI of the client
|
||||
|
||||
ID tokens are RS256 JWTs signed with KEY, an RSA key the web service makes on its first run on the router, named by
|
||||
the RFC 7638 thumbprint of its public key. sub is the entryUUID of the user's entry, so a user added again under the
|
||||
same name is another subject. The scopes: openid, required; profile, preferred_username and the names; email; groups,
|
||||
the names of the user's groups; their claims are in the ID token and the userinfo answer. A code is valid
|
||||
CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token REFRESH_SECONDS, used once. A session,
|
||||
the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes, tokens and sessions are kept in the
|
||||
database of vpnrouter_auth as their SHA-256 (vpnrouter_auth.OIDC_SQL); vpnrouter_auth.revoke removes a user's. A
|
||||
client is registered by an administrator, so a user is not asked for consent.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import collections
|
||||
import contextlib
|
||||
import datetime
|
||||
import hashlib
|
||||
import http.cookies
|
||||
import json
|
||||
import pathlib
|
||||
import secrets
|
||||
import time
|
||||
import types
|
||||
import urllib.parse
|
||||
|
||||
import jinja2
|
||||
import jwt
|
||||
import ldap
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from oauthlib.common import add_params_to_uri
|
||||
from oauthlib.oauth2 import AccessDeniedError, FatalClientError, InvalidRequestError, OAuth2Error
|
||||
from oauthlib.openid import RequestValidator, Server
|
||||
from oauthlib.openid.connect.core.exceptions import LoginRequired
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_ldap
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
|
||||
KEY = pathlib.Path('/etc/vpn-router/oidc.key')
|
||||
KEY_BITS = 3072
|
||||
|
||||
CODE_SECONDS = 60
|
||||
ACCESS_SECONDS = 300
|
||||
REFRESH_SECONDS = 8 * 3600
|
||||
SESSION_SECONDS = 8 * 3600
|
||||
|
||||
COOKIE = 'vpn_router_oidc'
|
||||
|
||||
#: The claims of each scope but openid, which gives sub.
|
||||
SCOPE_CLAIMS = {
|
||||
'profile': ('preferred_username', 'name', 'given_name', 'family_name'),
|
||||
'email': ('email',),
|
||||
'groups': ('groups',),
|
||||
}
|
||||
SCOPES = ('openid', *SCOPE_CLAIMS)
|
||||
|
||||
#: The authentication methods (RFC 8176) of a sign-in with a password, with a password and a TOTP code, and with a
|
||||
#: passkey, which verifies the user.
|
||||
PASSWORD_AMR = ['pwd']
|
||||
OTP_AMR = ['pwd', 'otp']
|
||||
PASSKEY_AMR = ['hwk', 'user']
|
||||
|
||||
TEMPLATE = 'oidc-page.html.j2'
|
||||
|
||||
#: The headers of the pages: no framing, and nothing loaded but from the page itself and the router.
|
||||
PAGE_HEADERS = [('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
|
||||
"connect-src 'self'; frame-ancestors 'none'")]
|
||||
|
||||
#: The headers of oauthlib's answers that the web service sets itself.
|
||||
OWN_HEADERS = ('content-type', 'cache-control', 'content-length')
|
||||
|
||||
#: A user's sign-in at the OpenID Provider: the user name, when, a datetime, and how (amr).
|
||||
Session = collections.namedtuple('Session', 'name auth_time amr')
|
||||
|
||||
_pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLATE_DIR)), autoescape=True,
|
||||
undefined=jinja2.StrictUndefined)
|
||||
|
||||
|
||||
def ensure_key():
|
||||
"""Make the signing key when it is missing."""
|
||||
if KEY.is_file() and KEY.stat().st_size:
|
||||
return
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_BITS)
|
||||
KEY.parent.mkdir(parents=True, exist_ok=True)
|
||||
KEY.touch(mode=0o600)
|
||||
KEY.chmod(0o600)
|
||||
KEY.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption()))
|
||||
|
||||
|
||||
def private_key():
|
||||
return serialization.load_pem_private_key(KEY.read_bytes(), None)
|
||||
|
||||
|
||||
def public_jwk(key):
|
||||
"""The JWK of the key's public key, its kid the RFC 7638 thumbprint."""
|
||||
numbers = key.public_key().public_numbers()
|
||||
jwk = {'e': numbers.e, 'kty': 'RSA', 'n': numbers.n}
|
||||
jwk = {name: vpnrouter_auth.b64encode(value.to_bytes((value.bit_length() + 7) // 8, 'big')) if isinstance(value, int)
|
||||
else value for name, value in jwk.items()}
|
||||
thumbprint = hashlib.sha256(json.dumps(jwk, sort_keys=True, separators=(',', ':')).encode()).digest()
|
||||
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
|
||||
|
||||
|
||||
def sign(claims):
|
||||
key = private_key()
|
||||
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']})
|
||||
|
||||
|
||||
def discovery(issuer):
|
||||
"""The discovery document of OpenID Connect Discovery 1.0."""
|
||||
return {
|
||||
'issuer': issuer,
|
||||
'authorization_endpoint': f'{issuer}/oidc/authorize',
|
||||
'token_endpoint': f'{issuer}/oidc/token',
|
||||
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
|
||||
'jwks_uri': f'{issuer}/oidc/jwks',
|
||||
'end_session_endpoint': f'{issuer}/oidc/logout',
|
||||
'scopes_supported': list(SCOPES),
|
||||
'response_types_supported': ['code'],
|
||||
'response_modes_supported': ['query'],
|
||||
'grant_types_supported': ['authorization_code', 'refresh_token'],
|
||||
'subject_types_supported': ['public'],
|
||||
'id_token_signing_alg_values_supported': ['RS256'],
|
||||
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
|
||||
'code_challenge_methods_supported': ['S256'],
|
||||
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash',
|
||||
*(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
|
||||
}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def directory():
|
||||
"""A connection to the router's directory as the user manager account, for reading."""
|
||||
connection = vpnrouter_ldap.manager()
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.unbind_s()
|
||||
|
||||
|
||||
def client_of(client_id):
|
||||
"""The client of the ID, a namespace of its vpnrouter_users.summary with client_id, when it is an enabled service
|
||||
account with redirect URIs; else None."""
|
||||
if not isinstance(client_id, str):
|
||||
return None
|
||||
with directory() as connection:
|
||||
try:
|
||||
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id))
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
if not user['service'] or not user['enabled'] or not user['redirect_uris']:
|
||||
return None
|
||||
return types.SimpleNamespace(client_id=client_id, **user)
|
||||
|
||||
|
||||
def subject(name, client):
|
||||
"""The claims of every scope of the user, with sub, when the user may sign in to the client: an enabled, unlocked
|
||||
user who is not a service account, a member of one of the client's allowed groups when it has any; else None."""
|
||||
with directory() as connection:
|
||||
try:
|
||||
attributes = vpnrouter_users.entry(connection, name)
|
||||
except vpnrouter_users.Failure:
|
||||
return None
|
||||
user = vpnrouter_users.summary(attributes)
|
||||
groups = sorted(vpnrouter_users.groups_of(connection, name))
|
||||
uuid = connection.search_s(vpnrouter_users.dn_of(name), ldap.SCOPE_BASE, attrlist=['entryUUID'])[0][1]['entryUUID']
|
||||
if user['service'] or not user['enabled'] or user['locked']:
|
||||
return None
|
||||
if client.allowed_groups and not set(client.allowed_groups) & set(groups):
|
||||
return None
|
||||
full = ' '.join(part for part in (user['first_name'], user['last_name']) if part)
|
||||
claims = {'sub': uuid[0].decode(), 'preferred_username': name, 'name': full or None, 'given_name': user['first_name'],
|
||||
'family_name': user['last_name'], 'email': user['email'], 'groups': groups}
|
||||
return {claim: value for claim, value in claims.items() if value is not None}
|
||||
|
||||
|
||||
def claims_of(person, scopes):
|
||||
"""The claims of the scopes of a subject's claims (subject)."""
|
||||
return {'sub': person['sub'], **{claim: person[claim] for scope in scopes for claim in SCOPE_CLAIMS.get(scope, ())
|
||||
if claim in person}}
|
||||
|
||||
|
||||
def cookie_value(cookies):
|
||||
"""The value of the session cookie in a Cookie header, or ''."""
|
||||
try:
|
||||
morsel = http.cookies.SimpleCookie(cookies or '').get(COOKIE)
|
||||
except http.cookies.CookieError:
|
||||
return ''
|
||||
return morsel.value if morsel is not None else ''
|
||||
|
||||
|
||||
def session_of(cfg, cookies):
|
||||
"""The unexpired Session the session cookie in a Cookie header names, or None."""
|
||||
value = cookie_value(cookies)
|
||||
if not value:
|
||||
return None
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
row = connection.execute('SELECT * FROM oidc_session_get(%s)', (vpnrouter_auth.digest(value),)).fetchone()
|
||||
return Session(*row) if row else None
|
||||
|
||||
|
||||
class Validator(RequestValidator):
|
||||
"""The checks and the storage of oauthlib's server for the router's clients, users and database.
|
||||
|
||||
Only the authorization code grant with PKCE S256 and the refresh token grant are allowed. The values of a code, a
|
||||
token or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg):
|
||||
super().__init__()
|
||||
self.cfg = cfg
|
||||
|
||||
def fetch(self, sql, values):
|
||||
with vpnrouter_auth.database(self.cfg) as connection:
|
||||
return connection.execute(sql, values).fetchone()
|
||||
|
||||
def call(self, *statements):
|
||||
with vpnrouter_auth.database(self.cfg) as connection:
|
||||
for sql, values in statements:
|
||||
connection.execute(sql, values)
|
||||
|
||||
def session(self, request):
|
||||
return session_of(self.cfg, request.headers.get('Cookie'))
|
||||
|
||||
# Clients.
|
||||
|
||||
def validate_client_id(self, client_id, request, *args, **kwargs):
|
||||
request.client = client_of(client_id)
|
||||
return request.client is not None
|
||||
|
||||
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
|
||||
return redirect_uri in request.client.redirect_uris
|
||||
|
||||
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs):
|
||||
return response_type == 'code'
|
||||
|
||||
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
|
||||
return 'openid' in scopes and set(scopes) <= set(SCOPES)
|
||||
|
||||
def get_default_scopes(self, client_id, request, *args, **kwargs):
|
||||
return ['openid']
|
||||
|
||||
def is_pkce_required(self, client_id, request):
|
||||
return True
|
||||
|
||||
def authenticate_client(self, request, *args, **kwargs):
|
||||
"""Check the client's secret, the password of its service account, given with HTTP Basic authentication, its
|
||||
ID and secret form-encoded (RFC 6749 2.3.1), or in the body."""
|
||||
name, secret = request.client_id, request.client_secret
|
||||
header = request.headers.get('Authorization', '')
|
||||
if header[:6].lower() == 'basic ':
|
||||
try:
|
||||
name, _, secret = base64.b64decode(header[6:].strip(), validate=True).decode().partition(':')
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
|
||||
client = client_of(name)
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
vpnrouter_users.login(name, secret, None)
|
||||
except vpnrouter_users.Failure:
|
||||
return False
|
||||
request.client = client
|
||||
return True
|
||||
|
||||
def authenticate_client_id(self, client_id, request, *args, **kwargs):
|
||||
return False
|
||||
|
||||
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
|
||||
return grant_type in ('authorization_code', 'refresh_token')
|
||||
|
||||
def validate_user(self, username, password, client, request, *args, **kwargs):
|
||||
return False
|
||||
|
||||
# Codes.
|
||||
|
||||
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_save(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(code['code']), client_id, request.user, request.redirect_uri, request.scopes,
|
||||
request.nonce, request.code_challenge, request.code_challenge_method, request.auth_time, request.amr,
|
||||
CODE_SECONDS)))
|
||||
|
||||
def code(self, code):
|
||||
row = self.fetch('SELECT * FROM oidc_code_get(%s)', (vpnrouter_auth.digest(code),)) if isinstance(code, str) else None
|
||||
return dict(zip(('client_id', 'name', 'redirect_uri', 'scopes', 'nonce', 'code_challenge', 'code_challenge_method',
|
||||
'auth_time', 'amr'), row)) if row else None
|
||||
|
||||
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
|
||||
found = self.code(code)
|
||||
return found['scopes'] if found else []
|
||||
|
||||
def validate_code(self, client_id, code, client, request, *args, **kwargs):
|
||||
found = self.code(code)
|
||||
if found is None or found['client_id'] != client_id:
|
||||
return False
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
|
||||
request.oidc_code, request.oidc_person = found, person
|
||||
return True
|
||||
|
||||
def get_code_challenge(self, code, request):
|
||||
return request.oidc_code['code_challenge']
|
||||
|
||||
def get_code_challenge_method(self, code, request):
|
||||
return request.oidc_code['code_challenge_method']
|
||||
|
||||
def confirm_redirect_uri(self, client_id, code, redirect_uri, client, request, *args, **kwargs):
|
||||
return redirect_uri == request.oidc_code['redirect_uri']
|
||||
|
||||
def get_authorization_code_nonce(self, client_id, code, redirect_uri, request):
|
||||
return request.oidc_code['nonce']
|
||||
|
||||
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
|
||||
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
|
||||
|
||||
# Tokens.
|
||||
|
||||
def token(self, token, kind):
|
||||
row = self.fetch('SELECT * FROM oidc_token_get(%s, %s)', (vpnrouter_auth.digest(token), kind)) \
|
||||
if isinstance(token, str) and token else None
|
||||
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr'), row)) if row else None
|
||||
|
||||
def save_bearer_token(self, token, request, *args, **kwargs):
|
||||
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up."""
|
||||
values = (request.client.client_id, request.user, request.scopes, request.auth_time, request.amr)
|
||||
statements = [('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['access_token']), 'access', *values, ACCESS_SECONDS))]
|
||||
if token.get('refresh_token'):
|
||||
statements.append(('CALL oidc_token_save(%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||
(vpnrouter_auth.digest(token['refresh_token']), 'refresh', *values, REFRESH_SECONDS)))
|
||||
if request.grant_type == 'refresh_token':
|
||||
statements.append(('CALL oidc_token_delete(%s)', (vpnrouter_auth.digest(request.refresh_token),)))
|
||||
self.call(*statements)
|
||||
|
||||
def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs):
|
||||
found = self.token(refresh_token, 'refresh')
|
||||
if found is None or found['client_id'] != client.client_id:
|
||||
return False
|
||||
person = subject(found['name'], client)
|
||||
if person is None:
|
||||
return False
|
||||
request.user, request.auth_time, request.amr = found['name'], found['auth_time'], found['amr']
|
||||
request.oidc_scopes, request.oidc_person = found['scopes'], person
|
||||
return True
|
||||
|
||||
def get_original_scopes(self, refresh_token, request, *args, **kwargs):
|
||||
return request.oidc_scopes
|
||||
|
||||
def validate_bearer_token(self, token, scopes, request):
|
||||
found = self.token(token, 'access')
|
||||
if found is None or not set(scopes) <= set(found['scopes']):
|
||||
return False
|
||||
client = client_of(found['client_id'])
|
||||
person = subject(found['name'], client) if client else None
|
||||
if person is None:
|
||||
return False
|
||||
request.client, request.user, request.oidc_scopes, request.oidc_person = client, found['name'], found['scopes'], person
|
||||
return True
|
||||
|
||||
def get_userinfo_claims(self, request):
|
||||
return claims_of(request.oidc_person, request.oidc_scopes)
|
||||
|
||||
# OpenID Connect.
|
||||
|
||||
def finalize_id_token(self, id_token, token, token_handler, request):
|
||||
now = int(time.time())
|
||||
id_token.update(claims_of(request.oidc_person, request.scopes))
|
||||
id_token.update({'iss': vpnrouter.oidc_issuer(self.cfg), 'exp': now + ACCESS_SECONDS,
|
||||
'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
|
||||
return sign(id_token)
|
||||
|
||||
def validate_silent_login(self, request):
|
||||
session = self.session(request)
|
||||
return session is not None and subject(session.name, request.client) is not None
|
||||
|
||||
def validate_silent_authorization(self, request):
|
||||
return True
|
||||
|
||||
def validate_user_match(self, id_token_hint, scopes, claims, request):
|
||||
"""Whether the user of the session, when there is one, is the subject of an ID token the router signed."""
|
||||
session = self.session(request) if id_token_hint else None
|
||||
if session is None:
|
||||
return True
|
||||
try:
|
||||
hint = jwt.decode(id_token_hint, private_key().public_key(), algorithms=['RS256'],
|
||||
options={'verify_exp': False, 'verify_aud': False})
|
||||
except jwt.InvalidTokenError:
|
||||
return False
|
||||
person = subject(session.name, request.client)
|
||||
return person is not None and hint.get('sub') == person['sub']
|
||||
|
||||
|
||||
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=()):
|
||||
"""An HTML page of TEMPLATE: a message, an error, and with form the sign-in form for the authorization request."""
|
||||
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
|
||||
name=name, issuer=vpnrouter.oidc_issuer(cfg))
|
||||
return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
|
||||
|
||||
|
||||
def document(status, value, headers=()):
|
||||
return status, 'application/json', json.dumps(value, indent=2).encode() + b'\n', list(headers)
|
||||
|
||||
|
||||
def relayed(headers, body, status):
|
||||
"""An answer of oauthlib's server, without the headers the web service sets."""
|
||||
kind = next((value for name, value in headers.items() if name.lower() == 'content-type'), 'application/json')
|
||||
return status, kind, (body or '').encode(), [(name, value) for name, value in headers.items()
|
||||
if name.lower() not in OWN_HEADERS]
|
||||
|
||||
|
||||
def redirect(location, headers=()):
|
||||
return 302, 'text/plain; charset=utf-8', b'', [('Location', location), *headers]
|
||||
|
||||
|
||||
def cookie(value, seconds):
|
||||
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
|
||||
|
||||
|
||||
def authorize(server, cfg, params, headers, fresh=None, name='', error=''):
|
||||
"""Answer an authorization request, the query or the form params: the sign-in page while the browser has no
|
||||
session, or with prompt=login or max_age older than the session, else a redirect to the client with a code.
|
||||
|
||||
fresh is the session of a sign-in just made, which counts whatever prompt and max_age say; the redirect then sets
|
||||
its cookie, fresh.cookie. error is why a sign-in failed: the sign-in page shows it, filled with the name.
|
||||
"""
|
||||
uri = f'{vpnrouter.oidc_issuer(cfg)}/oidc/authorize?{params}'
|
||||
try:
|
||||
scopes, info = server.validate_authorization_request(uri, 'GET', None, headers)
|
||||
except FatalClientError as error:
|
||||
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||
except OAuth2Error as error:
|
||||
if not error.redirect_uri:
|
||||
return page(error.status_code, cfg, 'The sign-in request is refused', error=error.description or error.error)
|
||||
return redirect(error.in_uri(error.redirect_uri))
|
||||
request = info['request']
|
||||
title = f'Sign in to {request.client.display_name or request.client.client_id}'
|
||||
if info.get('code_challenge_method') != 'S256':
|
||||
return redirect(InvalidRequestError(description='PKCE with code_challenge_method S256 is required',
|
||||
request=request).in_uri(request.redirect_uri))
|
||||
if error:
|
||||
return page(401, cfg, title, error=error, form=True, request=params, name=name)
|
||||
session = fresh.session if fresh else session_of(cfg, headers.get('Cookie'))
|
||||
max_age = getattr(request, 'max_age', None)
|
||||
stale = session is not None and isinstance(max_age, str) and max_age.isdigit() and \
|
||||
time.time() - session.auth_time.timestamp() > int(max_age)
|
||||
prompt = info.get('prompt') or set()
|
||||
if session is None or (not fresh and ('login' in prompt or stale)):
|
||||
if 'none' in prompt:
|
||||
return redirect(LoginRequired(request=request).in_uri(request.redirect_uri))
|
||||
return page(200, cfg, title, form=True, request=params, name=name)
|
||||
if subject(session.name, request.client) is None:
|
||||
if fresh:
|
||||
return redirect(AccessDeniedError(description=f'{session.name} may not sign in to this client',
|
||||
request=request).in_uri(request.redirect_uri), [fresh.cookie])
|
||||
return page(403, cfg, title, error=f'{session.name} may not sign in here; sign in as another user.', form=True,
|
||||
request=params)
|
||||
answer, _, _ = server.create_authorization_response(uri, 'GET', None, headers, scopes, credentials={
|
||||
'user': session.name, 'auth_time': session.auth_time, 'amr': session.amr})
|
||||
return redirect(answer['Location'], [fresh.cookie] if fresh else [])
|
||||
|
||||
|
||||
def login(server, cfg, form, headers):
|
||||
"""Sign a user in with the sign-in page's form, start the session and go on with its authorization request."""
|
||||
params = form.get('request', '')
|
||||
name = form.get('name', '').strip()
|
||||
try:
|
||||
if form.get('passkey_id'):
|
||||
with vpnrouter_users.session() as connection:
|
||||
name = vpnrouter_passkeys.login_complete(connection, form['passkey_id'], json.loads(form.get('credential', '')))
|
||||
amr = PASSKEY_AMR
|
||||
else:
|
||||
code = form.get('code', '').strip()
|
||||
vpnrouter_users.login(name, form.get('password', ''), code or None)
|
||||
with directory() as connection:
|
||||
if vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
|
||||
raise vpnrouter_users.Failure('the name, the password or the code is wrong', 5)
|
||||
amr = OTP_AMR if code else PASSWORD_AMR
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return authorize(server, cfg, params, headers, name=name, error=str(failure))
|
||||
except ValueError:
|
||||
return authorize(server, cfg, params, headers, name=name, error='the passkey is not accepted')
|
||||
value = secrets.token_urlsafe(32)
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_session_save(%s, %s, %s, %s)', (vpnrouter_auth.digest(value), name, amr, SESSION_SECONDS))
|
||||
fresh = types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
|
||||
cookie=cookie(value, SESSION_SECONDS))
|
||||
return authorize(server, cfg, params, headers, fresh=fresh)
|
||||
|
||||
|
||||
def logout(cfg, query, headers):
|
||||
"""End the session the cookie names, then go to post_logout_redirect_uri, with state, when it is a post-logout
|
||||
redirect URI of the client that client_id or the audience of id_token_hint names; else show that the user is
|
||||
signed out."""
|
||||
values = dict(urllib.parse.parse_qsl(query))
|
||||
value = cookie_value(headers.get('Cookie'))
|
||||
if value:
|
||||
with vpnrouter_auth.database(cfg) as connection:
|
||||
connection.execute('CALL oidc_session_delete(%s)', (vpnrouter_auth.digest(value),))
|
||||
client_id = values.get('client_id')
|
||||
if not client_id and values.get('id_token_hint'):
|
||||
try:
|
||||
audience = jwt.decode(values['id_token_hint'], private_key().public_key(), algorithms=['RS256'],
|
||||
options={'verify_exp': False, 'verify_aud': False}).get('aud')
|
||||
except jwt.InvalidTokenError:
|
||||
audience = None
|
||||
client_id = audience[0] if isinstance(audience, list) and audience else audience
|
||||
client = client_of(client_id)
|
||||
target = values.get('post_logout_redirect_uri')
|
||||
expired = cookie('', 0)
|
||||
if client is not None and target in client.post_logout_redirect_uris:
|
||||
return redirect(add_params_to_uri(target, [('state', values['state'])]) if values.get('state') else target, [expired])
|
||||
return page(200, cfg, 'Signed out', message='You are signed out of the router.', headers=[expired])
|
||||
|
||||
|
||||
def answer(method, target, headers, body):
|
||||
"""Answer a request of the OpenID Provider: the HTTP status, the content type, the body and [(header, value)]; 404
|
||||
while oidc.enabled is false. target is the path with the query, headers a mapping of the request's headers and
|
||||
body its bytes; HEAD is answered as GET."""
|
||||
path, _, query = target.partition('?')
|
||||
method = 'GET' if method == 'HEAD' else method
|
||||
try:
|
||||
cfg = vpnrouter.load_config()
|
||||
if not cfg['oidc']['enabled']:
|
||||
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||
issuer = vpnrouter.oidc_issuer(cfg)
|
||||
headers = dict(headers.items())
|
||||
text = body.decode('utf-8', 'replace') if body else ''
|
||||
server = Server(Validator(cfg), token_expires_in=ACCESS_SECONDS)
|
||||
if (method, path) == ('GET', '/.well-known/openid-configuration'):
|
||||
return document(200, discovery(issuer))
|
||||
if (method, path) == ('GET', '/oidc/jwks'):
|
||||
return document(200, {'keys': [public_jwk(private_key())]})
|
||||
if path == '/oidc/authorize' and method in ('GET', 'POST'):
|
||||
return authorize(server, cfg, query if method == 'GET' else text, headers)
|
||||
if (method, path) == ('POST', '/oidc/login'):
|
||||
return login(server, cfg, dict(urllib.parse.parse_qsl(text)), headers)
|
||||
if (method, path) == ('POST', '/oidc/passkey'):
|
||||
return document(200, vpnrouter_passkeys.login_begin())
|
||||
if (method, path) == ('POST', '/oidc/token'):
|
||||
return relayed(*server.create_token_response(f'{issuer}{path}', 'POST', text, headers))
|
||||
if path == '/oidc/userinfo' and method in ('GET', 'POST'):
|
||||
return relayed(*server.create_userinfo_response(f'{issuer}{target}', method, text or None, headers))
|
||||
if (method, path) == ('GET', '/oidc/logout'):
|
||||
return logout(cfg, query, headers)
|
||||
return 404, 'text/plain; charset=utf-8', b'Not found\n', []
|
||||
except OAuth2Error as error:
|
||||
return error.status_code, 'application/json', error.json.encode(), list(error.headers.items())
|
||||
except vpnrouter_users.Failure as failure:
|
||||
return document(400 if failure.status == 2 else 500, {'error': str(failure)})
|
||||
except (ldap.LDAPError, OSError) as exc:
|
||||
return document(500, {'error': f'the OpenID Provider failed: {exc}'})
|
||||
except vpnrouter_auth.DatabaseError as exc:
|
||||
return document(500, {'error': str(exc)})
|
||||
except SystemExit as exc:
|
||||
return document(500, {'error': str(exc.code)})
|
||||
except ValueError as exc:
|
||||
return document(400, {'error': 'invalid_request', 'error_description': str(exc)})
|
||||
@@ -0,0 +1,174 @@
|
||||
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
|
||||
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 HTTPS addresses on that name.
|
||||
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
|
||||
@@ -15,10 +15,16 @@ internal service accounts are those the router creates and manages for its own s
|
||||
included (internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||
Users exist whether or not a P2S system is enabled.
|
||||
|
||||
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_oidc), its name the client ID and
|
||||
its password the client secret (client_settings): redirect_uris, where the provider sends a user back after a sign-in,
|
||||
post_logout_redirect_uris, after a sign-out, and allowed_groups, the groups whose members may sign in to it, every
|
||||
user who is not a service account when there are none.
|
||||
|
||||
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
|
||||
optional description and at least one member; a group cannot be empty, so the last member
|
||||
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.
|
||||
cannot leave it and a user who is the last member of a group cannot be removed. ADMIN_GROUP grants the management of
|
||||
the router, and the allowed groups of the OpenID Provider's clients who may sign in to them; renaming a group renames it
|
||||
in those, and a group a client allows cannot be removed.
|
||||
|
||||
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
|
||||
@@ -30,13 +36,17 @@ vpnrouter_ldap.TOTP_PARAMS. Enrolment stores nothing until a code of the new sec
|
||||
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.
|
||||
|
||||
A member of ADMIN_GROUP 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 for an enrolled user. The
|
||||
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.
|
||||
@@ -53,10 +63,12 @@ import contextlib
|
||||
import fcntl
|
||||
import grp
|
||||
import ipaddress
|
||||
import json
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import ldap
|
||||
import ldap.filter
|
||||
@@ -69,6 +81,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.
|
||||
@@ -83,7 +97,15 @@ 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', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
|
||||
'uidNumber', 'vpnRouterWireGuardPeer']
|
||||
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
|
||||
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup']
|
||||
#: The keys of the URIs of an OpenID Provider client and their LDAP attributes.
|
||||
CLIENT_URIS = (('redirect_uris', 'vpnRouterRedirectUri'), ('post_logout_redirect_uris', 'vpnRouterPostLogoutRedirectUri'))
|
||||
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
|
||||
CLIENT_VALUES_MAX = 16
|
||||
URI_MAX = 1024
|
||||
#: The hosts a client's URI may name over plain HTTP: those of the system it runs on.
|
||||
LOOPBACK_HOSTS = ('localhost', '127.0.0.1', '::1')
|
||||
#: The most WireGuard peers of one user.
|
||||
WIREGUARD_PEERS_MAX = 16
|
||||
#: The vpnRouterScope of a service account.
|
||||
@@ -130,9 +152,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):
|
||||
@@ -186,6 +210,47 @@ def labels(values):
|
||||
return found
|
||||
|
||||
|
||||
def client_settings(connection, values):
|
||||
"""The OpenID Provider client settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
|
||||
allowed_groups}), checked, as {LDAP attribute: [value, ...]}; a key whose value is None is not given, [] means none.
|
||||
|
||||
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists. Each
|
||||
list has at most CLIENT_VALUES_MAX values.
|
||||
"""
|
||||
found = {}
|
||||
for key, attribute in CLIENT_URIS:
|
||||
uris = values.get(key)
|
||||
if uris is None:
|
||||
continue
|
||||
if not isinstance(uris, list) or len(uris) > CLIENT_VALUES_MAX or not all(isinstance(uri, str) for uri in uris):
|
||||
raise Failure(f'{key} must be a list of at most {CLIENT_VALUES_MAX} URIs', 2)
|
||||
for uri in uris:
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(uri)
|
||||
host = parts.hostname
|
||||
except ValueError:
|
||||
host = None
|
||||
if len(uri) > URI_MAX or not host or parts.fragment or any(character.isspace() for character in uri) or \
|
||||
not (parts.scheme == 'https' or parts.scheme == 'http' and host in LOOPBACK_HOSTS):
|
||||
raise Failure(f'{uri!r} is not an https URI, or an http URI of localhost, without a fragment and of at '
|
||||
f'most {URI_MAX} characters', 2)
|
||||
found[attribute] = [uri.encode() for uri in dict.fromkeys(uris)]
|
||||
allowed = values.get('allowed_groups')
|
||||
if allowed is not None:
|
||||
if not isinstance(allowed, list) or len(allowed) > CLIENT_VALUES_MAX:
|
||||
raise Failure(f'allowed_groups must be a list of at most {CLIENT_VALUES_MAX} group names', 2)
|
||||
for group in allowed:
|
||||
group_entry(connection, group)
|
||||
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
|
||||
return found
|
||||
|
||||
|
||||
def refuse_client_settings(name, values):
|
||||
"""Refuse OpenID Provider client settings for a user who is not a service account."""
|
||||
if any(values.get(key) is not None for key in ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups')):
|
||||
raise Failure(f'{name} is not a service account; only a service account is a client of the OpenID Provider', 2)
|
||||
|
||||
|
||||
def dn_of(name):
|
||||
return f'uid={name},{vpnrouter_ldap.USERS}'
|
||||
|
||||
@@ -237,7 +302,8 @@ def internal_accounts():
|
||||
def summary(attributes):
|
||||
"""A user as the answers show it; a person attribute or a label the entry does not have is None.
|
||||
|
||||
internal is whether it is an internal service account (internal_accounts).
|
||||
internal is whether it is an internal service account (internal_accounts). A service account also has its OpenID
|
||||
Provider client settings (client_settings), allowed_groups as group names.
|
||||
"""
|
||||
name = attributes['uid'][0].decode()
|
||||
lock = attributes.get('pwdAccountLockedTime', [None])[0]
|
||||
@@ -252,10 +318,17 @@ def summary(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)
|
||||
user[key] = values[0].decode() if values else None
|
||||
if user['service']:
|
||||
for key, attribute in CLIENT_URIS:
|
||||
user[key] = [value.decode() for value in attributes.get(attribute, [])]
|
||||
suffix = f',{vpnrouter_ldap.GROUPS}'
|
||||
user['allowed_groups'] = sorted(value.decode()[3:-len(suffix)] for value in attributes.get('vpnRouterAllowedGroup', [])
|
||||
if value.decode().startswith('cn=') and value.decode().endswith(suffix))
|
||||
return user
|
||||
|
||||
|
||||
@@ -284,6 +357,20 @@ def peers_of(attributes):
|
||||
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.
|
||||
|
||||
@@ -349,8 +436,8 @@ def free_uid(connection):
|
||||
|
||||
|
||||
def console(connection, name, enabled, account=None):
|
||||
"""Switch the console login of a member of ADMIN_GROUP on, as the local account given or, without one, as the
|
||||
user's own posixAccount, or off.
|
||||
"""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
|
||||
@@ -373,6 +460,8 @@ def console(connection, name, enabled, account=None):
|
||||
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)
|
||||
@@ -440,14 +529,19 @@ 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, its labels (labels) and its OpenID Provider client settings (client_settings); 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:
|
||||
raise Failure('a service account has no first or last name', 2)
|
||||
client = {}
|
||||
if service:
|
||||
details.update({attribute: value for attribute, value in labels(values).items() if value})
|
||||
client = {attribute: found for attribute, found in client_settings(connection, values).items() if found}
|
||||
else:
|
||||
refuse_client_settings(name, values)
|
||||
if vpn_user and len(details) != len(PERSON):
|
||||
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
|
||||
check_password(password)
|
||||
@@ -462,6 +556,7 @@ def add(connection, name, password, values, service=False):
|
||||
if service:
|
||||
record['vpnRouterScope'] = [SERVICE_SCOPE.encode()]
|
||||
record.update({attribute: [value.encode()] for attribute, value in details.items()})
|
||||
record.update(client)
|
||||
try:
|
||||
connection.add_s(dn_of(name), ldap.modlist.addModlist(record))
|
||||
except ldap.ALREADY_EXISTS:
|
||||
@@ -485,6 +580,7 @@ def update(connection, name, values):
|
||||
current = entry(connection, name)
|
||||
if service(current):
|
||||
return update_service(connection, name, values)
|
||||
refuse_client_settings(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)
|
||||
@@ -525,16 +621,18 @@ def apply_wireguard():
|
||||
|
||||
|
||||
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."""
|
||||
"""Change any of the e-mail address, the labels (labels) and the OpenID Provider client settings (client_settings) of
|
||||
a service account that values gives; '' removes the e-mail address or a label, [] the values of a client setting."""
|
||||
if not service(entry(connection, name)):
|
||||
raise Failure(f'{name} is not a service account', 3)
|
||||
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
|
||||
changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in client_settings(connection, values).items()]
|
||||
email = values.get('email')
|
||||
if email is not None:
|
||||
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
|
||||
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
|
||||
if not changes:
|
||||
raise Failure('give email, display_name or description', 2)
|
||||
raise Failure('give email, display_name, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2)
|
||||
connection.modify_s(dn_of(name), changes)
|
||||
return summary(entry(connection, name))
|
||||
|
||||
@@ -579,12 +677,13 @@ def unlock(connection, name):
|
||||
|
||||
|
||||
def login(name, password, code):
|
||||
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user.
|
||||
"""Check the user's password, and the TOTP code of an enrolled user, with a bind as the user; a service account may be
|
||||
named by a GUID.
|
||||
|
||||
A wrong name, password or code, a locked or a disabled user give the same Failure, status 5.
|
||||
"""
|
||||
refused = Failure('the name, the password or the code is wrong', 5)
|
||||
if not isinstance(name, str) or not NAME.match(name) or not isinstance(password, str) or not password:
|
||||
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)) or not isinstance(password, str) or not password:
|
||||
raise refused
|
||||
if code is not None and code != '' and (not isinstance(code, str) or not CODE.match(code)):
|
||||
raise refused
|
||||
@@ -639,13 +738,15 @@ 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))
|
||||
|
||||
|
||||
@@ -761,6 +862,13 @@ def groups_of(connection, name):
|
||||
return {attributes['cn'][0].decode(): member_names(attributes) for _, attributes in found}
|
||||
|
||||
|
||||
def allowing(connection, name):
|
||||
"""The names of the OpenID Provider's clients whose allowed groups have the group, sorted."""
|
||||
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL,
|
||||
f'(vpnRouterAllowedGroup={ldap.filter.escape_filter_chars(group_dn(name))})', ['uid'])
|
||||
return sorted(attributes['uid'][0].decode() for _, attributes in found)
|
||||
|
||||
|
||||
def list_groups(connection):
|
||||
found = connection.search_s(vpnrouter_ldap.GROUPS, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)', GROUP_ATTRIBUTES)
|
||||
return {'groups': sorted((group_summary(attributes) for _, attributes in found), key=lambda group: group['name'])}
|
||||
@@ -787,7 +895,8 @@ 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. Renaming ADMIN_GROUP
|
||||
switches the console login of its members off."""
|
||||
switches the console login of its members off; renaming a group a client of the OpenID Provider allows renames it
|
||||
there."""
|
||||
if new_name is None and description is None:
|
||||
raise Failure('give a new name or a description', 2)
|
||||
members = member_names(group_entry(connection, name))
|
||||
@@ -802,6 +911,9 @@ 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)
|
||||
for client in allowing(connection, name):
|
||||
connection.modify_s(dn_of(client), [(ldap.MOD_DELETE, 'vpnRouterAllowedGroup', [group_dn(name).encode()]),
|
||||
(ldap.MOD_ADD, 'vpnRouterAllowedGroup', [group_dn(new_name).encode()])])
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
name = new_name
|
||||
@@ -809,8 +921,12 @@ def group_update(connection, name, new_name=None, description=None):
|
||||
|
||||
|
||||
def group_remove(connection, name):
|
||||
"""Remove the group; removing ADMIN_GROUP switches the console login of its members off."""
|
||||
"""Remove the group, unless a client of the OpenID Provider allows it; removing ADMIN_GROUP switches the console login
|
||||
of its members off."""
|
||||
members = member_names(group_entry(connection, name))
|
||||
clients = allowing(connection, name)
|
||||
if clients:
|
||||
raise Failure(f'group {name} is an allowed group of the clients {", ".join(clients)}; take it out of them first', 2)
|
||||
connection.delete_s(group_dn(name))
|
||||
if name == ADMIN_GROUP:
|
||||
console_off(connection, members)
|
||||
|
||||
@@ -21,8 +21,8 @@ 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())
|
||||
@@ -38,25 +38,34 @@ Pages:
|
||||
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
|
||||
/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: POST signs the
|
||||
user in again for a one-time ticket (see shell_ticket()), GET with it is a WebSocket
|
||||
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, refresh and sign-out (see auth())
|
||||
/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())
|
||||
/logs the last lines of the journal of the router's units and of the files web.log_files lists
|
||||
(see logs())
|
||||
/postgresql
|
||||
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
|
||||
postgresql_databases()); /config has the clusters' settings
|
||||
/.well-known/openid-configuration, /oidc/...
|
||||
while oidc.enabled is true, the OpenID Provider (vpnrouter_oidc), answered only on the
|
||||
loopback address, where Caddy passes the requests of the HTTPS site; POST /oidc/login counts
|
||||
towards the limit of /auth calls
|
||||
|
||||
/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
|
||||
/status/full, /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /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, and switch a user's console login.
|
||||
These paths and /auth are not
|
||||
served (404) while the key does not exist. /auth is limited to AUTH_LIMIT calls per client
|
||||
served (404) while the key does not exist. /auth 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.
|
||||
|
||||
@@ -68,8 +77,8 @@ whether each service runs, whether Docker is installed, how many of the site-to-
|
||||
(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
|
||||
The service first initializes, without listening: it makes the signing key, the OpenID Provider's key
|
||||
(vpnrouter_oidc) and the refresh tokens' database (vpnrouter_auth), retrying every INIT_RETRY_SECONDS until all are ready and
|
||||
logging each failure. Then it listens. Its unit starts it only once wait-for-libpq has found libpq
|
||||
in the linker cache, where psycopg looks it up.
|
||||
|
||||
@@ -117,6 +126,8 @@ import vpnrouter_auth
|
||||
import vpnrouter_backup
|
||||
import vpnrouter_metrics
|
||||
import vpnrouter_net
|
||||
import vpnrouter_oidc
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_pki
|
||||
import vpnrouter_serve
|
||||
import vpnrouter_users
|
||||
@@ -148,6 +159,16 @@ REFRESH_SECONDS = 60
|
||||
POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers',
|
||||
'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone')
|
||||
|
||||
#: The units whose journal /logs/journal shows, besides the whole journal: the package's own and the services it runs.
|
||||
JOURNAL_UNITS = ('vpn-router-setup', 'vpn-router-web', 'vpn-router-metrics', 'vpn-router-pki', 'vpn-router-backup',
|
||||
'vpn-router-caddy', 'vpn-router-manager', 'caddy', 'slapd', 'nslcd', 'postgresql', 'strongswan',
|
||||
'wg-quick@wg0', 'openvpn-server@vpn-router', 'ssh')
|
||||
|
||||
#: The most lines /logs answers, the number it answers without lines, and the most bytes it reads from the end of a file.
|
||||
LOG_LINES_MAX = 1000
|
||||
LOG_LINES_DEFAULT = 200
|
||||
LOG_BYTES_MAX = 1024 * 1024
|
||||
|
||||
#: The largest request body accepted, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
|
||||
BODY_LIMIT = 65536
|
||||
|
||||
@@ -169,6 +190,9 @@ 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
|
||||
|
||||
@@ -189,6 +213,9 @@ _auth_lock = threading.Lock()
|
||||
_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."""
|
||||
@@ -523,6 +550,7 @@ 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
|
||||
|
||||
@@ -549,6 +577,7 @@ 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.
|
||||
@@ -575,20 +604,32 @@ def users(state, method, path, body):
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
def oidc_settings():
|
||||
"""{"enabled", "issuer"} of the OpenID Provider, issuer null while it is disabled."""
|
||||
cfg = vpnrouter.load_config()
|
||||
enabled = cfg['oidc']['enabled']
|
||||
return {'enabled': enabled, 'issuer': vpnrouter.oidc_issuer(cfg) if enabled else None}
|
||||
|
||||
|
||||
def service_accounts(method, path, body):
|
||||
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
|
||||
|
||||
GET /service-accounts the service accounts: {"service_accounts": [...]}
|
||||
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?}
|
||||
PUT /service-accounts/<name> change any of {"display_name", "description", "email"}; "" removes one
|
||||
GET /service-accounts the service accounts: {"service_accounts": [...], "oidc": {"enabled",
|
||||
"issuer"}}, issuer that of the OpenID Provider, null while it is disabled
|
||||
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?,
|
||||
"redirect_uris"?, "post_logout_redirect_uris"?, "allowed_groups"?}
|
||||
PUT /service-accounts/<name> change any of {"display_name", "description", "email", "redirect_uris",
|
||||
"post_logout_redirect_uris", "allowed_groups"}; "" removes one of the
|
||||
first three, [] the values of the others
|
||||
PUT /service-accounts/<name>/password set the password: {"password": ...}
|
||||
POST /service-accounts/<name>/disable lock it out
|
||||
POST /service-accounts/<name>/enable let it in again
|
||||
POST /service-accounts/<name>/unlock end a lockout after failed logins
|
||||
DELETE /service-accounts/<name> remove it
|
||||
|
||||
A name that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts),
|
||||
which the router manages, are only listed: every other request on one is refused with 403.
|
||||
A service account with redirect URIs is a client of the OpenID Provider (vpnrouter_users.client_settings). A name
|
||||
that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts), which
|
||||
the router manages, are only listed: every other request on one is refused with 403.
|
||||
"""
|
||||
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
|
||||
return 400, {'error': 'the body must be a JSON object'}
|
||||
@@ -596,10 +637,12 @@ def service_accounts(method, path, body):
|
||||
parts = path.strip('/').split('/')
|
||||
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
settings = {key: body.get(key) for key in ('email', 'display_name', 'description')}
|
||||
settings = {key: body.get(key) for key in ('email', 'display_name', 'description', 'redirect_uris',
|
||||
'post_logout_redirect_uris', 'allowed_groups')}
|
||||
actions = {
|
||||
('GET', 1, None): lambda connection: {
|
||||
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']]},
|
||||
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']],
|
||||
'oidc': oidc_settings()},
|
||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), settings,
|
||||
service=True),
|
||||
('PUT', 2, None): lambda connection: vpnrouter_users.update_service(connection, name, settings),
|
||||
@@ -622,6 +665,8 @@ def service_accounts(method, path, body):
|
||||
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)}
|
||||
return (201 if route == ('POST', 1, None) else 200), document
|
||||
|
||||
|
||||
@@ -972,20 +1017,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'):
|
||||
@@ -995,6 +1047,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'):
|
||||
@@ -1012,6 +1071,63 @@ def account(name, method, path, body):
|
||||
return 200, document
|
||||
|
||||
|
||||
def tail(path, count):
|
||||
"""The last count lines of a file, read from at most LOG_BYTES_MAX bytes at its end, invalid UTF-8 replaced."""
|
||||
with open(path, 'rb') as handle:
|
||||
size = handle.seek(0, os.SEEK_END)
|
||||
start = max(0, size - LOG_BYTES_MAX)
|
||||
handle.seek(start)
|
||||
lines = handle.read().decode('utf-8', 'replace').splitlines()
|
||||
# A read that starts inside the file starts inside a line, which is left out.
|
||||
return (lines[1:] if start and lines else lines)[-count:]
|
||||
|
||||
|
||||
def logs(method, path, query):
|
||||
"""Answer a /logs request: the HTTP status and the JSON document.
|
||||
|
||||
GET /logs {"units": JOURNAL_UNITS, "files": web.log_files, "max_lines": LOG_LINES_MAX}
|
||||
GET /logs/journal {"lines": [...]}: the last lines of the journal, of the unit unit= names, one of
|
||||
JOURNAL_UNITS, or of the whole journal without it
|
||||
GET /logs/file {"lines": [...]}: the last lines of the file path= names, one of web.log_files
|
||||
|
||||
lines= is the number of lines, 1 to LOG_LINES_MAX, LOG_LINES_DEFAULT without it.
|
||||
"""
|
||||
if method not in ('GET', 'HEAD'):
|
||||
return 405, {'error': f'{method} is not allowed on /logs'}
|
||||
try:
|
||||
files = [item.strip() for item in vpnrouter.load_config()['web']['log_files'] if item.strip()]
|
||||
except SystemExit as exc:
|
||||
return 500, {'error': str(exc.code)}
|
||||
if path == '/logs':
|
||||
return 200, {'units': list(JOURNAL_UNITS), 'files': files, 'max_lines': LOG_LINES_MAX}
|
||||
text = query.get('lines', [str(LOG_LINES_DEFAULT)])[0]
|
||||
if not text.isdigit() or not 1 <= int(text) <= LOG_LINES_MAX:
|
||||
return 400, {'error': f'lines must be a whole number from 1 to {LOG_LINES_MAX}'}
|
||||
count = int(text)
|
||||
if path == '/logs/journal':
|
||||
unit = query.get('unit', [''])[0]
|
||||
if unit and unit not in JOURNAL_UNITS:
|
||||
return 404, {'error': f'{unit} is not one of the units whose journal /logs shows'}
|
||||
try:
|
||||
result = subprocess.run(['journalctl', '--no-pager', '--quiet', '--output=short-iso', f'--lines={count}',
|
||||
*([f'--unit={unit}.service'] if unit else [])], capture_output=True, text=True,
|
||||
errors='replace', timeout=30)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return 500, {'error': f'journalctl cannot be run: {exc}'}
|
||||
if result.returncode != 0:
|
||||
return 500, {'error': result.stderr.strip() or f'journalctl ended with status {result.returncode}'}
|
||||
return 200, {'lines': result.stdout.splitlines()[-count:]}
|
||||
if path == '/logs/file':
|
||||
name = query.get('path', [''])[0]
|
||||
if name not in files:
|
||||
return 404, {'error': f'{name!r} is not one of web.log_files'}
|
||||
try:
|
||||
return 200, {'lines': tail(name, count)}
|
||||
except OSError as exc:
|
||||
return (404 if isinstance(exc, FileNotFoundError) else 500), {'error': f'{name} cannot be read: {exc.strerror}'}
|
||||
return 404, {'error': f'no route {method} {path}'}
|
||||
|
||||
|
||||
def metrics_history(view):
|
||||
"""Answer GET /metrics/history?range=<view> with vpnrouter_metrics.history: the HTTP status and the JSON document.
|
||||
|
||||
@@ -1067,7 +1183,10 @@ def refusal(claims, method, path, body):
|
||||
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
|
||||
"""
|
||||
@@ -1080,7 +1199,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}
|
||||
@@ -1105,19 +1230,29 @@ 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. The answer is {"ticket", "expires_in"}: a ticket that opens one shell (GET /shell) for the
|
||||
same user within SHELL_TICKET_SECONDS. 403 while web.shell is false or the user's console login is not on.
|
||||
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:
|
||||
if not vpnrouter.load_config()['web']['shell']:
|
||||
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'}
|
||||
if console_account(name) is None:
|
||||
return 403, {'error': f'console login is not enabled for {name}; vpn-router-user console sets it'}
|
||||
vpnrouter_users.login(name, body.get('password'), body.get('code'))
|
||||
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'}
|
||||
@@ -1132,14 +1267,20 @@ def shell_ticket(name, body):
|
||||
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, or None while its console login is not on."""
|
||||
"""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:
|
||||
console = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))['console']
|
||||
return console['account'] if console and console['enabled'] else None
|
||||
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):
|
||||
@@ -1264,8 +1405,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
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, or when the
|
||||
client closes the connection; the server then closes it with the reason. Each opening, end and refusal is logged
|
||||
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):
|
||||
@@ -1291,11 +1433,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not options['shell']:
|
||||
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
|
||||
try:
|
||||
login = console_account(name)
|
||||
login, refusal = console_account(name)
|
||||
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
|
||||
return refuse(500, f'the user directory failed: {exc}')
|
||||
if login is None:
|
||||
return refuse(403, f'console login is not enabled for {name}')
|
||||
if refusal:
|
||||
return refuse(403, refusal)
|
||||
try:
|
||||
account = pwd.getpwnam(login)
|
||||
except KeyError:
|
||||
@@ -1336,6 +1478,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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))
|
||||
@@ -1347,12 +1491,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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)
|
||||
@@ -1391,10 +1546,12 @@ 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', 'shell', 'remote-access')
|
||||
'ca', 'shell', 'remote-access', 'logs')
|
||||
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')
|
||||
oidc = (path == '/.well-known/openid-configuration' or path.startswith('/oidc/')) and \
|
||||
self.server.server_address[0] == vpnrouter_net.LOOPBACK
|
||||
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
|
||||
@@ -1405,7 +1562,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)))
|
||||
@@ -1452,12 +1609,24 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif directory == 'ca':
|
||||
code, document = ca(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory == 'logs':
|
||||
code, document = logs(self.command, path, urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query))
|
||||
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
|
||||
elif directory == 'service-accounts':
|
||||
code, document = service_accounts(self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif directory is not None:
|
||||
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
|
||||
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
|
||||
elif oidc:
|
||||
wait = auth_wait(self.client()) if (self.command, path) == ('POST', '/oidc/login') else 0
|
||||
if wait:
|
||||
code, kind = 429, 'application/json'
|
||||
body = json.dumps({'error': f'too many sign-in calls; retry in {wait} seconds'}).encode() + b'\n'
|
||||
extra.append(('Retry-After', str(wait)))
|
||||
else:
|
||||
code, kind, body, headers = vpnrouter_oidc.answer(self.command, self.path, self.headers, self.raw_body())
|
||||
extra += headers
|
||||
elif self.command not in ('GET', 'HEAD'):
|
||||
body, kind, code = b'Method not allowed\n', 'text/plain; charset=utf-8', 405
|
||||
elif path.startswith('/pki/') and vpnrouter.PKI_NAME.match(path[5:-4]) and path[-4:] in PUBLISHED and \
|
||||
@@ -1485,6 +1654,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if send_body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def raw_body(self):
|
||||
"""The request body's bytes, b'' without one or over BODY_LIMIT."""
|
||||
try:
|
||||
size = int(self.headers.get('Content-Length') or 0)
|
||||
except ValueError:
|
||||
return b''
|
||||
return self.rfile.read(size) if 0 < size <= BODY_LIMIT else b''
|
||||
|
||||
def request_body(self):
|
||||
"""The request body as JSON, None when there is none, or the string 'invalid'."""
|
||||
length = self.headers.get('Content-Length')
|
||||
@@ -1523,7 +1700,8 @@ def log(message):
|
||||
|
||||
|
||||
def initialize():
|
||||
"""Make the signing key and the refresh tokens' database, retrying every INIT_RETRY_SECONDS until both are ready."""
|
||||
"""Make the signing key, the OpenID Provider's key and the refresh tokens' database, retrying every
|
||||
INIT_RETRY_SECONDS until all are ready."""
|
||||
reported = None
|
||||
while True:
|
||||
phase = 'reading the configuration'
|
||||
@@ -1531,6 +1709,8 @@ def initialize():
|
||||
cfg = vpnrouter.load_config()
|
||||
phase = 'making the signing key'
|
||||
vpnrouter_auth.ensure_key()
|
||||
phase = 'making the OpenID Provider key'
|
||||
vpnrouter_oidc.ensure_key()
|
||||
phase = 'creating the token database'
|
||||
vpnrouter_auth.provision(cfg)
|
||||
phase = 'connecting to the token database'
|
||||
|
||||
@@ -9,18 +9,21 @@ number, true or false, a list of strings separated by commas (an empty value is
|
||||
or a string. remove takes site_to_site.NAME and removes that connection. The configuration with
|
||||
the change is checked as vpn-router-setup checks it, and only then written with
|
||||
vpnrouter.save_config, holding vpnrouter.config_lock; vpn-router-setup applies it at its next
|
||||
run, which set and remove remind of. A failure is a message on standard error; the exit status
|
||||
is 2 for an invalid request and 1 for any other failure. -h or --help shows the options.
|
||||
run, which set and remove remind of. With --restart they restart vpn-router-setup and wait until
|
||||
it has applied the change. A failure is a message on standard error; the exit status is 2 for an
|
||||
invalid request and 1 for any other failure, a failed run of vpn-router-setup included. -h or
|
||||
--help shows the options.
|
||||
|
||||
vpn-config-edit show
|
||||
vpn-config-edit set SECTION.OPTION VALUE
|
||||
vpn-config-edit set site_to_site.NAME.OPTION VALUE
|
||||
vpn-config-edit remove site_to_site.NAME
|
||||
vpn-config-edit set [--restart] SECTION.OPTION VALUE
|
||||
vpn-config-edit set [--restart] site_to_site.NAME.OPTION VALUE
|
||||
vpn-config-edit remove [--restart] site_to_site.NAME
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '/usr/lib/vpn-router')
|
||||
@@ -86,6 +89,9 @@ def main(argv):
|
||||
setter.add_argument('value', metavar='VALUE', help='its value; a list separated by commas')
|
||||
remover = commands.add_parser('remove', help='remove a site-to-site connection and write the configuration')
|
||||
remover.add_argument('key', metavar='site_to_site.NAME', help='the connection')
|
||||
for changer in (setter, remover):
|
||||
changer.add_argument('--restart', action='store_true',
|
||||
help='restart vpn-router-setup and wait until it has applied the change')
|
||||
options = parser.parse_args(argv)
|
||||
|
||||
if options.command == 'show':
|
||||
@@ -124,7 +130,14 @@ def main(argv):
|
||||
print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.')
|
||||
else:
|
||||
print(f'{options.key} removed from {vpnrouter.CONFIG_FILE}.')
|
||||
print('Apply it with: systemctl restart vpn-router-setup')
|
||||
if not options.restart:
|
||||
print('Apply it with: systemctl restart vpn-router-setup, or --restart')
|
||||
return 0
|
||||
print('Restarting vpn-router-setup...', flush=True)
|
||||
if subprocess.run(['systemctl', 'restart', 'vpn-router-setup.service']).returncode != 0:
|
||||
print('vpn-config-edit: vpn-router-setup failed; see journalctl -u vpn-router-setup', file=sys.stderr)
|
||||
return 1
|
||||
print('vpn-router-setup applied it.')
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -12,9 +12,15 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
|
||||
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
|
||||
vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
(a service account; password on stdin)
|
||||
vpn-router-user [--json] update <name> [--first-name F] [--last-name L] [--email E]
|
||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E] (a service account)
|
||||
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E]
|
||||
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
|
||||
(a service account)
|
||||
|
||||
A service account with a redirect URI is a client of the OpenID Provider; each of the three options may be repeated,
|
||||
and replaces all values of its kind; one '' removes them.
|
||||
vpn-router-user [--json] passwd <name> (password on stdin)
|
||||
vpn-router-user [--json] disable <name>
|
||||
vpn-router-user [--json] enable <name>
|
||||
@@ -25,6 +31,7 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
|
||||
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
|
||||
@@ -37,6 +44,7 @@ import ldap
|
||||
|
||||
import vpnrouter
|
||||
import vpnrouter_auth
|
||||
import vpnrouter_passkeys
|
||||
import vpnrouter_users
|
||||
from vpnrouter_users import Failure
|
||||
|
||||
@@ -57,8 +65,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 on, as the user's own POSIX account in the directory "
|
||||
'or as a local account, or off',
|
||||
'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.
|
||||
@@ -66,6 +75,13 @@ PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'em
|
||||
'display_name': "a service account's display name; '' removes it",
|
||||
'description': "a service account's description; '' removes it"}
|
||||
|
||||
#: The OpenID Provider client settings of a service account, their options and help.
|
||||
CLIENT_OPTIONS = (
|
||||
('redirect_uris', 'redirect-uri', 'a redirect URI of the OpenID Provider client'),
|
||||
('post_logout_redirect_uris', 'post-logout-redirect-uri', 'a post-logout redirect URI of the OpenID Provider client'),
|
||||
('allowed_groups', 'allowed-group', 'a group whose members may sign in to the OpenID Provider client'),
|
||||
)
|
||||
|
||||
|
||||
class Parser(argparse.ArgumentParser):
|
||||
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
|
||||
@@ -85,6 +101,9 @@ def parse(argv):
|
||||
if command in ('add', 'update'):
|
||||
for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
|
||||
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
|
||||
for key, option, text in CLIENT_OPTIONS:
|
||||
sub.add_argument(f'--{option}', dest=key, action='append', metavar='VALUE',
|
||||
help=f"{text}, of a service account; repeat it for more, '' for none")
|
||||
if command == 'add':
|
||||
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||
if command == 'otp-enrol':
|
||||
@@ -108,6 +127,10 @@ def run(connection, options, secret):
|
||||
if command == 'list':
|
||||
return vpnrouter_users.list_users(connection)
|
||||
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
|
||||
for key, _, _ in CLIENT_OPTIONS:
|
||||
given = getattr(options, key, None)
|
||||
if given is not None:
|
||||
values[key] = [value for value in given if value]
|
||||
if command == 'add':
|
||||
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||
if command == 'update':
|
||||
@@ -118,14 +141,28 @@ def run(connection, options, secret):
|
||||
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(console):
|
||||
"""The console login of a user as a cell: -, off, posix (uid N) or local and the account."""
|
||||
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'
|
||||
return f'local {console["account"]}' if console['local'] else f'posix (uid {console["uid"]})'
|
||||
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 client_text(user):
|
||||
"""The OpenID Provider client settings of a service account as a cell: -, or the numbers of its URIs and its allowed
|
||||
groups."""
|
||||
if not user.get('redirect_uris'):
|
||||
return '-'
|
||||
groups = ','.join(user['allowed_groups']) or 'all users'
|
||||
return f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}'
|
||||
|
||||
|
||||
def text_of(answer):
|
||||
@@ -139,12 +176,13 @@ 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', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'PASSKEYS', 'CONSOLE', 'FIRST NAME', 'LAST NAME', 'E-MAIL',
|
||||
'OIDC CLIENT')
|
||||
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 '-', console_text(user['console']),
|
||||
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 '-', client_text(user))
|
||||
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))
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# bash completion for vpn-config-edit -*- shell-script -*-
|
||||
|
||||
# Python of the package that answers from its schema (vpnrouter.SCHEMA, vpnrouter.CONNECTION) and, read as root, the
|
||||
# site-to-site connections of the configuration: settings prints SECTION.OPTION of every setting and
|
||||
# site_to_site.NAME.OPTION of every connection, connections site_to_site.NAME, and values the values of a boolean
|
||||
# setting, $1.
|
||||
_vpn_config_edit_python()
|
||||
{
|
||||
python3 -c '
|
||||
import sys
|
||||
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||
import vpnrouter
|
||||
try:
|
||||
connections = vpnrouter.read_settings().get("site_to_site", {})
|
||||
except (OSError, vpnrouter.ConfigError):
|
||||
connections = {}
|
||||
if sys.argv[1] == "settings":
|
||||
print("\n".join(f"{section}.{option}" for section, options in vpnrouter.SCHEMA.items() for option in options))
|
||||
print("\n".join(f"site_to_site.{name}.{option}" for name in connections for option in vpnrouter.CONNECTION))
|
||||
elif sys.argv[1] == "connections":
|
||||
print("\n".join(f"site_to_site.{name}" for name in connections))
|
||||
else:
|
||||
section, _, option = sys.argv[2].rpartition(".")
|
||||
defaults = vpnrouter.CONNECTION if section.startswith("site_to_site.") else vpnrouter.SCHEMA.get(section, {})
|
||||
if isinstance(defaults.get(option), bool):
|
||||
print("true\nfalse")
|
||||
' "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_config_edit()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it.
|
||||
local i command='' args=()
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
elif [[ ${words[i]} != -* ]]; then
|
||||
args+=("${words[i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "--help$([[ $command == @(set|remove) ]] && echo ' --restart')" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case $command,${#args[@]} in
|
||||
,*) COMPREPLY=($(compgen -W 'show set remove' -- "$cur")) ;;
|
||||
set,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python settings)" -- "$cur")) ;;
|
||||
set,1) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python values "${args[0]}")" -- "$cur")) ;;
|
||||
remove,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python connections)" -- "$cur")) ;;
|
||||
esac
|
||||
} &&
|
||||
complete -F _vpn_config_edit vpn-config-edit
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,41 @@
|
||||
# bash completion for vpn-router-backup -*- shell-script -*-
|
||||
|
||||
_vpn_router_backup()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it.
|
||||
local i command='' args=0 pbs=false
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
elif [[ ${words[i]} == --pbs ]]; then
|
||||
pbs=true
|
||||
elif [[ ${words[i]} != -* ]]; then
|
||||
((args++))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'run list restore' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "--help$([[ $command == restore ]] && echo ' --pbs --yes')" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# restore takes a local archive, or with --pbs a snapshot name, which vpn-router-backup list shows.
|
||||
if [[ $command == restore ]] && ((args == 0)) && ! $pbs; then
|
||||
_filedir
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_backup vpn-router-backup
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,98 @@
|
||||
# bash completion for vpn-router-ca -*- shell-script -*-
|
||||
|
||||
# Python of the package that prints the revocation reasons (reasons) or, from the configuration read as root, the
|
||||
# issuing CAs (cas) or the certificate templates (templates), which other users do not get.
|
||||
_vpn_router_ca_python()
|
||||
{
|
||||
python3 -c '
|
||||
import sys
|
||||
sys.path.insert(0, "/usr/lib/vpn-router")
|
||||
import vpnrouter
|
||||
import vpnrouter_pki
|
||||
if sys.argv[1] == "reasons":
|
||||
print("\n".join(vpnrouter_pki.REASONS))
|
||||
else:
|
||||
cfg = vpnrouter.load_config()
|
||||
print("\n".join(vpnrouter.issuing_cas(cfg) if sys.argv[1] == "cas" else vpnrouter.pki_templates(cfg)))
|
||||
' "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_ca()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command and, for ca and root, the action are the first words that are not options; the arguments are the
|
||||
# words after them but option values.
|
||||
local i command='' action='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
case ${words[i]} in
|
||||
--ca | --template | --csr | --subject | --san | --pfx | --reason | --out) ((i++)) ;;
|
||||
-*) ;;
|
||||
*)
|
||||
if [[ -z $command ]]; then
|
||||
command=${words[i]}
|
||||
elif [[ $command == @(ca|root) && -z $action ]]; then
|
||||
action=${words[i]}
|
||||
else
|
||||
((args++))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case $prev in
|
||||
--ca)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--template)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python templates)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--reason)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_ca_python reasons)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--csr | --pfx | --out)
|
||||
_filedir
|
||||
return
|
||||
;;
|
||||
--subject | --san)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'status list issue revoke maintain ca root' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command$action in
|
||||
list) options+=' --ca' ;;
|
||||
issue) options+=' --ca --template --csr --subject --san --pfx' ;;
|
||||
revoke) options+=' --reason' ;;
|
||||
rootexport-key) options+=' --out' ;;
|
||||
rootremove-key) options+=' --yes' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
case $command,$action,$args in
|
||||
ca,,*) COMPREPLY=($(compgen -W 'csr import' -- "$cur")) ;;
|
||||
root,,*) COMPREPLY=($(compgen -W 'export-key remove-key import-key import-crl' -- "$cur")) ;;
|
||||
ca,*,0) COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur")) ;;
|
||||
ca,import,1 | root,import-key,0 | root,import-crl,0) _filedir ;;
|
||||
esac
|
||||
} &&
|
||||
complete -F _vpn_router_ca vpn-router-ca
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,24 @@
|
||||
# bash completion for vpn-router-caddy -*- shell-script -*-
|
||||
|
||||
_vpn_router_caddy()
|
||||
{
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
# After the command only --help is left.
|
||||
local word
|
||||
for word in "${words[@]:1:cword-1}"; do
|
||||
if [[ $word != -* ]]; then
|
||||
[[ $cur == -* ]] && COMPREPLY=($(compgen -W '--help' -- "$cur"))
|
||||
return
|
||||
fi
|
||||
done
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'status apply use-upstream' -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_caddy vpn-router-caddy
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,74 @@
|
||||
# bash completion for vpn-router-group -*- shell-script -*-
|
||||
|
||||
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
|
||||
_vpn_router_group_users()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_group_names()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_group()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--member | --description | --name) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W 'list add update remove member-add member-remove' -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--member)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--description | --name)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --member --description' ;;
|
||||
update) options+=' --name --description' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The group, which add takes new, then the user of member-add and member-remove.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_names)" -- "$cur"))
|
||||
elif ((args == 1)) && [[ $command == member-@(add|remove) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_group vpn-router-group
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,25 @@
|
||||
# bash completion for vpn-router-token -*- shell-script -*-
|
||||
|
||||
_vpn_router_token()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
case $prev in
|
||||
--minutes | --hours | --days)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
# The lifetime is one of --minutes, --hours and --days.
|
||||
local options='--json --help' word
|
||||
for word in "${words[@]:1:cword-1}"; do
|
||||
[[ $word == --@(minutes|hours|days)* ]] && break
|
||||
done
|
||||
[[ $word == --@(minutes|hours|days)* ]] || options+=' --minutes --hours --days'
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
} &&
|
||||
complete -F _vpn_router_token vpn-router-token
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -0,0 +1,84 @@
|
||||
# bash completion for vpn-router-user -*- shell-script -*-
|
||||
|
||||
# The names of the users and of the groups of the router's directory, read as root; nothing for other users.
|
||||
_vpn_router_user_names()
|
||||
{
|
||||
vpn-router-user --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_user_groups()
|
||||
{
|
||||
vpn-router-group --json list 2>/dev/null |
|
||||
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
|
||||
}
|
||||
|
||||
_vpn_router_user()
|
||||
{
|
||||
local cur prev words cword split
|
||||
_init_completion -s || return
|
||||
|
||||
local commands='list add update passwd disable enable unlock remove profile otp-new otp-enrol otp-remove console
|
||||
passkeys-remove'
|
||||
|
||||
# The command is the first word that is not an option; the arguments are the words after it but option values.
|
||||
local i command='' args=0
|
||||
for ((i = 1; i < cword; i++)); do
|
||||
if [[ -z $command ]]; then
|
||||
[[ ${words[i]} != -* ]] && command=${words[i]}
|
||||
continue
|
||||
fi
|
||||
case ${words[i]} in
|
||||
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | \
|
||||
--post-logout-redirect-uri | --allowed-group | --account) ((i++)) ;;
|
||||
-*) ;;
|
||||
*) ((args++)) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z $command ]]; then
|
||||
if [[ $cur == -* ]]; then
|
||||
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
case $prev in
|
||||
--allowed-group)
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--account)
|
||||
# The local accounts console --account takes: a uid from 1000 to 19999, not nobody.
|
||||
COMPREPLY=($(compgen -W "$(getent passwd | awk -F: '$3 >= 1000 && $3 < 20000 && $1 != "nobody" { print $1 }')" -- "$cur"))
|
||||
return
|
||||
;;
|
||||
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | --post-logout-redirect-uri)
|
||||
return
|
||||
;;
|
||||
esac
|
||||
$split && return
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local options='--help'
|
||||
case $command in
|
||||
add) options+=' --first-name --last-name --email --service --display-name --description --redirect-uri
|
||||
--post-logout-redirect-uri --allowed-group' ;;
|
||||
update) options+=' --first-name --last-name --email --display-name --description --redirect-uri
|
||||
--post-logout-redirect-uri --allowed-group' ;;
|
||||
console) options+=' --posix --account --off' ;;
|
||||
esac
|
||||
COMPREPLY=($(compgen -W "$options" -- "$cur"))
|
||||
return
|
||||
fi
|
||||
|
||||
# The user name, which add takes new; otp-enrol then takes a code.
|
||||
if ((args == 0)) && [[ $command != @(list|add) ]]; then
|
||||
COMPREPLY=($(compgen -W "$(_vpn_router_user_names)" -- "$cur"))
|
||||
fi
|
||||
} &&
|
||||
complete -F _vpn_router_user vpn-router-user
|
||||
|
||||
# ex: filetype=sh
|
||||
@@ -63,7 +63,9 @@
|
||||
"trusted_proxies": [],
|
||||
"status_content": "basic",
|
||||
"shell": false,
|
||||
"shell_idle_minutes": 15
|
||||
"shell_idle_minutes": 15,
|
||||
"shell_auth_timeout": 15,
|
||||
"log_files": ["/var/log/syslog", "/var/log/auth.log"]
|
||||
},
|
||||
"ssh": {
|
||||
"ldap_login": false
|
||||
@@ -75,6 +77,9 @@
|
||||
"manager_port": 7443,
|
||||
"caddy_source": "ubuntu"
|
||||
},
|
||||
"oidc": {
|
||||
"enabled": false
|
||||
},
|
||||
"pki": {
|
||||
"root_name": "VPN Router Root CA",
|
||||
"root_days": 7300,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
-- The state of the router's OpenID Provider (vpnrouter_oidc) in the database of web.database.
|
||||
-- Codes, tokens and sessions are kept as the SHA-256 of their values; expired rows are purged when new ones are saved.
|
||||
-- The whole file runs as one transaction, serialized by an advisory lock, so that two processes can load it at once.
|
||||
|
||||
SELECT pg_advisory_xact_lock(hashtext('vpn-router oidc schema'));
|
||||
|
||||
-- An authorization code, valid until it is exchanged for tokens or expires.
|
||||
CREATE TABLE IF NOT EXISTS oidc_codes (
|
||||
hash text PRIMARY KEY,
|
||||
client_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
redirect_uri text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
nonce text,
|
||||
code_challenge text NOT NULL,
|
||||
code_challenge_method text NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- An access token or a refresh token (kind) a client was issued for a user.
|
||||
CREATE TABLE IF NOT EXISTS oidc_tokens (
|
||||
hash text PRIMARY KEY,
|
||||
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
|
||||
client_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
scopes text[] NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
|
||||
|
||||
-- A user's sign-in at the OpenID Provider, which the session cookie names.
|
||||
CREATE TABLE IF NOT EXISTS oidc_sessions (
|
||||
hash text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
auth_time timestamptz NOT NULL,
|
||||
amr text[] NOT NULL,
|
||||
expires timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_code_save(p_hash text, p_client_id text, p_name text, p_redirect_uri text,
|
||||
p_scopes text[], p_nonce text, p_code_challenge text,
|
||||
p_code_challenge_method text, p_auth_time timestamptz, p_amr text[],
|
||||
p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE expires < now();
|
||||
INSERT INTO oidc_codes VALUES (p_hash, p_client_id, p_name, p_redirect_uri, p_scopes, p_nonce, p_code_challenge,
|
||||
p_code_challenge_method, p_auth_time, p_amr, now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired code.
|
||||
CREATE OR REPLACE FUNCTION oidc_code_get(p_hash text)
|
||||
RETURNS TABLE (client_id text, name text, redirect_uri text, scopes text[], nonce text, code_challenge text,
|
||||
code_challenge_method text, auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr
|
||||
FROM oidc_codes WHERE hash = p_hash AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_code_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_token_save(p_hash text, p_kind text, p_client_id text, p_name text, p_scopes text[],
|
||||
p_auth_time timestamptz, p_amr text[], p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE expires < now();
|
||||
INSERT INTO oidc_tokens VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr,
|
||||
now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired token of the kind.
|
||||
CREATE OR REPLACE FUNCTION oidc_token_get(p_hash text, p_kind text)
|
||||
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens
|
||||
WHERE hash = p_hash AND kind = p_kind AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_token_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_tokens WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_session_save(p_hash text, p_name text, p_amr text[], p_seconds integer)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE expires < now();
|
||||
INSERT INTO oidc_sessions VALUES (p_hash, p_name, now(), p_amr, now() + make_interval(secs => p_seconds));
|
||||
$$;
|
||||
|
||||
-- The unexpired session.
|
||||
CREATE OR REPLACE FUNCTION oidc_session_get(p_hash text)
|
||||
RETURNS TABLE (name text, auth_time timestamptz, amr text[])
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT name, auth_time, amr FROM oidc_sessions WHERE hash = p_hash AND expires > now();
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE PROCEDURE oidc_session_delete(p_hash text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_sessions WHERE hash = p_hash;
|
||||
$$;
|
||||
|
||||
-- Remove every code, token and session of the user.
|
||||
CREATE OR REPLACE PROCEDURE oidc_revoke(p_name text)
|
||||
LANGUAGE sql AS $$
|
||||
DELETE FROM oidc_codes WHERE name = p_name;
|
||||
DELETE FROM oidc_tokens WHERE name = p_name;
|
||||
DELETE FROM oidc_sessions WHERE name = p_name;
|
||||
$$;
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f4f5f7; color: #1f2937; margin: 0; }
|
||||
main { max-width: 22rem; margin: 4rem auto; background: #fff; padding: 2rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 0.25rem; }
|
||||
p { margin: 0 0 1rem; color: #4b5563; }
|
||||
label { display: block; font-size: 0.875rem; margin: 0.75rem 0 0.25rem; }
|
||||
input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 1rem; }
|
||||
button { width: 100%; margin-top: 1.25rem; padding: 0.6rem; border: 0; border-radius: 0.25rem; font-size: 1rem; cursor: pointer; background: #1d4ed8; color: #fff; }
|
||||
button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8; margin-top: 0.75rem; }
|
||||
.error { background: #fee2e2; color: #991b1b; padding: 0.5rem 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
|
||||
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{{ title }}</h1>
|
||||
{%- if message %}
|
||||
<p>{{ message }}</p>
|
||||
{%- endif %}
|
||||
{%- if error %}
|
||||
<div class="error" role="alert">{{ error }}</div>
|
||||
{%- endif %}
|
||||
{%- if form %}
|
||||
<form method="post" action="/oidc/login">
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="code">Code from the authenticator, when you have one</label>
|
||||
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code">
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
<form id="passkey-form" method="post" action="/oidc/login" hidden>
|
||||
<input type="hidden" name="request" value="{{ request }}">
|
||||
<input type="hidden" name="passkey_id">
|
||||
<input type="hidden" name="credential">
|
||||
</form>
|
||||
<button id="passkey" class="secondary" type="button" hidden>Sign in with a passkey</button>
|
||||
<script>
|
||||
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to /oidc/login with the authorization request.
|
||||
(function () {
|
||||
var button = document.getElementById('passkey');
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
|
||||
return;
|
||||
}
|
||||
button.hidden = false;
|
||||
button.addEventListener('click', async function () {
|
||||
button.disabled = true;
|
||||
try {
|
||||
var begun = await (await fetch('/oidc/passkey', { method: 'POST' })).json();
|
||||
var credential = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(begun.options.publicKey) });
|
||||
var form = document.getElementById('passkey-form');
|
||||
form.elements.passkey_id.value = begun.id;
|
||||
form.elements.credential.value = JSON.stringify(credential.toJSON());
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{%- endif %}
|
||||
<p class="issuer">{{ issuer }}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user