Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ebd541b2 | ||
|
|
f01be41191 | ||
|
|
d1970b77b7 | ||
|
|
bf651da5a6 |
No files matched your search
@@ -558,7 +558,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 a service account that setup owns
|
## ADR-23: /metrics takes HTTP Basic authentication of an internal service account the router manages
|
||||||
|
|
||||||
Status: Accepted
|
Status: Accepted
|
||||||
|
|
||||||
@@ -567,20 +567,29 @@ 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`,
|
||||||
`vpn-router-setup` owns it: while metrics are enabled it adds the account `metrics.service_account`
|
which another system signs in with to use a router service. Administrators add service accounts
|
||||||
names, `metrics` by default, with a random password it writes to `/etc/vpn-router/metrics.secret`
|
with `vpn-router-user add --service` and the web service's `/service-accounts`.
|
||||||
(root only), and sets a new random password, ending a lockout, when the file is missing or no
|
`/service-accounts` is a path of its own, apart from `/users`, which does not list or serve them:
|
||||||
longer holds the password. `vpn-router-user` and `/users` cannot add one. `/auth` refuses it
|
a service account has no names, certificate, profile or TOTP, and is not a person an
|
||||||
with the message of a wrong password. `/metrics` accepts HTTP Basic only for that account, and
|
administrator manages with the users. Internal service accounts are those the router creates and
|
||||||
|
manages for its own services (`vpnrouter_users.internal_accounts`), so the system, not an
|
||||||
|
administrator, owns them; at present that is the metrics service's. `/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
|
||||||
|
|||||||
@@ -427,9 +427,14 @@ 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`, which
|
- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another
|
||||||
`vpn-router-setup` adds and keeps for the metrics service (see Metrics); it cannot sign in to
|
system signs in with to use a router service, such as an OpenTelemetry Collector reading
|
||||||
`/auth`, and `vpn-router-user` and `/users` cannot add one.
|
`/metrics`; it cannot sign in to `/auth`. `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; at present that is the metrics service's, the account
|
||||||
|
`metrics.service_account` names while metrics are enabled (see 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
|
||||||
@@ -441,6 +446,7 @@ after a command its options.
|
|||||||
```sh
|
```sh
|
||||||
echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com
|
echo 's3cr3t-pass' | vpn-router-user add alice --first-name Alice --last-name Smith --email alice@example.com
|
||||||
echo 'm4nager-pass' | vpn-router-user add ops
|
echo 'm4nager-pass' | vpn-router-user add ops
|
||||||
|
echo 's3rvice-pass' | vpn-router-user add backup --service
|
||||||
vpn-router-user update alice --email a.smith@example.com
|
vpn-router-user update alice --email a.smith@example.com
|
||||||
vpn-router-user list
|
vpn-router-user list
|
||||||
vpn-router-user profile alice > alice.ovpn
|
vpn-router-user profile alice > alice.ovpn
|
||||||
@@ -460,6 +466,7 @@ 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 |
|
||||||
| `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 |
|
||||||
| `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 |
|
||||||
@@ -474,8 +481,8 @@ A user name is 1 to 32 lowercase letters, digits and hyphens, starting with a le
|
|||||||
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
password has 8 to 128 characters; a first or last name has 1 to 64 printable characters; an
|
||||||
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
|
e-mail address has one `@`, no spaces and a dot in its domain; a TOTP secret is 32 base32
|
||||||
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
|
characters, as `otp-new` makes it, and a code 6 digits. Once enrolled, a user logs in to OpenVPN
|
||||||
and `/auth` with the password followed by a current code. In the answers `manager_only` tells the
|
and `/auth` with the password followed by a current code. In the answers `manager_only`, `service`
|
||||||
kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
|
and `internal` tell the kind, and a Manager-only account has its names as `null`; `update` changes only its e-mail
|
||||||
address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its
|
address, and `--email ''` removes it. Changing a user's password, disabling, unlocking or removing the user also revokes its
|
||||||
refresh tokens of `/auth`. The exit status is 0 on success, 2 for an invalid request, 3 when the
|
refresh tokens of `/auth`. The exit status is 0 on success, 2 for an invalid request, 3 when the
|
||||||
user does not exist, 4 when the user exists and 1 for any other failure.
|
user does not exist, 4 when the user exists and 1 for any other failure.
|
||||||
@@ -606,6 +613,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 |
|
||||||
@@ -630,7 +638,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
|
||||||
@@ -712,7 +720,7 @@ 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", "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 | 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": ...}` | the user |
|
| `PUT /users/<name>/password` | `{"password": ...}` | the user |
|
||||||
@@ -726,7 +734,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.
|
||||||
|
|
||||||
@@ -736,6 +745,26 @@ 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", "internal", "enabled", "locked", "email", ...}, ...]}`, each as `/users` shows a user |
|
||||||
|
| `POST /service-accounts` | `{"name", "password", "email"?}` | 201 and the account |
|
||||||
|
| `PUT /service-accounts/<name>` | `{"email"}`; `""` removes it | 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}` |
|
||||||
|
|
||||||
|
The internal service accounts, `internal` true, are only listed: every other request on one gets
|
||||||
|
403, as the router manages them. 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
|
||||||
|
|||||||
@@ -1,3 +1,59 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
* POST /users takes "service": true and adds a service account, which
|
||||||
|
cannot sign in to /auth; vpn-router-user add takes --service.
|
||||||
|
* The users of /users and vpn-router-user have internal, true for the
|
||||||
|
account metrics.service_account names while metrics are enabled;
|
||||||
|
vpn-router-user list shows its kind as internal.
|
||||||
|
* PUT /users/<name>/password refuses the internal service account,
|
||||||
|
whose password vpn-router-setup keeps.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 17:00:00 +0100
|
||||||
|
|
||||||
|
vpn-router (0.18.0-2) unstable; urgency=medium
|
||||||
|
|
||||||
|
update: release.py tags a Debian revision other than 1 as x.y.z-n.
|
||||||
|
|
||||||
|
* release.py tags the newest entry x.y.z for the Debian revision 1 and
|
||||||
|
x.y.z-n for a later revision, so a packaging change gets a release
|
||||||
|
of its own.
|
||||||
|
|
||||||
|
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Mon, 26 Oct 2026 14:00:00 +0100
|
||||||
|
|
||||||
vpn-router (0.18.0-1) unstable; urgency=medium
|
vpn-router (0.18.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
|
feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
|
||||||
|
|||||||
@@ -7,9 +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
|
account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which another
|
||||||
vpn-router-setup adds and keeps for the metrics service, as metrics.service_account names it; it
|
system signs in with to use a router service, and which cannot sign in to the web service. The
|
||||||
cannot sign in to the web service. Users exist whether or not a P2S system is enabled.
|
internal service accounts are those the router creates and manages for its own services
|
||||||
|
(internal_accounts); vpn-router-setup keeps the metrics service's.
|
||||||
|
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
|
||||||
optional description and at least one member; a group cannot be empty, so the last member
|
optional description and at least one member; a group cannot be empty, so the last member
|
||||||
@@ -192,14 +194,28 @@ 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 internal_accounts():
|
||||||
|
"""The names of the internal service accounts, which the router creates and manages for its own services: the one
|
||||||
|
metrics.service_account names while metrics are enabled."""
|
||||||
|
try:
|
||||||
|
metrics = vpnrouter.load_config()['metrics']
|
||||||
|
except SystemExit:
|
||||||
|
return set()
|
||||||
|
return {metrics['service_account']} if metrics['enabled'] else set()
|
||||||
|
|
||||||
|
|
||||||
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 the entry does not have is None.
|
||||||
|
|
||||||
|
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]
|
||||||
user = {
|
user = {
|
||||||
'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 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(),
|
||||||
@@ -225,8 +241,8 @@ 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, which only vpn-router-setup uses, a
|
with at most the e-mail address when it gives neither name, and, with service, a service account, with at most the
|
||||||
service account, with at most the e-mail address too."""
|
e-mail address too."""
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -452,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'}
|
||||||
@@ -460,7 +466,8 @@ 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: {
|
||||||
|
'users': [user for user in vpnrouter_users.list_users(connection)['users'] if not user['service']]},
|
||||||
('POST', 1, None): lambda connection: vpnrouter_users.add(connection, body.get('name'), body.get('password'), body),
|
('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')),
|
||||||
@@ -480,6 +487,8 @@ def users(state, method, path, body):
|
|||||||
return 404, {'error': f'no route {method} {path}'}
|
return 404, {'error': f'no route {method} {path}'}
|
||||||
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)
|
||||||
@@ -495,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", "email"?}
|
||||||
|
PUT /service-accounts/<name> change the e-mail address: {"email"}; "" removes it
|
||||||
|
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
|
||||||
|
email = {'email': body.get('email')}
|
||||||
|
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'), email,
|
||||||
|
service=True),
|
||||||
|
('PUT', 2, None): lambda connection: vpnrouter_users.update(connection, name, email),
|
||||||
|
('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.
|
||||||
|
|
||||||
@@ -504,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'}
|
||||||
@@ -511,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(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(
|
||||||
@@ -673,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:
|
||||||
@@ -681,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
|
||||||
|
|
||||||
@@ -795,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')
|
||||||
@@ -836,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'
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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] 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] 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>
|
||||||
@@ -40,7 +41,8 @@ from vpnrouter_users import Failure
|
|||||||
HELP = {
|
HELP = {
|
||||||
'list': 'list the users',
|
'list': 'list the users',
|
||||||
'add': 'add a VPN user with the three person options and issue the certificate, or without the names a '
|
'add': 'add a VPN user with the three person options and issue the certificate, or without the names a '
|
||||||
'Manager-only account, with an optional e-mail address; the password is read from standard input',
|
'Manager-only account, or with --service a service account, each with an optional e-mail address; the '
|
||||||
|
'password is read from standard input',
|
||||||
'update': "change the first name, the last name or the e-mail address; a Manager-only account has only the "
|
'update': "change the first name, the last name or the e-mail address; a Manager-only account has only the "
|
||||||
"e-mail address, which '' removes",
|
"e-mail address, which '' removes",
|
||||||
'passwd': 'set the password, read from standard input',
|
'passwd': 'set the password, read from standard input',
|
||||||
@@ -76,6 +78,8 @@ def parse(argv):
|
|||||||
if command in ('add', 'update'):
|
if command in ('add', 'update'):
|
||||||
for key, _ in vpnrouter_users.PERSON:
|
for key, _ in vpnrouter_users.PERSON:
|
||||||
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':
|
||||||
|
sub.add_argument('--service', action='store_true', help='add a service account, which cannot sign in to the web service')
|
||||||
if command == 'otp-enrol':
|
if command == 'otp-enrol':
|
||||||
sub.add_argument('code', help='a code of the secret from the authenticator')
|
sub.add_argument('code', help='a code of the secret from the authenticator')
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
@@ -93,7 +97,7 @@ def run(connection, options, secret):
|
|||||||
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}
|
||||||
if command == 'add':
|
if command == 'add':
|
||||||
return vpnrouter_users.add(connection, options.name, secret, values)
|
return vpnrouter_users.add(connection, options.name, secret, values, service=options.service)
|
||||||
if command == 'update':
|
if command == 'update':
|
||||||
return vpnrouter_users.update(connection, options.name, values)
|
return vpnrouter_users.update(connection, options.name, values)
|
||||||
if command == 'passwd':
|
if command == 'passwd':
|
||||||
@@ -115,7 +119,7 @@ def text_of(answer):
|
|||||||
if not users:
|
if not users:
|
||||||
return 'no users'
|
return 'no users'
|
||||||
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
head = ('USER', 'KIND', 'STATE', 'CERTIFICATE', 'OTP', 'FIRST NAME', 'LAST NAME', 'E-MAIL')
|
||||||
rows = [(user['name'], 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
rows = [(user['name'], 'internal' if user['internal'] else 'service' if user['service'] else 'manager' if user['manager_only'] else 'vpn',
|
||||||
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
'disabled' if not user['enabled'] else 'locked' if user['locked'] else 'enabled',
|
||||||
'issued' if user['certificate'] else 'missing',
|
'issued' if user['certificate'] else 'missing',
|
||||||
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
'enrolled' if user['otp'] else '-', user['first_name'] or '-', user['last_name'] or '-', user['email'] or '-')
|
||||||
|
|||||||
+6
-5
@@ -1,9 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Commit everything, tag the version of the newest changelog entry and push main with the tags.
|
"""Commit everything, tag the version of the newest changelog entry and push main with the tags.
|
||||||
|
|
||||||
The tag is the upstream version of the first entry of debian-package/debian/changelog,
|
The tag is the version of the first entry of debian-package/debian/changelog, without the
|
||||||
without the Debian revision: vpn-router (0.5.1-1) is tagged 0.5.1, which starts the
|
Debian revision 1: vpn-router (0.5.1-1) is tagged 0.5.1 and (0.5.1-2) 0.5.1-2. The tag
|
||||||
release workflow.
|
starts the release workflow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -48,13 +48,14 @@ def summary():
|
|||||||
|
|
||||||
|
|
||||||
def version():
|
def version():
|
||||||
"""The upstream version of the first changelog entry."""
|
"""The tag of the first changelog entry: its version, without the Debian revision when that is 1."""
|
||||||
first = CHANGELOG.read_text(encoding='utf-8').splitlines()[0]
|
first = CHANGELOG.read_text(encoding='utf-8').splitlines()[0]
|
||||||
start, end = first.find('('), first.find(')')
|
start, end = first.find('('), first.find(')')
|
||||||
if start == -1 or end < start:
|
if start == -1 or end < start:
|
||||||
sys.exit(f'{CHANGELOG}: the first line has no version: {first}')
|
sys.exit(f'{CHANGELOG}: the first line has no version: {first}')
|
||||||
full = first[start + 1:end]
|
full = first[start + 1:end]
|
||||||
return full.rsplit('-', 1)[0] if '-' in full else full
|
upstream, _, revision = full.rpartition('-')
|
||||||
|
return upstream if upstream and revision == '1' else full
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
Reference in new issue
Block a user