5.0 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 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.