Compare commits

...
5 Commits
9 changed files with 346 additions and 77 deletions

No files matched your search

+31 -20
View File
@@ -465,11 +465,14 @@ Decision: The package defines its schema, `cn=vpn-router,cn=schema,cn=config`, w
`vpnrouter_ldap.provision` adds when missing and keeps as `SCHEMA_ATTRIBUTE_TYPES` and `vpnrouter_ldap.provision` adds when missing and keeps as `SCHEMA_ATTRIBUTE_TYPES` and
`SCHEMA_OBJECT_CLASSES`. Its OID is `2.25.98477352595735834532404256531620013001`, made from a `SCHEMA_OBJECT_CLASSES`. Its OID is `2.25.98477352595735834532404256531620013001`, made from a
random UUID under the arc 2.25 (ITU-T X.667), which needs no registration; attribute types are random UUID under the arc 2.25 (ITU-T X.667), which needs no registration; attribute types are
under `.1` and object classes under `.2`. The first version has the attribute `vpnRouterScope` under `.1` and object classes under `.2`. It has the attribute `vpnRouterScope` (multi-valued,
(multi-valued, case-insensitive strings) and the auxiliary class `vpnRouterAccount`, case-insensitive strings) and the auxiliary class `vpnRouterAccount`,
`MAY ( mail $ vpnRouterScope )`. Manager-only accounts have the class from their creation, so an `MAY ( mail $ vpnRouterScope $ displayName $ description )`; `displayName` and `description`, the
e-mail address can be added later without changing their classes; VPN users get it once scopes are standard attributes, are the display name and the description of a service account, which
used. `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.
Consequences: The schema check stays strict. New attributes extend `vpnRouterAccount`, and their 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 OIDs continue the arcs. The scope `service` marks a service account (ADR-23). The OID must never
@@ -558,7 +561,7 @@ purge. A view shows only what its level keeps, so a shorter retention empties th
view. A month view needs the hourly rows and a year view the monthly rows. `/metrics` has only the view. A month view needs the hourly rows and a year view the monthly rows. `/metrics` has only the
last sample; history is not scraped. last sample; history is not scraped.
## ADR-23: /metrics takes HTTP Basic authentication of an internal service account that setup creates ## ADR-23: /metrics takes HTTP Basic authentication of an internal service account the router manages
Status: Accepted Status: Accepted
@@ -567,23 +570,31 @@ the router's tokens, which last minutes (ADR-3). The scraper should not be able
API or the Manager, and its password should be checked and locked out like any other. The account API or the Manager, and its password should be checked and locked out like any other. The account
belongs to the package's metrics, not to the people using the router. belongs to the package's metrics, not to the people using the router.
Decision: A service account is a Manager-only account (ADR-18) with `vpnRouterScope` `service`. Decision: A service account is a Manager-only account (ADR-18) with `vpnRouterScope` `service`,
Administrators add service accounts with `vpn-router-user add --service` and `/users`, for which another system signs in with to use a router service. Administrators add service accounts
services of their own that check passwords against the directory, and manage them like other with `vpn-router-user add --service` and the web service's `/service-accounts`.
accounts. The metrics service's is the internal one, created by `vpn-router-setup`: while metrics `/service-accounts` is a path of its own, apart from `/users`, which does not list or serve them:
are enabled it adds the account `metrics.service_account` names, `metrics` by default, with a a service account has no names, certificate, profile or TOTP, and is not a person an
random password it writes to `/etc/vpn-router/metrics.secret` (root only), and sets a new random administrator manages with the users. A service account has a display name and a description
password, ending a lockout, when the file is missing or no longer holds the password. The answers besides its name, so an administrator can tell what each is for. Internal service accounts are
of `/users` mark it as `internal`, and `/users` refuses to set its password, which setup would those the router creates and manages for its own services (`vpnrouter_users.internal_accounts`),
replace. `/auth` refuses every service account with the message of a wrong password. `/metrics` accepts HTTP Basic only for that account, and with every property, display name and description included, so the system, not an
administrator, owns them; at present that is the metrics service's, which setup names Metrics. `/service-accounts` marks
them as `internal` and only lists them: every other request on one, and putting one in or taking
one out of a group at `/groups`, gets 403, whatever the token. The router changes them through the
directory's user-manager account, not the API, and root may still with `vpn-router-user`. For the
metrics service, `vpn-router-setup` adds the account `metrics.service_account` names, `metrics` by
default, while metrics are enabled, with a random password it writes to
`/etc/vpn-router/metrics.secret` (root only), and sets a new random password, ending a lockout,
when the file is missing or no longer holds the password. `/auth` refuses every service account with the message of a wrong password. `/metrics` accepts HTTP Basic only for that account, and
only when it is a service account; the password is checked with a bind as the account, so the only when it is a service account; the password is checked with a bind as the account, so the
password policy's lockout applies (ADR-14). TLS is Caddy's, on `metrics.https_port` (ADR-21); password policy's lockout applies (ADR-14). TLS is Caddy's, on `metrics.https_port` (ADR-21);
plain HTTP is served on the interfaces `metrics.listen` names, `internal` by default. plain HTTP is served on the interfaces `metrics.listen` names, `internal` by default.
Consequences: Every scrape binds to slapd once. The collector's operator copies the password from Consequences: Every scrape binds to slapd once. The collector's operator copies the password from
the router; deleting the file and running setup changes it. A user who already has the name is not the router; deleting the file and running setup changes it. A user who already has the name is not
taken over; setup fails until `metrics.service_account` names another. A disabled account stays taken over; setup fails until `metrics.service_account` names another. An account root disabled
disabled. A scraper on the external interface over plain HTTP sends the password in the clear, so stays disabled. A scraper on the external interface over plain HTTP sends the password in the clear, so
`metrics.listen` stays `internal` unless HTTPS is used. `metrics.listen` stays `internal` unless HTTPS is used.
## ADR-24: The Router Manager upgrades the router's packages in a transient unit outside its own ## ADR-24: The Router Manager upgrades the router's packages in a transient unit outside its own
@@ -595,8 +606,8 @@ upgrade restarts the process that starts it: the manager's postinst restarts
`vpn-router-manager.service`, and the router's restarts `vpn-router-web` and `vpn-router-setup`. A `vpn-router-manager.service`, and the router's restarts `vpn-router-web` and `vpn-router-setup`. A
child of the manager would be stopped with the manager's cgroup in the middle of dpkg, leaving child of the manager would be stopped with the manager's cgroup in the middle of dpkg, leaving
packages half configured, and the manager's unit cannot run apt at all, since `ProtectSystem=strict` packages half configured, and the manager's unit cannot run apt at all, since `ProtectSystem=strict`
makes `/usr` and `/var/lib/dpkg` read-only. The package lists are updated by a cron job, not by the makes `/usr` and `/var/lib/dpkg` read-only. A cron job updates the package lists, so a release
manager. newer than its last run is not seen until the next one.
Decision: The manager starts the helper `/usr/lib/vpn-router-manager/upgrade` with `systemd-run` as Decision: The manager starts the helper `/usr/lib/vpn-router-manager/upgrade` with `systemd-run` as
the transient unit `vpn-router-manager-upgrade`, of its own cgroup and without a sandbox, and does the transient unit `vpn-router-manager-upgrade`, of its own cgroup and without a sandbox, and does
@@ -606,7 +617,7 @@ not wait for it. The helper:
- runs `apt-get install --only-upgrade` of the two packages, non-interactive, keeping changed configuration files, with `needrestart` suspended, and waiting for the dpkg lock that the cron job may hold; - runs `apt-get install --only-upgrade` of the two packages, non-interactive, keeping changed configuration files, with `needrestart` suspended, and waiting for the dpkg lock that the cron job may hold;
- records the state and the result in `/var/lib/vpn-router-manager/upgrade.json`, replaced by a rename, and the output in `upgrade.log`. - records the state and the result in `/var/lib/vpn-router-manager/upgrade.json`, replaced by a rename, and the output in `upgrade.log`.
A restarted manager reads them again. A state of running without an active unit is shown as interrupted. The manager reboots the router the same way, as a transient timer `vpn-router-manager-reboot` 5 seconds later, so the answer reaches the browser first. Only members of `admins`, as the router's `/account` answers, may start either. A restarted manager reads them again. A state of running without an active unit is shown as interrupted. The manager reboots the router the same way, as a transient timer `vpn-router-manager-reboot` 5 seconds later, so the answer reaches the browser first. It checks for updates the same way too: Check update runs `apt-get update` in the transient unit `vpn-router-manager-check`, which touches apt's update stamp when it succeeds, so an admin need not wait for the cron job. Only members of `admins`, as the router's `/account` answers, may start any of them.
Consequences: The fixed unit name allows one upgrade at a time. The page loses the manager and the Consequences: The fixed unit name allows one upgrade at a time. The page loses the manager and the
router's web service for a while during an upgrade and reads again until they answer. Sessions router's web service for a while during an upgrade and reads again until they answer. Sessions
+54 -13
View File
@@ -393,9 +393,10 @@ is switched on:
`account` for a Manager-only account (see Managing users); `account` for a Manager-only account (see Managing users);
- the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID - the package's own schema, `cn=vpn-router,cn=schema,cn=config`, under the OID
`2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class `2.25.98477352595735834532404256531620013001` of the UUID arc: the auxiliary class
`vpnRouterAccount` with `MAY ( mail $ vpnRouterScope )`, which Manager-only and service accounts `vpnRouterAccount` with `MAY ( mail $ vpnRouterScope $ displayName $ description )`, which
have, and the attribute `vpnRouterScope`, authorization scopes, of which `service` marks a Manager-only and service accounts have, and the attribute `vpnRouterScope`, authorization
service account; scopes, of which `service` marks a service account; `displayName` and `description` are the
display name and the description of a service account;
- `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in - `ou=groups` holds one `groupOfNames` per group, named by `cn`, with its members' DNs in
`member` and an optional `description`; `member` and an optional `description`;
- `cn=user-manager,ou=services` is the account that changes users and groups; it may write - `cn=user-manager,ou=services` is the account that changes users and groups; it may write
@@ -427,11 +428,16 @@ it is added:
name, the password and an optional e-mail address, for notifications: no names and no name, the password and an optional e-mail address, for notifications: no names and no
certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage certificate, so no VPN access. It can sign in to `/auth` and, as a member of `admins`, manage
the router. `add` without the first and the last name adds one; its names cannot be set later; the router. `add` without the first and the last name adds one; its names cannot be set later;
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, for a service - a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
that checks passwords against the directory; it cannot sign in to `/auth`. `add --service` and system signs in with to use a router service, such as an OpenTelemetry Collector reading
`POST /users` with `"service": true` add one. The internal one is the account `/metrics`; it cannot sign in to `/auth`. Besides its name, it has a display name and a
`metrics.service_account` names while metrics are enabled: `vpn-router-setup` adds it and keeps description. `add --service` adds one, and the web service manages them at `/service-accounts`,
its password (see Metrics), so `/users` refuses to set its password. 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
`vpn-router-setup` names Metrics. The API only lists
them: `/service-accounts` refuses every other request on one, and `/groups` refuses to put one
in or take one out of a group, with 403.
Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a Passwords and TOTP secrets are read from the first line of standard input. The answer is text: a
table of the users, the client profile, a TOTP secret or a short message, and a failure is a table of the users, the client profile, a TOTP secret or a short message, and a failure is a
@@ -463,8 +469,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 | | `list` | The users with their kind, names and e-mail addresses, whether each is enabled or locked, has a certificate and is enrolled for TOTP |
| `add <name> --first-name F --last-name L --email E` | Adds a VPN user and issues the certificate | | `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> [--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 [--email E]` | Adds a service account, with the e-mail address when given; the names are refused | | `add <name> --service [--display-name N] [--description D] [--email E]` | Adds a service account, with its display name, description and e-mail address when given; the names are refused |
| `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address | | `update <name> [--first-name F] [--last-name L] [--email E]` | Changes the names or the e-mail address |
| `update <name> [--display-name N] [--description D] [--email E]` | Changes the display name, the description or the e-mail address of a service account; `''` removes one |
| `passwd <name>` | Sets the password | | `passwd <name>` | Sets the password |
| `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again | | `disable <name>`, `enable <name>` | Locks the user out of OpenVPN and `/auth`, and lets the user in again |
| `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled | | `unlock <name>` | Ends a lockout after failed logins and clears the failure count; a disabled user stays disabled |
@@ -610,6 +617,7 @@ curl http://10.0.0.4:17081/
| `/auth` | Signing in with a user's password and TOTP code, refreshing and signing out | | `/auth` | Signing in with a user's password and TOTP code, refreshing and signing out |
| `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token | | `/config` | The configuration without its secret options, and what the router resolved and runs, as JSON; needs a token |
| `/users` | The users of OpenVPN and road-warrior access; needs a token | | `/users` | The users of OpenVPN and road-warrior access; needs a token |
| `/service-accounts` | The service accounts; needs a token |
| `/groups` | The groups of users; needs a token | | `/groups` | The groups of users; needs a token |
| `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token | | `/site-to-site` | The site-to-site connections: list, add, change and remove; needs a token |
| `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token | | `/account` | The account of the signed-in user: its details, its password and its TOTP enrolment; needs a user's token |
@@ -634,7 +642,7 @@ curl http://10.0.0.4:7081/status
### Tokens and signing in ### Tokens and signing in
`/config`, `/users`, `/groups`, `/site-to-site` and `/account` need a JWT in an `Authorization: Bearer` header, signed with `/config`, `/users`, `/service-accounts`, `/groups`, `/site-to-site` and `/account` need a JWT in an `Authorization: Bearer` header, signed with
HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only). HS256 by the key the web service makes on its first run, `/etc/vpn-router/web.key` (root only).
The key never leaves the router; while it does not exist these paths and `/auth` are not served The key never leaves the router; while it does not exist these paths and `/auth` are not served
(404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with (404). A missing, expired or wrongly signed token, or one of another algorithm, gets 401 with
@@ -717,9 +725,9 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
| Request | Body | Answer | | Request | Body | Answer |
| --- | --- | --- | | --- | --- | --- |
| `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` | | `GET /users` | | `{"users": [{"name", "manager_only", "service", "internal", "enabled", "locked", "certificate", "otp", "first_name", "last_name", "email"}, ...]}` |
| `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account; `{"name", "password", "service": true, "email"?}` for a service account | 201 and the user | | `POST /users` | `{"name", "password", "first_name", "last_name", "email"}` for a VPN user; `{"name", "password", "email"?}` for a Manager-only account | 201 and the user |
| `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user | | `PUT /users/<name>` | any of `{"first_name", "last_name", "email"}`; for a Manager-only account only `{"email"}`, `""` removing it | the user |
| `PUT /users/<name>/password` | `{"password": ...}`; refused with 400 for the internal service account | the user | | `PUT /users/<name>/password` | `{"password": ...}` | the user |
| `POST /users/<name>/disable` | | the user | | `POST /users/<name>/disable` | | the user |
| `POST /users/<name>/enable` | | the user | | `POST /users/<name>/enable` | | the user |
| `POST /users/<name>/unlock` | | the user | | `POST /users/<name>/unlock` | | the user |
@@ -730,7 +738,8 @@ and answer bodies are JSON; a request body is at most 4096 bytes.
| `DELETE /users/<name>/otp` | | the user | | `DELETE /users/<name>/otp` | | the user |
A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not A failure is `{"error": ...}` with 400 for an invalid request, 404 for a user that does not
exist, 409 for a user that exists and 500 otherwise. `/users` works whether or not OpenVPN or exist or is a service account, 409 for a user that exists and 500 otherwise. `/users` does not
list service accounts; `/service-accounts` serves them. `/users` works whether or not OpenVPN or
road-warrior access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP road-warrior access is enabled; the profile needs OpenVPN. Passwords, private keys and TOTP
secrets cross the internal network in plain HTTP, like the token. secrets cross the internal network in plain HTTP, like the token.
@@ -740,6 +749,27 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
http://10.0.0.4:7081/users http://10.0.0.4:7081/users
``` ```
### The /service-accounts API
`/service-accounts` manages the service accounts with the same token and the same module as
`/users`.
| Request | Body | Answer |
| --- | --- | --- |
| `GET /service-accounts` | | `{"service_accounts": [{"name", "display_name", "description", "internal", "enabled", "locked", "email", ...}, ...]}`, each as `/users` shows a user |
| `POST /service-accounts` | `{"name", "password", "display_name"?, "description"?, "email"?}` | 201 and the account |
| `PUT /service-accounts/<name>` | any of `{"display_name", "description", "email"}`; `""` removes one | the account |
| `PUT /service-accounts/<name>/password` | `{"password": ...}` | the account |
| `POST /service-accounts/<name>/disable` | | the account |
| `POST /service-accounts/<name>/enable` | | the account |
| `POST /service-accounts/<name>/unlock` | | the account |
| `DELETE /service-accounts/<name>` | | `{"name", "removed": true}` |
A display name has at most 64 printable characters and a description at most 256. The internal
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.
### The /account API ### The /account API
`/account` serves the user the access token names, whether or not the user is a member of `/account` serves the user the access token names, whether or not the user is a member of
@@ -882,6 +912,17 @@ How it works:
| `month` | 6 hours | hourly | | `month` | 6 hours | hourly |
| `year` | 1 month | monthly | | `year` | 1 month | monthly |
- **Site-to-site availability:** `site_to_site_up` of the same answer divides the view into slots of its own, `slot`, and gives for each slot and connection the percentage of the samples in which the connection was up, 0 to 100, as `points` of `{"time", "connections": {name: percent}}`:
| Range | Slot | Source |
|---|---|---|
| `hour` | 5 minutes | details |
| `day` | 1 hour | details |
| `month` | 1 day | hourly |
| `year` | 1 month | monthly |
A sample is taken every minute, so an interruption shorter than a minute may be missed; a slot without samples has no value, and the year view, like the others of its level, has the complete months only.
### Scraping ### Scraping
`GET /metrics` of the metrics service serves the last sample in the Prometheus text format, written by `prometheus_client`. `GET /metrics` of the metrics service serves the last sample in the Prometheus text format, written by `prometheus_client`.
+70
View File
@@ -1,3 +1,73 @@
vpn-router (0.19.1-2) unstable; urgency=medium
update: ADR-24 says that the Router Manager checks for updates on demand.
* ADR-24 describes Check update of the Router Manager, which updates
apt's package lists in the transient unit vpn-router-manager-check.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Tue, 27 Oct 2026 09:00:00 +0100
vpn-router (0.19.1-1) unstable; urgency=medium
fix: /metrics/history gives the availability of each site-to-site connection as the percentage of time it was up in slots of the view.
* site_to_site_up of /metrics/history divides the view into slots of 5
minutes for an hour, 1 hour for a day, 1 day for a month and 1 month
for a year, and gives for each slot and connection the percentage of
the samples in which the connection was up.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 21:00:00 +0100
vpn-router (0.19.0-1) unstable; urgency=medium
feature: service accounts have a display name and a description, which the router sets for its internal ones.
* vpnRouterAccount of the package's schema also takes displayName and
description; vpn-router-setup updates the schema of an existing
directory.
* /service-accounts shows display_name and description, takes them when
adding a service account and changes them with PUT; "" removes one.
/users shows them as null.
* vpn-router-user add --service and update take --display-name and
--description.
* vpn-router-setup gives the metrics service's internal account the
display name Metrics and its description, and sets them again when
they differ.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 20:00:00 +0100
vpn-router (0.18.3-1) unstable; urgency=medium
fix: internal service accounts are the router's own, of which the metrics service's is the one at present, and the documentation says what a service account is.
* /service-accounts, /groups and the internal field of the answers
take every internal service account, the accounts the router creates
and manages for its own services, instead of only the metrics
service's; the 403 answers say that the router manages them.
* The README, the ADR and the users module describe a service account
as the account another system, such as an OpenTelemetry Collector
reading /metrics, signs in with, and the internal ones as managed by
the router.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 19:00:00 +0100
vpn-router (0.18.2-1) unstable; urgency=medium
fix: service accounts are managed at /service-accounts, not /users, and the internal one of the metrics service is only listed.
* /service-accounts, for members of admins and admin tokens, lists the
service accounts, adds one with a password and an optional e-mail
address, changes the e-mail address, sets the password, disables,
enables, unlocks and removes them.
* /users no longer lists service accounts or takes "service" on POST,
and answers 404 to a request naming one.
* /service-accounts answers 403 to every request but the list on the
account metrics.service_account names while metrics are enabled, and
/groups to putting it in or taking it out of a group;
vpn-router-setup and root's vpn-router-user still change it.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 18:00:00 +0100
vpn-router (0.18.1-1) unstable; urgency=medium vpn-router (0.18.1-1) unstable; urgency=medium
fix: vpn-router-setup owns only the creation of the metrics service's account, which is marked internal, and administrators add service accounts with vpn-router-user add --service and /users. fix: vpn-router-setup owns only the creation of the metrics service's account, which is marked internal, and administrators add service accounts with vpn-router-user add --service and /users.
+9 -5
View File
@@ -458,14 +458,16 @@ def manage_admin():
def manage_metrics_account(ctx): def manage_metrics_account(ctx):
"""Keep the service account metrics.service_account names, whose password is in METRICS_SECRET; the problem, or ''. """Keep the service account metrics.service_account names, whose password is in METRICS_SECRET; the problem, or ''.
While metrics are enabled, setup owns the account: it adds it as a service account with a random password when While metrics are enabled, setup owns the account, an internal service account: it adds it as a service account
it is missing, and gives it a new random password, ending any lockout, when METRICS_SECRET is missing or does not with a random password and the labels of vpnrouter_users.internal_accounts when it is missing, sets those labels
hold its password. A disabled account is left as it is. An account of that name that is not a service account is when they differ, and gives it a new random password, ending any lockout, when METRICS_SECRET is missing or does
left alone and is a problem. not hold its password. The password of a disabled account is left as it is. An account of that name that is not a
service account is left alone and is a problem.
""" """
if not vpnrouter.metrics_ready(ctx): if not vpnrouter.metrics_ready(ctx):
return '' return ''
name = ctx['metrics_service_account'] name = ctx['metrics_service_account']
wanted = vpnrouter_users.internal_accounts().get(name, {})
try: try:
vpnrouter_users.check_name(name) vpnrouter_users.check_name(name)
try: try:
@@ -481,6 +483,8 @@ def manage_metrics_account(ctx):
current = None current = None
if current is not None and not vpnrouter_users.service(current): if current is not None and not vpnrouter_users.service(current):
return f'metrics.service_account: {name} is a user, not a service account' return f'metrics.service_account: {name} is a user, not a service account'
if current is not None and any(vpnrouter_users.summary(current)[key] != value for key, value in wanted.items()):
vpnrouter_users.update_service(connection, name, wanted)
# An administrator who disabled the account meant it; its password is left as it is. # An administrator who disabled the account meant it; its password is left as it is.
if current is not None and not vpnrouter_users.summary(current)['enabled']: if current is not None and not vpnrouter_users.summary(current)['enabled']:
return '' return ''
@@ -497,7 +501,7 @@ def manage_metrics_account(ctx):
METRICS_SECRET.chmod(0o600) METRICS_SECRET.chmod(0o600)
METRICS_SECRET.write_text(password + '\n', encoding='utf-8') METRICS_SECRET.write_text(password + '\n', encoding='utf-8')
if current is None: if current is None:
vpnrouter_users.add(connection, name, password, {}, service=True) vpnrouter_users.add(connection, name, password, wanted, service=True)
else: else:
vpnrouter_users.passwd(connection, name, password) vpnrouter_users.passwd(connection, name, password)
vpnrouter_users.unlock(connection, name) vpnrouter_users.unlock(connection, name)
@@ -52,8 +52,9 @@ OVERLAYS = ('ppolicy', 'otp')
#: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types #: The package's own schema, under an OID of the UUID arc 2.25 (ITU-T X.667): attribute types
#: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the #: under .1 and object classes under .2. vpnRouterAccount, auxiliary, adds properties of the
#: package to a user of either kind: mail, for a Manager-only account, and vpnRouterScope, #: package to a user of either kind: mail, for a Manager-only account, vpnRouterScope,
#: authorization scopes, of which service marks a service account. #: authorization scopes, of which service marks a service account, and displayName and
#: description, the labels of a service account.
SCHEMA_OID = '2.25.98477352595735834532404256531620013001' SCHEMA_OID = '2.25.98477352595735834532404256531620013001'
SCHEMA_NAME = 'vpn-router' SCHEMA_NAME = 'vpn-router'
SCHEMA_ATTRIBUTE_TYPES = ( SCHEMA_ATTRIBUTE_TYPES = (
@@ -62,7 +63,7 @@ SCHEMA_ATTRIBUTE_TYPES = (
) )
SCHEMA_OBJECT_CLASSES = ( SCHEMA_OBJECT_CLASSES = (
f"( {SCHEMA_OID}.2.1 NAME 'vpnRouterAccount' DESC 'VPN router: properties of a user or a Manager-only account' " 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 ) )", "SUP top AUXILIARY MAY ( mail $ vpnRouterScope $ displayName $ description ) )",
) )
#: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now. #: The TOTP parameters: digits, the OID of HMAC-SHA1, seconds per time step, and the time steps accepted before and after now.
@@ -44,6 +44,15 @@ LEVELS = (
#: The kinds of VPN clients of metrics_vpn. #: The kinds of VPN clients of metrics_vpn.
VPN_KINDS = ('p2s', 'openvpn', 'wireguard') VPN_KINDS = ('p2s', 'openvpn', 'wireguard')
#: The time slots of the site-to-site availability of each view of history(): the summary level read (None: the
#: details), the slot as PostgreSQL starts it from time, and its length.
UP_SLOTS = {
'hour': (None, "date_bin('5 minutes', time, timestamptz '2000-01-01 00:00:00+00')", '5 minutes'),
'day': (None, "date_trunc('hour', time, 'UTC')", '1 hour'),
'month': ('hourly', "date_trunc('day', time, 'UTC')", '1 day'),
'year': ('monthly', "date_trunc('month', time, 'UTC')", '1 month'),
}
#: The views of history(): the summary level read (None: the details), the bucket and the span. #: The views of history(): the summary level read (None: the details), the bucket and the span.
RANGES = { RANGES = {
'hour': (None, '1 minute', '1 hour'), 'hour': (None, '1 minute', '1 hour'),
@@ -148,8 +157,13 @@ def history(connection, view):
each interface to its rx and tx, clients, which maps each kind of VPN client to its clients, each interface to its rx and tx, clients, which maps each kind of VPN client to its clients,
and site_to_site, which maps each site-to-site connection to the values of and site_to_site, which maps each site-to-site connection to the values of
metrics_site_to_site; a value without data is None. metrics_site_to_site; a value without data is None.
site_to_site_up is the availability of each site-to-site connection in the time slots of UP_SLOTS, oldest first:
for each slot its start in Unix seconds and, per connection, the percentage of its samples in which the connection
was up, 0 to 100; the summaries' averages of up give it for the month and the year.
""" """
level, bucket, span = RANGES[view] level, bucket, span = RANGES[view]
up_level, up_start, up_slot = UP_SLOTS[view]
def rows(table): def rows(table):
keys, values = TABLES[table] keys, values = TABLES[table]
@@ -182,4 +196,12 @@ def history(connection, view):
for row in rows('metrics_site_to_site'): for row in rows('metrics_site_to_site'):
point(row[0])['site_to_site'][row[1]] = pairs(row[2:], TABLES['metrics_site_to_site'][1]) point(row[0])['site_to_site'][row[1]] = pairs(row[2:], TABLES['metrics_site_to_site'][1])
empty = {value: {'avg': None, 'max': None} for value in TABLES['metrics_system'][1]} empty = {value: {'avg': None, 'max': None} for value in TABLES['metrics_system'][1]}
return {'range': view, 'bucket': bucket, 'points': [{**empty, **points[start]} for start in sorted(points)]}
slots = {}
for start, name, percent in connection.execute(
f"SELECT extract(epoch FROM {up_start})::bigint AS slot, connection, avg({'up' if up_level is None else 'up_avg'}) * 100 "
f"FROM {'metrics_site_to_site' if up_level is None else f'metrics_site_to_site_{up_level}'} "
f"WHERE time >= now() - interval '{span}' GROUP BY slot, connection ORDER BY slot").fetchall():
slots.setdefault(start, {'time': start, 'connections': {}})['connections'][name] = percent
return {'range': view, 'bucket': bucket, 'points': [{**empty, **points[start]} for start in sorted(points)],
'site_to_site_up': {'slot': up_slot, 'points': [slots[start] for start in sorted(slots)]}}
@@ -7,10 +7,11 @@ common name, which OpenVPN and IKEv2 accept. A Manager-only account is an accoun
simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and simpleSecurityObject and vpnRouterAccount (the package's schema) with a name, a password and
optionally an e-mail address, no names, no certificate and so no VPN access; it is added when optionally an e-mail address, no names, no certificate and so no VPN access; it is added when
neither the first nor the last name is given, and can sign in to the web service. A service neither the first nor the last name is given, and can sign in to the web service. A service
account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which cannot account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which another
sign in to the web service; an administrator adds one for a service that checks passwords system signs in with to use a router service, and which cannot sign in to the web service; it has
against the directory. The internal one is the account metrics.service_account names while a display name and a description (labels), in displayName and description of vpnRouterAccount. The
metrics are enabled, which vpn-router-setup adds and whose password it keeps (metrics_account). internal service accounts are those the router creates and manages for its own services, labels
included (internal_accounts); vpn-router-setup keeps the metrics service's.
Users exist whether or not a P2S system is enabled. Users exist whether or not a P2S system is enabled.
A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an A group is a groupOfNames in ou=groups with a name, which follows the rules of user names, an
@@ -65,8 +66,11 @@ EMAIL = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
EMAIL_MAX = 254 EMAIL_MAX = 254
#: The keys of the person attributes and their LDAP attributes. #: The keys of the person attributes and their LDAP attributes.
PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail')) PERSON = (('first_name', 'givenName'), ('last_name', 'sn'), ('email', 'mail'))
#: The keys of the labels of a service account and their LDAP attributes.
LABELS = (('display_name', 'displayName'), ('description', 'description'))
#: The attributes read for a user. #: The attributes read for a user.
ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope'] ATTRIBUTES = ['uid', 'objectClass', 'pwdAccountLockedTime', 'givenName', 'sn', 'mail', 'oathTOTPToken', 'vpnRouterScope',
'displayName', 'description']
#: The vpnRouterScope of a service account. #: The vpnRouterScope of a service account.
SERVICE_SCOPE = 'service' SERVICE_SCOPE = 'service'
#: The object classes of a VPN user and of a Manager-only account. #: The object classes of a VPN user and of a Manager-only account.
@@ -147,6 +151,26 @@ def person(values, required):
return found return found
def labels(values):
"""The labels of a service account in values ({display_name, description}), checked, as {LDAP attribute: value}.
A key whose value is None is not given; '' means none. A display name has at most PERSON_NAME_MAX printable
characters and a description at most DESCRIPTION_MAX.
"""
found = {}
name = values.get('display_name')
if name is not None:
if not isinstance(name, str):
raise Failure('display_name must be a string', 2)
name = name.strip()
if len(name) > PERSON_NAME_MAX or any(not character.isprintable() for character in name):
raise Failure(f'the display name must have at most {PERSON_NAME_MAX} printable characters', 2)
found['displayName'] = name
if values.get('description') is not None:
found['description'] = check_description(values['description'])
return found
def dn_of(name): def dn_of(name):
return f'uid={name},{vpnrouter_ldap.USERS}' return f'uid={name},{vpnrouter_ldap.USERS}'
@@ -194,19 +218,26 @@ def service(attributes):
return SERVICE_SCOPE.encode() in (value.lower() for value in attributes.get('vpnRouterScope', [])) return SERVICE_SCOPE.encode() in (value.lower() for value in attributes.get('vpnRouterScope', []))
def metrics_account(): def internal_accounts():
"""The internal service account: the one metrics.service_account names while metrics are enabled, else None.""" """The internal service accounts, which the router creates and manages for its own services, by name, each with
the labels the router gives it ({display_name, description}): the one metrics.service_account names while metrics
are enabled."""
try: try:
metrics = vpnrouter.load_config()['metrics'] metrics = vpnrouter.load_config()['metrics']
except SystemExit: except SystemExit:
return None return {}
return metrics['service_account'] if metrics['enabled'] else None if not metrics['enabled']:
return {}
return {metrics['service_account']: {
'display_name': 'Metrics',
'description': 'The account an OpenTelemetry Collector signs in with to read /metrics of the metrics service.',
}}
def summary(attributes): def summary(attributes):
"""A user as the answers show it; a person attribute the entry does not have is None. """A user as the answers show it; a person attribute or a label the entry does not have is None.
internal is whether it is the internal service account (metrics_account). internal is whether it is an internal service account (internal_accounts).
""" """
name = attributes['uid'][0].decode() name = attributes['uid'][0].decode()
lock = attributes.get('pwdAccountLockedTime', [None])[0] lock = attributes.get('pwdAccountLockedTime', [None])[0]
@@ -214,13 +245,13 @@ def summary(attributes):
'name': name, 'name': name,
'manager_only': manager_only(attributes), 'manager_only': manager_only(attributes),
'service': service(attributes), 'service': service(attributes),
'internal': service(attributes) and name == metrics_account(), 'internal': service(attributes) and name in internal_accounts(),
'enabled': lock != LOCKED, 'enabled': lock != LOCKED,
'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS, 'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS,
'certificate': certificate(name)[0].is_file(), 'certificate': certificate(name)[0].is_file(),
'otp': 'oathTOTPToken' in attributes, 'otp': 'oathTOTPToken' in attributes,
} }
for key, attribute in PERSON: for key, attribute in PERSON + LABELS:
values = attributes.get(attribute) values = attributes.get(attribute)
user[key] = values[0].decode() if values else None user[key] = values[0].decode() if values else None
return user return user
@@ -241,12 +272,14 @@ def list_users(connection):
def add(connection, name, password, values, service=False): 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 """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 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 too.""" e-mail address and its labels (labels)."""
check_name(name) check_name(name)
details = person(values, required=False) details = person(values, required=False)
vpn_user = 'givenName' in details or 'sn' in details vpn_user = 'givenName' in details or 'sn' in details
if service and vpn_user: if service and vpn_user:
raise Failure('a service account has no first or last name', 2) raise Failure('a service account has no first or last name', 2)
if service:
details.update({attribute: value for attribute, value in labels(values).items() if value})
if vpn_user and len(details) != len(PERSON): if vpn_user and len(details) != len(PERSON):
raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2) raise Failure('give first_name, last_name and email for a VPN user, or at most email for a Manager-only account', 2)
check_password(password) check_password(password)
@@ -276,9 +309,12 @@ def add(connection, name, password, values, service=False):
def update(connection, name, values): def update(connection, name, values):
"""Change the person attributes values gives; the cn follows the first and the last name. """Change the person attributes values gives; the cn follows the first and the last name.
A Manager-only account has only the e-mail address, which '' removes. A Manager-only account has only the e-mail address, which '' removes; a service account also its labels
(update_service).
""" """
current = entry(connection, name) current = entry(connection, name)
if service(current):
return update_service(connection, name, values)
if manager_only(current): if manager_only(current):
if values.get('first_name') is not None or values.get('last_name') is not None: if values.get('first_name') is not None or values.get('last_name') is not None:
raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2) raise Failure(f'user {name} is a Manager-only account, which has no first or last name', 2)
@@ -302,6 +338,21 @@ def update(connection, name, values):
return summary(entry(connection, name)) return summary(entry(connection, name))
def update_service(connection, name, values):
"""Change any of the e-mail address and the labels (labels) of a service account that values gives; '' removes one."""
if not service(entry(connection, name)):
raise Failure(f'{name} is not a service account', 3)
changes = [(ldap.MOD_REPLACE, attribute, [value.encode()] if value else []) for attribute, value in labels(values).items()]
email = values.get('email')
if email is not None:
address = '' if isinstance(email, str) and email.strip() == '' else person({'email': email}, required=False)['mail']
changes.append((ldap.MOD_REPLACE, 'mail', [address.encode()] if address else []))
if not changes:
raise Failure('give email, display_name or description', 2)
connection.modify_s(dn_of(name), changes)
return summary(entry(connection, name))
def passwd(connection, name, password): def passwd(connection, name, password):
check_password(password) check_password(password)
entry(connection, name) entry(connection, name)
+81 -16
View File
@@ -12,7 +12,11 @@ Pages:
runs, as JSON runs, as JSON
/users the users in the router's directory, whether or not a P2S system is enabled: list, /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 add, change, set the password, disable, enable, remove, the OpenVPN client
profile, and TOTP enrolment (see users()) 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 /groups the groups of the router's directory: list, add, rename or describe, remove, and
add and take out members (see groups()) add and take out members (see groups())
/account the account of the signed-in user: its details, its password and its TOTP /account the account of the signed-in user: its details, its password and its TOTP
@@ -28,7 +32,7 @@ Pages:
the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see the databases of each PostgreSQL cluster on the router with their sizes, as JSON (see
postgresql_databases()); /config has the clusters' settings postgresql_databases()); /config has the clusters' settings
/config, /metrics/history, /postgresql, /users, /groups, /site-to-site and /account need an HS256 JWT in an Authorization: Bearer header signed /config, /metrics/history, /postgresql, /users, /service-accounts, /groups, /site-to-site 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; 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 /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 token of vpn-router-token has the admin claim and names no user; it may call every path but
@@ -441,11 +445,9 @@ def users(state, method, path, body):
GET /users the users GET /users the users
POST /users add a user: {"name", "password", "first_name", "last_name", "email"}; POST /users add a user: {"name", "password", "first_name", "last_name", "email"};
without the names a Manager-only account, and with "service": true a without the names a Manager-only account
service account
PUT /users/<name> change any of {"first_name", "last_name", "email"} PUT /users/<name> change any of {"first_name", "last_name", "email"}
PUT /users/<name>/password set the password: {"password": ...}; refused for the internal service PUT /users/<name>/password set the password: {"password": ...}
account, whose password vpn-router-setup keeps
POST /users/<name>/disable lock the user out of OpenVPN POST /users/<name>/disable lock the user out of OpenVPN
POST /users/<name>/enable let the user in again POST /users/<name>/enable let the user in again
POST /users/<name>/unlock end a lockout after failed logins POST /users/<name>/unlock end a lockout after failed logins
@@ -454,6 +456,8 @@ def users(state, method, path, body):
POST /users/<name>/otp a new TOTP secret and its otpauth URI; nothing is stored 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 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>/otp remove the TOTP 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)): if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'} return 400, {'error': 'the body must be a JSON object'}
@@ -462,9 +466,9 @@ def users(state, method, path, body):
route = (method, len(parts), parts[2] if len(parts) == 3 else None) route = (method, len(parts), parts[2] if len(parts) == 3 else None)
name = parts[1] if len(parts) > 1 else None name = parts[1] if len(parts) > 1 else None
actions = { actions = {
('GET', 1, None): lambda connection: vpnrouter_users.list_users(connection), ('GET', 1, None): lambda connection: {
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body, 'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']]},
service=body.get('service') is True), ('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', 2, None): lambda connection: vpnrouter_users.update(connection, name, body),
('PUT', 3, 'password'): lambda connection: vpnrouter_users.passwd(connection, name, body.get('password')), ('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, 'disable'): lambda connection: vpnrouter_users.disable(connection, name),
@@ -481,12 +485,10 @@ def users(state, method, path, body):
('DELETE', 2, None): 'remove'} ('DELETE', 2, None): 'remove'}
if route not in actions: if route not in actions:
return 404, {'error': f'no route {method} {path}'} return 404, {'error': f'no route {method} {path}'}
if route == ('POST', 1, None) and not isinstance(body.get('service', False), bool):
return 400, {'error': 'service must be true or false'}
if route == ('PUT', 3, 'password') and name == vpnrouter_users.metrics_account():
return 400, {'error': f'{name} is the internal service account of the metrics service; vpn-router-setup keeps its password'}
try: try:
with vpnrouter_users.session() as connection: 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) document = actions[route](connection)
if operations.get(route) in vpnrouter_auth.REVOKING: if operations.get(route) in vpnrouter_auth.REVOKING:
vpnrouter_auth.revoke(vpnrouter.load_config(), name) vpnrouter_auth.revoke(vpnrouter.load_config(), name)
@@ -502,6 +504,56 @@ def users(state, method, path, body):
return (201 if route == ('POST', 1, None) else 200), document return (201 if route == ('POST', 1, None) else 200), document
def service_accounts(method, path, body):
"""Answer a /service-accounts request with vpnrouter_users: the HTTP status and the JSON document.
GET /service-accounts the service accounts: {"service_accounts": [...]}
POST /service-accounts add one: {"name", "password", "display_name"?, "description"?, "email"?}
PUT /service-accounts/<name> change any of {"display_name", "description", "email"}; "" removes one
PUT /service-accounts/<name>/password set the password: {"password": ...}
POST /service-accounts/<name>/disable lock it out
POST /service-accounts/<name>/enable let it in again
POST /service-accounts/<name>/unlock end a lockout after failed logins
DELETE /service-accounts/<name> remove it
A name that is not a service account is 404. The internal service accounts (vpnrouter_users.internal_accounts),
which the router manages, are only listed: every other request on one is refused with 403.
"""
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')}
actions = {
('GET', 1, None): lambda connection: {
'service_accounts': [user for user in vpnrouter_users.list_users(connection)['users'] if user['service']]},
('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),
}
if route not in actions:
return 404, {'error': f'no route {method} {path}'}
if name is not None and name in vpnrouter_users.internal_accounts():
return 403, {'error': f'{name} is an internal service account, which the router manages'}
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)
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 groups(state, method, path, body): def groups(state, method, path, body):
"""Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document. """Answer a /groups request with vpnrouter_users: the HTTP status and the JSON document.
@@ -511,6 +563,9 @@ def groups(state, method, path, body):
DELETE /groups/<name> remove the group; its members are kept DELETE /groups/<name> remove the group; its members are kept
POST /groups/<name>/members add a member: {"user": ...} POST /groups/<name>/members add a member: {"user": ...}
DELETE /groups/<name>/members/<user> take a member out; the last member cannot leave 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)): if body == 'invalid' or (body is not None and not isinstance(body, dict)):
return 400, {'error': 'the body must be a JSON object'} return 400, {'error': 'the body must be a JSON object'}
@@ -518,6 +573,12 @@ def groups(state, method, path, body):
parts = path.strip('/').split('/') parts = path.strip('/').split('/')
name = parts[1] if len(parts) > 1 else None name = parts[1] if len(parts) > 1 else None
route = (method, len(parts), parts[2] if len(parts) > 2 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 = { actions = {
('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection), ('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection),
('POST', 1, None): lambda connection: vpnrouter_users.group_add( ('POST', 1, None): lambda connection: vpnrouter_users.group_add(
@@ -680,7 +741,8 @@ def metrics_history(view):
def changes_admins(connection, method, parts, body): def changes_admins(connection, method, parts, body):
"""Whether a /users or /groups request creates, renames to, changes or removes ADMIN_GROUP, or removes a member of it.""" """Whether a /users, /service-accounts or /groups request creates, renames to, changes or removes ADMIN_GROUP, or
removes a member of it."""
admins = vpnrouter_users.ADMIN_GROUP admins = vpnrouter_users.ADMIN_GROUP
if parts[0] == 'groups': if parts[0] == 'groups':
if method == 'POST' and len(parts) == 1: if method == 'POST' and len(parts) == 1:
@@ -688,7 +750,7 @@ def changes_admins(connection, method, parts, body):
if method != 'GET' and len(parts) > 1 and parts[1] == admins: if method != 'GET' and len(parts) > 1 and parts[1] == admins:
return True return True
return method == 'PUT' and len(parts) == 2 and body.get('name') == admins return method == 'PUT' and len(parts) == 2 and body.get('name') == admins
if parts[0] == 'users' and method == 'DELETE' and len(parts) == 2: 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 admins in vpnrouter_users.groups_of(connection, parts[1])
return False return False
@@ -802,7 +864,7 @@ class Handler(BaseHTTPRequestHandler):
# A body that is not read must not be taken for the next request. # A body that is not read must not be taken for the next request.
if self.command not in ('GET', 'HEAD'): if self.command not in ('GET', 'HEAD'):
self.close_connection = True self.close_connection = True
directory = next((name for name in ('users', 'groups', 'account', 'site-to-site') directory = next((name for name in ('users', 'service-accounts', 'groups', 'account', 'site-to-site')
if path == f'/{name}' or path.startswith(f'/{name}/')), None) if path == f'/{name}' or path.startswith(f'/{name}/')), None)
protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None protected = path in ('/config', '/metrics/history', '/postgresql') or directory is not None
signing = path in ('/auth', '/auth/refresh', '/auth/logout') signing = path in ('/auth', '/auth/refresh', '/auth/logout')
@@ -843,6 +905,9 @@ class Handler(BaseHTTPRequestHandler):
elif directory == 'site-to-site': elif directory == 'site-to-site':
code, document = site_to_site(self.command, path, payload) code, document = site_to_site(self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
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: elif directory is not None:
code, document = (users if directory == 'users' else groups)(state, self.command, path, payload) code, document = (users if directory == 'users' else groups)(state, self.command, path, payload)
body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json' body, kind = json.dumps(document, indent=2).encode() + b'\n', 'application/json'
+8 -4
View File
@@ -11,8 +11,10 @@ user exists, 1 anything else. -h or --help shows the commands and their options.
vpn-router-user [--json] list vpn-router-user [--json] list
vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin) vpn-router-user [--json] add <name> --first-name F --last-name L --email E (password on stdin)
vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin) vpn-router-user [--json] add <name> [--email E] (a Manager-only account; password on stdin)
vpn-router-user [--json] add <name> --service [--email E] (a service account; password on stdin) vpn-router-user [--json] add <name> --service [--display-name N] [--description D] [--email E]
(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> [--first-name F] [--last-name L] [--email E]
vpn-router-user [--json] update <name> [--display-name N] [--description D] [--email E] (a service account)
vpn-router-user [--json] passwd <name> (password on stdin) vpn-router-user [--json] passwd <name> (password on stdin)
vpn-router-user [--json] disable <name> vpn-router-user [--json] disable <name>
vpn-router-user [--json] enable <name> vpn-router-user [--json] enable <name>
@@ -57,7 +59,9 @@ HELP = {
} }
#: The help of the person options. #: The help of the person options.
PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address'} PERSON_HELP = {'first_name': 'the first name', 'last_name': 'the last name', 'email': 'the e-mail address',
'display_name': "a service account's display name; '' removes it",
'description': "a service account's description; '' removes it"}
class Parser(argparse.ArgumentParser): class Parser(argparse.ArgumentParser):
@@ -76,7 +80,7 @@ def parse(argv):
if command != 'list': if command != 'list':
sub.add_argument('name', help='the user name') sub.add_argument('name', help='the user name')
if command in ('add', 'update'): if command in ('add', 'update'):
for key, _ in vpnrouter_users.PERSON: for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS:
sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key]) sub.add_argument(f'--{key.replace("_", "-")}', dest=key, metavar='TEXT', help=PERSON_HELP[key])
if command == 'add': if command == 'add':
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service') sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
@@ -95,7 +99,7 @@ def run(connection, options, secret):
command = options.command command = options.command
if command == 'list': if command == 'list':
return vpnrouter_users.list_users(connection) return vpnrouter_users.list_users(connection)
values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON} values = {key: getattr(options, key, None) for key, _ in vpnrouter_users.PERSON + vpnrouter_users.LABELS}
if command == 'add': if command == 'add':
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service) return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
if command == 'update': if command == 'update':