78 lines
5.0 KiB
Markdown
78 lines
5.0 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.
|