Compare commits

...
7 Commits
32 changed files with 4120 additions and 2397 deletions

No files matched your search

+130 -41
View File
@@ -481,11 +481,14 @@ standard attributes, are the display name and the description of a service accou
`account` cannot hold. Manager-only accounts have the class from their creation, so an e-mail
address can be added later without changing their classes; VPN users get it once scopes are used.
`vpnrouter_ldap.provision` replaces the definitions in `cn=config` that differ, so an extended class
reaches an existing directory at the next setup run.
reaches an existing directory at the next setup run. The structural class `vpnRouterRole`,
`MUST cn MAY ( description $ member )`, is a role of a resource of the OpenID Provider (ADR-37),
an entry `cn=<resource>:<role>` of `ou=roles`, which provision makes; the manager account manages
that subtree, and every account reads it.
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their
OIDs continue the arcs. The scope `service` marks a service account (ADR-23). The OID must never
be reused for anything else.
Consequences: The schema check stays strict. New attributes of accounts extend
`vpnRouterAccount`, and their OIDs continue the arcs. The scope `service` marks a service account
(ADR-23). The OID must never be reused for anything else.
## ADR-20: Users manage their own account through /account, proving who they are again
@@ -908,8 +911,14 @@ Decision: `/shell` of the web service is off until `web.shell` is set on the rou
Manager cannot set it. The shell runs as the console account of the signed-in user (ADR-34), so
it is never root and each administrator's shell is the administrator's own; a user without
console login gets none. `POST /shell` signs the user in again with the password
and TOTP code and gives a one-time ticket valid 30 seconds; a WebSocket on `GET /shell` with that
ticket gets the shell. Asking for the password and code at every opening of the page is tiresome,
and TOTP code, or with one of the user's own passkeys (ADR-36), and gives a one-time ticket valid 30
seconds; a WebSocket on `GET /shell` with that ticket gets the shell. A passkey with user
verification is a possession and a PIN or biometric in one step and cannot be phished, so it is
as good as the password and the code; `POST /shell/passkey` begins a proof bound to the signed-in
user and its credentials, and python3-fido2 refuses an answer without the user-verified flag. The
TOTP enrolment stays required with a passkey: the PAM login session of the shell, through the
`pam_authz_search` of nslcd for a directory account, and sudo in it need it, and loosening that
filter would let such a user log in over SSH with the password alone. 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
@@ -920,9 +929,10 @@ makes the terminal its controlling terminal so window sizes reach the shell. A s
Manager checks the session and the WebSocket's origin, and relays the bytes to the router after
both handshakes.
Consequences: Who can sign in as a member of `admins` and knows the password and the current code
can get a shell when the router allows one and the user's console login is on; a session alone
cannot. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
Consequences: Who can sign in as a member of `admins` and knows the password and the current code,
or holds one of the user's passkeys and unlocks it, can get a shell when the router allows one and
the user's console login is on; a session alone cannot. A failed passkey proof is refused but not
counted as a failed login, as with the passkey sign-in. The ticket travels in the URL and appears in request logs, used up. The shell's sessions
show up as `vpn-router-shell-<id>` units and logind sessions of the console account. The shell's
traffic is as protected as the Router Manager's: HTTPS to Caddy, plain HTTP on the internal
network to the web service.
@@ -1006,43 +1016,97 @@ Router Manager on another name, or in development on `localhost`, cannot use the
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
## ADR-37: The web service is an OpenID Provider built on python3-oauthlib; its clients and resources 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.
Context:
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.
- **What is needed:** 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. Programs on hosts
without a browser, and jobs without a user, need tokens as well. Services such as an S3 gateway
should learn from a token what its holder may do there, as resources of Entra ID do with app
roles and scopes.
- **What the archive has:** no packaged identity provider. `python3-oauthlib` and `python3-jwt`
are in main for noble and resolute; `python3-authlib` and `python3-jwcrypto` are in universe
only.
- **What oauthlib does:** it implements the OAuth 2.0 and OpenID Connect flows, including the
client credentials grant, and leaves the checks, the storage and the signatures to the
application. Noble's 3.2.2 has no device authorization grant (RFC 8628). Resolute's 3.3.1 has
one, as has the current 4.0.0, but it checks no device code and never answers
`authorization_pending` or `slow_down`.
- **What the web service has:** it 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 or a resource verifies a token itself.
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.
Decision:
- **Where it runs:** 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.
- **Clients:**
- A client is a service account with redirect URIs or grants, kept with the account in
attributes of `vpnRouterAccount`: `vpnRouterRedirectUri`, `vpnRouterPostLogoutRedirectUri`,
`vpnRouterAllowedGroup`, `vpnRouterGrantType`, `vpnRouterPublicClient` and
`vpnRouterClientScope`.
- Its name is the client ID and its password the secret, checked with a bind; a public client has
none.
- Allowed groups limit who may sign in; without them every user may.
- **Grants:**
- the authorization code with PKCE S256, which redirect URIs give;
- `client_credentials`, for confidential clients only;
- the device code;
- the refresh token for the first and the last.
The device grant is the router's own grant class on oauthlib's token endpoint, with its
authorization endpoint and pages, so it works the same with every oauthlib release. A loopback
redirect URI matches with any port (RFC 8252).
- **Consent:** a registered client is trusted, so there is no consent page, except for a device:
the user who enters its code is asked to allow it, as RFC 8628 advises against remote phishing.
- **The sign-in page:** the web service's own, a Jinja2 template with the password, the code and a
passkey, so the provider works without the Router Manager.
- **Resources, as Entra ID:**
- Every service account is a resource. Its scopes are in `vpnRouterResourceScope`, and its roles
are `vpnRouterRole` entries (ADR-19) whose members are groups and accounts.
- A token request names its resource with `resource` (RFC 8707) or by the resource's scopes,
`<resource>:<scope>`, which the client's scopes must allow.
- Without a resource, the tokens are for the client.
- The grant keeps the resource, so a code, a device code or a refresh token gives tokens for that
resource only.
- **Tokens:**
- Both kinds are RS256 JWTs, 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`.
- An ID token is for the client and carries the user's roles of the client.
- An access token follows RFC 9068: it is for the resource and carries the holder's roles there
and the scopes granted, so a resource needs nothing from the router but the JWKS.
- Roles are read at every issue.
- **Storage:**
- Codes, device codes, access and refresh tokens and sessions are kept as their SHA-256 in the
database of the refresh tokens.
- They are defined in `/usr/share/vpn-router/sql/oidc.sql`, with the procedures and functions
the module calls; a process loads the file at its first connection.
- The operations that revoke the refresh tokens of `/auth`, of users and of service accounts,
also revoke them, and every use of a code, a device code or a token checks the user and the
client's allowed groups again.
Consequences:
- **Requirements:** the package depends on `python3-oauthlib` and `python3-jwt`. The provider needs
HTTPS, a public name and the web service.
- **Management:** clients, resources and their roles are managed like service accounts, by the
CLI, `vpn-router-role`, `/service-accounts` and the Router Manager.
- **The directory keeps itself consistent:**
- a group a client allows cannot be removed, and renaming it renames it in the clients and the
roles;
- a resource scope a client may ask for cannot be removed.
- **Revocation:** an access token, a JWT, is valid at a resource until it expires, 5 minutes after
issue, whatever the router revokes. The signing key does not rotate; replacing it invalidates
every token issued.
- **Lockout:** wrong client secrets lock the client like any account.
- **Not offered:** the implicit and hybrid flows, the password grant and dynamic client
registration.
## ADR-38: Bash completions are files per command in bash-completion's completions directory
@@ -1253,3 +1317,28 @@ changed since. Files open only while their shell lives.
Consequences: The editor edits only what the console account may; a file only root may write is
edited in the terminal. `vre` run under sudo is refused, as its uid is not the console account's.
Each read and save starts a transient unit. Files are limited to 1 MiB of UTF-8 text.
## ADR-47: The web service is the package vpnrouter_web, with one module per family of paths
Status: Accepted
Context: The web service answers about twenty families of paths: the status, the sign-in, the
identities, the connections, backups, the PKI, logs, web sites, DNS, metrics and the shell with
its editor. Kept in one script, the code of unrelated paths is read and changed together.
Decision: `/usr/lib/vpn-router/web` only initializes and serves. The package
`/usr/lib/vpn-router/vpnrouter_web/` holds the rest, laid out next to `vpnrouter_platforms/`:
- `handler`: the `Handler` class, which reads a request, checks its token and routes it.
- One module per family of paths (`status`, `auth`, `identities`, `connectivity`, `backup`, `pki`,
`logs`, `sites`, `dns`, `metrics`, `shell`), each with its constants and state.
- `common`: what several of them need.
A module imports from `common` and from no other route module, except `auth`, which takes the
paths of a site's files from `sites`. `handler` imports them all, so the imports have no cycle. A
function that needs the request takes the `Handler` as its first parameter instead of being a
method.
Consequences: A change to one family of paths touches one module and the routing in `handler`.
State shared between requests, such as the open shells, lives in the module that owns it; other
modules change it and never rebind it.
+201 -59
View File
@@ -375,7 +375,7 @@ 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`, `vpn-router-caddy`
`vpn-router-role`, `vpn-router-token`, `vpn-config-edit`, `vpn-router-backup`, `vpn-router-ca`, `vpn-router-caddy`
and `vpn-router-site` 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
@@ -702,8 +702,9 @@ 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, 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`,
description, a service account with redirect URIs or grants is a client of the OpenID Provider,
and every one is a resource of it, with its scopes and roles (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
@@ -741,9 +742,9 @@ vpn-router-user otp-remove alice
| `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] [--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 |
| `add <name> --service [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...] [--grant G ...] [--public-client yes\|no] [--client-scope R:S ...] [--resource-scope S ...]` | Adds a service account, with its display name, description, e-mail address and OpenID Provider 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] [--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 |
| `update <name> [--display-name N] [--description D] [--email E] [--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...] [--grant G ...] [--public-client yes\|no] [--client-scope R:S ...] [--resource-scope S ...]` | Changes the display name, the description, the e-mail address or the OpenID Provider settings of a service account; `''` removes one; a repeated 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 |
@@ -855,9 +856,10 @@ A disabled or locked user is refused a passkey sign-in too.
`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. 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`.
of a client of the OpenID Provider admit their members to it, and a role of a resource assigned to
a group is a role of its members; other groups record membership only. Renaming a group renames it
in the clients that allow it and in its roles, a group a client allows cannot be removed, and
removing a group takes it out of its roles. 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'
@@ -952,6 +954,14 @@ The service first makes its signing key, the OpenID Provider's key and the refre
database, retrying every 15 seconds, for example while PostgreSQL is not running yet, and logs
each failure; then it listens.
The service is `/usr/lib/vpn-router/web`, which starts it, and the package
`/usr/lib/vpn-router/vpnrouter_web/`, which answers the requests:
- `handler`: reads each request, checks its token, and passes it to the module of its path.
- `common`: what the modules share.
- One module per family of paths: `status`, `auth`, `identities`, `connectivity`, `backup`,
`pki`, `logs`, `sites`, `dns`, `metrics` and `shell`.
### Bootstrap diagnostics
Until a run of `vpn-router-setup` has succeeded, every run starts `vpn-router-bootstrap`, which
@@ -1072,8 +1082,9 @@ every service and tunnel while the public status page shows less.
### The shell
While `web.shell` is `true`, `/shell` gives a member of `admins` whose console login is on and
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's console account, for the
Router Manager's Shell page. It is off by default and switched on and off on the router only:
who is enrolled for TOTP (see Console login and SSH) a login shell on the router, as the user's
console account, for the Router Manager's Shell page. It is off by default and switched on and
off on the router only:
```sh
vpn-router-user console alice --posix
@@ -1082,20 +1093,29 @@ vpn-config-edit set web.shell true
| Request | Body | Answer |
| --- | --- | --- |
| `POST /shell` | `{"password", "code"}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
| `POST /shell/passkey` | `{}` | `{"id", "options"}`, `options` those of `navigator.credentials.get` for the user's own passkeys |
| `POST /shell` | `{"password", "code"}`, or `{"passkey": {"id", "credential"}}`; `{}` within `web.shell_auth_timeout` | `{"ticket", "expires_in": 30}` |
| `GET /shell?ticket=<ticket>` | a WebSocket handshake | the terminal |
`POST /shell` needs an access token of `/auth`, not one of `vpn-router-token`, and signs its user
in again: a wrong password or code gets 403 and counts as a failed login; it gets 403 too while
`web.shell` is `false`, the user's console login is off or the user is not enrolled for TOTP. Without a password it gives a ticket
while the user's last sign-in of `POST /shell` or keystroke in a shell is less than
`web.shell_auth_timeout` minutes ago, and `{"ticket": null}` otherwise. The ticket opens one
in again, with the password and the TOTP code, or with a passkey: `POST /shell/passkey` begins a
proof for one of the user's own passkeys with user verification, which the browser answers within
two minutes, and `POST /shell` takes its `id` and the browser's answer as `credential`. A passkey
with user verification is two factors in one, so it takes the place of the password and the code.
A wrong password or code gets 403 and counts as a failed login, and a passkey that is not accepted
gets 403. `POST /shell` gets 403 too while `web.shell` is `false`, the user's console login is off
or the user is not enrolled for TOTP: also with a passkey, the PAM login session of the shell,
`pam_authz_search` of nslcd for a directory account, and sudo need the enrolment. Without a
password or a passkey it gives a ticket while the user's last sign-in of
`POST /shell` or keystroke in a shell is less than `web.shell_auth_timeout` minutes ago, and
`{"ticket": null}` otherwise. The ticket opens one
shell for the same user within 30 seconds. Over the WebSocket, binary messages of the client
are keystrokes, a text message `{"cols", "rows"}` sets the window size, and binary messages of
the router are the terminal's output. The shell is a PAM login session of the console account,
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH. It is closed after `web.shell_idle_minutes` without a keystroke, when the shell
exits, when the connection closes, or within a minute of the user's console login being switched
off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
run by `systemd-run` in a transient unit `vpn-router-shell-<id>`; sudo works as for SSH, with the
password and the TOTP code. It is closed after `web.shell_idle_minutes` without a keystroke, when
the shell exits, when the connection closes, or within a minute of the user's console login being
switched off or the TOTP enrolment removed; the router then sends a close frame with the reason. The
journal of `vpn-router-web` records who opened each shell, from which address, in which unit,
when it ended and why, and each refused ticket or shell with the reason.
@@ -1140,6 +1160,7 @@ vre /etc/vpn-router/vpn-router.json ~/notes.txt
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
| `postgresql` | `clusters`: each PostgreSQL cluster of `pg_lsclusters` with its version, name, port, status and data directory, and for one that is online its `settings`: `server_version`, `listen_addresses`, `port`, `max_connections`, `shared_buffers`, `effective_cache_size`, `work_mem`, `password_encryption`, `ssl` and `timezone`; `null` when `pg_lsclusters` fails |
| `caddy` | The installed Caddy: `version`, `source`, `upstream` once Caddy's repository is configured and `ubuntu` otherwise, and `service`, the state of `caddy.service`; `null` when Caddy is not installed |
| `oidc` | The OpenID Provider: `enabled`, and `issuer`, its issuer URL, `null` while it is disabled |
### The /postgresql page
@@ -1260,22 +1281,33 @@ 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", "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 |
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", "grants", "public_client", "client_scopes", "resource_scopes", "roles", ...}, ...], "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"?, "grants"?, "public_client"?, "client_scopes"?, "resource_scopes"?, "roles"?}` | 201 and the account |
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email", "redirect_uris", "post_logout_redirect_uris", "allowed_groups", "grants", "public_client", "client_scopes", "resource_scopes", "roles"}`; `""` removes one of the first three, `[]` the values of the lists, `false` the public client | 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 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.
A display name has at most 64 printable characters and a description at most 256. The settings of
the OpenID Provider (see OpenID Provider) are:
- **URIs and allowed groups:** 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;
- **`grants`:** `client_credentials` and `device_code`; `public_client`, `true` or `false`;
- **`client_scopes`:** at most 16 scopes of resources, `<resource>:<scope>`;
- **`resource_scopes`:** at most 16 scope names of lowercase letters, digits, dots, underscores and
hyphens;
- **`roles`:** `[{"name", "description", "groups", "accounts"}]`, which replaces the account's roles:
role names of letters, digits, dots, underscores and hyphens, and existing groups and accounts.
The internal service accounts, `internal` true, are only listed: every other request on one, and
a role assigned to one, gets 403, as the router manages them, their display name and description
included. A password change, disabling, unlocking and removing revoke the tokens the account was
issued. A failure is otherwise as for `/users`, with 404 also for a name that is not a service
account.
### The /account API
@@ -1503,22 +1535,33 @@ 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 /oidc/jwks` | The public key ID tokens and access 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 |
| `POST /oidc/token` | Exchanges a code, a refresh token, a device code or a client's credentials for tokens |
| `POST /oidc/device_authorization` | Begins the device authorization grant: a device code and a user code |
| `GET`, `POST /oidc/device` | The page where a user enters a device's user code, signs in and allows the device |
| `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
**Clients**
A client is a service account with a redirect URI or a grant. 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:
wrong secrets lock the client for 15 minutes. A public client sends only its `client_id` (`none`).
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.
- **redirect URIs:** where the provider sends the browser back with a code; they give the client
the authorization code grant. A URI uses `https`, or `http` for `localhost`, `127.0.0.1` or
`::1`, and is matched exactly, but the port of an `http` one, which a native program chooses
when it starts (RFC 8252);
- **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;
- **grants:** `client_credentials` and `device_code`, besides the authorization code;
- **public client:** the client has no secret, as a native program or a CLI, which cannot keep
one; it cannot have `client_credentials`;
- **client scopes:** the scopes of resources the client may ask for, `<resource>:<scope>`.
```sh
echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b --service \
@@ -1527,34 +1570,131 @@ echo 'cl1ent-s3cret' | vpn-router-user add 3f2b8c1e-9a4d-4e6f-8b2a-1c5d7e9f0a3b
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.
**Grants**
| Grant | Who signs in | Tokens |
| --- | --- | --- |
| `authorization_code` | A user, in the browser, with PKCE (`S256`) | Access, refresh and ID token |
| `urn:ietf:params:oauth:grant-type:device_code` | A user, on another device | Access, refresh and ID token |
| `client_credentials` | The service account itself, with its secret | Access token |
| `refresh_token` | | A new access and refresh token |
The implicit and hybrid flows, the password grant and dynamic registration are refused. A
registered client is trusted, so a user is not asked for consent, except for a device.
**Signing in**
- **The page:** 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).
- **Lockout and rate limit:** failed sign-ins count towards the lockout. `POST /oidc/login`,
`/oidc/device` and `/oidc/device_authorization` take at most 10 calls a minute from one client
address, as `/auth` does.
- **Who may not sign in:** a disabled, locked or removed user, a service account, and a user
outside the client's allowed groups. The codes and tokens already issued to such a user stop
working.
- **The session:** the sign-in starts 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.
**Device authorization (RFC 8628)**
A client with `device_code`, such as a CLI on a host without a browser, signs a user in like this:
1. It posts `client_id` (and its secret, unless it is public), `scope` and optionally `resource` to
`/oidc/device_authorization`. The answer holds `device_code`, `user_code` (8 letters, shown as
`XXXX-XXXX`), `verification_uri`, `verification_uri_complete`, `expires_in` (600 seconds) and
`interval` (5 seconds).
2. It shows the user the code and the URI, then polls `/oidc/token` with
`grant_type=urn:ietf:params:oauth:grant-type:device_code` and `device_code`. Until the user
decides, the answer is `authorization_pending`; a poll sooner than the interval answers
`slow_down` and adds 5 seconds to it.
3. The user opens the URI on any device, enters the code, signs in, and allows or denies the
client, the resource and the scopes shown. The next poll answers the tokens, or
`access_denied`; an expired code answers `expired_token`.
```sh
curl -d client_id=backup-cli -d 'scope=openid profile' \
https://router.example.com:7444/oidc/device_authorization
```
**Client credentials**
A confidential client with `client_credentials` gets an access token as its own service account,
without a user. The scopes are those of a resource, and the profile scopes give the service
account's claims; `openid` is refused, and no refresh token is issued.
```sh
curl -u backup-job:'j0b-s3cret' -d grant_type=client_credentials -d resource=s3 -d scope=s3:read \
https://router.example.com:7444/oidc/token
```
**Resources, roles and scopes**
Every service account is also a resource, an application that checks the router's tokens, as an
API registration of Entra ID:
- **Scopes:** the resource scopes the account defines, such as `read`, which clients ask for as
`<resource>:<scope>` (`s3:read`) when their client scopes allow them.
- **Roles:** named roles, such as `Reader`, each assigned to groups, users and service accounts;
`vpn-router-role` manages them.
```sh
echo 's3-s3cret' | vpn-router-user add s3 --service --resource-scope read --resource-scope write
vpn-router-role add s3 Reader --description 'Reads every bucket'
vpn-router-role member-add s3 Reader --group backup
vpn-router-user update backup-job --grant client_credentials --client-scope s3:read
```
A request is for a resource when it names it with `resource` (RFC 8707), its name or
`<issuer>/<name>`, or asks for its scopes; more than one resource, or one that is not a
resource, is `invalid_target`. A scope the client may not ask for is `invalid_scope`. Without a
resource, the tokens are for the client itself. The resource is kept with the code, the device code
and the refresh token, and a token request naming another one is `invalid_target`.
| Command | What it does |
| --- | --- |
| `vpn-router-role list [<resource>]` | The roles with the groups and accounts they are assigned to |
| `vpn-router-role add <resource> <role> [--description D]` | Add a role |
| `vpn-router-role remove <resource> <role>` | Remove a role |
| `vpn-router-role member-add <resource> <role> --group G\|--account A` | Assign the role |
| `vpn-router-role member-remove <resource> <role> --group G\|--account A` | Take the role away |
A scope a client may ask for cannot be removed from its resource, nor the resource while a client
may ask for its scopes. Removing a service account removes its roles and takes it out of the
others; removing or renaming a group does the same in the roles.
**Tokens**
| Scope | Claims |
| --- | --- |
| `openid` (required) | `sub`, the `entryUUID` of the user's entry |
| `openid` (required but by `client_credentials`, which refuses it) | `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 |
| `<resource>:<scope>` | None; the scope is in the access token's `scope` |
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.
- **ID tokens:** RS256 JWTs for the client (`aud`), valid for 5 minutes, with the claims of the
scopes, `auth_time`, `amr` (`pwd`, `pwd` and `otp`, or `hwk` and `user` for a passkey), and
`roles`, the user's roles of the client itself. `/oidc/userinfo` answers the same claims.
- **Access tokens:** RS256 JWTs of RFC 9068 (`typ` `at+jwt`), valid for 5 minutes, which a
resource verifies with the JWKS. Their claims:
- `iss`, `sub`, `iat`, `exp` and `jti`;
- `aud`, the resource, or the client without one, and `client_id`, the client;
- `scope`, the scopes granted;
- `roles`, the roles of the resource the user or the service account holds, directly or by a
group;
- the claims of the scopes, and of a user `auth_time` and `amr`.
- **Roles are read at every issue,** refresh included, so a role taken away is gone from the next
token.
- **The key:** tokens are 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.
- **Lifetimes:** a code is valid for 1 minute and used once, a device code for 10 minutes, and a
refresh token for 8 hours and used once: each refresh answers a new one.
- **Storage and revocation:** the codes, device codes, tokens and sessions are kept, as their
SHA-256, in the database of the refresh tokens of `/auth`. Changing the password of a user or a
service account, disabling, unlocking or removing it ends its sessions and revokes its codes and
tokens.
## Web sites
@@ -1911,4 +2051,6 @@ cd debian-package
```
The build runs in a container and writes the package to `debian-package/out/`.
`./publish.sh` uploads it to the configured repository.
`./publish.sh` uploads it to the component `main` of the distributions `noble` and `resolute` of
the configured repository, skipping a distribution that has the version already;
`DISTRIBUTIONS` names others.
+94
View File
@@ -1,3 +1,97 @@
vpn-router (0.37.2-1) unstable; urgency=medium
fix: the web service, the metrics service and the bootstrap server log a failure in one line instead of a traceback, and nothing when a client goes away.
* A request whose client closed the connection is no longer logged.
* Any other failure of a request, a thread or the service is logged in
one line with the exception's type and message.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 01:59:19 +0100
vpn-router (0.37.1-1) unstable; urgency=medium
fix: /config answers the OpenID Provider's state and issuer.
* /config has oidc: enabled, and issuer, the issuer URL, null while the
provider is disabled.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 01:53:55 +0100
vpn-router (0.37.0-1) unstable; urgency=medium
feature: the OpenID Provider has the client credentials and device authorization grants, public clients, and service accounts as resources with roles and scopes in JWT access tokens.
* A client may have the grants client_credentials, by which a
confidential client gets an access token as its own service account,
and the device code (RFC 8628): POST /oidc/device_authorization gives
a device code and a user code, the user enters the code on
/oidc/device, signs in and allows or denies the device, and the device
polls /oidc/token. A public client has no secret.
* An http loopback redirect URI matches with any port.
* Every service account is a resource: it defines scopes, which clients
ask for as <resource>:<scope> when their client scopes allow them, and
roles, assigned to groups, users and service accounts. A token request
names its resource with resource (RFC 8707) or by its scopes.
* Access tokens are RS256 JWTs of RFC 9068 for the resource, or the
client, with client_id, scope, roles and the claims of the scopes; ID
tokens and /oidc/userinfo carry the user's roles of the client.
* vpn-router-user takes --grant, --public-client, --client-scope and
--resource-scope; the new vpn-router-role manages the roles; and
/service-accounts takes and answers grants, public_client,
client_scopes, resource_scopes and roles.
* Changing the password of a service account, disabling, unlocking or
removing it through /service-accounts revokes its tokens.
* POST /oidc/device and /oidc/device_authorization are rate limited as
/oidc/login is.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 30 Oct 2026 00:57:11 +0100
vpn-router (0.36.0-1) unstable; urgency=medium
feature: a shell opens with one of the user's passkeys in place of the password and the TOTP code.
* POST /shell/passkey begins a proof for one of the signed-in user's own
passkeys, with user verification; POST /shell takes the browser's
answer as {"passkey": {"id", "credential"}} in place of the password
and the code, and gives the ticket of the shell.
* A passkey that is not accepted is refused with 403. The shell still
needs the user's TOTP enrolment, which its login session and sudo in
it need.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 23:09:43 +0100
vpn-router (0.35.2-2) unstable; urgency=medium
update: the package is published for Ubuntu 26.04 (resolute) as well as 24.04 (noble).
* publish.sh uploads the package to the distributions noble and resolute
of the repository, component main, skipping a distribution that has
the version already; DISTRIBUTIONS names others. A router on Ubuntu
26.04 installs vpn-router from the distribution resolute.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 21:41:32 +0100
vpn-router (0.35.2-1) unstable; urgency=medium
fix: the web service answers /, /status and /status/full again while IKEv2 runs.
* The status of IKEv2 clients is collected again; the requests that
collect the status were dropped with a NameError since 0.35.1.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 19:03:16 +0100
vpn-router (0.35.1-1) unstable; urgency=medium
update: the router web service is split into the package vpnrouter_web, with one module per family of paths.
* /usr/lib/vpn-router/web initializes and serves; the package
/usr/lib/vpn-router/vpnrouter_web/ answers the requests, with a module
for the status, the sign-in, the identities, the connections, backups,
the PKI, logs, web sites, DNS, metrics and the shell. The paths, the
answers and the log messages are the same.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Thu, 29 Oct 2026 18:20:50 +0100
vpn-router (0.35.0-1) unstable; urgency=medium
feature: vre opens files from the Router Manager's shell in the editor of its Shell page.
+2
View File
@@ -21,6 +21,7 @@ src/usr/lib/vpn-router/editor-file usr/lib/vpn-router/
src/usr/bin/vpn-router-editor usr/bin/
src/usr/sbin/vpn-router-user usr/sbin/
src/usr/sbin/vpn-router-group usr/sbin/
src/usr/sbin/vpn-router-role usr/sbin/
src/usr/sbin/vpn-router-token usr/sbin/
src/usr/sbin/vpn-config-edit usr/sbin/
src/usr/sbin/vpn-router-backup usr/sbin/
@@ -31,6 +32,7 @@ src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
src/usr/lib/vpn-router/setup usr/lib/vpn-router/
src/usr/lib/vpn-router/web usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_web/*.py usr/lib/vpn-router/vpnrouter_web/
src/usr/lib/vpn-router/metrics usr/lib/vpn-router/
src/usr/lib/vpn-router/bootstrap usr/lib/vpn-router/
src/usr/lib/vpn-router/wait-for-libpq usr/lib/vpn-router/
+15 -10
View File
@@ -11,6 +11,8 @@ esac
OWNER="${OWNER:-slawek}"
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
# The Ubuntu releases whose distribution of the repository gets the package, in its component main.
DISTRIBUTIONS="${DISTRIBUTIONS:-noble resolute}"
# Reads the changelog copied into the builder image, so no bind mount is needed.
changelog_field() {
@@ -27,14 +29,17 @@ if [ ! -f "$DEB" ]; then
exit 1
fi
STATUS="$(curl -s -o /dev/null -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
"$SERVER_URL/api/v1/packages/$OWNER/debian/$PACKAGE/$VERSION")"
case "$STATUS" in
200) printf '%s %s is already published, skipping.\n' "$PACKAGE" "$VERSION"; exit 0 ;;
404) ;;
*) printf 'ERROR: version check returned HTTP %s.\n' "$STATUS" >&2; exit 1 ;;
esac
BODY="$(mktemp)"
trap 'rm -f "$BODY"' EXIT
curl --fail-with-body --user "$OWNER:$PACKAGES_TOKEN" \
--upload-file "$DEB" \
"$SERVER_URL/api/packages/$OWNER/debian/pool/noble/main/upload"
# Gitea answers 409 for a version that is in the distribution already.
for DISTRIBUTION in $DISTRIBUTIONS; do
STATUS="$(curl -s -o "$BODY" -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
--upload-file "$DEB" \
"$SERVER_URL/api/packages/$OWNER/debian/pool/$DISTRIBUTION/main/upload")"
case "$STATUS" in
2??) printf '%s %s is published in %s/main.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
409) printf '%s %s is already in %s/main, skipping.\n' "$PACKAGE" "$VERSION" "$DISTRIBUTION" ;;
*) printf 'ERROR: the upload to %s/main returned HTTP %s: %s\n' "$DISTRIBUTION" "$STATUS" "$(cat "$BODY")" >&2; exit 1 ;;
esac
done
@@ -8,6 +8,7 @@ the logs.
"""
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = 17081
@@ -51,5 +52,15 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body)
def failed(request, client_address):
"""Log a failed request in one line, and nothing when the client went away."""
failure = sys.exc_info()[1]
if not isinstance(failure, ConnectionError):
print(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}', file=sys.stderr, flush=True)
if __name__ == '__main__':
ThreadingHTTPServer(('', PORT), Handler).serve_forever()
sys.excepthook = lambda kind, value, _: print(f'failed: {kind.__name__}: {value}', file=sys.stderr, flush=True)
server = ThreadingHTTPServer(('', PORT), Handler)
server.handle_error = failed
server.serve_forever()
@@ -18,6 +18,9 @@ vpnRouterConsoleLogin TRUE to the system over the local socket.
ou=pki holds the router's PKI (vpnrouter_pki): an entry per CA, and under it an entry per
certificate the CA issued. Private keys are readable by root alone, the database's root DN.
ou=roles holds the roles of the resources of the OpenID Provider: an entry per role,
cn=<resource>:<role>, whose members are the groups, users and service accounts it is assigned to.
"""
import os
@@ -39,6 +42,7 @@ GROUPS = f'ou=groups,{BASE}'
SERVICES = f'ou=services,{BASE}'
POLICIES = f'ou=policies,{BASE}'
PKI = f'ou=pki,{BASE}'
ROLES = f'ou=roles,{BASE}'
DEFAULT_POLICY = f'cn=default,{POLICIES}'
TOTP_PARAMS = f'cn=totp,{POLICIES}'
MANAGER = f'cn=user-manager,{SERVICES}'
@@ -71,7 +75,12 @@ OVERLAYS = ('ppolicy', 'otp')
#: 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
#: groups whose members may sign in to it; vpnRouterGrantType, the further grants it may use,
#: vpnRouterPublicClient, whether it has no secret, and vpnRouterClientScope, the scopes of
#: resources it may ask for, as <resource>:<scope>. A service account with vpnRouterResourceScope
#: is a resource of the OpenID Provider with those scopes. vpnRouterRole is a role of a resource,
#: named cn=<resource>:<role>, with the DNs of the groups and accounts it is assigned to in
#: member. 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
@@ -112,18 +121,29 @@ SCHEMA_ATTRIBUTE_TYPES = (
"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 )",
f"( {SCHEMA_OID}.1.20 NAME 'vpnRouterGrantType' DESC 'VPN router: a grant an OpenID Connect client may use besides "
"the authorization code' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
f"( {SCHEMA_OID}.1.21 NAME 'vpnRouterPublicClient' DESC 'VPN router: whether an OpenID Connect client has no secret' "
"EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE )",
f"( {SCHEMA_OID}.1.22 NAME 'vpnRouterClientScope' DESC 'VPN router: a scope of a resource an OpenID Connect client "
"may ask for' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )",
f"( {SCHEMA_OID}.1.23 NAME 'vpnRouterResourceScope' DESC 'VPN router: a scope of a resource of the OpenID Provider' "
"EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{64} )",
)
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 $ vpnRouterPasskey $ vpnRouterRedirectUri $ "
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup ) )",
"vpnRouterPostLogoutRedirectUri $ vpnRouterAllowedGroup $ vpnRouterGrantType $ vpnRouterPublicClient $ "
"vpnRouterClientScope $ vpnRouterResourceScope ) )",
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 ) )",
f"( {SCHEMA_OID}.2.3 NAME 'vpnRouterCertificate' DESC 'VPN router: a certificate a CA of the PKI issued' SUP top "
"STRUCTURAL MUST serialNumber MAY ( cn $ userCertificate $ vpnRouterPrivateKey $ vpnRouterKind $ vpnRouterTemplate $ "
"vpnRouterSubjectAltName $ vpnRouterNotAfter $ vpnRouterRevoked $ vpnRouterRevocationReason ) )",
f"( {SCHEMA_OID}.2.4 NAME 'vpnRouterRole' DESC 'VPN router: a role of a resource of the OpenID Provider' SUP top "
"STRUCTURAL MUST cn MAY ( description $ member ) )",
)
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
@@ -152,6 +172,7 @@ ACCESS = (
f'to attrs=oathSecret by dn.exact="{MANAGER}" =w by * none',
f'to dn.subtree="{USERS}" by dn.exact="{MANAGER}" manage by * read',
f'to dn.subtree="{GROUPS}" by dn.exact="{MANAGER}" manage by * read',
f'to dn.subtree="{ROLES}" by dn.exact="{MANAGER}" manage by * read',
'to * by * read',
)
@@ -333,7 +354,7 @@ def provision():
database = _ensure_database(connection)
_ensure_overlays(connection, database)
_ensure_entry(connection, BASE, {'objectClass': [b'dcObject', b'organization'], 'dc': [b'vpn-router'], 'o': [b'vpn-router']})
for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI):
for unit in (USERS, GROUPS, SERVICES, POLICIES, PKI, ROLES):
_ensure_entry(connection, unit, {'objectClass': [b'organizationalUnit'], 'ou': [unit.split(',')[0][3:].encode()]})
_ensure_entry(connection, DEFAULT_POLICY, {
'objectClass': [b'organizationalRole', b'pwdPolicy'],
@@ -3,28 +3,45 @@
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.
clients, the service accounts with redirect URIs or grants (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 public client
sends only its ID. 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 /oidc/jwks the public key the ID tokens and the access 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
POST /oidc/token exchange a code, a refresh token or a device code for tokens, or a client's
credentials for an access token
POST /oidc/device_authorization begin the device authorization grant (RFC 8628): a device code and a user code
GET, POST /oidc/device the page where the user enters the user code, signs in and is asked to allow
the device
POST /oidc/device/decide allow or deny the device
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.
The grants: authorization_code for a client with redirect URIs, client_credentials, by which a confidential client
gets an access token as itself, and the device code, as the client's grants give them, each with refresh_token but
client_credentials. A client asks for tokens for a resource, a service account, with resource (RFC 8707), its name or
<issuer>/<name>, or by the resource's scopes it asks for, <resource>:<scope>, of its client scopes; without one the
tokens are for the client itself.
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, for the client. Access tokens are RS256 JWTs of RFC 9068 with the same key, for
the resource or the client: iss, sub, aud, client_id, iat, exp, jti, scope, roles, the claims of the scopes, and of a
user auth_time and amr. 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 but with client_credentials, which refuses it; profile, preferred_username and
the names; email; groups, the names of the user's groups; their claims are in the ID token, the access token and the
userinfo answer; and the scopes of the resources. roles are the roles of the resource assigned to the user or service
account or a group of it (vpnrouter_users.roles_held), read at every issue; the ID token and the userinfo answer have
those of the client. A code is valid CODE_SECONDS, an access token and an ID token ACCESS_SECONDS, a refresh token
REFRESH_SECONDS, used once, and a device code DEVICE_SECONDS, polled at most every DEVICE_INTERVAL seconds, 5 more after
each poll that came too soon. A session, the cookie COOKIE of the issuer's /oidc/ path, lasts SESSION_SECONDS. Codes,
tokens, device codes 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 or a service account's. A client is registered by an
administrator, so a user is not asked for consent, but for a device, which the user allows on the router's page.
"""
import base64
@@ -45,8 +62,13 @@ 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.common import Request, add_params_to_uri
from oauthlib.oauth2 import (AccessDeniedError, FatalClientError, InvalidClientError, InvalidGrantError,
InvalidRequestError, InvalidScopeError, OAuth2Error, UnauthorizedClientError,
UnsupportedGrantTypeError)
from oauthlib.oauth2.rfc6749 import utils
from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase
from oauthlib.oauth2.rfc6749.tokens import random_token_generator
from oauthlib.openid import RequestValidator, Server
from oauthlib.openid.connect.core.exceptions import LoginRequired
@@ -63,6 +85,14 @@ CODE_SECONDS = 60
ACCESS_SECONDS = 300
REFRESH_SECONDS = 8 * 3600
SESSION_SECONDS = 8 * 3600
DEVICE_SECONDS = 600
DEVICE_INTERVAL = 5
#: The grant type of the device authorization grant.
DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
#: A user code: USER_CODE_LENGTH of these letters, shown in two halves.
USER_CODE_LETTERS = 'BCDFGHJKLMNPQRSTVWXZ'
USER_CODE_LENGTH = 8
COOKIE = 'vpn_router_oidc'
@@ -75,10 +105,11 @@ SCOPE_CLAIMS = {
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.
#: passkey, which verifies the user; a service account's client credentials are its password.
PASSWORD_AMR = ['pwd']
OTP_AMR = ['pwd', 'otp']
PASSKEY_AMR = ['hwk', 'user']
SERVICE_AMR = ['pwd']
TEMPLATE = 'oidc-page.html.j2'
@@ -96,6 +127,26 @@ _pages = jinja2.Environment(loader=jinja2.FileSystemLoader(str(vpnrouter.TEMPLAT
undefined=jinja2.StrictUndefined)
class InvalidTarget(OAuth2Error):
"""A resource that is unknown, or more than one (RFC 8707)."""
error = 'invalid_target'
class AuthorizationPending(OAuth2Error):
"""A device code the user has not yet allowed or denied (RFC 8628)."""
error = 'authorization_pending'
class SlowDown(OAuth2Error):
"""A device code polled sooner than its interval (RFC 8628)."""
error = 'slow_down'
class ExpiredToken(OAuth2Error):
"""A device code that has expired (RFC 8628)."""
error = 'expired_token'
def ensure_key():
"""Make the signing key when it is missing."""
if KEY.is_file() and KEY.stat().st_size:
@@ -122,30 +173,33 @@ def public_jwk(key):
return {**jwk, 'kid': vpnrouter_auth.b64encode(thumbprint), 'use': 'sig', 'alg': 'RS256'}
def sign(claims):
def sign(claims, headers=None):
"""The claims as an RS256 JWT with the key's kid and the headers given."""
key = private_key()
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid']})
return jwt.encode(claims, key, algorithm='RS256', headers={'kid': public_jwk(key)['kid'], **(headers or {})})
def discovery(issuer):
"""The discovery document of OpenID Connect Discovery 1.0."""
def discovery(issuer, resource_scopes):
"""The discovery document of OpenID Connect Discovery 1.0, with the device authorization endpoint of RFC 8628;
resource_scopes are the scopes of the resources."""
return {
'issuer': issuer,
'authorization_endpoint': f'{issuer}/oidc/authorize',
'token_endpoint': f'{issuer}/oidc/token',
'device_authorization_endpoint': f'{issuer}/oidc/device_authorization',
'userinfo_endpoint': f'{issuer}/oidc/userinfo',
'jwks_uri': f'{issuer}/oidc/jwks',
'end_session_endpoint': f'{issuer}/oidc/logout',
'scopes_supported': list(SCOPES),
'scopes_supported': [*SCOPES, *resource_scopes],
'response_types_supported': ['code'],
'response_modes_supported': ['query'],
'grant_types_supported': ['authorization_code', 'refresh_token'],
'grant_types_supported': ['authorization_code', 'refresh_token', 'client_credentials', DEVICE_GRANT],
'subject_types_supported': ['public'],
'id_token_signing_alg_values_supported': ['RS256'],
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post'],
'token_endpoint_auth_methods_supported': ['client_secret_basic', 'client_secret_post', 'none'],
'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)],
'claims_supported': ['iss', 'sub', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'amr', 'at_hash', 'client_id',
'scope', 'roles', *(claim for claims in SCOPE_CLAIMS.values() for claim in claims)],
}
@@ -159,19 +213,52 @@ def directory():
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):
def account_of(name):
"""A namespace of the vpnrouter_users.summary of the service account of the name, else None."""
if not isinstance(name, str):
return None
with directory() as connection:
try:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, client_id))
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
except vpnrouter_users.Failure:
return None
if not user['service'] or not user['enabled'] or not user['redirect_uris']:
return types.SimpleNamespace(**user) if user['service'] else None
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 or grants; else None."""
account = account_of(client_id)
if account is None or not account.enabled or not (account.redirect_uris or account.grants):
return None
return types.SimpleNamespace(client_id=client_id, **user)
account.client_id = client_id
return account
def resource_of(name):
"""The resource of the name, a namespace of its vpnrouter_users.summary, when it is an enabled service account that
is not internal; else None."""
account = account_of(name)
return account if account is not None and account.enabled and not account.internal else None
def resource_scopes():
"""The scopes of every resource, <resource>:<scope>, sorted."""
with directory() as connection:
users = vpnrouter_users.list_users(connection)['users']
return sorted(f'{user["name"]}:{scope}' for user in users
if user['service'] and user['enabled'] and not user['internal'] for scope in user['resource_scopes'])
def account_claims(connection, name):
"""The summary of the entry of the name and its claims of every scope, with sub; Failure when there is none."""
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
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']
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 user, {claim: value for claim, value in claims.items() if value is not None}
def subject(name, client):
@@ -179,20 +266,26 @@ def subject(name, client):
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)
user, claims = account_claims(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):
if client.allowed_groups and not set(client.allowed_groups) & set(claims['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}
return claims
def service_subject(name):
"""The claims of every scope of the service account, with sub."""
with directory() as connection:
return account_claims(connection, name)[1]
def roles_at(resource, name):
"""The roles of the resource the user or service account holds."""
with directory() as connection:
return vpnrouter_users.roles_held(connection, resource, name)
def claims_of(person, scopes):
@@ -201,6 +294,46 @@ def claims_of(person, scopes):
if claim in person}}
def target_of(issuer, resource, scopes):
"""The name of the resource a request with the resource parameter, None when absent, and the scopes names, None
for none; InvalidTarget when they name more than one or one that is not a resource."""
names = {scope.partition(':')[0] for scope in scopes if scope not in SCOPES}
if resource is not None:
names.add(resource[len(issuer) + 1:] if isinstance(resource, str) and resource.startswith(f'{issuer}/') else resource)
if not names:
return None
if len(names) > 1:
raise InvalidTarget(description='the resource and the scopes name more than one resource')
name = names.pop()
if resource_of(name) is None:
raise InvalidTarget(description=f'{name} is not a resource')
return name
def scopes_allowed(client, scopes, target, grant_type):
"""Whether the client may ask for the scopes: openid, required but by client_credentials, which refuses it, the
other scopes of SCOPES, and those of its client scopes that belong to the target."""
for scope in scopes:
if scope not in SCOPES and (scope not in client.client_scopes or scope.partition(':')[0] != target):
return False
return ('openid' not in scopes) if grant_type == 'client_credentials' else ('openid' in scopes)
def same_redirect(registered, given):
"""Whether the redirect URI given is the registered one; an http URI of a loopback host may have any port (RFC 8252
7.3)."""
if registered == given:
return True
try:
wanted, found = urllib.parse.urlsplit(registered), urllib.parse.urlsplit(given)
# A port that is not a number makes .port raise ValueError.
wanted.port, found.port
except (ValueError, TypeError, AttributeError):
return False
return wanted.scheme == 'http' and wanted.hostname in vpnrouter_users.LOOPBACK_HOSTS and not found.fragment and \
(wanted.scheme, wanted.hostname, wanted.path, wanted.query) == (found.scheme, found.hostname, found.path, found.query)
def cookie_value(cookies):
"""The value of the session cookie in a Cookie header, or ''."""
try:
@@ -220,11 +353,31 @@ def session_of(cfg, cookies):
return Session(*row) if row else None
def access_token(cfg, request):
"""The access token of a token request: a JWT of RFC 9068 for the request's target, or the client."""
now = int(time.time())
client_id = request.client.client_id
service = request.grant_type == 'client_credentials'
name = client_id if service else request.user
person = service_subject(client_id) if service else request.oidc_person
target = getattr(request, 'oidc_target', None)
claims = {**claims_of(person, request.scopes), 'iss': vpnrouter.oidc_issuer(cfg), 'aud': target or client_id,
'client_id': client_id, 'iat': now, 'exp': now + ACCESS_SECONDS, 'jti': secrets.token_urlsafe(16),
'scope': ' '.join(request.scopes)}
roles = roles_at(target or client_id, name)
if roles:
claims['roles'] = roles
if not service:
claims.update({'auth_time': int(request.auth_time.timestamp()), 'amr': request.amr})
return sign(claims, {'typ': 'at+jwt'})
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.
The grants are those of validate_grant_type, with PKCE S256 for the authorization code. The values of a code, a
token, a device code or a session found are kept on the request as oidc_code, oidc_scopes and oidc_person, the
resource the tokens are for as oidc_target.
"""
def __init__(self, cfg):
@@ -243,6 +396,14 @@ class Validator(RequestValidator):
def session(self, request):
return session_of(self.cfg, request.headers.get('Cookie'))
def check_target(self, request, stored):
"""Refuse a resource parameter that names another resource than the one the grant was made for."""
resource = getattr(request, 'resource', None)
if 'resource' in request.duplicate_params or \
resource is not None and target_of(vpnrouter.oidc_issuer(self.cfg), resource, []) != stored:
raise InvalidTarget(description='the grant was made for another resource', request=request)
request.oidc_target = stored
# Clients.
def validate_client_id(self, client_id, request, *args, **kwargs):
@@ -250,7 +411,7 @@ class Validator(RequestValidator):
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
return any(same_redirect(uri, redirect_uri) for uri in request.client.redirect_uris)
def get_default_redirect_uri(self, client_id, request, *args, **kwargs):
return None
@@ -259,17 +420,29 @@ class Validator(RequestValidator):
return response_type == 'code'
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
return 'openid' in scopes and set(scopes) <= set(SCOPES)
"""Whether the client may ask for the scopes; the resource they are for is kept as oidc_target."""
if 'resource' in request.duplicate_params:
raise InvalidTarget(description='give one resource', request=request)
try:
request.oidc_target = target_of(vpnrouter.oidc_issuer(self.cfg), getattr(request, 'resource', None), scopes)
except InvalidTarget as error:
raise InvalidTarget(description=error.description, request=request)
return scopes_allowed(client, scopes, request.oidc_target, request.grant_type)
def get_default_scopes(self, client_id, request, *args, **kwargs):
return ['openid']
return [] if request.grant_type == 'client_credentials' else ['openid']
def is_pkce_required(self, client_id, request):
return True
def client_authentication_required(self, request, *args, **kwargs):
"""Whether the client must sign the request with its secret: unless it is a public client named by client_id."""
client = client_of(request.client_id)
return client is None or not client.public_client
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."""
ID and secret form-encoded (RFC 6749 2.3.1), or in the body; a public client has none."""
name, secret = request.client_id, request.client_secret
header = request.headers.get('Authorization', '')
if header[:6].lower() == 'basic ':
@@ -279,7 +452,7 @@ class Validator(RequestValidator):
return False
name, secret = urllib.parse.unquote_plus(name), urllib.parse.unquote_plus(secret)
client = client_of(name)
if client is None:
if client is None or client.public_client:
return False
try:
vpnrouter_users.login(name, secret, None)
@@ -289,10 +462,24 @@ class Validator(RequestValidator):
return True
def authenticate_client_id(self, client_id, request, *args, **kwargs):
return False
"""Accept a public client by its ID."""
client = client_of(client_id)
if client is None or not client.public_client:
return False
request.client = client
return True
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
return grant_type in ('authorization_code', 'refresh_token')
"""The grants of the client: authorization_code with redirect URIs, client_credentials but for a public client,
the device code, and refresh_token with either of the first and the last."""
grants = set(client.grants) - ({'client_credentials'} if client.public_client else set())
if client.redirect_uris:
grants.add('authorization_code')
if 'device_code' in grants:
grants.add(DEVICE_GRANT)
if grants & {'authorization_code', DEVICE_GRANT}:
grants.add('refresh_token')
return grant_type in grants
def validate_user(self, username, password, client, request, *args, **kwargs):
return False
@@ -300,15 +487,15 @@ class Validator(RequestValidator):
# 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)',
self.call(('CALL oidc_code_save(%s, %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)))
getattr(request, 'oidc_target', None), 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
'auth_time', 'amr', 'resource'), row)) if row else None
def get_authorization_code_scopes(self, client_id, code, redirect_uri, request):
found = self.code(code)
@@ -321,6 +508,7 @@ class Validator(RequestValidator):
person = subject(found['name'], client)
if person is None:
return False
self.check_target(request, found['resource'])
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
@@ -340,20 +528,53 @@ class Validator(RequestValidator):
def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs):
self.call(('CALL oidc_code_delete(%s)', (vpnrouter_auth.digest(code),)))
# Device codes.
def poll_device(self, code, request):
"""Poll the device code for the request's client: with the user's sign-in on the request when the user allowed
it, else the error RFC 8628 gives."""
row = self.fetch('SELECT * FROM oidc_device_poll(%s)', (vpnrouter_auth.digest(code),)) \
if isinstance(code, str) and code else None
found = dict(zip(('client_id', 'scopes', 'resource', 'state', 'name', 'auth_time', 'amr', 'expired', 'too_soon'),
row)) if row else None
if found is None or found['client_id'] != request.client.client_id:
raise InvalidGrantError(description='the device code is unknown', request=request)
if found['expired']:
raise ExpiredToken(description='the device code has expired', request=request)
if found['state'] == 'pending':
raise (SlowDown if found['too_soon'] else AuthorizationPending)(request=request)
if found['state'] == 'denied':
self.call(('CALL oidc_device_delete(%s)', (vpnrouter_auth.digest(code),)))
raise AccessDeniedError(description='the user denied the device', request=request)
person = subject(found['name'], request.client)
if person is None:
raise AccessDeniedError(description=f'{found["name"]} may not sign in to this client', request=request)
self.check_target(request, found['resource'])
request.user, request.scopes, request.auth_time, request.amr = found['name'], found['scopes'], found['auth_time'], found['amr']
request.oidc_person = person
def delete_device(self, code):
self.call(('CALL oidc_device_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
return dict(zip(('client_id', 'name', 'scopes', 'auth_time', 'amr', 'resource'), 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)',
"""Keep the access token and the refresh token; a refresh token that was exchanged is used up. A token of
client_credentials is kept for its service account."""
if request.grant_type == 'client_credentials':
name, auth_time, amr = request.client.client_id, datetime.datetime.now(datetime.timezone.utc), SERVICE_AMR
else:
name, auth_time, amr = request.user, request.auth_time, request.amr
values = (request.client.client_id, name, request.scopes, auth_time, amr, getattr(request, 'oidc_target', None))
statements = [('CALL oidc_token_save(%s, %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)',
statements.append(('CALL oidc_token_save(%s, %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),)))
@@ -366,6 +587,7 @@ class Validator(RequestValidator):
person = subject(found['name'], client)
if person is None:
return False
self.check_target(request, found['resource'])
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
@@ -385,7 +607,9 @@ class Validator(RequestValidator):
return True
def get_userinfo_claims(self, request):
return claims_of(request.oidc_person, request.oidc_scopes)
claims = claims_of(request.oidc_person, request.oidc_scopes)
roles = roles_at(request.client.client_id, request.user)
return {**claims, 'roles': roles} if roles else claims
# OpenID Connect.
@@ -394,6 +618,9 @@ class Validator(RequestValidator):
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})
roles = roles_at(request.client.client_id, request.user)
if roles:
id_token['roles'] = roles
return sign(id_token)
def validate_silent_login(self, request):
@@ -417,10 +644,100 @@ class Validator(RequestValidator):
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."""
class DeviceCodeGrant(GrantTypeBase):
"""The token request of the device authorization grant (RFC 8628): a device code the user allowed gives an access
token, a refresh token and, with openid, an ID token; until then the request is answered authorization_pending or
slow_down."""
def create_token_response(self, request, token_handler):
headers = self._get_default_headers()
try:
self.validate_token_request(request)
except OAuth2Error as error:
headers.update(error.headers)
return headers, error.json, error.status_code
token = token_handler.create_token(request, refresh_token=True)
if 'openid' in request.scopes:
token['id_token'] = self.request_validator.finalize_id_token(
{'aud': request.client.client_id, 'iat': int(time.time())}, token, token_handler, request)
self.request_validator.save_token(token, request)
self.request_validator.delete_device(request.device_code)
return headers, json.dumps(token), 200
def validate_token_request(self, request):
if request.grant_type != DEVICE_GRANT:
raise UnsupportedGrantTypeError(request=request)
for param in ('grant_type', 'device_code'):
if param in request.duplicate_params:
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
request.device_code = getattr(request, 'device_code', None)
if not request.device_code:
raise InvalidRequestError(description='Missing device_code parameter.', request=request)
if self.request_validator.client_authentication_required(request):
if not self.request_validator.authenticate_client(request):
raise InvalidClientError(request=request)
elif not self.request_validator.authenticate_client_id(request.client_id, request):
raise InvalidClientError(request=request)
self.validate_grant_type(request)
self.request_validator.poll_device(request.device_code, request)
def user_code_of(value):
"""The user code a user typed, without the dash and spaces, in capitals; '' when it is not one."""
code = ''.join(character for character in (value or '').upper() if character.isalnum())
return code if len(code) == USER_CODE_LENGTH and all(character in USER_CODE_LETTERS for character in code) else ''
def device_authorization(validator, cfg, text, headers):
"""Answer a device authorization request (RFC 8628 3.1): the client, public by client_id or confidential with its
secret, with the device_code grant, the scopes and the resource; the device code, the user code and the
verification URIs."""
issuer = vpnrouter.oidc_issuer(cfg)
request = Request(f'{issuer}/oidc/device_authorization', 'POST', text, headers)
for param in ('scope', 'resource'):
if param in request.duplicate_params:
raise InvalidRequestError(description=f'Duplicate {param} parameter.', request=request)
if validator.client_authentication_required(request):
if not validator.authenticate_client(request):
raise InvalidClientError(request=request)
elif not validator.authenticate_client_id(request.client_id, request):
raise InvalidClientError(request=request)
if not validator.validate_grant_type(request.client.client_id, DEVICE_GRANT, request.client, request):
raise UnauthorizedClientError(description='the client may not use the device authorization grant', request=request)
scopes = utils.scope_to_list(request.scope) or ['openid']
target = target_of(issuer, getattr(request, 'resource', None), scopes)
if not scopes_allowed(request.client, scopes, target, DEVICE_GRANT):
raise InvalidScopeError(description='the client may not ask for these scopes; openid is required', request=request)
device_code = secrets.token_urlsafe(32)
user_code = ''.join(secrets.choice(USER_CODE_LETTERS) for _ in range(USER_CODE_LENGTH))
with vpnrouter_auth.database(cfg) as connection:
connection.execute('CALL oidc_device_save(%s, %s, %s, %s, %s, %s, %s)',
(vpnrouter_auth.digest(device_code), vpnrouter_auth.digest(user_code), request.client.client_id,
scopes, target, DEVICE_INTERVAL, DEVICE_SECONDS))
shown = f'{user_code[:USER_CODE_LENGTH // 2]}-{user_code[USER_CODE_LENGTH // 2:]}'
return document(200, {'device_code': device_code, 'user_code': shown, 'verification_uri': f'{issuer}/oidc/device',
'verification_uri_complete': f'{issuer}/oidc/device?' + urllib.parse.urlencode({'user_code': shown}),
'expires_in': DEVICE_SECONDS, 'interval': DEVICE_INTERVAL})
def device_of(cfg, user_code):
"""The waiting device code of a user code, {client_id, scopes, resource}, or None."""
code = user_code_of(user_code)
if not code:
return None
with vpnrouter_auth.database(cfg) as connection:
row = connection.execute('SELECT * FROM oidc_device_find(%s)', (vpnrouter_auth.digest(code),)).fetchone()
return dict(zip(('client_id', 'scopes', 'resource'), row)) if row else None
def page(status, cfg, title, message='', error='', form=False, request='', name='', headers=(), action='/oidc/login',
device=False, user_code='', decide=False, items=()):
"""An HTML page of TEMPLATE: a message, an error and items; with form the sign-in form, posted to action with the
authorization request, or with device the user code field, with or without the sign-in fields; with decide the
Allow and Deny buttons of the device of the user code."""
body = _pages.get_template(TEMPLATE).render(title=title, message=message, error=error, form=form, request=request,
name=name, issuer=vpnrouter.oidc_issuer(cfg))
name=name, issuer=vpnrouter.oidc_issuer(cfg), action=action, device=device,
user_code=user_code, decide=decide, items=list(items))
return status, 'text/html; charset=utf-8', body.encode(), [*PAGE_HEADERS, *headers]
@@ -443,6 +760,30 @@ def cookie(value, seconds):
return 'Set-Cookie', f'{COOKIE}={value}; Path=/oidc/; Max-Age={seconds}; Secure; HttpOnly; SameSite=Lax'
def sign_in(form):
"""The name and the amr of a sign-in with the form's name, password and code, or its passkey; Failure when it is
refused, ValueError when the passkey's answer is not JSON."""
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', '')))
return name, PASSKEY_AMR
name, code = form.get('name', '').strip(), 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)
return name, OTP_AMR if code else PASSWORD_AMR
def start_session(cfg, name, amr):
"""Start a session of the user: a namespace of the Session and the header that sets its cookie."""
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))
return types.SimpleNamespace(session=Session(name, datetime.datetime.now(datetime.timezone.utc), amr),
cookie=cookie(value, SESSION_SECONDS))
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.
@@ -491,27 +832,72 @@ def login(server, cfg, form, headers):
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
name, amr = sign_in(form)
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)
return authorize(server, cfg, params, headers, fresh=start_session(cfg, name, amr))
def device_page(cfg, status=200, signed_in=False, user_code='', name='', error='', headers=()):
"""The page where the user enters the user code of a device, with the sign-in fields unless signed_in."""
return page(status, cfg, 'Sign in on a device', message='Enter the code the device shows.', error=error,
form=not signed_in, name=name, headers=headers, action='/oidc/device', device=True, user_code=user_code)
def device(cfg, form, headers):
"""The device page's form: sign in when the browser has no session, then ask the user to allow the device of the
user code."""
user_code, fresh = form.get('user_code', ''), None
session = session_of(cfg, headers.get('Cookie'))
if session is None:
try:
name, amr = sign_in(form)
except vpnrouter_users.Failure as failure:
return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(), error=str(failure))
except ValueError:
return device_page(cfg, 401, user_code=user_code, name=form.get('name', '').strip(),
error='the passkey is not accepted')
fresh = start_session(cfg, name, amr)
session = fresh.session
extra = [fresh.cookie] if fresh else []
found = device_of(cfg, user_code)
client = client_of(found['client_id']) if found else None
if client is None:
return device_page(cfg, 400, signed_in=True, user_code=user_code, error='the code is wrong or has expired',
headers=extra)
shown = client.display_name or client.client_id
if subject(session.name, client) is None:
return device_page(cfg, 403, signed_in=True, user_code=user_code, headers=extra,
error=f'{session.name} may not sign in to {shown}')
items = ([f'resource: {found["resource"]}'] if found['resource'] else []) + [f'scope: {scope}' for scope in found['scopes']]
return page(200, cfg, f'Allow {shown}?', message=f'{shown} asks to sign in as {session.name} on a device.',
items=items, decide=True, user_code=user_code_of(user_code), headers=extra)
def decide(cfg, form, headers):
"""Allow or deny the device of the user code as the user of the session."""
session = session_of(cfg, headers.get('Cookie'))
if session is None:
return device_page(cfg, 401, user_code=form.get('user_code', ''), error='sign in first')
found = device_of(cfg, form.get('user_code', ''))
client = client_of(found['client_id']) if found else None
if client is None or subject(session.name, client) is None:
return device_page(cfg, 400, signed_in=True, user_code=form.get('user_code', ''),
error='the code is wrong or has expired')
allowed = form.get('decision') == 'allow'
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)
decided = connection.execute('SELECT oidc_device_decide(%s, %s, %s, %s, %s)',
(vpnrouter_auth.digest(user_code_of(form['user_code'])), session.name,
session.auth_time, session.amr, allowed)).fetchone()[0]
if not decided:
return device_page(cfg, 400, signed_in=True, error='the code is wrong or has expired')
shown = client.display_name or client.client_id
if allowed:
return page(200, cfg, 'The device is signed in', message=f'{shown} is signed in as {session.name}. You can return '
'to the device.')
return page(200, cfg, 'The device is denied', message=f'{shown} is not signed in.')
def logout(cfg, query, headers):
@@ -552,9 +938,12 @@ def answer(method, target, headers, body):
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)
validator = Validator(cfg)
server = Server(validator, token_expires_in=ACCESS_SECONDS, token_generator=lambda request: access_token(cfg, request),
refresh_token_generator=random_token_generator)
server.grant_types[DEVICE_GRANT] = DeviceCodeGrant(validator)
if (method, path) == ('GET', '/.well-known/openid-configuration'):
return document(200, discovery(issuer))
return document(200, discovery(issuer, resource_scopes()))
if (method, path) == ('GET', '/oidc/jwks'):
return document(200, {'keys': [public_jwk(private_key())]})
if path == '/oidc/authorize' and method in ('GET', 'POST'):
@@ -565,6 +954,15 @@ def answer(method, target, headers, body):
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 (method, path) == ('POST', '/oidc/device_authorization'):
return device_authorization(validator, cfg, text, headers)
if (method, path) == ('GET', '/oidc/device'):
signed_in = session_of(cfg, headers.get('Cookie')) is not None
return device_page(cfg, signed_in=signed_in, user_code=dict(urllib.parse.parse_qsl(query)).get('user_code', ''))
if (method, path) == ('POST', '/oidc/device'):
return device(cfg, dict(urllib.parse.parse_qsl(text)), headers)
if (method, path) == ('POST', '/oidc/device/decide'):
return decide(cfg, dict(urllib.parse.parse_qsl(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'):
@@ -1,5 +1,5 @@
"""The passkeys of the router's users, for signing in to the Router Manager and the OpenID Provider (vpnrouter_oidc),
with python3-fido2.
and for opening a shell, 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.
@@ -8,10 +8,11 @@ user entry: a JSON object {"id", "credential", "label", "created"}, id the crede
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.
Registration, sign-in and the proof of a signed-in user for a shell 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 or a proof that fails
gives status 5 whatever the reason.
"""
import json
@@ -172,3 +173,30 @@ def login_complete(connection, pending, response):
except (ValueError, KeyError, TypeError):
raise refused
return name
def shell_begin(connection, name):
"""Begin the proof of the signed-in user for a shell: {"id", "options"}, options those of navigator.credentials.get
for the user's own passkeys, with user verification."""
own = credentials(vpnrouter_users.entry(connection, name))
if not own:
raise vpnrouter_users.Failure(f'{name} has no passkey; add one on the Account page', 2)
options, state = server().authenticate_begin(own, user_verification=UserVerificationRequirement.REQUIRED)
return {'id': keep('shell', name, state), 'options': dict(options)}
def shell_complete(connection, name, pending, response):
"""Check the browser's answer to a begun proof of the user for a shell: one of the user's own passkeys, with the
user verified, while the user is enabled and not locked."""
refused = vpnrouter_users.Failure('the passkey is not accepted', 5)
state = take('shell', name, pending)
if state is None or not isinstance(response, dict):
raise refused
current = vpnrouter_users.entry(connection, name)
user = vpnrouter_users.summary(current)
if not user['enabled'] or user['locked']:
raise refused
try:
server().authenticate_complete(state, credentials(current), response)
except (ValueError, KeyError, TypeError):
raise refused
@@ -4,6 +4,7 @@ Only the Python standard library is used, so the bootstrap server, which must ru
is broken, can use it.
"""
import sys
import threading
import time
from http.server import ThreadingHTTPServer
@@ -14,9 +15,21 @@ def serve(wanted, handler, log, interval):
wanted answers ([(address, port), ...], '') or ([], the reason not to listen). A server whose
place is no longer wanted is stopped, and a new one listens at each new place; a place that
cannot be bound is tried again the next round. Every new message is logged with log. Does
not return.
cannot be bound is tried again the next round. Every new message is logged with log. A request
whose client went away is not logged; any other exception a request, a thread or the process
does not handle is logged with log in one line, without a traceback. Does not return.
"""
def failed(request, client_address):
failure = sys.exc_info()[1]
if not isinstance(failure, ConnectionError):
log(f'a request from {client_address[0]} failed: {type(failure).__name__}: {failure}')
def thread_failed(hook):
if not issubclass(hook.exc_type, (SystemExit, ConnectionError)):
log(f'{hook.thread.name if hook.thread else "a thread"} failed: {hook.exc_type.__name__}: {hook.exc_value}')
threading.excepthook = thread_failed
sys.excepthook = lambda kind, value, _: log(f'failed: {kind.__name__}: {value}')
servers, reported = {}, None
while True:
targets, reason = wanted()
@@ -34,6 +47,7 @@ def serve(wanted, handler, log, interval):
except OSError as exc:
messages.append(f'cannot listen on {target[0]}:{target[1]}: {exc}')
continue
server.handle_error = failed
thread = threading.Thread(target=server.serve_forever, name=f'server {target[0]}', daemon=True)
thread.start()
servers[target] = (server, thread)
@@ -15,10 +15,18 @@ 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 service account with redirect URIs or grants 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, allowed_groups, the groups whose members may sign in to it,
every user who is not a service account when there are none, grants, those of GRANTS it may use besides the
authorization code, public_client, whether it signs no request with its secret, which a client with client_credentials
must, and client_scopes, the scopes of resources it may ask for, as <resource>:<scope>.
A service account is also a resource of the OpenID Provider, the audience of the tokens issued for it: resource_scopes
are the scopes it defines, and its roles (roles_of_resource) are entries cn=<resource>:<role> of vpnrouter_ldap.ROLES
whose members are the groups and accounts the role is assigned to. A scope a client may ask for cannot be removed, nor
the resource while a client may ask for its scopes; removing an account removes its roles and takes it out of the
others, and removing or renaming a group does the same in the roles.
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
@@ -105,9 +113,20 @@ LABELS = (('display_name', 'displayName'), ('description', 'description'))
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
'displayName', 'description', 'userCertificate;binary', 'vpnRouterConsoleLogin', 'vpnRouterConsoleAccount',
'uidNumber', 'vpnRouterWireGuardPeer', 'vpnRouterPasskey', 'vpnRouterRedirectUri',
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup']
'vpnRouterPostLogoutRedirectUri', 'vpnRouterAllowedGroup', 'vpnRouterGrantType', 'vpnRouterPublicClient',
'vpnRouterClientScope', 'vpnRouterResourceScope']
#: The grants an OpenID Provider client may have besides the authorization code, which its redirect URIs give it.
GRANTS = ('client_credentials', 'device_code')
#: The name of a role of a resource, and of a scope a resource defines.
ROLE_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9._-]{0,63}$')
SCOPE_NAME = re.compile(r'^[a-z][a-z0-9._-]{0,63}$')
#: The attributes read for a role.
ROLE_ATTRIBUTES = ['cn', 'description', 'member']
#: 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 keys of the OpenID Provider settings of a service account (client_settings, and roles of set_roles).
CLIENT_KEYS = ('redirect_uris', 'post_logout_redirect_uris', 'allowed_groups', 'grants', 'public_client', 'client_scopes',
'resource_scopes', 'roles')
#: The most URIs of each kind and allowed groups of one client, and the longest URI.
CLIENT_VALUES_MAX = 16
URI_MAX = 1024
@@ -218,11 +237,13 @@ def labels(values):
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.
"""The OpenID Provider settings of a service account in values ({redirect_uris, post_logout_redirect_uris,
allowed_groups, grants, public_client, client_scopes, resource_scopes}), checked, as {LDAP attribute: [value, ...]};
a key whose value is None is not given, [] or false 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.
A URI is absolute, has no fragment and uses https, or http for a host of LOOPBACK_HOSTS; an allowed group exists; a
grant is one of GRANTS; a client scope is a scope of a resource, <resource>:<scope>; a resource scope is a
SCOPE_NAME. Each list has at most CLIENT_VALUES_MAX values.
"""
found = {}
for key, attribute in CLIENT_URIS:
@@ -249,13 +270,68 @@ def client_settings(connection, values):
for group in allowed:
group_entry(connection, group)
found['vpnRouterAllowedGroup'] = [group_dn(group).encode() for group in sorted(set(allowed))]
grants = values.get('grants')
if grants is not None:
if not isinstance(grants, list) or not all(isinstance(grant, str) and grant in GRANTS for grant in grants):
raise Failure(f'grants must be a list of {", ".join(GRANTS)}', 2)
found['vpnRouterGrantType'] = [grant.encode() for grant in GRANTS if grant in grants]
public = values.get('public_client')
if public is not None:
if not isinstance(public, bool):
raise Failure('public_client must be true or false', 2)
found['vpnRouterPublicClient'] = [b'TRUE'] if public else []
scopes = values.get('client_scopes')
if scopes is not None:
if not isinstance(scopes, list) or len(scopes) > CLIENT_VALUES_MAX or not all(isinstance(scope, str) for scope in scopes):
raise Failure(f'client_scopes must be a list of at most {CLIENT_VALUES_MAX} scopes', 2)
for scope in scopes:
resource, _, name = scope.partition(':')
if name not in resource_scopes(connection, resource):
raise Failure(f'{scope!r} is not a scope of a resource, <resource>:<scope>', 2)
found['vpnRouterClientScope'] = [scope.encode() for scope in sorted(set(scopes))]
defined = values.get('resource_scopes')
if defined is not None:
if not isinstance(defined, list) or len(defined) > CLIENT_VALUES_MAX or \
not all(isinstance(scope, str) and SCOPE_NAME.match(scope) for scope in defined):
raise Failure(f'resource_scopes must be a list of at most {CLIENT_VALUES_MAX} scope names: lowercase letters, '
'digits, dots, underscores and hyphens, starting with a letter, at most 64', 2)
found['vpnRouterResourceScope'] = [scope.encode() for scope in sorted(set(defined))]
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)
"""Refuse OpenID Provider settings for a user who is not a service account."""
if any(values.get(key) is not None for key in CLIENT_KEYS):
raise Failure(f'{name} is not a service account; only a service account is a client or a resource of the OpenID '
'Provider', 2)
def check_client(name, current, changes):
"""Refuse the client settings an entry's attributes would have with changes ({LDAP attribute: [value, ...]}) when the
client is public and has client_credentials, which needs its secret."""
merged = {**current, **changes}
if merged.get('vpnRouterPublicClient') == [b'TRUE'] and b'client_credentials' in merged.get('vpnRouterGrantType', []):
raise Failure(f'{name}: a public client has no secret, so it cannot use client_credentials', 2)
def resource_scopes(connection, name):
"""The scopes the service account defines as a resource, [] when the name is not that of a service account."""
if not isinstance(name, str) or not (NAME.match(name) or GUID.match(name)):
return []
try:
attributes = entry(connection, name)
except Failure:
return []
return [value.decode() for value in attributes.get('vpnRouterResourceScope', [])] if service(attributes) else []
def asking(connection, scopes):
"""The names of the clients whose client scopes have any of the scopes, <resource>:<scope>, sorted."""
if not scopes:
return []
condition = ''.join(f'(vpnRouterClientScope={ldap.filter.escape_filter_chars(scope)})' for scope in scopes)
found = connection.search_s(vpnrouter_ldap.USERS, ldap.SCOPE_ONELEVEL, f'(|{condition})', ['uid'])
return sorted(attributes['uid'][0].decode() for _, attributes in found)
def dn_of(name):
@@ -310,7 +386,7 @@ 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). A service account also has its OpenID
Provider client settings (client_settings), allowed_groups as group names.
Provider settings (client_settings), allowed_groups as group names; with_roles adds its roles.
"""
name = attributes['uid'][0].decode()
lock = attributes.get('pwdAccountLockedTime', [None])[0]
@@ -336,9 +412,18 @@ def summary(attributes):
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))
user['grants'] = [value.decode() for value in attributes.get('vpnRouterGrantType', [])]
user['public_client'] = attributes.get('vpnRouterPublicClient') == [b'TRUE']
user['client_scopes'] = [value.decode() for value in attributes.get('vpnRouterClientScope', [])]
user['resource_scopes'] = [value.decode() for value in attributes.get('vpnRouterResourceScope', [])]
return user
def with_roles(connection, user):
"""The summary of a service account with roles, its roles as a resource (roles_of_resource)."""
return {**user, 'roles': roles_of_resource(connection, user['name'])}
def console_of(attributes):
"""The console login of an entry as the answers show it: None when it was never set, else {enabled, account, local,
uid}: account the system account the console runs as, the local one of vpnRouterConsoleAccount (local true) or the
@@ -536,8 +621,8 @@ def list_users(connection):
def add(connection, name, password, values, service=False):
"""Add a user: a VPN user with its certificate when values gives all three person attributes, a Manager-only account
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
e-mail address, its labels (labels) and its OpenID Provider client settings (client_settings); only a service
account may be named by a GUID."""
e-mail address, its labels (labels), its OpenID Provider settings (client_settings) and its roles (set_roles); 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
@@ -547,6 +632,9 @@ def add(connection, name, password, values, service=False):
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}
check_client(name, {}, client)
if values.get('roles') is not None:
checked_roles(connection, values['roles'])
else:
refuse_client_settings(name, values)
if vpn_user and len(details) != len(PERSON):
@@ -574,6 +662,10 @@ def add(connection, name, password, values, service=False):
vpnrouter_pki.issue_user(vpnrouter.load_config(), name, details['mail'])
except vpnrouter_pki.Failure as failure:
raise Failure(f'the certificate cannot be issued: {failure}', failure.status)
if service:
if values.get('roles') is not None:
set_roles(connection, name, values['roles'])
return with_roles(connection, summary(entry(connection, name)))
return summary(entry(connection, name))
@@ -628,20 +720,37 @@ def apply_wireguard():
def update_service(connection, name, values):
"""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)):
"""Change any of the e-mail address, the labels (labels), the OpenID Provider settings (client_settings) and the
roles (set_roles) of a service account that values gives; '' removes the e-mail address or a label, [] the values
of a setting. A resource scope a client may ask for is not removed."""
current = entry(connection, name)
if not service(current):
raise Failure(f'{name} is not a service account', 3)
settings = client_settings(connection, values)
check_client(name, current, settings)
if 'vpnRouterResourceScope' in settings:
removed = {value.decode() for value in current.get('vpnRouterResourceScope', [])} - \
{value.decode() for value in settings['vpnRouterResourceScope']}
clients = asking(connection, [f'{name}:{scope}' for scope in sorted(removed)])
if clients:
raise Failure(f'the clients {", ".join(clients)} may ask for the scopes of {name} being removed; take them out '
'of their client scopes first', 2)
roles = values.get('roles')
if roles is not None:
checked_roles(connection, roles)
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()]
changes += [(ldap.MOD_REPLACE, attribute, found) for attribute, found in settings.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, description, redirect_uris, post_logout_redirect_uris or allowed_groups', 2)
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
if not changes and roles is None:
raise Failure(f'give email, display_name, description or {", ".join(CLIENT_KEYS)}', 2)
if changes:
connection.modify_s(dn_of(name), changes)
if roles is not None:
set_roles(connection, name, roles)
return with_roles(connection, summary(entry(connection, name)))
def passwd(connection, name, password):
@@ -761,15 +870,24 @@ def remove(connection, name):
"""Remove the user from its groups and the directory, revoke the certificate and end the sessions of its own
posixAccount.
A user who is the last member of a group is not removed.
A user who is the last member of a group is not removed, nor a resource whose scopes a client may ask for. The roles
of a removed service account are removed, and the account is taken out of the other roles.
"""
current = entry(connection, name)
groups = groups_of(connection, name)
alone = sorted(group for group, members in groups.items() if members == [name])
if alone:
raise Failure(f'user {name} is the last member of {", ".join(alone)}; remove the group first', 2)
clients = sorted(set(asking(connection, [f'{name}:{scope.decode()}' for scope in current.get('vpnRouterResourceScope', [])]))
- {name})
if clients:
raise Failure(f'the clients {", ".join(clients)} may ask for the scopes of {name}; take them out of their client '
'scopes first', 2)
for group in groups:
connection.modify_s(group_dn(group), [(ldap.MOD_DELETE, 'member', [dn_of(name).encode()])])
for role in roles_of_resource(connection, name):
connection.delete_s(role_dn(name, role['name']))
leave_roles(connection, dn_of(name))
try:
vpnrouter_pki.revoke_user(vpnrouter.load_config(), name)
except vpnrouter_pki.Failure as failure:
@@ -977,7 +1095,7 @@ 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; renaming a group a client of the OpenID Provider allows renames it
there, and renaming the editors group of web sites renames it in the sites."""
there, as in the roles it is assigned, and renaming the editors group of web sites renames it in the sites."""
import vpnrouter_sites
if new_name is None and description is None:
raise Failure('give a new name or a description', 2)
@@ -996,6 +1114,9 @@ def group_update(connection, name, new_name=None, description=None):
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()])])
for dn in assigned(connection, group_dn(name)):
connection.modify_s(dn, [(ldap.MOD_DELETE, 'member', [group_dn(name).encode()]),
(ldap.MOD_ADD, 'member', [group_dn(new_name).encode()])])
try:
vpnrouter_sites.rename_group(name, new_name)
except vpnrouter_sites.DatabaseError as exc:
@@ -1007,8 +1128,8 @@ def group_update(connection, name, new_name=None, description=None):
def group_remove(connection, name):
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites;
removing ADMIN_GROUP switches the console login of its members off."""
"""Remove the group, unless a client of the OpenID Provider allows it or it is the editors group of web sites, and
take it out of the roles it is assigned; removing ADMIN_GROUP switches the console login of its members off."""
import vpnrouter_sites
members = member_names(group_entry(connection, name))
clients = allowing(connection, name)
@@ -1021,6 +1142,7 @@ def group_remove(connection, name):
if sites:
raise Failure(f'group {name} is the editors group of the web sites {", ".join(sites)}; change them first', 2)
connection.delete_s(group_dn(name))
leave_roles(connection, group_dn(name))
if name == ADMIN_GROUP:
console_off(connection, members)
return {'name': name, 'removed': True}
@@ -1049,6 +1171,186 @@ def group_member_remove(connection, name, user):
return group_summary(group_entry(connection, name))
def role_dn(resource, role):
return f'cn={resource}:{role},{vpnrouter_ldap.ROLES}'
def check_resource(connection, resource):
"""Refuse a name that is not that of a service account."""
if not service(entry(connection, resource)):
raise Failure(f'{resource} is not a service account; only a service account is a resource of the OpenID Provider', 2)
def check_role_name(role):
if not isinstance(role, str) or not ROLE_NAME.match(role):
raise Failure('a role name is 1 to 64 letters, digits, dots, underscores and hyphens, starting with a letter', 2)
def role_summary(attributes):
"""A role as the answers show it: {name, description, groups, accounts}, the groups and accounts it is assigned to."""
groups, accounts = [], []
for value in attributes.get('member', []):
dn = value.decode()
if dn.startswith('cn=') and dn.endswith(f',{vpnrouter_ldap.GROUPS}'):
groups.append(dn[3:-len(f',{vpnrouter_ldap.GROUPS}')])
elif dn.startswith('uid=') and dn.endswith(f',{vpnrouter_ldap.USERS}'):
accounts.append(dn[4:-len(f',{vpnrouter_ldap.USERS}')])
values = attributes.get('description')
return {'name': attributes['cn'][0].decode().partition(':')[2], 'description': values[0].decode() if values else '',
'groups': sorted(groups), 'accounts': sorted(accounts)}
def role_search(connection, resource, condition=''):
"""The entries of the resource's roles that also match the LDAP filter condition, as [(DN, attributes)]."""
prefix = ldap.filter.escape_filter_chars(f'{resource}:')
found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL,
f'(&(objectClass=vpnRouterRole)(cn={prefix}*){condition})', ROLE_ATTRIBUTES)
return [(dn, attributes) for dn, attributes in found if attributes['cn'][0].decode().startswith(f'{resource}:')]
def roles_of_resource(connection, resource):
"""The roles of the resource, sorted by name."""
return sorted((role_summary(attributes) for _, attributes in role_search(connection, resource)),
key=lambda role: role['name'])
def role_entry(connection, resource, role):
"""The attributes of the role's entry; Failure with status 3 when there is none."""
check_role_name(role)
try:
return connection.search_s(role_dn(resource, role), ldap.SCOPE_BASE, attrlist=ROLE_ATTRIBUTES)[0][1]
except ldap.NO_SUCH_OBJECT:
raise Failure(f'{resource} has no role {role}', 3)
def role_add(connection, resource, role, description=''):
"""Add a role to the resource, with a description ('' for none) and no members."""
check_resource(connection, resource)
check_role_name(role)
description = check_description(description if description is not None else '')
record = {'objectClass': [b'vpnRouterRole'], 'cn': [f'{resource}:{role}'.encode()]}
if description:
record['description'] = [description.encode()]
try:
connection.add_s(role_dn(resource, role), ldap.modlist.addModlist(record))
except ldap.ALREADY_EXISTS:
raise Failure(f'{resource} has a role {role}', 4)
return role_summary(role_entry(connection, resource, role))
def role_remove(connection, resource, role):
role_entry(connection, resource, role)
connection.delete_s(role_dn(resource, role))
return {'resource': resource, 'name': role, 'removed': True}
def member_dn(connection, group=None, account=None):
"""The DN of the existing group or account a role is assigned to; exactly one is given."""
if (group is None) == (account is None):
raise Failure('give a group or an account', 2)
if group is not None:
group_entry(connection, group)
return group_dn(group)
entry(connection, account)
return dn_of(account)
def role_member_add(connection, resource, role, group=None, account=None):
"""Assign the role to the group or the account."""
attributes = role_entry(connection, resource, role)
dn = member_dn(connection, group, account)
if dn.encode() not in attributes.get('member', []):
connection.modify_s(role_dn(resource, role), [(ldap.MOD_ADD, 'member', [dn.encode()])])
return role_summary(role_entry(connection, resource, role))
def role_member_remove(connection, resource, role, group=None, account=None):
"""Take the role from the group or the account."""
attributes = role_entry(connection, resource, role)
if (group is None) == (account is None):
raise Failure('give a group or an account', 2)
dn = group_dn(group) if group is not None else dn_of(account)
if dn.encode() not in attributes.get('member', []):
raise Failure(f'role {role} of {resource} is not assigned to {group or account}', 3)
connection.modify_s(role_dn(resource, role), [(ldap.MOD_DELETE, 'member', [dn.encode()])])
return role_summary(role_entry(connection, resource, role))
def checked_roles(connection, roles):
"""The roles of set_roles, checked: [{name, description, groups, accounts}], names unique, every group and account
existing; description, groups and accounts may be left out."""
if not isinstance(roles, list) or not all(isinstance(role, dict) for role in roles):
raise Failure('roles must be a list of {"name", "description", "groups", "accounts"}', 2)
checked = []
for role in roles:
check_role_name(role.get('name'))
groups, accounts = role.get('groups') or [], role.get('accounts') or []
if not isinstance(groups, list) or not isinstance(accounts, list):
raise Failure(f'the groups and the accounts of role {role["name"]} must be lists of names', 2)
for group in groups:
group_entry(connection, group)
for account in accounts:
entry(connection, account)
checked.append({'name': role['name'], 'description': check_description(role.get('description') or ''),
'members': sorted({*(group_dn(group) for group in groups), *(dn_of(account) for account in accounts)})})
if len({role['name'] for role in checked}) != len(checked):
raise Failure('each role name may appear once', 2)
return checked
def set_roles(connection, resource, roles):
"""Make the resource's roles those of roles (checked_roles): roles left out are removed, new ones added, and the
others get the description and the members given."""
check_resource(connection, resource)
wanted = {role['name']: role for role in checked_roles(connection, roles)}
for role in roles_of_resource(connection, resource):
if role['name'] not in wanted:
connection.delete_s(role_dn(resource, role['name']))
present = {role['name'] for role in roles_of_resource(connection, resource)}
for name, role in wanted.items():
members = [dn.encode() for dn in role['members']]
description = [role['description'].encode()] if role['description'] else []
if name in present:
connection.modify_s(role_dn(resource, name), [(ldap.MOD_REPLACE, 'description', description),
(ldap.MOD_REPLACE, 'member', members)])
continue
record = {'objectClass': [b'vpnRouterRole'], 'cn': [f'{resource}:{name}'.encode()]}
if description:
record['description'] = description
if members:
record['member'] = members
connection.add_s(role_dn(resource, name), ldap.modlist.addModlist(record))
return roles_of_resource(connection, resource)
def assigned(connection, dn):
"""The DNs of the roles assigned to the group or account of the DN."""
found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL,
f'(&(objectClass=vpnRouterRole)(member={ldap.filter.escape_filter_chars(dn)}))', ['1.1'])
return [role for role, _ in found]
def leave_roles(connection, dn):
"""Take the group or account of the DN out of every role."""
for role in assigned(connection, dn):
connection.modify_s(role, [(ldap.MOD_DELETE, 'member', [dn.encode()])])
def list_roles(connection, resource=None):
"""The roles of every resource, or of one, as {"roles": [{resource, name, description, groups, accounts}]}."""
found = connection.search_s(vpnrouter_ldap.ROLES, ldap.SCOPE_ONELEVEL, '(objectClass=vpnRouterRole)', ROLE_ATTRIBUTES)
roles = [{'resource': attributes['cn'][0].decode().partition(':')[0], **role_summary(attributes)} for _, attributes in found]
return {'roles': sorted((role for role in roles if resource is None or role['resource'] == resource),
key=lambda role: (role['resource'], role['name']))}
def roles_held(connection, resource, name):
"""The names of the resource's roles assigned to the account or to a group it is a member of, sorted."""
members = [dn_of(name), *(group_dn(group) for group in groups_of(connection, name))]
condition = '(|' + ''.join(f'(member={ldap.filter.escape_filter_chars(dn)})' for dn in members) + ')'
return sorted(role_summary(attributes)['name'] for _, attributes in role_search(connection, resource, condition))
def extract_pem(text):
"""The PEM blocks of the certificates in a file that also holds their text form."""
begin, end = '-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----'
@@ -0,0 +1,102 @@
"""The router's web service, run by vpn-router-web.service; the entry point is /usr/lib/vpn-router/web.
Pages:
/ the status for people: plain text for curl and wget, a styled page
(Tailwind CSS from a CDN) for a browser
/status the status as JSON
/status/full
the whole status as JSON, whatever web.status_content says, for the Router Manager
/config the configuration without its secrets, and what the router resolved and
runs, with the installed Caddy and where it comes from, as JSON
/users the users in the router's directory, whether or not a P2S system is enabled: list,
add, change, set the password, disable, enable, remove, the OpenVPN client
profile, and TOTP enrolment (see users()); service accounts are not among them
/service-accounts
the service accounts: list, add, change the e-mail address, set the password,
disable, enable, unlock and remove; the internal ones are only listed (see
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, its TOTP
enrolment and its passkeys (see account())
/site-to-site
the site-to-site connections of the configuration: list, add, change and remove,
applied by vpn-router-setup (see site_to_site())
/backup the backup settings, the last run and the local archives: show, change and run
now (see backup())
/remote-access
the settings of IKEv2, OpenVPN and WireGuard access and whether each
runs: show and change, applied by vpn-router-setup (see remote_access())
/ca the PKI: its settings, the root and issuing CAs, the certificates issued; change the
settings, issue and revoke certificates, and take the certificate of an issuing CA
and a root CRL signed offline (see ca())
/pki/<name>.crt, /pki/<name>.crl
the DER certificate and CRL of the root (root) and of each issuing CA, which the
certificates name in their AIA and CRL distribution points; no authentication
/shell while web.shell is true, a login shell of the user's console account on a terminal, for
a member of admins whose console login is on and who has a TOTP token: POST signs the user in again,
with the password and the TOTP code or with a passkey whose proof POST /shell/passkey begins,
unless the user used a shell within web.shell_auth_timeout minutes, for a one-time ticket
(see shell_ticket() and shell_passkey()), GET with it is a WebSocket
that carries the terminal (see Handler.shell()); /shell/editor/<session>/<id> reads, saves and
closes a file the shell opened with vpn-router-editor (vre) over EDITOR_SOCKET (see
shell_editor() and editor_request())
/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())
/metrics/settings
the settings of the metrics service: show and change, applied by vpn-router-setup (see
metrics_settings())
/logs the last lines of the journal of the router's units and of the files web.log_files lists
(see logs())
/sites the web sites Caddy serves: the settings of sites, list, add, change and remove (see sites()),
and the files of each: list, download, upload, extract a zip archive, create a folder, rename and
remove (see Handler.site_files()); a member of a site's editors group may use the site's files
/dns the primary name server of the router's domains: its settings, and the domains: add, change and
remove, each with its secondaries, the records it serves, and its manual records (see dns())
/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, /metrics/settings, /postgresql, /users, /service-accounts, /groups, /site-to-site, /backup, /remote-access, /ca, /logs, /sites, /dns, /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 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.
/ and /status show only the status, as much as web.status_content says (see shown): basic, the
default, the overall status, the time and the client's address (its address as for the rate limit
below); standard also the package version, the installed version of vpn-router-manager
(manager_version, null when it is not installed) and the number of active and other services; full
whether each service runs, whether Docker is installed, how many of the site-to-site connections are established
(site_to_site: connections and established, null when swanctl cannot be asked), and IKEv2, OpenVPN
and WireGuard. No configuration data, key or connection name is shown, and no address but the client's.
Modules, one per family of paths, with what they share in common:
common the paths of the configuration and of setup's state, the body limit, the HTTP statuses of the
errors of the router's modules, reading the configuration and the state, changing a section, log
status /, /status, /status/full, /config and /postgresql, with the cache of the status
auth /auth, its rate limit, and the rules of who may call a protected path (refusal)
identities /users, /service-accounts, /groups and /account
connectivity /site-to-site and /remote-access
backup /backup
pki /ca, and the files /pki/ serves
logs /logs
sites /sites, with the files of each site
dns /dns
metrics /metrics/history and /metrics/settings
shell /shell, its WebSocket, and the editor of the shell with its socket
handler Handler, which reads a request, checks its token and passes it to the module of its path
"""
@@ -0,0 +1,130 @@
"""/auth, the sign-in, refresh and sign-out of the web service, its rate limit per client address, and the rules of
who may call a protected path (refusal)."""
import collections
import threading
import time
import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_passkeys
import vpnrouter_sites
import vpnrouter_users
from .common import USER_STATUS
from .sites import SITE_FILES
#: The calls of /auth allowed per client address in AUTH_WINDOW seconds.
AUTH_LIMIT = 10
AUTH_WINDOW = 60
#: The times of the recent /auth calls of each client address.
_auth_calls = collections.defaultdict(collections.deque)
_auth_lock = threading.Lock()
def changes_admins(connection, method, parts, body):
"""Whether a /users, /service-accounts or /groups request creates, renames to, changes or removes ADMIN_GROUP, or
removes a member of it."""
admins = vpnrouter_users.ADMIN_GROUP
if parts[0] == 'groups':
if method == 'POST' and len(parts) == 1:
return body.get('name') == admins
if method != 'GET' and len(parts) > 1 and parts[1] == admins:
return True
return method == 'PUT' and len(parts) == 2 and body.get('name') == admins
if parts[0] in ('users', 'service-accounts') and method == 'DELETE' and len(parts) == 2:
return admins in vpnrouter_users.groups_of(connection, parts[1])
return False
def refusal(claims, method, path, body):
"""Why the holder of the claims may not make the request to a protected path, or '' when it may.
The files of a site may also be read and changed by a member of the site's editors group.
"""
name = claims.get('sub')
if path == '/account' or path.startswith('/account/'):
return '' if isinstance(name, str) else 'the token names no user'
if claims.get('admin') is True:
return ''
if not isinstance(name, str):
return 'the token names no user'
admins = vpnrouter_users.ADMIN_GROUP
with vpnrouter_users.session() as connection:
if not vpnrouter_users.is_admin(connection, name):
files = SITE_FILES.match(path)
if files and files.group(1) in vpnrouter_sites.editable(vpnrouter_users.groups_of(connection, name)):
return ''
return f'user {name} is not a member of {admins}'
parts = path.strip('/').split('/')
if changes_admins(connection, method, parts, body if isinstance(body, dict) else {}):
return f'only a token of vpn-router-token may change the group {admins} or remove its members'
if parts[0] == 'users' and len(parts) == 3 and parts[2] == 'console' and method != 'GET':
return 'only a token of vpn-router-token may change console login'
return ''
def auth(path, body):
"""Answer a POST to /auth with vpnrouter_users and vpnrouter_auth: the HTTP status and the JSON document.
POST /auth sign in: {"name", "password", "code"?}; the tokens 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
"""
if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'}
try:
cfg = vpnrouter.load_config()
if path == '/auth':
vpnrouter_users.login(body.get('name'), body.get('password'), body.get('code'))
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']), '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}
if name is None:
return 401, {'error': 'the refresh token is not valid'}
with vpnrouter_users.session() as connection:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
if not user['enabled'] or user['locked']:
return 401, {'error': f'user {name} is disabled or locked'}
return 200, vpnrouter_auth.issue(cfg, name)
except vpnrouter_users.Failure as failure:
return (401 if failure.status == 3 else USER_STATUS.get(failure.status, 500)), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': str(exc)}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
def auth_wait(address):
"""Seconds until the client address may call /auth again, 0 when it may now; a call that may is counted."""
now = time.monotonic()
with _auth_lock:
for idle in [key for key, times in _auth_calls.items() if times[-1] <= now - AUTH_WINDOW]:
del _auth_calls[idle]
calls = _auth_calls[address]
while calls and calls[0] <= now - AUTH_WINDOW:
calls.popleft()
if len(calls) >= AUTH_LIMIT:
return int(calls[0] + AUTH_WINDOW - now) + 1
calls.append(now)
return 0
@@ -0,0 +1,84 @@
"""/backup: the backup settings, the last run and the local archives, and a run now."""
import configparser
import shutil
import vpnrouter
import vpnrouter_backup
from vpnrouter_status import run
def backup_settings(settings):
"""The backup section as /backup shows it: its settings but pbs_password, with pbs_password_set."""
return {**{option: value for option, value in settings.items() if option != 'pbs_password'},
'pbs_password_set': bool(settings['pbs_password'])}
def rclone_remotes():
"""The names of the remotes of vpnrouter_backup.RCLONE_CONFIG; [] when it cannot be read, as when it is encrypted."""
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read(vpnrouter_backup.RCLONE_CONFIG, encoding='utf-8')
except (OSError, configparser.Error):
return []
return parser.sections()
def backup(method, path, body):
"""Answer a /backup request: the HTTP status and the JSON document.
GET /backup {"settings", "state", "archives", "next", "client", "rclone"}: the backup section without
pbs_password, with pbs_password_set; the last run (vpnrouter_backup.state), null before the
first; the local archives of this host, newest first, [{name, time, size}], [] with the pbs
target; the next run of the timer as systemd shows it, null while backups are disabled;
whether proxmox-backup-client is installed; and {"installed", "config", "remotes"} of rclone
PUT /backup change any of the settings of the backup section; an empty or absent pbs_password keeps the
current secret
POST /backup/run start a backup now, without waiting for it; 409 while one runs
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
if (method, path) == ('POST', '/backup/run'):
if vpnrouter_backup.running():
return 409, {'error': 'a backup is running'}
result = run('systemctl', 'start', '--no-block', vpnrouter_backup.UNIT)
if result is None or result.returncode != 0:
return 500, {'error': f'the backup cannot be started: {result.stderr.strip() if result else "no systemctl"}'}
return 202, {'started': True}
if (method, path) not in (('GET', '/backup'), ('PUT', '/backup')):
return 404, {'error': f'no route {method} {path}'}
try:
if method == 'GET':
settings = vpnrouter.load_config()['backup']
timer = run('systemctl', 'show', '-p', 'NextElapseUSecRealtime', '--value', vpnrouter_backup.TIMER)
next_run = timer.stdout.strip() if timer and timer.returncode == 0 else ''
return 200, {
'settings': backup_settings(settings),
'state': vpnrouter_backup.state() or None,
'archives': vpnrouter_backup.archives(settings['path']) if settings['target'] == 'local' else [],
'next': next_run if settings['enabled'] and next_run not in ('', 'n/a') else None,
'client': vpnrouter_backup.PBS_CLIENT.exists(),
'rclone': {'installed': shutil.which('rclone') is not None,
'config': vpnrouter_backup.RCLONE_CONFIG.is_file(), 'remotes': rclone_remotes()},
}
settings = {option: body[option] for option in vpnrouter.SCHEMA['backup'] if option in body}
if not settings.get('pbs_password'):
settings.pop('pbs_password', None)
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get('backup')
data['backup'] = {**(current if isinstance(current, dict) else {}), **settings}
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except OSError as exc:
return 500, {'error': f'the configuration cannot be written: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, {'settings': backup_settings(cfg['backup'])}
@@ -0,0 +1,85 @@
"""What the modules of the web service share: the paths of the configuration and of the state vpn-router-setup writes,
the limit of request bodies, the HTTP statuses of the failures of the router's modules, reading the configuration and
the state, changing a section of the configuration, the state of a unit, and the log."""
import json
import sys
import vpnrouter
import vpnrouter_auth
from vpnrouter_status import run
STATE_FILE = '/run/vpn-router/web.json'
CONFIG_FILE = '/etc/vpn-router/vpn-router.json'
#: The largest request body accepted, in bytes; a CSR or a certificate chain in PEM is a few kilobytes.
BODY_LIMIT = 65536
#: The HTTP status for each status of a vpnrouter_users.Failure; any other is 500.
USER_STATUS = {2: 400, 3: 404, 4: 409, 5: 401}
#: The HTTP status for each status of a vpnrouter_pki.Failure; any other is 500.
PKI_STATUS = {2: 400, 3: 404, 4: 409}
#: The HTTP status for each status of a vpnrouter_sites.Failure; any other is 500.
SITE_STATUS = {2: 400, 3: 404, 4: 409}
#: The bytes in a MiB, the unit of sites.upload_max_mb.
MIB = 1024 * 1024
def read_json(path):
"""The object in a JSON file, or {} when the file is missing or holds no object."""
try:
with open(path, encoding='utf-8') as handle:
data = json.load(handle)
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def section(configuration, name):
"""A section of the configuration, or {} when it is missing or not an object."""
value = configuration.get(name)
return value if isinstance(value, dict) else {}
def current():
"""What a request needs, read now: the features and facts setup resolved, and the signing key.
Before setup has run the router has no features, and without a key the protected paths and
/auth are not served.
"""
return dict(read_json(STATE_FILE), key=vpnrouter_auth.key())
def unit_state(unit):
result = run('systemctl', 'is-active', unit)
return result.stdout.strip() if result and result.stdout.strip() else 'unknown'
def section_change(name, body):
"""Change any of the settings of the configuration's section as /backup changes its own; the section, or the HTTP
status and the error document as a tuple."""
settings = {option: body[option] for option in vpnrouter.SCHEMA[name] if option in body}
try:
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get(name)
data[name] = {**(current if isinstance(current, dict) else {}), **settings}
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except OSError as exc:
return 500, {'error': f'the configuration cannot be written: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return cfg[name]
def log(message):
print(f'vpn-router-web: {message}', file=sys.stderr, flush=True)
@@ -0,0 +1,155 @@
"""The connections of the router: /site-to-site and /remote-access, the IKEv2, OpenVPN and WireGuard access."""
import ipaddress
import vpnrouter
from vpnrouter_status import established_each, run
from .common import section
#: The settings of a site_to_site connection that /site-to-site adds and changes.
CONNECTION_SETTINGS = ('addrs', 'id', 'cidrs', 'local_cidrs', 'local_id_mode', 'auth', 'psk', 'remote_ca')
#: The sections of the remote access systems /remote-access changes: IKEv2, OpenVPN and WireGuard.
REMOTE_ACCESS = ('p2s', 'openvpn', 'wireguard')
def connection_of(name, connection, count):
"""A site_to_site connection as /site-to-site shows it: its settings but psk, psk_set and established.
count is the number of its established IKE SAs, None when it is not known.
"""
return {'name': name, **{option: value for option, value in connection.items() if option != 'psk'},
'psk_set': bool(connection['psk']), 'established': None if count is None else count > 0}
def site_to_site(method, path, body):
"""Answer a /site-to-site request: the HTTP status and the JSON document.
GET /site-to-site the connections, {"connections": [...]}: each with its settings but psk, with
psk_set, whether psk holds a key, and established, whether its tunnel is up,
null when swanctl cannot be asked
POST /site-to-site add a connection: {"name", "addrs", "id", "cidrs", "local_cidrs", "local_id_mode",
"auth", "psk", "remote_ca"}; psk is required with auth psk, the default, and an
existing name is 409
PUT /site-to-site/<name> change any of the settings of POST but name; an empty or absent psk keeps the
current key, and a psk given replaces psk_file
DELETE /site-to-site/<name> remove the connection
A change is made holding vpnrouter.config_lock, checked as vpn-router-setup checks the configuration (400 with
the reason), written with vpnrouter.save_config, and applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
name = parts[1] if len(parts) == 2 else None
if (method, len(parts)) not in (('GET', 1), ('POST', 1), ('PUT', 2), ('DELETE', 2)):
return 404, {'error': f'no route {method} {path}'}
try:
if method == 'GET':
connections = vpnrouter.load_config()['site_to_site']
counts = established_each(connections)
return 200, {'connections': [connection_of(key, value, counts[key]) for key, value in connections.items()]}
settings = {option: body[option] for option in CONNECTION_SETTINGS if option in body}
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
connections = data.setdefault('site_to_site', {})
if method == 'POST':
name = body.get('name')
if not isinstance(name, str) or not name:
return 400, {'error': 'name is required'}
if name in connections:
return 409, {'error': f'connection {name} exists'}
if settings.get('auth', 'psk') == 'psk' and (not isinstance(settings.get('psk'), str) or not settings['psk']):
return 400, {'error': 'psk is required with auth psk'}
connections[name] = settings
elif name not in connections:
return 404, {'error': f'no connection {name}'}
elif method == 'PUT':
connection = dict(connections[name]) if isinstance(connections[name], dict) else {}
if settings.get('psk'):
connection.pop('psk_file', None)
else:
settings.pop('psk', None)
connections[name] = {**connection, **settings}
else:
del connections[name]
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except OSError as exc:
return 500, {'error': f'the configuration cannot be written: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
if method == 'DELETE':
return 200, {'name': name, 'removed': True}
return (201 if method == 'POST' else 200), connection_of(name, cfg['site_to_site'][name], None)
def wireguard_server(cfg, state):
"""What a WireGuard peer's configuration needs of the router: {public_key, endpoint, allowed_ips, dns}; public_key is
null before setup made the key pair, endpoint, <wan.local_fqdn>:<wireguard.listen_port>, null without a name,
allowed_ips the networks reached through the tunnel, the router's local CIDRs and the WireGuard network, and dns the
addresses of the remote access clients' resolver, [] while it does not run."""
try:
public_key = vpnrouter.WG_PUBLIC_KEY.read_text(encoding='utf-8').strip() or None
except OSError:
public_key = None
fqdn = cfg['wan']['local_fqdn'].strip()
address = cfg['wireguard']['address'].strip()
networks = section(state, 'network').get('local_cidrs')
allowed = [cidr for cidr in networks if isinstance(cidr, str)] if isinstance(networks, list) else []
if address:
allowed.append(str(ipaddress.ip_interface(address).network))
dns = section(state, 'network').get('client_dns')
return {'public_key': public_key, 'endpoint': f'{fqdn}:{cfg["wireguard"]["listen_port"]}' if fqdn else None,
'allowed_ips': list(dict.fromkeys(allowed)),
'dns': [address for address in dns if isinstance(address, str)] if isinstance(dns, list) else []}
def remote_access(state, method, path, body):
"""Answer a /remote-access request: the HTTP status and the JSON document.
GET /remote-access {"p2s", "openvpn", "wireguard", "running", "local_fqdn", "wireguard_server"}: the
settings of the three remote access systems (REMOTE_ACCESS), whether setup runs
each (running: {p2s, openvpn, wireguard}), wan.local_fqdn, which IKEv2 and OpenVPN
need with the router's VPN server certificate, and what a WireGuard peer's
configuration needs of the router (wireguard_server)
PUT /remote-access/<system> change any of the settings of the system's section; answers the section
A change is made as /site-to-site makes one, and applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
try:
if (method, len(parts)) == ('GET', 1):
cfg = vpnrouter.load_config()
return 200, {**{system: cfg[system] for system in REMOTE_ACCESS},
'running': {system: bool(state.get(system)) for system in REMOTE_ACCESS},
'local_fqdn': cfg['wan']['local_fqdn'],
'wireguard_server': wireguard_server(cfg, state)}
if method != 'PUT' or len(parts) != 2 or parts[1] not in REMOTE_ACCESS:
return 404, {'error': f'no route {method} {path}'}
system = parts[1]
settings = {option: body[option] for option in vpnrouter.SCHEMA[system] if option in body}
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get(system)
data[system] = {**(current if isinstance(current, dict) else {}), **settings}
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except OSError as exc:
return 500, {'error': f'the configuration cannot be written: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, cfg[system]
@@ -0,0 +1,81 @@
"""/dns: the primary name server of the router's domains."""
import vpnrouter
import vpnrouter_dns
from vpnrouter_status import run
from .common import section_change, unit_state
def dns(method, path, query, body):
"""Answer a /dns request with vpnrouter_dns: the HTTP status and the JSON document.
GET /dns {"settings", "installed", "running", "domains", "problem"}: the dns section of the
configuration, whether PowerDNS is installed and runs, the domains, each {"name", "sites",
"ttl", "secondaries", "manual", "records"} with its secondaries {"nameserver", "address"},
its manual records and the records the name server serves, and why the domains cannot be
read, null when they can
PUT /dns change any of the settings of the dns section
POST /dns/domains add a domain: {"name", "sites"?, "ttl"?}; sites gives the host names of the enabled sites
in it records; ttl is dns.ttl when null or absent; an existing name is 409
PUT /dns/domains/<domain>
change "sites" or "ttl" of the domain
DELETE /dns/domains/<domain>
remove the domain with its secondaries and manual records
POST /dns/domains/<domain>/secondaries
add a secondary name server of the domain, or change its address: {"nameserver", "address"}
DELETE /dns/domains/<domain>/secondaries/<nameserver>
remove the secondary name server
POST /dns/domains/<domain>/records
add a manual record: {"name", "type", "content", "ttl"?}; name is relative to the domain,
@ for the domain itself, or absolute with a final dot; ttl is the domain's without it
DELETE /dns/domains/<domain>/records
remove the manual records of name= and type=, with content= only that one
A change is applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
route = (method, *parts[:2], *parts[3:4])
try:
cfg = vpnrouter.load_config()
if route == ('GET', 'dns'):
domains, problem = [], None
try:
domains = vpnrouter_dns.listing()
except vpnrouter_dns.DatabaseError as exc:
problem = str(exc)
return 200, {'settings': cfg['dns'], 'installed': vpnrouter_dns.PDNS_SERVER.exists(),
'running': unit_state(vpnrouter_dns.UNIT) == 'active', 'domains': domains,
'problem': problem}
if route == ('PUT', 'dns'):
changed = section_change('dns', body)
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
if route == ('POST', 'dns', 'domains') and len(parts) == 2:
document, code = vpnrouter_dns.domain_add(body), 201
elif route == ('PUT', 'dns', 'domains') and len(parts) == 3:
document, code = vpnrouter_dns.domain_change(parts[2], body), 200
elif route == ('DELETE', 'dns', 'domains') and len(parts) == 3:
document, code = vpnrouter_dns.domain_remove(parts[2]), 200
elif route == ('POST', 'dns', 'domains', 'secondaries') and len(parts) == 4:
document, code = vpnrouter_dns.secondary_add(parts[2], body), 201
elif route == ('DELETE', 'dns', 'domains', 'secondaries') and len(parts) == 5:
document, code = vpnrouter_dns.secondary_remove(parts[2], parts[4]), 200
elif route == ('POST', 'dns', 'domains', 'records') and len(parts) == 4:
document, code = vpnrouter_dns.add(cfg, parts[2], body), 201
elif route == ('DELETE', 'dns', 'domains', 'records') and len(parts) == 4:
content = query.get('content', [None])[0]
document = vpnrouter_dns.remove(parts[2], query.get('name', [''])[0], query.get('type', [''])[0], content)
code = 200
else:
return 404, {'error': f'no route {method} {path}'}
except vpnrouter_dns.Failure as failure:
return {3: 404, 4: 409}.get(failure.status, 400), {'error': str(failure)}
except vpnrouter_dns.DatabaseError as exc:
return 500, {'error': str(exc)}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return code, document
@@ -0,0 +1,240 @@
"""The request handler of the web service: it reads a request, checks its token and passes it to the module of its
path (answer)."""
import json
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler
import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_net
import vpnrouter_oidc
import vpnrouter_pki
import vpnrouter_sites
from .auth import auth, auth_wait, refusal
from .backup import backup
from .common import BODY_LIMIT, CONFIG_FILE, current, log, read_json, section
from .connectivity import remote_access, site_to_site
from .dns import dns
from .identities import account, groups, service_accounts, users
from .logs import logs
from .metrics import metrics_history, metrics_settings
from .pki import PUBLISHED, ca
from .shell import EDITOR_BODY_LIMIT, shell, shell_editor, shell_passkey, shell_ticket
from .sites import SITE_FILES, site_files, sites
from .status import cached, details, html_of, postgresql_databases, shown, text_of
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def claims(self, secret):
"""The claims of the request's token signed with the secret, or None without an acceptable one."""
header = self.headers.get('Authorization', '')
return vpnrouter_auth.verify(header[7:].strip(), secret) if header.startswith('Bearer ') else None
def client(self):
"""The client address: the last X-Forwarded-For entry when the peer is a trusted proxy, else the peer."""
peer = self.client_address[0]
trusted = section(read_json(CONFIG_FILE), 'web').get('trusted_proxies') or [self.server.server_address[0]]
forwarded = self.headers.get('X-Forwarded-For', '')
if isinstance(trusted, list) and peer in trusted and forwarded.strip():
return forwarded.split(',')[-1].strip()
return peer
def refusal(self, claims, path, payload):
"""The HTTP status and the message that refuse a request to a protected path, or None when it may be made."""
try:
reason = refusal(claims, self.command, path, payload)
except (ldap.LDAPError, OSError) as exc:
return 500, f'the user directory failed: {exc}'
except vpnrouter_sites.DatabaseError as exc:
return 500, str(exc)
return (403, reason) if reason else None
def answer(self, send_body):
path = self.path.split('?', 1)[0]
extra = []
self.principal = None
# A body that is not read must not be taken for the next request.
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', 'logs', 'sites', 'dns')
if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/status/full', '/config', '/metrics/history', '/metrics/settings', '/postgresql') or \
directory is not None
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
files = SITE_FILES.match(path) if directory == 'sites' else None
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
state = current() if protected or signing else {}
claims = self.claims(state['key']) if protected and state['key'] else None
# The body of a file upload is read as a stream, after the request is allowed.
payload = self.request_body(EDITOR_BODY_LIMIT if path.startswith('/shell/editor/') else BODY_LIMIT) \
if (protected or signing) and self.command not in ('GET', 'HEAD') and not (files and self.command == 'PUT') \
else None
if claims is not None:
self.principal = 'admin' if claims.get('admin') is True else claims.get('sub')
if (protected or signing) and not state['key']:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
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 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)))
else:
code, document = auth(path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif protected and claims is None:
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
extra.append(('WWW-Authenticate', 'Bearer'))
elif protected and (refused := self.refusal(claims, path, payload)):
code, kind = refused[0], 'application/json'
body = json.dumps({'error': refused[1]}, indent=2).encode() + b'\n'
elif path == '/status/full' and self.command in ('GET', 'HEAD'):
document = shown(cached(), 'full', self.client())
body, kind, code = json.dumps(document, indent=2).encode() + b'\n', 'application/json', 200
elif path == '/config' and self.command in ('GET', 'HEAD'):
body, kind, code = json.dumps(details(state), indent=2).encode() + b'\n', 'application/json', 200
elif path == '/postgresql' and self.command in ('GET', 'HEAD'):
body, kind, code = json.dumps(postgresql_databases(), indent=2).encode() + b'\n', 'application/json', 200
elif path == '/metrics/history' and self.command in ('GET', 'HEAD'):
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
code, document = metrics_history(query.get('range', ['hour'])[0])
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
elif path == '/metrics/settings':
code, document = metrics_settings(self.command, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'shell' and (path, self.command) == ('/shell', 'GET'):
return shell(self, claims.get('sub'))
elif directory == 'shell' and path.startswith('/shell/editor/'):
code, document = shell_editor(claims.get('sub'), self.command, path.strip('/').split('/'), payload)
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
elif directory == 'shell':
code, document = shell_ticket(claims.get('sub'), payload) if (path, self.command) == ('/shell', 'POST') \
else shell_passkey(claims.get('sub')) if (path, self.command) == ('/shell/passkey', 'POST') \
else (404, {'error': f'no route {self.command} {path}'})
if code != 200:
log(f'shell ticket of {claims.get("sub")} from {self.client()} refused: {document["error"]}')
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'account':
code, document = account(claims['sub'], self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'site-to-site':
code, document = site_to_site(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'remote-access':
code, document = remote_access(state, self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'backup':
code, document = backup(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
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, query)
body, kind = json.dumps(document).encode() + b'\n', 'application/json'
elif files:
answered = site_files(self, files.group(1), files.group(2) or '', query, payload, send_body)
if answered is None:
return
code, document = answered
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'sites':
code, document = sites(self.command, path, query, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif directory == 'dns':
code, document = dns(self.command, path, query, payload)
body, kind = json.dumps(document, indent=2).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 == 'POST' and path in (
'/oidc/login', '/oidc/device', '/oidc/device_authorization') 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 \
(vpnrouter_pki.PUBLISH_DIR / path[5:]).is_file():
body, kind, code = (vpnrouter_pki.PUBLISH_DIR / path[5:]).read_bytes(), PUBLISHED[path[-4:]], 200
elif path in ('/status', '/'):
document = shown(cached(), section(read_json(CONFIG_FILE), 'web').get('status_content'), self.client())
agent = self.headers.get('User-Agent', '').lower()
if path == '/status':
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
elif 'curl' in agent or 'wget' in agent:
body, kind = text_of(document).encode(), 'text/plain; charset=utf-8'
else:
body, kind = html_of(document).encode(), 'text/html; charset=utf-8'
code = 200
else:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
self.send_response(code)
self.send_header('Content-Type', kind)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
for name, value in extra:
self.send_header(name, value)
self.end_headers()
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, limit=BODY_LIMIT):
"""The request body as JSON, None when there is none, or the string 'invalid', also when it is larger than
limit bytes."""
length = self.headers.get('Content-Length')
if not length:
return None
try:
size = int(length)
if not 0 <= size <= limit:
return 'invalid'
return json.loads(self.rfile.read(size) or b'null')
except ValueError:
return 'invalid'
def do_GET(self):
self.answer(True)
def do_HEAD(self):
self.answer(False)
def do_POST(self):
self.answer(True)
def do_PUT(self):
self.answer(True)
def do_DELETE(self):
self.answer(True)
def log_message(self, format, *args):
principal = getattr(self, 'principal', None) or '-'
sys.stderr.write('%s - %s [%s] %s\n' % (self.client_address[0], principal, self.log_date_time_string(), format % args))
@@ -0,0 +1,299 @@
"""The identities of the router's directory: /users, /service-accounts, /groups and the signed-in user's /account."""
import urllib.parse
import ldap
import vpnrouter
import vpnrouter_auth
import vpnrouter_passkeys
import vpnrouter_users
from .common import USER_STATUS
def users(state, method, path, body):
"""Answer a /users request with vpnrouter_users: the HTTP status and the JSON document.
GET /users the users, and {"enabled", "address"} of wireguard, whose network their
WireGuard peers take their addresses from
POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
without the names a Manager-only account
PUT /users/<name> change any of {"first_name", "last_name", "email", "wireguard_peers"};
wireguard_peers, [{"public_key", "ips"}], replaces the user's peers
PUT /users/<name>/password set the password: {"password": ...}
POST /users/<name>/disable lock the user out of OpenVPN
POST /users/<name>/enable let the user in again
POST /users/<name>/unlock end a lockout after failed logins
DELETE /users/<name> remove the user and revoke the certificate
GET /users/<name>/profile the OpenVPN client profile
POST /users/<name>/apple-profile
the Apple configuration profile of IKEv2 access: {"password"}, which
encrypts the user's key in it; {"name", "profile"}
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /users/<name>/otp store a TOTP secret: {"secret", "code"}, code a code of the secret
DELETE /users/<name>/otp remove the TOTP token
DELETE /users/<name>/passkeys remove all the user's passkeys
PUT /users/<name>/console switch console login on or off: {"enabled", "account"?}, account a local account,
without which the user's own posixAccount; only a token of vpn-router-token
Service accounts are served by /service-accounts: they are not listed, and a request naming one is 404.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
route = (method, len(parts), parts[2] if len(parts) == 3 else None)
name = parts[1] if len(parts) > 1 else None
actions = {
('GET', 1, None): lambda connection: {
'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']],
'wireguard': {option: vpnrouter.load_config()['wireguard'][option] for option in ('enabled', 'address')}},
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
('GET', 3, 'profile'): lambda connection: vpnrouter_users.profile(connection, name),
('POST', 3, 'apple-profile'): lambda connection: vpnrouter_users.apple_profile(connection, name,
body.get('password')),
('POST', 3, 'otp'): lambda connection: vpnrouter_users.otp_new(connection, name),
('PUT', 3, 'otp'): lambda connection: vpnrouter_users.otp_enrol(connection, name, body.get('secret'), body.get('code')),
('DELETE', 3, 'otp'): lambda connection: vpnrouter_users.otp_remove(connection, name),
('DELETE', 3, 'passkeys'): lambda connection: vpnrouter_passkeys.remove(connection, name),
('PUT', 3, 'console'): lambda connection: vpnrouter_users.console(connection, name, body.get('enabled'), body.get('account')),
}
# The vpnrouter_users operation of each route that may revoke refresh tokens.
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
('DELETE', 2, None): 'remove'}
if route not in actions:
return 404, {'error': f'no route {method} {path}'}
try:
with vpnrouter_users.session() as connection:
if name is not None and vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
raise vpnrouter_users.Failure(f'{name} is a service account; see /service-accounts', 3)
document = actions[route](connection)
if operations.get(route) in vpnrouter_auth.REVOKING:
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
except vpnrouter_users.Failure as failure:
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': str(exc)}
except SystemExit as exc:
# vpnrouter ends with SystemExit, carrying the reason, on a configuration it cannot read.
return 500, {'error': str(exc.code)}
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, each with its roles: {"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"?,
"grants"?, "public_client"?, "client_scopes"?, "resource_scopes"?,
"roles"?}
PUT /service-accounts/<name> change any of {"display_name", "description", "email", "redirect_uris",
"post_logout_redirect_uris", "allowed_groups", "grants",
"public_client", "client_scopes", "resource_scopes", "roles"}; ""
removes one of the first three, [] the values of the lists
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 service account with redirect URIs or grants is a client of the OpenID Provider, and every service account a
resource (vpnrouter_users.client_settings); roles, [{"name", "description", "groups", "accounts"}], replaces its
roles (vpnrouter_users.set_roles). 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, and a
role assigned to one, is refused with 403. A password change, disabling, unlocking and removing revoke the tokens
the account was issued.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
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', *vpnrouter_users.CLIENT_KEYS)}
actions = {
('GET', 1, None): lambda connection: {
'service_accounts': [vpnrouter_users.with_roles(connection, 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),
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')),
('POST', 3, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
('POST', 3, 'enable'): lambda connection: vpnrouter_users.enable(connection, name),
('POST', 3, 'unlock'): lambda connection: vpnrouter_users.unlock(connection, name),
('DELETE', 2, None): lambda connection: vpnrouter_users.remove(connection, name),
}
# The vpnrouter_users operation of each route that may revoke tokens.
operations = {('PUT', 3, 'password'): 'passwd', ('POST', 3, 'disable'): 'disable', ('POST', 3, 'unlock'): 'unlock',
('DELETE', 2, None): 'remove'}
if route not in actions:
return 404, {'error': f'no route {method} {path}'}
internal = vpnrouter_users.internal_accounts()
if name is not None and name in internal:
return 403, {'error': f'{name} is an internal service account, which the router manages'}
roles = settings['roles'] if isinstance(settings['roles'], list) else []
assigned = sorted(set(internal).intersection(account for role in roles if isinstance(role, dict)
for account in (role.get('accounts') or [])
if isinstance(account, str)))
if assigned:
return 403, {'error': f'{", ".join(assigned)}: internal service accounts, which the router manages, are not '
'assigned roles'}
try:
with vpnrouter_users.session() as connection:
if name is not None and not vpnrouter_users.service(vpnrouter_users.entry(connection, name)):
raise vpnrouter_users.Failure(f'{name} is not a service account', 3)
document = actions[route](connection)
if operations.get(route) in vpnrouter_auth.REVOKING:
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
except vpnrouter_users.Failure as failure:
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': str(exc)}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
return (201 if route == ('POST', 1, None) else 200), document
def groups(state, method, path, body):
"""Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document.
GET /groups the groups with their members
POST /groups add a group: {"name", "members": [...], "description"?}
PUT /groups/<name> rename it or change the description: {"name"?, "description"?}
DELETE /groups/<name> remove the group; its members are kept
POST /groups/<name>/members add a member: {"user": ...}
DELETE /groups/<name>/members/<user> take a member out; the last member cannot leave
An internal service account (vpnrouter_users.internal_accounts) is not put in or taken out of a group: such a
request 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'}
body = body or {}
parts = path.strip('/').split('/')
name = parts[1] if len(parts) > 1 else None
route = (method, len(parts), parts[2] if len(parts) > 2 else None)
members = body.get('members') if route == ('POST', 1, None) else [body.get('user')] if route == ('POST', 3, 'members') \
else [parts[3]] if route == ('DELETE', 4, 'members') else []
internal = sorted(set(vpnrouter_users.internal_accounts()).intersection(
member for member in (members if isinstance(members, list) else []) if isinstance(member, str)))
if internal:
return 403, {'error': f'{", ".join(internal)}: internal service accounts, which the router manages, are not group members'}
actions = {
('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection),
('POST', 1, None): lambda connection: vpnrouter_users.group_add(
connection, body.get('name'), body.get('description'), body.get('members')),
('PUT', 2, None): lambda connection: vpnrouter_users.group_update(
connection, name, body.get('name'), body.get('description')),
('DELETE', 2, None): lambda connection: vpnrouter_users.group_remove(connection, name),
('POST', 3, 'members'): lambda connection: vpnrouter_users.group_member_add(connection, name, body.get('user')),
('DELETE', 4, 'members'): lambda connection: vpnrouter_users.group_member_remove(connection, name, parts[3]),
}
if route not in actions:
return 404, {'error': f'no route {method} {path}'}
try:
with vpnrouter_users.session() as connection:
document = actions[route](connection)
except vpnrouter_users.Failure as failure:
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}'}
return (201 if route == ('POST', 1, None) else 200), document
def account(name, method, path, body):
"""Answer an /account request of the user named by the token with vpnrouter_users: the HTTP status and the JSON document.
GET /account the user, with admin, whether the user is a member of ADMIN_GROUP
PUT /account/password set the password: {"current_password", "current_code"?, "password"}
POST /account/otp a new TOTP secret and its otpauth URI; nothing is stored
PUT /account/otp store a TOTP secret: {"current_password", "current_code"?, "secret", "code"},
code a code of the secret
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
POST /account/profile the user's OpenVPN client profile: {"current_password", "current_code"?}; {"name",
"profile"}
POST /account/apple-profile
the user's Apple configuration profile of IKEv2 access:
{"current_password", "current_code"?, "password"}, password encrypting the user's key in
it; {"name", "profile"}
A change but the removal of a passkey, and the profiles, need 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('/'))
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'),
('POST', '/account/profile'), ('POST', '/account/apple-profile')):
return 404, {'error': f'no route {method} {path}'}
try:
if route == ('PUT', '/account/password'):
vpnrouter_users.check_password(body.get('password'))
if route in (('PUT', '/account/password'), ('PUT', '/account/otp'), ('POST', '/account/passkeys'),
('POST', '/account/profile'), ('POST', '/account/apple-profile')):
vpnrouter_users.login(name, body.get('current_password'), body.get('current_code'))
with vpnrouter_users.session() as connection:
if route == ('GET', '/account'):
document = {**vpnrouter_users.summary(vpnrouter_users.entry(connection, name)),
'admin': vpnrouter_users.is_admin(connection, name)}
elif route == ('PUT', '/account/password'):
document = vpnrouter_users.passwd(connection, name, body['password'])
elif route == ('POST', '/account/otp'):
document = vpnrouter_users.otp_new(connection, name)
elif route == ('POST', '/account/profile'):
document = vpnrouter_users.profile(connection, name)
elif route == ('POST', '/account/apple-profile'):
document = vpnrouter_users.apple_profile(connection, name, body.get('password'))
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'):
vpnrouter_auth.revoke(vpnrouter.load_config(), name)
except vpnrouter_users.Failure as failure:
if failure.status == 5:
return 403, {'error': 'the current password or code is wrong'}
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except vpnrouter_auth.DatabaseError as exc:
return 500, {'error': str(exc)}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
return 200, document
@@ -0,0 +1,75 @@
"""/logs: the last lines of the journal of the router's units and of the files web.log_files lists."""
import os
import subprocess
import vpnrouter
#: 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', 'vpn-router-resolver', 'vpn-router-webdav', 'pdns')
#: 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
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}'}
@@ -0,0 +1,55 @@
"""/metrics/history and /metrics/settings: the metrics the metrics service keeps, and its settings."""
import vpnrouter
import vpnrouter_metrics
from .common import section_change
def metrics_history(view):
"""Answer GET /metrics/history?range=<view> with vpnrouter_metrics.history: the HTTP status and the JSON document.
view is hour, the default, day, month or year (vpnrouter_metrics.RANGES).
"""
if view not in vpnrouter_metrics.RANGES:
return 400, {'error': f'range must be one of {", ".join(vpnrouter_metrics.RANGES)}'}
try:
with vpnrouter_metrics.database(vpnrouter.load_config()) as connection:
return 200, vpnrouter_metrics.history(connection, view)
except vpnrouter_metrics.DatabaseError as exc:
return 500, {'error': str(exc)}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
def metrics_shown(settings):
"""The metrics section as /metrics/settings shows it: its settings but otlp_password, with otlp_password_set."""
return {**{option: value for option, value in settings.items() if option != 'otlp_password'},
'otlp_password_set': bool(settings['otlp_password'])}
def metrics_settings(method, body):
"""Answer a /metrics/settings request: the HTTP status and the JSON document.
GET /metrics/settings {"settings"}: the metrics section without otlp_password, with otlp_password_set
PUT /metrics/settings change any of the settings of the metrics section; an empty or absent otlp_password keeps
the current secret, and an empty otlp_username removes it
A change is applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
if method in ('GET', 'HEAD'):
try:
return 200, {'settings': metrics_shown(vpnrouter.load_config()['metrics'])}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
if method != 'PUT':
return 404, {'error': f'no route {method} /metrics/settings'}
body = dict(body or {})
if not body.get('otlp_password'):
body.pop('otlp_password', None)
if body.get('otlp_username') == '':
body['otlp_password'] = ''
changed = section_change('metrics', body)
return changed if isinstance(changed, tuple) else (200, {'settings': metrics_shown(changed)})
@@ -0,0 +1,90 @@
"""The PKI: /ca, and the certificates and CRLs /pki/ serves without authentication."""
import ldap
import vpnrouter
import vpnrouter_pki
from vpnrouter_status import run
from .common import PKI_STATUS
#: A file /pki/ serves: the certificate or the CRL of a CA, and its content type.
PUBLISHED = {'.crt': 'application/pkix-cert', '.crl': 'application/pkix-crl'}
def ca(method, path, body):
"""Answer a /ca request with vpnrouter_pki: the HTTP status and the JSON document.
GET /ca {"settings", "templates", "extended_key_usages", "status",
"certificates"}: the pki section, the templates with the built-in
ones, the extended key usages, each {oid, builtin}, the root, the
issuing CAs, the retired CAs and the warnings (vpnrouter_pki.status),
and the certificates issued, newest first
PUT /ca change any of the settings of the pki section
POST /ca/certificates issue a certificate: {"ca", "template", "csr"} answers {"serial",
"certificate", "chain"}; {"ca", "template", "subject", "sans",
"password"} answers {"serial", "pfx"}, a PKCS#12 file in base64
POST /ca/certificates/<serial>/revoke revoke a certificate: {"reason"?}
GET /ca/bundle/<name> the PEM certificates a client trusts the CA with: {"name", "pem"}; for
root the root alone, for an issuing CA the issuing CA and the root
GET /ca/issuing/<name>/csr the CSR of an issuing CA that waits for the offline root, with the
openssl extensions to sign it with: {"name", "csr", "extensions"}
PUT /ca/issuing/<name>/certificate take its certificate signed by the root: {"certificate"}
PUT /ca/root/crl take a root CRL signed offline: {"crl"}
A change of the settings is made as /site-to-site makes one; it, a revoked router certificate and an imported
issuing CA certificate are applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
route = (method, *parts[1:])
try:
cfg = vpnrouter.load_config()
if len(route) == 3 and route[:2] == ('GET', 'bundle'):
return 200, vpnrouter_pki.bundle(route[2])
if route == ('GET',):
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg),
'extended_key_usages': {name: {'oid': oid, 'builtin': name in vpnrouter.EXTENDED_KEY_USAGES}
for name, oid in vpnrouter.extended_key_usages(cfg).items()},
'status': vpnrouter_pki.status(cfg), 'certificates': vpnrouter_pki.certificates()}
if route == ('PUT',):
settings = {option: body[option] for option in vpnrouter.SCHEMA['pki'] if option in body}
with vpnrouter.config_lock():
data = vpnrouter.read_settings()
current = data.get('pki')
data['pki'] = {**(current if isinstance(current, dict) else {}), **settings}
cfg = vpnrouter.with_defaults(data)
vpnrouter.validate(cfg)
vpnrouter.save_config(data)
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, {'settings': cfg['pki'], 'templates': vpnrouter.pki_templates(cfg)}
with vpnrouter_pki.locked():
if route == ('POST', 'certificates'):
return 201, vpnrouter_pki.issue(cfg, body.get('ca'), body.get('template'), csr=body.get('csr'),
subject=body.get('subject'), sans=body.get('sans'),
password=body.get('password'))
if len(route) == 4 and route[:2] == ('POST', 'certificates') and route[3] == 'revoke':
document = vpnrouter_pki.revoke(cfg, route[2], body.get('reason'))
elif len(route) == 4 and route[:2] == ('GET', 'issuing') and route[3] == 'csr':
return 200, vpnrouter_pki.csr(cfg, route[2])
elif len(route) == 4 and route[:2] == ('PUT', 'issuing') and route[3] == 'certificate':
document = vpnrouter_pki.import_ca(cfg, route[2], body.get('certificate'))
elif route == ('PUT', 'root', 'crl'):
return 200, vpnrouter_pki.import_root_crl(body.get('crl'))
else:
return 404, {'error': f'no route {method} {path}'}
except vpnrouter.ConfigError as exc:
return 400, {'error': str(exc)}
except vpnrouter_pki.Failure as failure:
return PKI_STATUS.get(failure.status, 500), {'error': str(failure)}
except ldap.LDAPError as exc:
return 500, {'error': f'the directory, which holds the PKI, failed: {exc}'}
except OSError as exc:
return 500, {'error': f'the PKI cannot be changed: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
if route[1] == 'issuing' or document.get('reissue'):
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return 200, document
@@ -0,0 +1,500 @@
"""/shell: the ticket (shell_ticket), with the passkey proof it may take (shell_passkey), the WebSocket of the terminal
(shell), and the editor of the shell: the socket vpn-router-editor names files on (editor_listen) and /shell/editor/...
(shell_editor)."""
import base64
import fcntl
import hashlib
import json
import os
import pwd
import secrets
import select
import socket
import struct
import subprocess
import termios
import threading
import time
import urllib.parse
import ldap
import vpnrouter
import vpnrouter_passkeys
import vpnrouter_users
from vpnrouter_status import run
from .common import BODY_LIMIT, MIB, USER_STATUS, log
#: Seconds a ticket of POST /shell may open a shell.
SHELL_TICKET_SECONDS = 30
#: Seconds between the checks of an open shell's user, which end the shell once its console is refused.
SHELL_CHECK_SECONDS = 60
#: The largest WebSocket message accepted from a client, in bytes.
MESSAGE_LIMIT = 65536
#: The socket vpn-router-editor (vre) names files on to open them in the editor of a shell (see editor_listen()).
EDITOR_SOCKET = '/run/vpn-router/editor.sock'
#: The helper that reads and writes a file of the editor as the shell's user.
EDITOR_FILE = '/usr/lib/vpn-router/editor-file'
#: The largest file the editor opens, in bytes, as editor-file allows; a body of PUT /shell/editor/... may hold its
#: content escaped in JSON.
EDITOR_MAX_BYTES = MIB
EDITOR_BODY_LIMIT = 2 * EDITOR_MAX_BYTES + BODY_LIMIT
#: The most files a shell has open in its editor at once.
EDITOR_FILES_MAX = 20
#: Seconds a reader of the editor socket and a run of editor-file may take.
EDITOR_TIMEOUT = 30
#: The GUID of RFC 6455 that the Sec-WebSocket-Accept of a handshake is made with.
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
#: WebSocket opcodes.
TEXT, BINARY, CLOSE, PING, PONG = 1, 2, 8, 9, 10
#: The unused tickets of POST /shell: ticket -> (user, monotonic time of expiry).
_shell_tickets = {}
_shell_lock = threading.Lock()
#: The last use of a shell of each user, a sign-in of POST /shell or a keystroke: user -> monotonic time.
_shell_used = {}
#: The open shells by their session, which each shell has in VPN_ROUTER_SHELL: session -> {"user", "account", "uid",
#: "send", the function that sends a WebSocket frame to its client, "files": {file id: path}}.
_shell_sessions = {}
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"}, its password and TOTP code, or with
{"passkey": {"id", "credential"}}, the answer of the browser to a proof begun by POST /shell/passkey (see
shell_passkey) with one of its own passkeys and the user verified. A wrong password or code counts as a failed
login; it and a passkey that is not accepted are 403. Without either, the user is not asked to sign in while its
last use of a shell (see _shell_used) is less than web.shell_auth_timeout minutes ago. The answer is {"ticket",
"expires_in"}: a ticket that opens one shell (GET /shell) for the same user within SHELL_TICKET_SECONDS, or a null
ticket when the user must sign in. 403 while web.shell is false, the user's console login is not on or the user has
no TOTP token.
"""
if not isinstance(name, str):
return 403, {'error': 'the shell needs a token of a signed-in user'}
if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'}
try:
options = vpnrouter.load_config()['web']
if not options['shell']:
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
refusal = console_account(name)[1]
if refusal:
return 403, {'error': refusal}
if 'passkey' in body:
passkey = body['passkey'] if isinstance(body['passkey'], dict) else {}
with vpnrouter_users.session() as connection:
vpnrouter_passkeys.shell_complete(connection, name, passkey.get('id'), passkey.get('credential'))
elif 'password' in body:
vpnrouter_users.login(name, body.get('password'), body.get('code'))
else:
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}
except vpnrouter_users.Failure as failure:
if failure.status == 5:
return 403, {'error': 'the passkey is not accepted' if 'passkey' in body else 'the password or the code is wrong'}
return USER_STATUS.get(failure.status, 500), {'error': str(failure)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the user directory failed: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
ticket = secrets.token_urlsafe(32)
now = time.monotonic()
with _shell_lock:
for unused in [key for key, (_, expires) in _shell_tickets.items() if expires <= now]:
del _shell_tickets[unused]
_shell_tickets[ticket] = (name, now + SHELL_TICKET_SECONDS)
_shell_used[name] = now
return 200, {'ticket': ticket, 'expires_in': SHELL_TICKET_SECONDS}
def shell_passkey(name):
"""Answer POST /shell/passkey: begin the proof of the signed-in user with one of its own passkeys for POST /shell,
{"id", "options"}, options those of navigator.credentials.get; the HTTP status and the JSON document. 403 while
the user may not use a shell (console_account), 400 for a user without a passkey."""
if not isinstance(name, str):
return 403, {'error': 'the shell needs a token of a signed-in user'}
try:
if not vpnrouter.load_config()['web']['shell']:
return 403, {'error': 'the shell is disabled; vpn-config-edit set web.shell true enables it'}
refusal = console_account(name)[1]
if refusal:
return 403, {'error': refusal}
with vpnrouter_users.session() as connection:
return 200, vpnrouter_passkeys.shell_begin(connection, name)
except vpnrouter_users.Failure as failure:
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)}
def console_account(name):
"""The system account the console of the user runs as and None, or None and the reason the user may not use a
console: its console login is off, or it has no TOTP token, which the PAM login session of the shell and sudo need
also when a passkey opens the shell."""
with vpnrouter_users.session() as connection:
user = vpnrouter_users.summary(vpnrouter_users.entry(connection, name))
if not user['console'] or not user['console']['enabled']:
return None, f'console login is not enabled for {name}; vpn-router-user console sets it'
if not user['otp']:
return None, f'{name} has no TOTP token; the shell needs one, enrolled on the Account page'
return user['console']['account'], None
def take_ticket(name, ticket):
"""Whether the ticket of POST /shell is unexpired and was given to the user; it is used up either way."""
with _shell_lock:
holder, expires = _shell_tickets.pop(ticket, (None, 0.0))
return holder == name and expires > time.monotonic()
def shell_command(account, unit, session):
"""The command that runs the login shell of the system user account in the transient unit, on its terminal, with
the shell's session in VPN_ROUTER_SHELL for vpn-router-editor.
setsid makes the terminal the controlling one of systemd-run, which passes the window size on; systemd-run starts
the shell outside the web service's sandbox, in a PAM login session of the user.
"""
return ['setsid', '--ctty', 'systemd-run', '--quiet', '--collect', '--pty', '--send-sighup', f'--unit={unit}',
f'--uid={account.pw_name}', '--property=PAMName=login', f'--working-directory={account.pw_dir}',
'--setenv=TERM=xterm-256color', f'--setenv=VPN_ROUTER_SHELL={session}', account.pw_shell or '/bin/sh', '-l']
def editor_file(account, action, path, content=b'', version=''):
"""Run editor-file as the system user account outside the sandbox: read the file, or write content to it when it
still has version. The exit status of editor-file and its output, or its message on failure."""
command = ['systemd-run', '--quiet', '--collect', '--pipe', f'--uid={account}', EDITOR_FILE, action, path]
try:
result = subprocess.run(command + ([version] if action == 'write' else []), input=content, capture_output=True,
timeout=EDITOR_TIMEOUT)
except (OSError, subprocess.TimeoutExpired) as exc:
return 1, f'editor-file cannot be run: {exc}'.encode()
return result.returncode, result.stdout if result.returncode == 0 else result.stderr.strip() or \
f'editor-file ended with status {result.returncode}'.encode()
def shell_editor(name, method, parts, body):
"""Answer a request of a file open in the editor of one of the user's shells: the HTTP status and the JSON document.
GET /shell/editor/<session>/<id> {"path", "content", "version"}, version the SHA-256 of the content
PUT /shell/editor/<session>/<id> {"content", "version"} writes the content when the file still has version,
else 409; answers {"path", "version"}
DELETE /shell/editor/<session>/<id> closes the file
The file is read and written as the shell's console account (editor_file). 404 for a session that is not a shell of
the user or a file it does not have open.
"""
if len(parts) != 4:
return 404, {'error': f'no route {method} /{"/".join(parts)}'}
with _shell_lock:
session = _shell_sessions.get(parts[2])
path = session['files'].get(parts[3]) if session and session['user'] == name else None
account = session['account'] if path else None
if path is None:
return 404, {'error': 'no such file is open in a shell of yours'}
if method == 'DELETE':
with _shell_lock:
session['files'].pop(parts[3], None)
return 200, {'path': path, 'closed': True}
if method == 'GET':
status, output = editor_file(account, 'read', path)
if status != 0:
return (400 if status == 2 else 500), {'error': output.decode(errors='replace')}
return 200, {'path': path, 'content': output.decode(), 'version': hashlib.sha256(output).hexdigest()}
if method != 'PUT':
return 405, {'error': f'{method} is not allowed on a file of the editor'}
if not isinstance(body, dict) or not isinstance(body.get('content'), str) or not isinstance(body.get('version'), str):
return 400, {'error': 'the body must be {"content", "version"}'}
content = body['content'].encode()
if len(content) > EDITOR_MAX_BYTES:
return 413, {'error': f'the content is larger than {EDITOR_MAX_BYTES // 1024} KiB'}
status, output = editor_file(account, 'write', path, content, body['version'])
if status != 0:
return {2: 400, 3: 409}.get(status, 500), {'error': output.decode(errors='replace')}
log(f'editor of {name} saved {path} as {account}')
return 200, {'path': path, 'version': hashlib.sha256(content).hexdigest()}
def editor_request(connection):
"""Answer one request of vpn-router-editor on the editor socket: {"session", "path"} on a line opens the file in the
editor of the shell of the session, whose client is sent {"editor": "open", "session", "id", "path"} as a text
message.
The peer must run as the shell's console account and the path must be absolute. The answer is {"ok": true} or
{"error"} on a line.
"""
try:
connection.settimeout(EDITOR_TIMEOUT)
_, uid, _ = struct.unpack('iII', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
struct.calcsize('iII')))
request = json.loads(connection.makefile('rb').readline(4096) or b'null')
if not isinstance(request, dict) or not isinstance(request.get('session'), str) or \
not isinstance(request.get('path'), str):
answer = {'error': 'the request must be {"session", "path"}'}
elif not os.path.isabs(request['path']):
answer = {'error': 'the path must be absolute'}
else:
with _shell_lock:
session = _shell_sessions.get(request['session'])
if session is None or session['uid'] != uid:
answer = {'error': 'no shell of the Router Manager has this session; vre works only in its shell'}
elif len(session['files']) >= EDITOR_FILES_MAX:
answer = {'error': f'the editor has {EDITOR_FILES_MAX} files open; close one first'}
else:
file_id = secrets.token_urlsafe(8)
session['files'][file_id] = request['path']
answer = None
if answer is None:
session['send'](TEXT, json.dumps({'editor': 'open', 'session': request['session'], 'id': file_id,
'path': request['path']}).encode())
log(f'editor of {session["user"]} opened {request["path"]}')
answer = {'ok': True}
connection.sendall(json.dumps(answer).encode() + b'\n')
except (OSError, ValueError, struct.error):
pass
finally:
connection.close()
def editor_listen():
"""Listen on EDITOR_SOCKET, which any local user may connect to, answering each connection in a thread with
editor_request; the socket of an earlier run is replaced. Does not return; a failure to listen is logged and the
editor stays closed."""
try:
if os.path.lexists(EDITOR_SOCKET):
os.unlink(EDITOR_SOCKET)
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(EDITOR_SOCKET)
os.chmod(EDITOR_SOCKET, 0o666)
listener.listen(16)
except OSError as exc:
log(f'the editor socket {EDITOR_SOCKET} cannot be opened: {exc}')
return
while True:
try:
connection, _ = listener.accept()
except OSError:
continue
threading.Thread(target=editor_request, args=(connection,), name='editor request', daemon=True).start()
def frame(opcode, payload):
"""A final, unmasked WebSocket frame of a server."""
size = len(payload)
if size < 126:
length = bytes([size])
elif size < 65536:
length = bytes([126]) + size.to_bytes(2, 'big')
else:
length = bytes([127]) + size.to_bytes(8, 'big')
return bytes([0x80 | opcode]) + length + payload
def exact(handler, size):
"""The next size bytes from the client of the Handler handler; ConnectionError when it closes first."""
data = handler.rfile.read(size)
if len(data) != size:
raise ConnectionError('the client closed the connection')
return data
def message(handler, send):
"""The opcode and the payload of the next WebSocket data message of the client of the Handler handler, or CLOSE
and its payload.
Fragments are joined and unmasked; a ping is answered with send, a pong ignored. ValueError for an unmasked
frame or a message over MESSAGE_LIMIT.
"""
opcode, payload = None, b''
while True:
head = exact(handler, 2)
code, size = head[0] & 0x0F, head[1] & 0x7F
if not head[1] & 0x80:
raise ValueError('a frame of the client is not masked')
if size == 126:
size = int.from_bytes(exact(handler, 2), 'big')
elif size == 127:
size = int.from_bytes(exact(handler, 8), 'big')
if len(payload) + size > MESSAGE_LIMIT:
raise ValueError(f'a message is larger than {MESSAGE_LIMIT} bytes')
mask = exact(handler, 4) * (size // 4 + 1)
data = bytes(byte ^ key for byte, key in zip(exact(handler, size), mask))
if code == PING:
send(PONG, data)
continue
if code == PONG:
continue
if code == CLOSE:
return CLOSE, data
opcode = code if opcode is None else opcode
payload += data
if head[0] & 0x80:
return opcode, payload
def shell(handler, name):
"""Answer GET /shell?ticket=<ticket>, a WebSocket handshake the Handler handler received, with a login shell of the
user's console account (vpnrouter_users.console) on a terminal.
The ticket must come from POST /shell for the user named by the token. Binary messages of the client are its
keystrokes, a text message {"cols", "rows"} sets the window size, and binary messages of the server are the
terminal's output; a text message of the server {"editor": "open", "session", "id", "path"} opens a file the
shell named with vpn-router-editor (editor_request), which the client reads and saves with
/shell/editor/<session>/<id> (shell_editor) while the shell lives. The shell ends after web.shell_idle_minutes
without a keystroke, when it exits, when the client closes the connection, or when a check of the user (SHELL_CHECK_SECONDS) finds its console login off or
its TOTP token gone; the server then closes it with the reason. Each opening, end and refusal is logged
with its reason.
"""
def refuse(code, message):
log(f'shell of {name} from {handler.client()} refused: {message}')
body = json.dumps({'error': message}).encode() + b'\n'
handler.send_response(code)
handler.send_header('Content-Type', 'application/json')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(body)
key = handler.headers.get('Sec-WebSocket-Key', '').strip()
if handler.headers.get('Upgrade', '').lower() != 'websocket' or 'upgrade' not in handler.headers.get('Connection', '').lower() \
or handler.headers.get('Sec-WebSocket-Version') != '13' or not key:
return refuse(400, 'GET /shell must be a WebSocket handshake')
query = urllib.parse.parse_qs(urllib.parse.urlsplit(handler.path).query)
if not take_ticket(name, query.get('ticket', [''])[0]):
return refuse(403, 'the ticket is not valid; POST /shell gives one')
try:
options = vpnrouter.load_config()['web']
except SystemExit as exc:
return refuse(500, str(exc.code))
if not options['shell']:
return refuse(403, 'the shell is disabled; vpn-config-edit set web.shell true enables it')
try:
login, refusal = console_account(name)
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
return refuse(500, f'the user directory failed: {exc}')
if refusal:
return refuse(403, refusal)
try:
account = pwd.getpwnam(login)
except KeyError:
return refuse(500, f'the system does not know the account {login}; is nslcd running?')
unit = f'vpn-router-shell-{secrets.token_hex(4)}'
session = secrets.token_urlsafe(24)
master, terminal = os.openpty()
try:
process = subprocess.Popen(shell_command(account, unit, session), stdin=terminal, stdout=terminal, stderr=terminal,
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'})
except OSError as exc:
os.close(master)
return refuse(500, f'the shell cannot be started: {exc}')
finally:
os.close(terminal)
accept = base64.b64encode(hashlib.sha1((key + WEBSOCKET_GUID).encode()).digest()).decode()
handler.send_response(101, 'Switching Protocols')
handler.send_header('Upgrade', 'websocket')
handler.send_header('Connection', 'Upgrade')
handler.send_header('Sec-WebSocket-Accept', accept)
handler.end_headers()
handler.close_connection = True
client, started, idle = handler.client(), time.monotonic(), options['shell_idle_minutes'] * 60
log(f'shell of {name} from {client} opened as {account.pw_name} in {unit}')
lock, ended, typed = threading.Lock(), threading.Event(), [started]
def send(opcode, payload):
with lock:
handler.wfile.write(frame(opcode, payload))
with _shell_lock:
_shell_sessions[session] = {'user': name, 'account': account.pw_name, 'uid': account.pw_uid, 'send': send,
'files': {}}
def receive():
"""Pass the client's keystrokes and window sizes to the terminal until it closes the connection."""
try:
while True:
opcode, payload = message(handler, send)
if opcode == CLOSE:
break
if opcode == BINARY:
os.write(master, payload)
typed[0] = time.monotonic()
with _shell_lock:
_shell_used[name] = typed[0]
elif opcode == TEXT:
size = json.loads(payload)
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', int(size['rows']), int(size['cols']), 0, 0))
except (OSError, ValueError, KeyError, TypeError, struct.error):
pass
finally:
ended.set()
reader = threading.Thread(target=receive, name=f'{unit} input', daemon=True)
reader.start()
reason = 'the shell ended'
checked = started
try:
while not ended.is_set():
left = typed[0] + idle - time.monotonic()
if left <= 0:
reason = f'no keystroke for {options["shell_idle_minutes"]} minutes'
break
# The user's console login and TOTP token are checked again every SHELL_CHECK_SECONDS.
if time.monotonic() - checked >= SHELL_CHECK_SECONDS:
checked = time.monotonic()
try:
refusal = console_account(name)[1]
except (vpnrouter_users.Failure, ldap.LDAPError, OSError) as exc:
refusal = f'the user directory failed: {exc}'
if refusal:
reason = refusal
break
if select.select([master], [], [], min(left, 1))[0]:
try:
output = os.read(master, 65536)
except OSError:
output = b''
if not output:
break
send(BINARY, output)
else:
reason = 'the client closed the connection'
except OSError:
reason = 'the connection failed'
with _shell_lock:
_shell_sessions.pop(session, None)
run('systemctl', 'stop', unit)
try:
send(CLOSE, (1000).to_bytes(2, 'big') + reason.encode()[:120])
except OSError:
pass
try:
handler.connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
reader.join()
os.close(master)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
log(f'shell of {name} from {client} in {unit} ended after {int(time.monotonic() - started)} seconds: {reason}')
@@ -0,0 +1,148 @@
"""/sites: the settings of the web sites Caddy serves, the sites, and the files of each (site_files)."""
import mimetypes
import os
import re
import shutil
import urllib.parse
import ldap
import vpnrouter
import vpnrouter_sites
from vpnrouter_status import run
from .common import MIB, SITE_STATUS, section_change
#: A path of a site's files: the site and the path in its folder.
SITE_FILES = re.compile(r'^/sites/([^/]+)/files(?:/(.*))?$')
def sites(method, path, query, body):
"""Answer a /sites request but one of a site's files with vpnrouter_sites: the HTTP status and the JSON document.
GET /sites {"settings", "sites"}: the sites section of the configuration and the sites, each with its
settings but private_key, with private_key_set, modified and its folder
PUT /sites change any of the settings of the sites section
POST /sites add a site: {"name", and any of "enabled", "hosts", "port", "listen", "tls", "ca",
"certificate", "private_key", "index_file", "spa", "editors"}; an existing name is 409
GET /sites/<name> the site
PUT /sites/<name> change any of the settings of POST but name; an empty or absent private_key keeps the key
DELETE /sites/<name> remove the site, with content=true also its folder
A change is applied by restarting vpn-router-setup without waiting for it.
"""
if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'}
body = body or {}
parts = path.strip('/').split('/')
route = (method, len(parts))
try:
cfg = vpnrouter.load_config()
if route == ('GET', 1):
return 200, {'settings': cfg['sites'], 'sites': vpnrouter_sites.listing()}
if route == ('PUT', 1):
changed = section_change('sites', body)
return changed if isinstance(changed, tuple) else (200, {'settings': changed})
if route == ('POST', 1):
name = body.get('name')
document = vpnrouter_sites.save(cfg, name, {key: value for key, value in body.items() if key != 'name'},
True)
code = 201
elif route == ('GET', 2):
with vpnrouter_sites.database() as connection:
return 200, vpnrouter_sites.shown(vpnrouter_sites.get(connection, parts[1]))
elif route == ('PUT', 2):
document, code = vpnrouter_sites.save(cfg, parts[1], body, False), 200
elif route == ('DELETE', 2):
document = vpnrouter_sites.remove(parts[1], query.get('content', [''])[0] == 'true')
code = 200
else:
return 404, {'error': f'no route {method} {path}'}
except vpnrouter_sites.Failure as failure:
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
except vpnrouter_sites.DatabaseError as exc:
return 500, {'error': str(exc)}
except (ldap.LDAPError, OSError) as exc:
return 500, {'error': f'the site cannot be changed: {exc}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
run('systemctl', 'restart', '--no-block', 'vpn-router-setup.service')
return code, document
def site_file_action(name, target, body):
"""Answer a POST to a site's file or folder: {"folder": true} creates the folder, {"rename": "<path>"} moves the file
or folder to the path in the site's folder; the HTTP status and the JSON document."""
if not isinstance(body, dict):
return 400, {'error': 'the body must be a JSON object'}
try:
if body.get('folder') is True:
vpnrouter_sites.make_folder(target)
return 201, {'path': vpnrouter_sites.relative_of(name, target), 'folder': True}
if isinstance(body.get('rename'), str):
moved = vpnrouter_sites.rename(name, target, body['rename'])
return 200, {'path': vpnrouter_sites.relative_of(name, moved)}
except vpnrouter_sites.Failure as failure:
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
except OSError as exc:
return 500, {'error': f'{exc.filename or target.name}: {exc.strerror}'}
return 400, {'error': 'give {"folder": true} or {"rename": "<path>"}'}
def site_files(handler, name, relative, query, payload, send_body):
"""Answer a request of a site's files the Handler handler received: the HTTP status and the JSON document, or None
once a file was sent.
GET /sites/<name>/files/<path> a folder: {"path", "entries": [{name, folder, size, modified}]}; a file:
the file itself, as an attachment
PUT /sites/<name>/files/<path> write the body to the file, its folders created; with unzip=true the body
is a zip archive extracted into the folder of the path: {"path", "files"}
POST /sites/<name>/files/<path> {"folder": true} creates the folder, {"rename": "<path>"} moves it
DELETE /sites/<name>/files/<path> remove the file or folder
The body of a PUT needs a Content-Length of at most sites.upload_max_mb MiB.
"""
try:
target = vpnrouter_sites.resolve(name, urllib.parse.unquote(relative))
if handler.command in ('GET', 'HEAD'):
if target.is_dir():
return 200, vpnrouter_sites.entries(name, target)
if not target.is_file():
return 404, {'error': f'{relative} does not exist'}
with open(target, 'rb') as handle:
size = os.fstat(handle.fileno()).st_size
handler.send_response(200)
handler.send_header('Content-Type', mimetypes.guess_type(target.name)[0] or 'application/octet-stream')
handler.send_header('Content-Length', str(size))
handler.send_header('Content-Disposition',
f"attachment; filename*=UTF-8''{urllib.parse.quote(target.name)}")
handler.send_header('Cache-Control', 'no-store')
handler.end_headers()
if send_body:
shutil.copyfileobj(handle, handler.wfile, vpnrouter_sites.CHUNK)
return None
if handler.command == 'PUT':
length = handler.headers.get('Content-Length')
if not length or not length.isdigit():
return 411, {'error': 'the body needs a Content-Length'}
limit = vpnrouter.load_config()['sites']['upload_max_mb'] * MIB
if query.get('unzip', [''])[0] == 'true':
count = vpnrouter_sites.extract(handler.rfile, int(length), target, limit)
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': count}
vpnrouter_sites.receive(handler.rfile, int(length), target, limit)
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'files': 1}
if handler.command == 'POST':
return site_file_action(name, target, payload)
if handler.command == 'DELETE':
if not target.exists() and not target.is_symlink():
return 404, {'error': f'{relative} does not exist'}
vpnrouter_sites.delete(name, target)
return 200, {'path': vpnrouter_sites.relative_of(name, target), 'removed': True}
except vpnrouter_sites.Failure as failure:
return SITE_STATUS.get(failure.status, 500), {'error': str(failure)}
except OSError as exc:
return 500, {'error': f'{exc.filename or relative}: {exc.strerror}'}
except SystemExit as exc:
return 500, {'error': str(exc.code)}
return 405, {'error': f'{handler.command} is not allowed on a site\'s files'}
@@ -0,0 +1,325 @@
"""The status of the router: / for people, /status, /status/full, /config and /postgresql, with the cache of the
status that the requests of CACHE_SECONDS share (cached)."""
import json
import threading
import time
from html import escape
import vpnrouter
from vpnrouter_status import established, established_each, openvpn_clients, run, swanctl, wireguard_peers
from .common import current, unit_state
#: Seconds a collected status is answered again.
CACHE_SECONDS = 2
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
#: Seconds after which the status page in a browser reloads itself.
REFRESH_SECONDS = 60
#: The PostgreSQL settings /config shows for each cluster.
POSTGRESQL_SETTINGS = ('server_version', 'listen_addresses', 'port', 'max_connections', 'shared_buffers',
'effective_cache_size', 'work_mem', 'password_encryption', 'ssl', 'timezone')
_cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()
def package_version(package='vpn-router'):
"""The installed version of the package, or None when it is not installed."""
result = run('dpkg-query', '-W', '-f=${Version}', package)
return (result.stdout.strip() or None) if result and result.returncode == 0 else None
def docker_status():
"""Whether Docker is installed, and the state of its service and of the Docker rules when it is."""
result = run('systemctl', 'list-unit-files', '--no-legend', 'docker.service')
if result is None or result.returncode != 0 or not result.stdout.strip():
return {'installed': False}
return {
'installed': True,
'service': unit_state('docker.service'),
'rules': unit_state('vpn-router-docker.service'),
}
def postgresql_clusters():
"""The state of each PostgreSQL cluster unit (postgresql@<version>-<name>), or {'postgresql': 'no cluster'} without one.
postgresql.service, which only runs /bin/true, is not reported.
"""
lines = lines_of('systemctl', 'list-units', 'postgresql@*.service', '--all', '--plain', '--no-legend') or []
names = [line.split()[0].removesuffix('.service') for line in lines if line.strip()]
return {name: unit_state(f'{name}.service') for name in names} or {'postgresql': 'no cluster'}
def collect(state):
"""The status document. state says which features the router has."""
units = {'vpn-router-setup': unit_state('vpn-router-setup.service'), 'slapd': unit_state('slapd.service'),
**postgresql_clusters()}
manager_version = package_version('vpn-router-manager')
if manager_version:
units['vpn-router-manager'] = unit_state('vpn-router-manager.service')
if state.get('site_to_site') or state.get('p2s'):
units['strongswan'] = unit_state('strongswan.service')
if state.get('wireguard'):
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
if state.get('openvpn'):
units['openvpn-server@vpn-router'] = unit_state('openvpn-server@vpn-router.service')
status = {
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
'version': package_version(),
'manager_version': manager_version,
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
'units': units,
'docker': docker_status(),
}
names = state.get('site_to_site')
if isinstance(names, list) and names:
counts = established_each(names).values()
status['site_to_site'] = {'connections': len(names),
'established': None if None in counts else sum(1 for count in counts if count)}
if state.get('p2s'):
status['p2s'] = {'clients': established('road-warrior')}
if state.get('wireguard'):
status['wireguard'] = {'peers': wireguard_peers()}
if state.get('openvpn'):
status['openvpn'] = {'clients': openvpn_clients()}
return status
def shown(status, content, client):
"""The status document as web.status_content shows it to the client at its address.
Every content has status, time and client_ip, the client's address, and content, which one it is: basic nothing
more; standard adds version, manager_version and services, the number of active units and of units in every
other state; full adds the whole status document of collect. Content that is not one of
vpnrouter.STATUS_CONTENTS, or not set, is basic.
"""
if content not in vpnrouter.STATUS_CONTENTS:
content = 'basic'
if content == 'full':
return {**status, 'content': content, 'client_ip': client}
document = {'status': status['status'], 'content': content, 'time': status['time'], 'client_ip': client}
if content == 'standard':
active = sum(1 for value in status['units'].values() if value == 'active')
document.update(version=status['version'], manager_version=status['manager_version'],
services={'active': active, 'failed': len(status['units']) - active})
return document
def lines_of(*args):
"""The output lines of a command, or None when it fails."""
result = run(*args)
return result.stdout.splitlines() if result and result.returncode == 0 else None
def routes():
"""The IPv4 routes as ip -j prints them, or None when ip fails."""
result = run('ip', '-j', '-4', 'route', 'show')
if result is None or result.returncode != 0:
return None
try:
return json.loads(result.stdout)
except ValueError:
return None
def wireguard_details():
"""The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
result = run('wg', 'show', 'wg0', 'dump')
if result is None or result.returncode != 0:
return None
rows = [line.split('\t') for line in result.stdout.splitlines()]
now = int(time.time())
return {
'public_key': rows[0][1] if rows and len(rows[0]) > 2 else None,
'listen_port': int(rows[0][2]) if rows and len(rows[0]) > 2 else None,
'peers': [{
'public_key': row[0],
'endpoint': None if row[2] == '(none)' else row[2],
'allowed_ips': row[3].split(','),
'handshake_seconds_ago': now - int(row[4]) if int(row[4]) else None,
'received_bytes': int(row[5]),
'sent_bytes': int(row[6]),
} for row in rows[1:] if len(row) >= 7],
}
def postgresql_query(cluster, sql):
"""The JSON value an SQL statement answers on a cluster (version/name), run by psql as postgres, or None when it fails."""
result = run('runuser', '-u', 'postgres', '--', 'psql', '--cluster', cluster, '-X', '-q', '-t', '-A',
'-v', 'ON_ERROR_STOP=1', '-d', 'postgres', '-c', sql)
if result is None or result.returncode != 0:
return None
try:
return json.loads(result.stdout)
except ValueError:
return None
def postgresql_clusters_of():
"""The PostgreSQL clusters of pg_lsclusters, each with version, name, port, status and data_directory, or None when it fails."""
lines = lines_of('pg_lsclusters', '--no-header')
if lines is None:
return None
clusters = []
for line in lines:
fields = line.split()
if len(fields) >= 6:
clusters.append({'version': fields[0], 'name': fields[1], 'port': int(fields[2]) if fields[2].isdigit() else None,
'status': fields[3], 'data_directory': fields[5]})
return clusters
def postgresql_configuration():
"""The PostgreSQL clusters on the router with the settings of POSTGRESQL_SETTINGS of each one that is online (None
for one that is not), or None when pg_lsclusters fails."""
clusters = postgresql_clusters_of()
if clusters is None:
return None
names = ', '.join(f"'{name}'" for name in POSTGRESQL_SETTINGS)
for cluster in clusters:
cluster['settings'] = postgresql_query(
f"{cluster['version']}/{cluster['name']}",
f'SELECT json_object_agg(name, current_setting(name) ORDER BY name) FROM pg_settings WHERE name IN ({names})',
) if cluster['status'].startswith('online') else None
return {'clusters': clusters}
def postgresql_databases():
"""What /postgresql shows: the PostgreSQL clusters on the router, each with its databases (name, owner, encoding and
size in bytes) and their total size, None for a cluster that is not online; clusters is None when pg_lsclusters fails."""
clusters = postgresql_clusters_of()
if clusters is None:
return {'clusters': None}
for cluster in clusters:
databases = postgresql_query(
f"{cluster['version']}/{cluster['name']}",
"SELECT coalesce(json_agg(json_build_object('name', datname, 'owner', pg_get_userbyid(datdba), "
"'encoding', pg_encoding_to_char(encoding), 'size', pg_database_size(oid)) ORDER BY datname), '[]') "
'FROM pg_database',
) if cluster['status'].startswith('online') else None
cluster['databases'] = databases
cluster['size'] = sum(database['size'] for database in databases) if isinstance(databases, list) else None
return {'clusters': clusters}
def details(state):
"""What /config shows: the configuration without its secrets, and what the router resolved and runs; caddy is the
installed Caddy, {version, source, service}, service the state of caddy.service, null when it is not installed; oidc
is {enabled, issuer} of the OpenID Provider, issuer null while it is disabled."""
configuration = state.get('configuration', {})
caddy = package_version('caddy')
oidc = configuration.get('oidc', {}).get('enabled') is True
return {
'configuration': configuration,
'network': dict(state.get('network', {}), routes=routes()),
'ipsec': {
'connections': swanctl('--list-conns'),
'sas': swanctl('--list-sas'),
},
'wireguard': wireguard_details(),
'docker': {
'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []),
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
},
'postgresql': postgresql_configuration(),
# Caddy is upstream's once its repository is configured (vpn-router-caddy).
'caddy': {'version': caddy, 'source': 'upstream' if vpnrouter.CADDY_APT_SOURCE.is_file() else 'ubuntu',
'service': unit_state('caddy.service')} if caddy else None,
'oidc': {'enabled': oidc, 'issuer': vpnrouter.oidc_issuer(configuration) if oidc else None},
}
def text_of(status):
"""The status document of shown for people, as plain text: a header, a blank line, then name: value lines."""
lines = ['VPN Router', f'Status: {status["status"]}']
if 'version' in status:
lines.append(f'Version: {status["version"] or "unknown"}')
lines += ['', f'Client IP: {status["client_ip"]}']
if 'manager_version' in status:
lines.append(f'Router Manager: {status["manager_version"] or "not installed"}')
if 'services' in status:
lines.append(f'Services: {status["services"]["active"]} active, {status["services"]["failed"]} failed')
lines += [f'{name}: {value}' for name, value in status.get('units', {}).items()]
if 'docker' in status:
docker = status['docker']
lines.append('Docker: not installed' if not docker['installed']
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
if 'site_to_site' in status:
established, connections = status['site_to_site']['established'], status['site_to_site']['connections']
lines.append(f'Site to site: {"unknown" if established is None else established} of {connections} established')
if 'p2s' in status:
clients = status['p2s']['clients']
lines.append(f'IKEv2: {"unknown" if clients is None else f"{clients} connected"}')
if 'openvpn' in status:
clients = status['openvpn']['clients']
lines.append(f'OpenVPN: {"unknown" if clients is None else f"{clients} connected"}')
if 'wireguard' in status:
peers = status['wireguard']['peers']
if peers is None:
lines.append('WireGuard: down')
else:
lines.append(f'WireGuard: {len(peers)} peers')
for number, peer in enumerate(peers, 1):
age = peer['handshake_seconds_ago']
lines.append(f' peer {number}: {"no handshake yet" if age is None else f"handshake {age} s ago"}, '
f'received {peer["received_bytes"]} B, sent {peer["sent_bytes"]} B')
return '\n'.join(lines) + '\n'
def html_of(status):
"""The status document of shown for a browser, as a page styled with Tailwind CSS."""
colour = 'bg-emerald-600 dark:bg-emerald-700' if status['status'] == 'ok' else 'bg-amber-600 dark:bg-amber-700'
version = f', version {status["version"] or "unknown"}' if 'version' in status else ''
# Each "name: value" line after the header of the text is a row; indented lines, such as WireGuard peers, are indented here too.
rows = []
for line in text_of(status).split('\n\n', 1)[1].splitlines():
if not line:
continue
name, _, value = line.partition(': ')
indent = ' pl-8' if line.startswith(' ') else ''
rows.append(f'<tr class="border-t border-slate-200 first:border-t-0 dark:border-slate-700">'
f'<th class="py-2 pr-4{indent} text-left font-medium text-slate-600 dark:text-slate-300">{escape(name.strip())}</th>'
f'<td class="py-2">{escape(value)}</td></tr>')
body = ''.join(rows)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta http-equiv="refresh" content="{REFRESH_SECONDS}">
<title>VPN Router</title>
<script src="{TAILWIND}"></script>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
<div class="mx-auto max-w-2xl px-4 py-10">
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
<div class="{colour} px-6 py-4 text-white">
<h1 class="text-xl font-semibold">VPN Router</h1>
<p class="mt-1 text-sm">Status: {escape(status['status'] + version)}</p>
</div>
<div class="px-6 py-4">
<table class="w-full text-sm"><tbody>{body}</tbody></table>
</div>
<p class="px-6 pb-6 text-xs text-slate-500 dark:text-slate-400">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
</div>
</div>
</body>
</html>
"""
def cached():
"""The status of collect, collected again when it is older than CACHE_SECONDS."""
with _lock:
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
_cache['data'] = collect(current())
_cache['at'] = time.monotonic()
return _cache['data']
+4 -2136
View File
File diff suppressed because it is too large. Load diff
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/python3
"""Manage the roles of the resources of the router's OpenID Provider from the command line, with vpnrouter_users.
A resource is a service account; a role of it has a name, an optional description, and the groups and accounts it is
assigned to, whose tokens for the resource carry it in their roles claim. The answer is text for people on standard
output, and a failure is a message on standard error. With --json the answer is JSON on standard output, and a failure
is {"error": ...} there too. The exit status says what failed: 2 the request is invalid, 3 the resource, the role, the
group or the account does not exist, 4 the role exists, 1 anything else. -h or --help shows the commands and their
options.
vpn-router-role [--json] list [<resource>]
vpn-router-role [--json] add <resource> <role> [--description D]
vpn-router-role [--json] remove <resource> <role>
vpn-router-role [--json] member-add <resource> <role> (--group G | --account A)
vpn-router-role [--json] member-remove <resource> <role> (--group G | --account A)
"""
import argparse
import json
import sys
sys.path.insert(0, '/usr/lib/vpn-router')
import ldap
import vpnrouter_users
from vpnrouter_users import Failure
#: The help line of each command.
HELP = {
'list': 'list the roles of every resource, or of one, with the groups and accounts they are assigned to',
'add': 'add a role to a resource',
'remove': 'remove a role of a resource',
'member-add': 'assign the role to a group or an account',
'member-remove': 'take the role from a group or an account',
}
class Parser(argparse.ArgumentParser):
"""An argument parser whose errors are Failures with status 2, so that --json answers them as JSON."""
def error(self, message):
raise Failure(f'{message}; see {self.prog} --help', 2)
def parse(argv):
parser = Parser(prog='vpn-router-role', description='Manage the roles of the resources of the OpenID Provider.')
parser.add_argument('--json', action='store_true', help='answer in JSON')
commands = parser.add_subparsers(dest='command', required=True, metavar='command', parser_class=Parser)
for command, text in HELP.items():
sub = commands.add_parser(command, help=text, description=text[0].upper() + text[1:] + '.')
if command == 'list':
sub.add_argument('resource', nargs='?', help='the service account whose roles are listed')
continue
sub.add_argument('resource', help='the service account the role belongs to')
sub.add_argument('role', help='the role name')
if command == 'add':
sub.add_argument('--description', metavar='TEXT', help='the description')
if command in ('member-add', 'member-remove'):
member = sub.add_mutually_exclusive_group(required=True)
member.add_argument('--group', metavar='GROUP', help='the group')
member.add_argument('--account', metavar='USER', help='the user or service account')
return parser.parse_args(argv)
def run(connection, options):
"""The answer of the command the options name."""
command = options.command
if command == 'list':
if options.resource is not None:
vpnrouter_users.check_resource(connection, options.resource)
return vpnrouter_users.list_roles(connection, options.resource)
if command == 'add':
role = vpnrouter_users.role_add(connection, options.resource, options.role, options.description)
elif command == 'remove':
return vpnrouter_users.role_remove(connection, options.resource, options.role)
elif command == 'member-add':
role = vpnrouter_users.role_member_add(connection, options.resource, options.role, options.group, options.account)
else:
role = vpnrouter_users.role_member_remove(connection, options.resource, options.role, options.group, options.account)
return {'resource': options.resource, **role}
def text_of(answer):
"""The answer as text for people."""
if answer.get('removed'):
return f'role {answer["name"]} of {answer["resource"]} removed'
roles = answer['roles'] if 'roles' in answer else [answer]
if not roles:
return 'no roles'
head = ('RESOURCE', 'ROLE', 'GROUPS', 'ACCOUNTS', 'DESCRIPTION')
rows = [(role['resource'], role['name'], ', '.join(role['groups']) or '-', ', '.join(role['accounts']) or '-',
role['description'] or '-') for role in roles]
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))
def main(argv):
# A request that cannot be parsed is answered in JSON when --json is among the arguments.
as_json = '--json' in argv
try:
options = parse(argv)
as_json = options.json
with vpnrouter_users.session() as connection:
answer = run(connection, options)
except Failure as failure:
message, status = str(failure), failure.status
except (ldap.LDAPError, OSError) as exc:
message, status = f'the user directory failed: {exc}', 1
else:
print(json.dumps(answer) if as_json else text_of(answer))
return 0
if as_json:
print(json.dumps({'error': message}))
else:
print(f'vpn-router-role: {message}', file=sys.stderr)
return status
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+26 -8
View File
@@ -13,14 +13,17 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
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 ...]
[--grant G ...] [--public-client yes|no] [--client-scope R:S ...] [--resource-scope S ...]
(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]
[--redirect-uri U ...] [--post-logout-redirect-uri U ...] [--allowed-group G ...]
[--grant G ...] [--public-client yes|no] [--client-scope R:S ...] [--resource-scope S ...]
(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.
A service account with a redirect URI or a grant (client_credentials, device_code) is a client of the OpenID Provider,
and one with a resource scope a resource; each of the repeatable options replaces all values of its kind, and one ''
removes them. vpn-router-role manages the roles of a resource.
vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name>
vpn-router-user [--json] enable <name>
@@ -80,6 +83,9 @@ 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'),
('grants', 'grant', 'a grant the client may use besides the authorization code: client_credentials or device_code'),
('client_scopes', 'client-scope', 'a scope of a resource the client may ask for, <resource>:<scope>'),
('resource_scopes', 'resource-scope', 'a scope the service account defines as a resource'),
)
@@ -104,6 +110,8 @@ def parse(argv):
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")
sub.add_argument('--public-client', choices=('yes', 'no'),
help='whether the client of a service account is public, without a secret')
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':
@@ -131,6 +139,8 @@ def run(connection, options, secret):
given = getattr(options, key, None)
if given is not None:
values[key] = [value for value in given if value]
if getattr(options, 'public_client', None) is not None:
values['public_client'] = options.public_client == 'yes'
if command == 'add':
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
if command == 'update':
@@ -157,12 +167,20 @@ def console_text(user):
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}'
"""The OpenID Provider settings of a service account as a cell: -, or the numbers of its URIs, its allowed groups,
grants, public, its client scopes and its resource scopes."""
parts = []
if user.get('redirect_uris'):
groups = ','.join(user['allowed_groups']) or 'all users'
parts.append(f'{len(user["redirect_uris"])} redirect, {len(user["post_logout_redirect_uris"])} logout, {groups}')
parts += user.get('grants') or []
if user.get('public_client'):
parts.append('public')
if user.get('client_scopes'):
parts.append('asks ' + ','.join(user['client_scopes']))
if user.get('resource_scopes'):
parts.append('defines ' + ','.join(user['resource_scopes']))
return '; '.join(parts) or '-'
def text_of(answer):
@@ -0,0 +1,94 @@
# bash completion for vpn-router-role -*- shell-script -*-
# The service accounts, the users, the groups and the roles of a resource of the router's directory, read as root;
# nothing for other users.
_vpn_router_role_resources()
{
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"] if user["service"]))' 2>/dev/null
}
_vpn_router_role_accounts()
{
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_role_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_role_names()
{
vpn-router-role --json list "$1" 2>/dev/null |
python3 -c 'import json, sys; print("\n".join(role["name"] for role in json.load(sys.stdin)["roles"]))' 2>/dev/null
}
_vpn_router_role()
{
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 resource=''
for ((i = 1; i < cword; i++)); do
if [[ -z $command ]]; then
[[ ${words[i]} != -* ]] && command=${words[i]}
continue
fi
case ${words[i]} in
--description | --group | --account) ((i++)) ;;
-*) ;;
*)
((args == 0)) && resource=${words[i]}
((args++))
;;
esac
done
if [[ -z $command ]]; then
if [[ $cur == -* ]]; then
COMPREPLY=($(compgen -W '--json --help' -- "$cur"))
else
COMPREPLY=($(compgen -W 'list add remove member-add member-remove' -- "$cur"))
fi
return
fi
case $prev in
--group)
COMPREPLY=($(compgen -W "$(_vpn_router_role_groups)" -- "$cur"))
return
;;
--account)
COMPREPLY=($(compgen -W "$(_vpn_router_role_accounts)" -- "$cur"))
return
;;
--description)
return
;;
esac
$split && return
if [[ $cur == -* ]]; then
local options='--help'
case $command in
add) options+=' --description' ;;
member-add | member-remove) options+=' --group --account' ;;
esac
COMPREPLY=($(compgen -W "$options" -- "$cur"))
return
fi
# The resource, then the role, which add takes new.
if ((args == 0)); then
COMPREPLY=($(compgen -W "$(_vpn_router_role_resources)" -- "$cur"))
elif ((args == 1)) && [[ $command != @(list|add) ]]; then
COMPREPLY=($(compgen -W "$(_vpn_router_role_names "$resource")" -- "$cur"))
fi
} &&
complete -F _vpn_router_role vpn-router-role
# ex: filetype=sh
@@ -13,6 +13,14 @@ _vpn_router_user_groups()
python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null
}
# The scopes of the resources, <resource>:<scope>, read as root.
_vpn_router_user_scopes()
{
vpn-router-user --json list 2>/dev/null |
python3 -c 'import json, sys; print("\n".join(user["name"] + ":" + scope for user in json.load(sys.stdin)["users"]
for scope in user.get("resource_scopes", [])))' 2>/dev/null
}
_vpn_router_user()
{
local cur prev words cword split
@@ -30,7 +38,8 @@ _vpn_router_user()
fi
case ${words[i]} in
--first-name | --last-name | --email | --display-name | --description | --redirect-uri | \
--post-logout-redirect-uri | --allowed-group | --account) ((i++)) ;;
--post-logout-redirect-uri | --allowed-group | --account | --grant | --public-client | --client-scope | \
--resource-scope) ((i++)) ;;
-*) ;;
*) ((args++)) ;;
esac
@@ -50,6 +59,21 @@ _vpn_router_user()
COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur"))
return
;;
--grant)
COMPREPLY=($(compgen -W 'client_credentials device_code' -- "$cur"))
return
;;
--public-client)
COMPREPLY=($(compgen -W 'yes no' -- "$cur"))
return
;;
--client-scope)
COMPREPLY=($(compgen -W "$(_vpn_router_user_scopes)" -- "$cur"))
return
;;
--resource-scope)
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"))
@@ -65,9 +89,9 @@ _vpn_router_user()
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' ;;
--post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;;
update) options+=' --first-name --last-name --email --display-name --description --redirect-uri
--post-logout-redirect-uri --allowed-group' ;;
--post-logout-redirect-uri --allowed-group --grant --public-client --client-scope --resource-scope' ;;
console) options+=' --posix --account --off' ;;
esac
COMPREPLY=($(compgen -W "$options" -- "$cur"))
@@ -1,10 +1,12 @@
-- 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.
-- Codes, tokens, device codes 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.
-- An authorization code, valid until it is exchanged for tokens or expires; resource is the resource the tokens are
-- for, null for the client itself.
CREATE TABLE IF NOT EXISTS oidc_codes (
hash text PRIMARY KEY,
client_id text NOT NULL,
@@ -16,10 +18,13 @@ CREATE TABLE IF NOT EXISTS oidc_codes (
code_challenge_method text NOT NULL,
auth_time timestamptz NOT NULL,
amr text[] NOT NULL,
expires timestamptz NOT NULL
expires timestamptz NOT NULL,
resource text
);
ALTER TABLE oidc_codes ADD COLUMN IF NOT EXISTS resource text;
-- An access token or a refresh token (kind) a client was issued for a user.
-- An access token or a refresh token (kind) a client was issued for a user, or for itself as a service account with
-- client credentials; resource as in oidc_codes.
CREATE TABLE IF NOT EXISTS oidc_tokens (
hash text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('access', 'refresh')),
@@ -28,9 +33,30 @@ CREATE TABLE IF NOT EXISTS oidc_tokens (
scopes text[] NOT NULL,
auth_time timestamptz NOT NULL,
amr text[] NOT NULL,
expires timestamptz NOT NULL,
resource text
);
ALTER TABLE oidc_tokens ADD COLUMN IF NOT EXISTS resource text;
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
-- A device code of the device authorization grant and its user code (user_hash): pending until the user allows or
-- denies it, then the user, when and how they signed in; poll_seconds is the least time between two polls, polled
-- the last poll.
CREATE TABLE IF NOT EXISTS oidc_devices (
hash text PRIMARY KEY,
user_hash text NOT NULL UNIQUE,
client_id text NOT NULL,
scopes text[] NOT NULL,
resource text,
state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'allowed', 'denied')),
name text,
auth_time timestamptz,
amr text[],
poll_seconds integer NOT NULL,
polled timestamptz,
expires timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS oidc_tokens_name ON oidc_tokens (name);
CREATE INDEX IF NOT EXISTS oidc_devices_name ON oidc_devices (name);
-- A user's sign-in at the OpenID Provider, which the session cookie names.
CREATE TABLE IF NOT EXISTS oidc_sessions (
@@ -41,22 +67,30 @@ CREATE TABLE IF NOT EXISTS oidc_sessions (
expires timestamptz NOT NULL
);
-- The functions and procedures whose parameters or results differ from those of the same name.
DROP PROCEDURE IF EXISTS oidc_code_save(text, text, text, text, text[], text, text, text, timestamptz, text[], integer);
DROP FUNCTION IF EXISTS oidc_code_get(text);
DROP PROCEDURE IF EXISTS oidc_token_save(text, text, text, text, text[], timestamptz, text[], integer);
DROP FUNCTION IF EXISTS oidc_token_get(text, text);
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)
p_resource 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));
INSERT INTO oidc_codes (hash, client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method,
auth_time, amr, resource, expires)
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, p_resource, 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[])
code_challenge_method text, auth_time timestamptz, amr text[], resource text)
LANGUAGE sql STABLE AS $$
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr
SELECT client_id, name, redirect_uri, scopes, nonce, code_challenge, code_challenge_method, auth_time, amr, resource
FROM oidc_codes WHERE hash = p_hash AND expires > now();
$$;
@@ -66,18 +100,19 @@ LANGUAGE sql AS $$
$$;
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)
p_auth_time timestamptz, p_amr text[], p_resource 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));
INSERT INTO oidc_tokens (hash, kind, client_id, name, scopes, auth_time, amr, resource, expires)
VALUES (p_hash, p_kind, p_client_id, p_name, p_scopes, p_auth_time, p_amr, p_resource,
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[])
RETURNS TABLE (client_id text, name text, scopes text[], auth_time timestamptz, amr text[], resource text)
LANGUAGE sql STABLE AS $$
SELECT client_id, name, scopes, auth_time, amr FROM oidc_tokens
SELECT client_id, name, scopes, auth_time, amr, resource FROM oidc_tokens
WHERE hash = p_hash AND kind = p_kind AND expires > now();
$$;
@@ -86,6 +121,59 @@ LANGUAGE sql AS $$
DELETE FROM oidc_tokens WHERE hash = p_hash;
$$;
CREATE OR REPLACE PROCEDURE oidc_device_save(p_hash text, p_user_hash text, p_client_id text, p_scopes text[],
p_resource text, p_poll_seconds integer, p_seconds integer)
LANGUAGE sql AS $$
DELETE FROM oidc_devices WHERE expires < now();
INSERT INTO oidc_devices (hash, user_hash, client_id, scopes, resource, poll_seconds, expires)
VALUES (p_hash, p_user_hash, p_client_id, p_scopes, p_resource, p_poll_seconds,
now() + make_interval(secs => p_seconds));
$$;
-- The unexpired device code of the user code while it waits for the user.
CREATE OR REPLACE FUNCTION oidc_device_find(p_user_hash text)
RETURNS TABLE (client_id text, scopes text[], resource text)
LANGUAGE sql STABLE AS $$
SELECT client_id, scopes, resource FROM oidc_devices
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now();
$$;
-- Allow or deny the waiting device code of the user code as the user; whether there was one.
CREATE OR REPLACE FUNCTION oidc_device_decide(p_user_hash text, p_name text, p_auth_time timestamptz, p_amr text[],
p_allowed boolean)
RETURNS boolean
LANGUAGE sql AS $$
WITH decided AS (
UPDATE oidc_devices SET state = CASE WHEN p_allowed THEN 'allowed' ELSE 'denied' END, name = p_name,
auth_time = p_auth_time, amr = p_amr
WHERE user_hash = p_user_hash AND state = 'pending' AND expires > now()
RETURNING 1
)
SELECT count(*) > 0 FROM decided;
$$;
-- A poll of the device code: the row, whether it has expired, and whether it came sooner than poll_seconds after the
-- last one, which adds 5 seconds to poll_seconds.
CREATE OR REPLACE FUNCTION oidc_device_poll(p_hash text)
RETURNS TABLE (client_id text, scopes text[], resource text, state text, name text, auth_time timestamptz, amr text[],
expired boolean, too_soon boolean)
LANGUAGE sql AS $$
UPDATE oidc_devices AS device
SET polled = now(),
poll_seconds = device.poll_seconds +
CASE WHEN device.polled + make_interval(secs => device.poll_seconds) > now() THEN 5 ELSE 0 END
FROM (SELECT hash, polled, poll_seconds FROM oidc_devices WHERE hash = p_hash) AS previous
WHERE device.hash = previous.hash
RETURNING device.client_id, device.scopes, device.resource, device.state, device.name, device.auth_time, device.amr,
device.expires <= now(),
previous.polled IS NOT NULL AND previous.polled + make_interval(secs => previous.poll_seconds) > now();
$$;
CREATE OR REPLACE PROCEDURE oidc_device_delete(p_hash text)
LANGUAGE sql AS $$
DELETE FROM oidc_devices 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();
@@ -104,10 +192,11 @@ LANGUAGE sql AS $$
DELETE FROM oidc_sessions WHERE hash = p_hash;
$$;
-- Remove every code, token and session of the user.
-- Remove every code, token, device code and session of the user or service account.
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_devices WHERE name = p_name;
DELETE FROM oidc_sessions WHERE name = p_name;
$$;
@@ -15,6 +15,8 @@ input { box-sizing: border-box; width: 100%; padding: 0.5rem; border: 1px solid
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; }
ul { margin: 0 0 1rem; padding-left: 1.25rem; color: #4b5563; }
#user_code { text-transform: uppercase; letter-spacing: 0.15em; font-family: ui-monospace, monospace; }
.issuer { font-size: 0.75rem; color: #6b7280; margin: 1.5rem 0 0; }
</style>
</head>
@@ -27,25 +29,52 @@ button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8;
{%- if error %}
<div class="error" role="alert">{{ error }}</div>
{%- endif %}
{%- if form %}
<form method="post" action="/oidc/login">
{%- if items %}
<ul>
{%- for item in items %}
<li>{{ item }}</li>
{%- endfor %}
</ul>
{%- endif %}
{%- if decide %}
<form method="post" action="/oidc/device/decide">
<input type="hidden" name="user_code" value="{{ user_code }}">
<button type="submit" name="decision" value="allow">Allow</button>
<button class="secondary" type="submit" name="decision" value="deny">Deny</button>
</form>
{%- endif %}
{%- if form or device %}
<form id="sign-in" method="post" action="{{ action }}">
{%- if device %}
<label for="user_code">Code shown on the device</label>
<input id="user_code" name="user_code" value="{{ user_code }}" autocapitalize="characters" autocomplete="off" spellcheck="false" required{% if not form %} autofocus{% endif %}>
{%- else %}
<input type="hidden" name="request" value="{{ request }}">
{%- endif %}
{%- if form %}
<label for="name">Name</label>
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required autofocus>
<input id="name" name="name" value="{{ name }}" autocomplete="username webauthn" autocapitalize="none" spellcheck="false" required{% if not device %} autofocus{% endif %}>
<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>
{%- else %}
<button type="submit">Continue</button>
{%- endif %}
</form>
<form id="passkey-form" method="post" action="/oidc/login" hidden>
{%- endif %}
{%- if form %}
<form id="passkey-form" method="post" action="{{ action }}" hidden>
<input type="hidden" name="request" value="{{ request }}">
<input type="hidden" name="user_code">
<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.
// A passkey sign-in begins at /oidc/passkey and posts the browser's answer to the form's action with the authorization
// request, or with the user code the sign-in form holds.
(function () {
var button = document.getElementById('passkey');
if (!window.PublicKeyCredential || !PublicKeyCredential.parseRequestOptionsFromJSON) {
@@ -53,11 +82,16 @@ button.secondary { background: #fff; color: #1d4ed8; border: 1px solid #1d4ed8;
}
button.hidden = false;
button.addEventListener('click', async function () {
var code = document.getElementById('user_code');
if (code && !code.reportValidity()) {
return;
}
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.user_code.value = code ? code.value : '';
form.elements.passkey_id.value = begun.id;
form.elements.credential.value = JSON.stringify(credential.toJSON());
form.submit();