Files
linux-cloud-router/ADR.md
T
2026-10-24 03:00:35 +02:00

17 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, bound to the internal address

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. 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 the address of the internal interface and on web.port, 7081 by default, 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 the internal interface in UFW and writes the features and the facts it resolves to /run/vpn-router/web.json.

Consequences: Nothing outside the internal network can reach it, since it listens on the internal address only and the firewall opens the port on the internal interface only. / 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: /config is authenticated with a short-lived JWT signed with a shared secret

Status: Accepted

Context: A client such as the Router Manager needs the configuration and the resolved state of the router, which / and /status must not show. The service listens on plain HTTP on the internal network, and its configuration file is readable by root only. The platform that deploys the router generates one secret and gives it to the router's configuration and to the client.

Decision: /config needs a JWT in an Authorization: Bearer header. The service verifies HS256 tokens with hmac and hashlib, with the secret web.jwt_secret, and accepts no other algorithm, because a shared secret is what both sides have; RS256 or ES256 would need a key pair and its distribution. The token must carry exp, not more than ten minutes ahead, so that the client makes a fresh token for each use and a leaked one expires. Without a secret the page is not served. The configuration shown has no secret options (ADR-8).

Consequences: The client and the router share one secret, and whoever holds it can make tokens, so it is long and random; it is checked to have at least 32 characters. The secret is only in the configuration file, readable by root only. The token and the answer cross the network unencrypted, so the page 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, /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 token of /config, so whoever has the secret can 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 internal address, 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 internal address 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 the HTTP server in a thread of its own at that address and port, stops it and starts a new one when they change, and runs none while the service is disabled, the address is not known or it cannot be bound. Each request reads the token secret from 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 the internal address exists, whatever state setup is in, and follows a changed address, port or web.enabled within 30 seconds without a restart. A change of the secret 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, 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 needs a token signed with web.jwt_secret for /config and /users and sends no CORS headers, so a browser cannot call it with the secret kept away from the page.

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-jwt and python3-requests, serves them and passes every /api/<path> call to the router's /<path>, signing a short-lived token with the router's secret; it holds no logic of the router's own. 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 like the status web service, with the same discovery thread. vpn-router-setup opens the manager's port on the internal interface while the manager is installed, so the firewall stays in one place. The manager asks for no authentication.

Consequences: The router needs no Node.js; the package needs only python3 and packages of the Ubuntu archive. The secret never reaches the browser. The application checks the shape of every answer of the router. Anyone who reaches the manager's port manages the router, so on another system it listens on 127.0.0.1 unless told otherwise. 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 to be enrolled for TOTP one-time passwords, with nothing checking the codes yet. 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. Ubuntu packages python3-pyotp. An authenticator app is enrolled from a QR code of an otpauth:// URI.

Decision: The otp module is loaded for its schema; its overlay is not configured. 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. Configuring the overlay later makes slapd check the stored tokens without moving data. 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.