Compare commits

...
2 Commits
7 changed files with 89 additions and 31 deletions

No files matched your search

+9 -6
View File
@@ -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 that setup creates
Status: Accepted Status: Accepted
@@ -568,11 +568,14 @@ API or the Manager, and its password should be checked and locked out like any o
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` Administrators add service accounts with `vpn-router-user add --service` and `/users`, for
names, `metrics` by default, with a random password it writes to `/etc/vpn-router/metrics.secret` services of their own that check passwords against the directory, and manage them like other
(root only), and sets a new random password, ending a lockout, when the file is missing or no accounts. The metrics service's is the internal one, created by `vpn-router-setup`: while metrics
longer holds the password. `vpn-router-user` and `/users` cannot add one. `/auth` refuses it are enabled it adds the account `metrics.service_account` names, `metrics` by default, with a
with the message of a wrong password. `/metrics` accepts HTTP Basic only for that account, and 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. The answers
of `/users` mark it as `internal`, and `/users` refuses to set its password, which setup would
replace. `/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.
+12 -8
View File
@@ -427,9 +427,11 @@ 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`, for a service
`vpn-router-setup` adds and keeps for the metrics service (see Metrics); it cannot sign in to that checks passwords against the directory; it cannot sign in to `/auth`. `add --service` and
`/auth`, and `vpn-router-user` and `/users` cannot add one. `POST /users` with `"service": true` add one. The internal one is the account
`metrics.service_account` names while metrics are enabled: `vpn-router-setup` adds it and keeps
its password (see Metrics), so `/users` refuses to set its password.
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 +443,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 +463,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 +478,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.
@@ -712,10 +716,10 @@ 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; `{"name", "password", "service": true, "email"?}` for a service 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": ...}`; refused with 400 for the internal service account | 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 |
+24
View File
@@ -1,3 +1,27 @@
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 cannot
vpn-router-setup adds and keeps for the metrics service, as metrics.service_account names it; it sign in to the web service; an administrator adds one for a service that checks passwords
cannot sign in to the web service. Users exist whether or not a P2S system is enabled. against the directory. The internal one is the account metrics.service_account names while
metrics are enabled, which vpn-router-setup adds and whose password it keeps (metrics_account).
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,27 @@ 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():
"""The internal service account: the one metrics.service_account names while metrics are enabled, else None."""
try:
metrics = vpnrouter.load_config()['metrics']
except SystemExit:
return None
return metrics['service_account'] if metrics['enabled'] else None
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 the internal service account (metrics_account).
"""
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 == metrics_account(),
'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 +240,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
+10 -3
View File
@@ -441,9 +441,11 @@ 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 without the names a Manager-only account, and with "service": true a
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": ...} PUT /users/<name>/password set the password: {"password": ...}; refused for the internal service
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
@@ -461,7 +463,8 @@ def users(state, method, path, body):
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: vpnrouter_users.list_users(connection),
('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,
service=body.get('service') is True),
('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),
@@ -478,6 +481,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:
document = actions[route](connection) document = actions[route](connection)
+7 -3
View File
@@ -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
View File
@@ -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():