151 lines
9.6 KiB
Markdown
151 lines
9.6 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 status web service of the standard library, 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 that uses only the standard library, in
|
|
the way of a small test server. It binds to the address of the internal interface and to
|
|
`web.port`, on by default at 8080. `/` 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 and
|
|
WireGuard features, and nothing from the configuration. `vpn-router-setup` resolves the
|
|
interface address, writes it with the port and the features to `/run/vpn-router/web.json`,
|
|
opens the port on the internal interface in UFW and restarts the service, so the service does
|
|
not need the package's configuration code, which needs Jinja2.
|
|
|
|
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. It has no
|
|
authentication, which is acceptable because it is read only and shows 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. A change of the internal
|
|
address is picked up at the next run of `vpn-router-setup`.
|
|
|
|
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
|
|
|
|
Status: Accepted
|
|
|
|
Context: A client such as manage-ui needs the configuration and the resolved state of the
|
|
router, which `/` and `/status` must not show. The service has only the standard library, listens
|
|
on plain HTTP on the internal network, and its configuration file is readable by root only.
|
|
|
|
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 verifying RS256 or ES256 needs a library outside the standard library. 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 secrets in the
|
|
configuration shown are masked by name (`psk`, and options with `secret`, `password`, `token` or a
|
|
`_key` ending in the name).
|
|
|
|
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 in the
|
|
configuration file and in `/run/vpn-router/web.json`, both 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. The masking is by option name, so a new
|
|
secret option needs a name that matches.
|
|
|
|
## 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. The options weighed were an external OIDC provider (Entra ID, Keycloak)
|
|
with openvpn-auth-oauth2, an OIDC server embedded on the router, a file of password hashes, and
|
|
OpenLDAP on the router. OIDC for OpenVPN needs a browser on the client and a program from outside
|
|
the Ubuntu archive, and it cannot authenticate IKEv2 EAP-TLS or WireGuard. 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. A directory
|
|
can also be read later by an OIDC provider 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 first
|
|
library outside the standard library besides `jinja2`. The service account's password is a
|
|
root-only file. The directory is user data: it is kept when the features are switched off and
|
|
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 already 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 manages users by running vpn-router-user
|
|
|
|
Status: Accepted
|
|
|
|
Context: The web service has only the standard library (ADR-2), and managing users needs
|
|
`python3-ldap` and `simple-ca`. Its unit is sandboxed with `ProtectSystem=strict`.
|
|
|
|
Decision: `/users` runs `vpn-router-user` and returns its JSON answer, mapping its exit status
|
|
to the HTTP status. Passwords go to the program on standard input, so they never appear in a
|
|
command line. The unit allows writing the paths the program needs: `/run/vpn-router` for its
|
|
lock, `/etc/vpn-router/pki` for the certificates and the CA's state, and `/etc/swanctl/x509crl`
|
|
for the published CRL. A lock serializes the program's runs, because `simple-ca` keeps its state
|
|
in one file.
|
|
|
|
Consequences: The web service still imports nothing outside the standard library, and the same
|
|
program serves the command line and the API. Each request starts a process, which is fine for
|
|
user administration. The API is protected by the token of `/config`, so whoever has the secret
|
|
can manage users.
|