580 lines
38 KiB
Markdown
580 lines
38 KiB
Markdown
# 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. Both show as
|
|
much as `web.status_content` says, since the router is reachable from the internet and the
|
|
operator decides how much it tells strangers: `basic`, the default because it tells the least,
|
|
the overall status and the Client IP, `standard` also the package versions and the number of
|
|
active and other services, and `full` also each service and the state of the site-to-site, road-warrior, OpenVPN and
|
|
WireGuard features. The Client IP is the address the request came from, through the trusted
|
|
proxies, so a person can see the address the router sees. Nothing comes from the configuration.
|
|
`/status` names its content, so clients such as manage-ui and the Router Manager show what it
|
|
has. `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 itself;
|
|
HTTPS is Caddy's (ADR-21), so on the plain HTTP port 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. The scope `service` marks a service account (ADR-23). 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.
|
|
|
|
## ADR-21: Caddy from the Ubuntu archive serves HTTPS with a certificate from Let's Encrypt
|
|
|
|
Status: Accepted
|
|
|
|
Context: The status web service and the Router Manager are reachable from the internet (ADR-2),
|
|
and `/auth` and the Manager's sign-in carry passwords. Both are small servers on the standard
|
|
library's `http.server`, which would need certificates, their renewal and a TLS setup of their
|
|
own. Browsers trust a public CA, not the router's.
|
|
|
|
Decision: Caddy, the `caddy` package of the Ubuntu archive, is the HTTPS proxy. While
|
|
`https.enabled` is `true` (the default) and `wan.local_fqdn` is set, `vpn-router-setup` writes
|
|
`/etc/caddy/vpn-router.Caddyfile`: a site for `wan.local_fqdn` on `https.web_port` (7444) for the
|
|
web service and, while the Router Manager is installed, one on `https.manager_port` (7443) for
|
|
the manager, each passing calls to the service on `127.0.0.1`. Caddy obtains and renews the
|
|
certificate from Let's Encrypt with the HTTP challenge on port 80, which the firewall opens on
|
|
the external interface; `auto_https disable_redirects` keeps port 80 to the challenge. The
|
|
package's drop-in for `caddy.service` points it at that file and keeps it from starting before
|
|
the file exists, so `/etc/caddy/Caddyfile`, a configuration file of the `caddy` package, is left
|
|
unchanged. Both services always listen on `127.0.0.1` as well as on the interfaces their
|
|
`listen` settings name, and trust `X-Forwarded-For` from it, which Caddy sets from the peer and
|
|
never takes from the client.
|
|
|
|
Consequences: HTTPS needs `wan.local_fqdn` in public DNS pointing at the router and port 80
|
|
reachable from the internet; until then Caddy retries and the HTTPS ports do not answer. The
|
|
certificate and the ACME account are in `/var/lib/caddy`. Plain HTTP stays as the `listen`
|
|
settings set it, so `internal` keeps passwords off the internet. Caddy's admin endpoint listens
|
|
on `localhost:2019`, which `caddy reload` uses. Port 80 and the HTTPS ports are not available
|
|
to Docker containers while HTTPS is on. The Manager's HTTPS port is a setting of the router, not
|
|
of the manager.
|
|
|
|
## ADR-22: Metrics are kept in PostgreSQL and served in the Prometheus format, pushed with OTLP
|
|
|
|
Status: Accepted
|
|
|
|
Context: The router should keep its CPU, memory, disk, network, load and VPN client metrics for
|
|
hour, day, month and year views, as Proxmox VE does, and expose them to OpenTelemetry. Ubuntu
|
|
noble has no OpenTelemetry Python SDK and no OpenTelemetry Collector package. OTLP itself is
|
|
push-only; a collector reads a scraped endpoint in the Prometheus format with its `prometheus`
|
|
receiver. The router already runs PostgreSQL for the refresh tokens (ADR-15).
|
|
|
|
Decision: A service of its own, `vpn-router-metrics`, takes a sample every minute with `psutil`
|
|
(`python3-psutil`) and the VPN readers it shares with the web service (`vpnrouter_status`). It
|
|
keeps the samples in the router's database, in three detail tables (`metrics_system`,
|
|
`metrics_network`, `metrics_vpn`). Each detail table has hourly, daily, monthly and yearly summary
|
|
tables, with the average and the maximum of each value per period, written when the clock passes a
|
|
period from the level below with `INSERT ... ON CONFLICT DO NOTHING`, so a stopped service catches
|
|
up. Garbage collection runs on start and once a day, not with every write, and deletes the rows
|
|
older than the retention of their level, `metrics.keep_*`, PostgreSQL intervals. The service serves
|
|
the last sample as `/metrics` in the Prometheus text format with `prometheus_client`
|
|
(`python3-prometheus-client`) and pushes it as OTLP/HTTP JSON, built with the standard library, to
|
|
`metrics.otlp_endpoint`. The names follow the OpenTelemetry semantic conventions. The web service
|
|
answers the views from the tables at `/metrics/history`, protected like `/config`, for the Router
|
|
Manager.
|
|
|
|
Consequences: The metrics live in the database of the refresh tokens and are dropped with it on
|
|
purge. A view shows only what its level keeps, so a shorter retention empties the older part of a
|
|
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 a service account that setup owns
|
|
|
|
Status: Accepted
|
|
|
|
Context: An OpenTelemetry Collector's `prometheus` receiver authenticates with HTTP Basic, not with
|
|
the router's tokens, which last minutes (ADR-3). The scraper should not be able to sign in to the
|
|
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`.
|
|
`vpn-router-setup` owns it: 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. `vpn-router-user` and `/users` cannot add one. `/auth` refuses it
|
|
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.
|
|
|
|
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
|
|
taken over; setup fails until `metrics.service_account` names another. A disabled account 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.
|