250 lines
15 KiB
Markdown
250 lines
15 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, 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.
|