31 KiB
Architecture Decision Records
ADR-1: Docker's published ports are filtered in DOCKER-USER by the package
Status: Accepted
Context: Docker adds its own iptables rules for published ports. They are evaluated before
UFW's, so a published port is reachable from the external interface whatever UFW says. Docker
reserves the DOCKER-USER chain, evaluated first in FORWARD, for rules of the operator. The
router is an internet-facing host, and Docker is installed on it for services run alongside the
router.
Decision: The package secures Docker itself. A docker configuration section has mode
(auto, on, off) and exposed_ports. In auto mode the rules are applied when
docker.service is installed, which is what the package looks up; on requires Docker and
off removes the rules. The rules are in a chain of their own, VPN-ROUTER-DOCKER, entered by
one jump at the top of DOCKER-USER. For traffic from the external interface they accept IPsec
protected traffic, TCP to exposed_ports and established connections, and drop the rest. The
vpn-router-docker unit applies them after docker.service starts and removes them when it
stops; vpn-router-setup restarts it while Docker runs.
Consequences: Installing Docker first is recommended; auto mode then finds it as soon as the
package is configured. When Docker is installed after the package, the rules are applied by
restarting vpn-router-setup, a reboot or a restart of Docker, not by Docker's first start,
because vpn-router-docker is already active. A published
port is reachable from the external interface only when it is in exposed_ports. Other rules
in DOCKER-USER are not touched, because the package owns only its own chain. Restarting Docker
restores the rules. The rules cover IPv4 only.
ADR-2: A small status web service, on the interfaces web.listen chooses
Status: Accepted
Context: The router has no way to see its state without a shell. A check from the protected network, by a person or by a monitor, needs a page or an endpoint, and so does the platform that deploys the router, which reaches it on its public address. The router is an internet-facing host, so what the service shows and where it listens matter.
Decision: The vpn-router-web unit runs one Python script on the standard library's
http.server, with the package's own modules and the Python packages of the Ubuntu archive that
the package depends on, and no web framework. It listens on web.port, 7081 by default, on the
address of the internal interface, of the external interface or of both, as web.listen sets
(internal, external or both, the default), with a server for each address, and is on
unless web.enabled is false (ADR-7). / is for people, in plain text for curl and wget
and as a Tailwind CSS page for a browser, and /status is the same status as JSON. It shows the
state of the services, the package version and the state of the site-to-site, road-warrior,
OpenVPN and WireGuard features, and nothing from the configuration. vpn-router-setup opens the
port on both interfaces in UFW, so the choice of web.listen is made by the service alone, and
writes the features and the facts it resolves to /run/vpn-router/web.json.
Consequences: By default the service is reachable from the internet on the external address;
web.listen set to internal keeps it to the internal network. It serves plain HTTP, so the
passwords of /auth and the tokens cross the internet unencrypted. A change of web.listen
needs no run of setup, since the firewall does not depend on it; the package's upgrade runs
setup, which writes the rules. / and /status have no authentication, which is acceptable because they are
read only and show no configuration. It runs as root, because swanctl and wg need it, with NoNewPrivileges,
ProtectSystem=strict, ProtectHome and PrivateTmp. The Tailwind style comes from a CDN, so a
browser on a network without internet shows the unstyled page, and curl is unaffected.
ADR-3: The protected paths take JWTs that only the router signs
Status: Accepted
Context: Clients such as the Router Manager, and people with curl, need the configuration,
the users and the groups of the router, which / and /status must not show. Both people and
programs sign in with a user of the directory. A secret shared with the platform that deploys
the router would let whoever holds it, and every copy of the deployment's state, make tokens.
The service listens on plain HTTP.
Decision: /config, /users and /groups need a JWT in an Authorization: Bearer header,
HS256, verified with hmac and hashlib; no other algorithm is accepted. The key is
/etc/vpn-router/web.key, random, made by the web service on its first run and readable by root
only; it is never configured or output. The router itself issues
the tokens: POST /auth checks a user's password and TOTP code with a bind (ADR-14) and answers
an access token naming the user in sub, valid for 5 minutes, and a refresh token valid for 8
hours (ADR-15); vpn-router-token makes tokens with the admin claim (ADR-13). A token without
admin must expire within ten minutes, so a leaked one is of little use. Without the key the
paths and /auth are not served. The configuration shown has no secret options (ADR-8).
Consequences: Nothing outside the router can make a token, so a token is either issued to a
user who signed in or made by root on the router. A client keeps the refresh token to stay
signed in. A token of vpn-router-token can be revoked only by replacing the key, which ends
every token. Tokens, passwords and answers cross the network unencrypted, so the API is for a
trusted internal network; HTTPS would need a certificate that the package does not have.
ADR-4: Users are kept in a local OpenLDAP directory
Status: Accepted
Context: OpenVPN needs user accounts with passwords, and the users are meant to cover every P2S
system of the router, including IKEv2 EAP-TLS and WireGuard, which an OIDC provider cannot
authenticate. Everything the router runs comes from the Ubuntu archive. slapd, its Python module
python3-ldap and the OpenVPN plugin openvpn-auth-ldap are all in the archive; the plugin
checks a password by binding as the user, so the hash scheme is slapd's alone. An OIDC provider
can read the directory through its LDAP connector.
Decision: The users are entries in slapd. The package keeps its own database,
dc=vpn-router,dc=local in /var/lib/ldap/vpn-router (the path the slapd AppArmor profile
allows), next to the database the slapd package creates from the host's domain, so the package
does not depend on the debconf answers of slapd. Its root DN is root's peer-credential DN on the
local socket, so vpn-router-setup manages it with SASL EXTERNAL and no administrator password
exists. Users are changed by cn=user-manager,ou=services, whose ACLs allow writing ou=users
and setting passwords (=wx) but not reading them. Passwords are hashed with ARGON2, and the
ppolicy overlay locks a disabled user (pwdAccountLockedTime, effective with pwdLockout).
slapd listens on the local socket only. Provisioning checks every step before changing it, so it
runs at every start of vpn-router-setup.
Consequences: The package depends on slapd, python3-ldap, openvpn and
openvpn-auth-ldap, and vpn-router-setup and vpn-router-user import python3-ldap. The
service account's password is a root-only file. The directory is user data: it is kept on purge,
and it is backed up with slapcat, not by copying files.
ADR-5: OpenVPN clients need a certificate and the password of the user it names
Status: Accepted
Context: The router CA issues client certificates for IKEv2. OpenVPN can check a certificate, a
password, or both. With both, a certificate alone and a password alone are not enough, but
openvpn-auth-ldap checks only the password of the user name given at login, which need not be
the user of the certificate.
Decision: OpenVPN checks the certificate against the CA and the CRL, the password with
openvpn-auth-ldap, and runs openvpn-same-user as auth-user-pass-verify with via-env,
which accepts the login only when the user name equals the certificate's common name. OpenVPN
calls both and needs both to pass. A user's certificate has the user name as its common name, is
issued when the user is added and revoked when the user is removed. The CRL is created with the
CA, so that crl-verify has a file from the start, and is valid for ten years, because the router
renews it only when it revokes a certificate. The server uses the router's own certificate, whose
common name is wan.local_fqdn; the client profile checks that name (verify-x509-name), since
the CA's certificates carry both the server and the client purpose.
Consequences: Disabling a user locks the password, which stops OpenVPN but not IKEv2, which checks certificates only; removing a user revokes the certificate for both. A user's certificate expires after the CA's default of a year and is renewed by removing and adding the user again.
ADR-6: The web service and vpn-router-user share one module for users
Status: Accepted
Context: Users are managed from the command line, by an operator at a terminal, and through
/users of the web service, by the Router Manager. Both must apply the same rules to names,
passwords and person attributes, and both change the directory and the CA. simple-ca keeps
the CA's state in one file, so two changes must not run at the same time. The web unit is
sandboxed with ProtectSystem=strict.
Decision: vpnrouter_users.py holds the user operations, the checks and a session that takes a
lock and the connection of the user manager account. The web service imports it and maps a
Failure to the HTTP status; vpn-router-user imports it and adds the argument parsing and
the output, text for people and JSON with --json. The web unit allows writing the paths the
operations need: /run/vpn-router for the lock, its RuntimeDirectory, which systemd creates
before the service starts, also before setup has run, and keeps when it stops, as setup's state
files are there too; /etc/vpn-router/pki for the certificates and the CA's state, and
/etc/swanctl/x509crl for the published CRL.
Consequences: The rules exist once. A request starts no process, and a password never leaves
the process that received it. The web service imports python3-ldap and the package's
configuration module. The API is protected by the tokens of ADR-3, so only members of admins
and the holders of an admin token manage users.
ADR-7: The web service starts on its own and reads its inputs at each request
Status: Accepted
Context: The status page is the tool to see a problem of vpn-router-setup, so it must run
whatever state setup is in, also before setup has run and while it fails. The addresses,
web.listen, web.port and web.enabled can change at any time.
Decision: The service depends on nothing to start and does not end on its own. A discovery
thread works out, every 30 seconds, the addresses of the interfaces web.listen names from
vpn-router.json as general.mode directs, with the discovery code in vpnrouter_net.py,
which vpnrouter.py uses too, and web.port and web.enabled from the same file. It runs an
HTTP server in a thread of its own at each address and the port, stops one and starts a new one
when they change, and runs none while the service is disabled or no address is known; an address
that cannot be bound is tried again the next round. Each request
reads the signing key and vpn-router.json, and the features and resolved facts from
web.json, which setup writes when it applies the configuration, so the service shows what setup
applied; while that file is missing the router shows no features. The unit is not
ordered after vpn-router-setup, and setup does not restart it.
Consequences: The status page answers as soon as an address exists, whatever state setup is in,
and follows a changed address, web.listen, port or web.enabled within 30 seconds without a
restart. A new signing key applies to the next request. The interfaces are read every 30
seconds, which costs a few ip commands.
ADR-8: /config leaves secret options out
Status: Accepted
Context: A secret has no use outside the router, so a client never needs it, and showing even that a secret is set tells a reader more than it needs.
Decision: The configuration that vpn-router-setup writes for the web service has no secret
options at all. They are recognised by name: psk, database, whose connection string may hold
a password, and options with secret, password, token or a _key ending in the name.
Consequences: No secret reaches the web service's state file or any answer of the API. A client cannot tell whether a secret is set. A new secret option needs a name that matches.
ADR-9: The Router Manager is a separate package with a small Python server
Status: Accepted
Context: The router needs a web application to manage it, which may run on the router or on
another Linux system. The router's web service issues tokens through /auth (ADR-3) and sends
no CORS headers, so a browser cannot call it directly, and tokens kept in the page could be read
by its scripts.
Decision: vpn-router-manager is a Debian package of its own, built in the repository
linux-cloud-router-manager. A React and MUI application in the style of manage-ui is built
with Vite when the package is built and shipped as static files. A Python server, using the
Ubuntu packages python3-requests and python3-psycopg, serves them, signs users in through the
router's /auth, keeps their tokens in a server-side session in PostgreSQL behind an
HttpOnly cookie, and passes every other /api/<path> call to the router's /<path> with the
session's access token, refreshing it as needed; it holds no logic of the router's own and no
key of it. It reads its settings from /etc/vpn-router-manager/manager.json at each request; on
the router the settings not given come from vpn-router.json, and it listens on the internal
address, the external address or both, as its own listen sets, like the status web service,
with the same discovery thread. vpn-router-setup opens the manager's port on the internal and
the external interface while the manager is installed, so the firewall stays in one place.
Consequences: The router needs no Node.js; the package needs only python3 and packages of the
Ubuntu archive. Tokens never reach the browser, and sessions survive a restart of the manager.
The router decides what a user may do; the manager only signs in. The application checks the
shape of every answer of the router. The router's firewall depends on whether the manager is
installed, and a changed manager port needs vpn-router-setup to run again.
ADR-10: Users and their certificates do not depend on a P2S system being enabled
Status: Accepted
Context: Users are prepared before a P2S system is switched on, and the Router Manager manages them on any configured router, with or without one.
Decision: Once a platform is chosen, vpn-router-setup always provisions the user directory in
slapd and the CA with its CRL; the server certificate follows once wan.local_fqdn is set.
/users and vpn-router-user rely only on the directory and the CA. Only the OpenVPN client
profile needs OpenVPN to be enabled.
Consequences: slapd always holds the directory and the CA always exists on a configured router, even when no P2S system uses them. An operator who wants a CA of their own must place it before choosing the platform.
ADR-11: Groups are groupOfNames entries that never become empty
Status: Accepted
Context: Users are to be organised in groups. Groups record membership only; nothing on the
router reads them. LDAP clients that read groups, such as openvpn-auth-ldap and the LDAP
connectors of OIDC providers, expect groupOfNames with member DNs, and OpenLDAP's core schema
requires a groupOfNames to have at least one member.
Decision: A group is cn=<name>,ou=groups,dc=vpn-router,dc=local, a groupOfNames with the
DNs of its members and an optional description. A group is created with at least one member,
and taking out its last member is refused; such a group is removed instead. Removing a user
takes it out of its groups and is refused while the user is the last member of one. The group
operations are in vpnrouter_users.py with the user operations, so they share the session and
its lock; vpn-router-group is a tool of its own beside vpn-router-user, and the web service
serves them under /groups. Group names follow the rules of user names.
Consequences: A group always satisfies the schema without a placeholder member, and an empty group cannot exist. Removing a user or a member can need another step first. A later use of groups, such as allowing OpenVPN only to some, can read them as they are.
ADR-12: TOTP secrets are kept with the OATH-LDAP schema of slapd's otp module
Status: Accepted
Context: Users are enrolled for TOTP one-time passwords, and enrolled users must give a code
whenever they log in, to OpenVPN and to /auth. Ubuntu's slapd ships the otp module, which
holds the OATH-LDAP schema and an overlay that checks a code appended to the password at bind
time; both OpenVPN (openvpn-auth-ldap) and /auth check passwords with a bind. Ubuntu
packages python3-pyotp. An authenticator app is enrolled from a QR code of an otpauth:// URI.
Decision: The otp module is loaded and its overlay is configured on the package's database,
after ppolicy, so every bind of an enrolled user takes the password followed by a current code
and slapd records the time step used. The TOTP token is the user entry itself: enrolment adds oathTOTPUser and oathTOTPToken to it, with
oathTOTPToken naming the entry, the secret in oathSecret and oathTOTPParams naming one
shared parameter entry, cn=totp,ou=policies, with the parameters authenticator apps assume
(6 digits, HMAC-SHA1, 30 seconds). An ACL lets the user manager account set oathSecret and
no one read it. Enrolment is confirmed: otp_new makes a secret with pyotp and returns it
with its URI, storing nothing, and otp_enrol stores the secret it is given back only when a
code of it verifies, so a secret is never stored that the user's app does not produce. The
router answers the URI; the client renders the QR code.
Consequences: Removing a user removes its token. One check covers OpenVPN and /auth, and
an enrolled user types the code after the password in the OpenVPN client. The secret passes
through the client between otp_new and otp_enrol, so the router keeps no pending state;
enrolling again replaces the secret only once a code of the new one verifies.
ADR-13: Members of admins manage the router; only CLI tokens change admins
Status: Accepted
Context: Signing in through /auth (ADR-3) is open to every user of the directory, most of
whom only use OpenVPN. Finer permissions per operation are not needed. Somebody must be able to
manage the router before any administrator exists and after every administrator lost access,
and administrators must not be able to grant themselves or others the administrator role over
the API.
Decision: A token naming a user in sub may call the protected paths only while the user is a
member of the group admins, read at each request; other users get 403. vpn-router-token, run
as root on the router, makes tokens with the claim admin: true and a lifetime given at
creation, without a maximum, signed with the router's key; /auth never issues the claim. An
admin token may call everything, and only an admin token may change admins: create it, rename a
group to it, change or remove it, add or take out its members, or remove a user who is a member
of it. The first member is added with vpn-router-group on the router, which acts as root.
Consequences: Removing a user from admins takes effect at the next request, without waiting
for the user's tokens to expire. Membership of admins changes only through root on the router.
An admin token is as powerful as root over the API and is revoked only by replacing the key, so
it is given a short lifetime unless it is meant for automation.
ADR-14: Failed logins lock the account in slapd; /auth is rate limited per client
Status: Accepted
Context: /auth is open to the internal network and checks passwords, so it invites guessing;
OpenVPN checks passwords from the internet. Both check with a bind, so slapd sees every attempt.
ppolicy locks an account after a number of failed binds for a fixed time; it has no growing
lockout. A Router Manager signs all its users in from one address.
Decision: The default password policy locks an account for 15 minutes after 5 failed binds
within 15 minutes; a successful bind clears the count; vpn-router-user unlock and
POST /users/<name>/unlock end a lockout and clear the count, and leave a disabled user disabled.
/auth answers the same 401 for a wrong name, password or code and for a locked or disabled
user. /auth and /auth/refresh allow 10 calls a minute per client address, counted in memory,
and answer 429 with Retry-After beyond that. The client address is the peer, or the last
X-Forwarded-For entry for a peer in web.trusted_proxies, by default the address the call came
in on, which is the address a manager on the router calls from.
Consequences: One lockout covers OpenVPN and /auth, kept by slapd alone. Someone who knows a
user name can lock the user out for 15 minutes at a time. The rate limit restarts with the
service. A manager elsewhere than on the router has its address added to web.trusted_proxies,
or its users share one limit.
ADR-15: Refresh tokens are kept in PostgreSQL
Status: Accepted
Context: An access token lives 5 minutes, and a client stays signed in with a refresh token for 8 hours; refresh tokens must be revocable when a user signs out, is disabled, changes the password or is removed, and must survive a restart of the service. The Router Manager keeps its sessions in PostgreSQL too.
Decision: Refresh tokens are random strings, stored as their SHA-256 with the user name and the
expiry in the table refresh_tokens, through psycopg 3 (python3-psycopg). The database is
web.database, or by default the database and role vpn-router on the router, which the web
service creates on its first run (ADR-16) with runuser -u postgres -- psql and a random password in
/etc/vpn-router/database.secret, reached over TCP on localhost, as Ubuntu's pg_hba.conf
takes peer authentication on the local socket. A refresh token is used once: refreshing deletes
it and issues a new one, after checking that the user is neither disabled nor locked.
Consequences: The package depends on postgresql and python3-psycopg. A stolen database holds
no usable token. Changing a user's password, disabling, unlocking or removing the user, and
signing out, end the user's sessions at once; an access token already issued lives out its
5 minutes.
ADR-16: Each service creates what it needs on its first run
Status: Accepted
Context: The package manager configures packages in an order of its own; on a new VM the Router Manager was configured before the PostgreSQL server, so a database created at installation was missing. PostgreSQL may also start after the services at boot.
Decision: The web service makes its signing key and creates the refresh tokens' database itself,
and the Router Manager its sessions database; neither vpn-router-setup nor a maintainer script
creates them. Each service does it before it listens, retrying every 15 seconds and logging each
failure, then listens. vpnrouter_auth turns every failure of the database into
DatabaseError, the only database exception the web service and vpn-router-user handle.
psycopg (Ubuntu ships only its pure-Python implementation) finds libpq through the dynamic linker
cache, which dpkg refreshes with its deferred ldconfig only at the end of an installation, so a
service started during the installation cannot import it yet; a failed import cannot be repeated
in the same process either, as psycopg caches the lookup. The services therefore import psycopg
plainly, and their units wait instead: ExecStartPre=wait-for-libpq, a shell script of each
package, waits until ldconfig -p lists libpq.so.5, within TimeoutStartSec=5min. The
maintainer scripts start the units with systemctl --no-block (debhelper's start is turned off),
because a start that waited would hold dpkg before its ldconfig.
Consequences: The order of installation and of the start of PostgreSQL and of the linker cache
does not matter; a database that cannot be created is retried until the cause is gone, and the
reason is in the service's log. Outside an installation the wait ends at once. The web unit may
write /etc/vpn-router for the key and the database password.
ADR-17: Bootstrap diagnostics run only until the first setup succeeds
Status: Accepted
Context: A new VM is reachable only once its setup has worked, and the reason of a failure must
be readable from the internal network before an SSH session is possible. A service that reports
the setup cannot be one of the services being set up: those import packages that may not load yet
(psycopg could not find libpq before ldconfig ran), and they would fail before reporting
anything. Once the router has been set up, SSH is the way to diagnose it. The more a diagnostic
page does, the more ways it has to fail itself.
Decision: Each package ships a bootstrap service of the Python standard library alone, a script
that answers every request with the plain-text output of a fixed list of commands: the setup
state, the services, the failed units, the addresses and the end of the logs. The router's
listens on 17081 and the Router Manager's on 17080, beside the ports of the services, 7081 and
7080, on every address. The units are never enabled and not started at installation;
vpn-router-setup starts them while /var/lib/vpn-router/setup.done does not exist, and opens
their ports on the internal interface. The first successful run writes that file, closes the ports
and stops them; a failed run leaves them running, so the reason stays readable. Setup records its
state, step and failure in /run/vpn-router/setup.json at every step; vpnrouter.fail ends with
a SystemExit carrying its message, so setup records it.
Consequences: The diagnostics need nothing the setup provides and cannot collide with the real services. After the first success they never run again, also not after an upgrade or a reboot. The page shows log lines to whoever reaches the internal network during the first setup.
ADR-18: Manager-only accounts are a second kind of user; the package can create the first admin
Status: Accepted
Context: The Router Manager needs a first administrator before anyone has added users, as other
packages create a default user from their debconf answers. Such an account, and others that only
manage the router, have no person behind them and need no VPN access. A VPN user is an
inetOrgPerson, which requires a last name and a common name, and its certificate needs the
names. The Manager is configured before the router's directory exists during an installation.
Decision: A user is one of two kinds in ou=users, fixed when it is added: a VPN user, an
inetOrgPerson with the three person attributes and a certificate, or a Manager-only account,
an account, simpleSecurityObject and vpnRouterAccount (ADR-19) with the name, the password,
an optional e-mail address for notifications, and no certificate. add with all three person
attributes adds a VPN user and without the first and the last name a Manager-only account; one
name without the rest is refused. The CLI and the API add both kinds; the Manager's dialog
requires the names unless Manager access only is chosen, which offers the e-mail address only.
Answers carry manager_only, and update changes only the e-mail address of a Manager-only
account. The Manager's package asks admin_username and admin_password; with both
it writes them to a root-only file, clears the answers, and vpn-router-setup creates the account
after it provisions the directory, adds it to admins (creating the group when missing) and
removes the file. Without a name nothing is created. vpnrouter_users knows only the directory,
so setup can use it before PostgreSQL's driver can be loaded; web and vpn-router-user revoke
refresh tokens after the operations of vpnrouter_auth.REVOKING.
Consequences: A Manager-only account cannot become a VPN user, as LDAP cannot change an entry's structural class and its password hash cannot be copied; it is removed and added again instead. It has no certificate, so OpenVPN and road-warrior access refuse it. The administrator's password is on disk only until setup has run.
ADR-19: The package has an LDAP schema of its own under a UUID OID
Status: Accepted
Context: A Manager-only account (ADR-18) is an account, which allows no mail, yet notifications
need an address for it; no standard auxiliary class allows mail except extensibleObject, which
allows every attribute. Authorization scopes for users of both kinds may follow. An attribute or
class of its own needs an OID, and the package has no registered arc.
Decision: The package defines its schema, cn=vpn-router,cn=schema,cn=config, which
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
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
(multi-valued, case-insensitive strings) and the auxiliary class vpnRouterAccount,
MAY ( mail $ vpnRouterScope ). 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.
Consequences: The schema check stays strict. New attributes extend vpnRouterAccount, and their
OIDs continue the arcs. Nothing reads vpnRouterScope yet. The OID must never be reused for
anything else.
ADR-20: Users manage their own account through /account, proving who they are again
Status: Accepted
Context: Users who are not members of admins can sign in but get 403 on every path, so they
cannot change their password or enrol an authenticator without an administrator. A session
token alone is a weak proof for such changes: whoever holds an open session could lock the user
out. A wrong current password must not look like an ended session to the Manager, which signs a
user out on 401.
Decision: /account serves the user named in the token's sub, admin or not: GET the user
and whether they are a member of admins, PUT /account/password, and POST and
PUT /account/otp to enrol or replace the TOTP enrolment. The routes do not take a user name.
Each change sends the current password and, for an enrolled user, a current code, checked with a
bind as the user like /auth, so it counts toward the lockout (ADR-14); a wrong one is 403. A
new password is checked before the bind, so an invalid one does not use up a code. A user cannot
remove their own enrolment; /users and vpn-router-user do. Tokens of vpn-router-token name
no user and get 403 there.
Consequences: slapd's otp overlay accepts each code once, so a change right after signing in
needs the next code. Changing the password revokes the user's refresh tokens, so the user's
Manager session ends with its access token. An enrolled user who has lost the authenticator
needs an administrator.