From 68ebd541b249f35c21f3f7aee2053d5bb202148e Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 25 Oct 2026 18:27:10 +0100 Subject: [PATCH] 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. --- ADR.md | 27 ++++++++++--------- README.md | 18 +++++++------ debian-package/debian/changelog | 15 +++++++++++ .../src/usr/lib/vpn-router/vpnrouter_users.py | 21 ++++++++------- debian-package/src/usr/lib/vpn-router/web | 19 ++++++------- 5 files changed, 60 insertions(+), 40 deletions(-) diff --git a/ADR.md b/ADR.md index c069c3c..75f0d34 100644 --- a/ADR.md +++ b/ADR.md @@ -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 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 @@ -567,20 +567,21 @@ 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 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`. -Administrators add service accounts with `vpn-router-user add --service` and the web service's -`/service-accounts`, for services of their own that check passwords against the directory. +Decision: A service account is a Manager-only account (ADR-18) with `vpnRouterScope` `service`, +which another system signs in with to use a router service. Administrators add service accounts +with `vpn-router-user add --service` and the web service's `/service-accounts`. `/service-accounts` is a path of its own, apart from `/users`, which does not list or serve them: a service account has no names, certificate, profile or TOTP, and is not a person an -administrator manages with the users. The metrics service's is the internal one, created by -`vpn-router-setup`: while metrics are enabled it adds the account `metrics.service_account` names, -`metrics` by default, 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. `/service-accounts` marks it as `internal` and only lists it: every other -request on it, and putting it in or taking it out of a group at `/groups`, gets 403, whatever the -token. Setup -changes it through the directory's user-manager account, not the API, and root may still with -`vpn-router-user`. `/auth` refuses every service account 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 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. diff --git a/README.md b/README.md index 66166a5..d5f568f 100644 --- a/README.md +++ b/README.md @@ -427,12 +427,14 @@ it is added: 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 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 - that checks passwords against the directory; it cannot sign in to `/auth`. `add --service` adds - one, and the web service manages them at `/service-accounts`, not at `/users`. 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). The API only lists it: `/service-accounts` refuses every - other request on it, and `/groups` refuses to put it in or take it out of a group, with 403. +- a service account, a Manager-only account whose `vpnRouterScope` is `service`, which another + system signs in with to use a router service, such as an OpenTelemetry Collector reading + `/metrics`; it cannot sign in to `/auth`. `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 table of the users, the client profile, a TOTP secret or a short message, and a failure is a @@ -759,8 +761,8 @@ curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ | `POST /service-accounts//unlock` | | the account | | `DELETE /service-accounts/` | | `{"name", "removed": true}` | -The internal account, `internal` true, is only listed: every other request on it gets 403, as -`vpn-router-setup` adds and keeps it. A failure is otherwise as for `/users`, with 404 also for a +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 diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index dc3e2a0..f14e62d 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,18 @@ +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 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. diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py index e0f2bcf..8b45d9d 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter_users.py @@ -7,10 +7,10 @@ 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 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 -account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which cannot -sign in to the web service; an administrator adds one for a service that checks passwords -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). +account is a Manager-only account with the scope SERVICE_SCOPE in vpnRouterScope, which another +system signs in with to use a router service, and which cannot sign in to the web service. The +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 @@ -194,19 +194,20 @@ def service(attributes): 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.""" +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 None - return metrics['service_account'] if metrics['enabled'] else None + return set() + return {metrics['service_account']} if metrics['enabled'] else set() def summary(attributes): """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). + internal is whether it is an internal service account (internal_accounts). """ name = attributes['uid'][0].decode() lock = attributes.get('pwdAccountLockedTime', [None])[0] @@ -214,7 +215,7 @@ def summary(attributes): 'name': name, 'manager_only': manager_only(attributes), 'service': service(attributes), - 'internal': service(attributes) and name == metrics_account(), + 'internal': service(attributes) and name in internal_accounts(), 'enabled': lock != LOCKED, 'locked': lock not in (None, LOCKED) and locked_since(lock) > time.time() - vpnrouter_ldap.LOCKOUT_SECONDS, 'certificate': certificate(name)[0].is_file(), diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index a224868..2cf78f5 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -15,7 +15,7 @@ Pages: 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 one is only listed (see + disable, enable, unlock and remove; the internal ones are only listed (see service_accounts()) /groups the groups of the router's directory: list, add, rename or describe, remove, and add and take out members (see groups()) @@ -516,8 +516,8 @@ def service_accounts(method, path, body): POST /service-accounts//unlock end a lockout after failed logins DELETE /service-accounts/ remove it - A name that is not a service account is 404. The internal one (vpnrouter_users.metrics_account), which - vpn-router-setup adds and keeps, is only listed: every other request on it is refused with 403. + 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'} @@ -540,8 +540,8 @@ def service_accounts(method, path, body): } if route not in actions: return 404, {'error': f'no route {method} {path}'} - if name is not None and name == vpnrouter_users.metrics_account(): - return 403, {'error': f'{name} is the internal service account of the metrics service; only vpn-router-setup changes it'} + 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)): @@ -564,7 +564,7 @@ def groups(state, method, path, body): POST /groups//members add a member: {"user": ...} DELETE /groups//members/ take a member out; the last member cannot leave - The internal service account (vpnrouter_users.metrics_account) is not put in or taken out of a group: such a + 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)): @@ -573,11 +573,12 @@ def groups(state, method, path, body): parts = path.strip('/').split('/') name = parts[1] if len(parts) > 1 else None route = (method, len(parts), parts[2] if len(parts) > 2 else None) - internal = vpnrouter_users.metrics_account() 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 [] - if internal is not None and isinstance(members, list) and internal in members: - return 403, {'error': f'{internal} is the internal service account of the metrics service; only vpn-router-setup changes it'} + 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 = { ('GET', 1, None): lambda connection: vpnrouter_users.list_groups(connection), ('POST', 1, None): lambda connection: vpnrouter_users.group_add(