diff --git a/ADR.md b/ADR.md new file mode 100644 index 0000000..2207fee --- /dev/null +++ b/ADR.md @@ -0,0 +1,28 @@ +# 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. diff --git a/README.md b/README.md index 52ce439..bafa899 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A Debian package that configures a Linux host or virtual machine as a VPN router - road-warrior (P2S) access using IKEv2 EAP-TLS, with a bundled certificate authority - optional WireGuard endpoint - routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules +- firewall rules for the ports Docker publishes, when Docker is installed Nothing in the package is specific to a cloud provider. It is built on the external and internal NIC names, and a `mode` setting says how much of the rest you are supplying and @@ -28,6 +29,10 @@ service that applies it. systemctl restart vpn-router-setup apply it ``` +If the host runs Docker, the recommended order is Docker first and the package second. The +package secures the ports Docker publishes when it finds the Docker service; the steps for a +host that already runs Docker, and for installing Docker later, are under [Docker](#docker). + ### By hand ```sh @@ -108,6 +113,8 @@ must have the type shown below; an unknown section or setting is an error. | `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint | | `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` | | `wireguard` | `listen_port` | integer | `51820` | UDP port | +| `docker` | `mode` | string | `auto` | `auto` secures Docker when its service is installed, `on` requires it, `off` removes the rules | +| `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two | A minimal site-to-site configuration in `auto` mode: @@ -153,6 +160,56 @@ interface does not exist the service stops and says so. In `interfaces` and `aut the internal gateway is taken from the routing table, falling back to the first host address of the connected subnet with a warning when the routing table has nothing to say. +## Docker + +Docker publishes container ports in its own firewall rules, which bypass UFW and can expose +ports on the external interface. Docker evaluates the `DOCKER-USER` chain before its own +rules, so the package filters there. With `docker.mode` set to `auto`, the package secures +Docker when `docker.service` is installed, and does nothing otherwise. + +### On a host that already runs Docker + +Installing Docker first is the recommended order. After installing the package: + +1. Set `general.platform` in `/etc/vpn-router/vpn-router.json`. While it is `none`, nothing is + configured, the Docker rules included. +2. List the TCP ports the running containers must expose to the internet, for example + `"docker": { "exposed_ports": ["443"] }`. The default is none: once the rules are applied, + every published port that is not listed is unreachable from the external interface, and + remains reachable through the IPsec tunnel. +3. Apply the configuration with `systemctl restart vpn-router-setup`. Docker is running, so + this also restarts `vpn-router-docker`, which installs the rules. +4. Check the result: + + ```sh + iptables -S DOCKER-USER # "-j VPN-ROUTER-DOCKER" is the first rule + iptables -S VPN-ROUTER-DOCKER # the rules of the external interface + ``` + +### When Docker is installed after the package + +The Docker rules are not applied when Docker starts for the first time, so run +`systemctl restart vpn-router-setup` after installing Docker, then check as above. A reboot or a +restart of Docker applies them as well. + +The rules sit in their own chain, `VPN-ROUTER-DOCKER`, entered by one jump at the top of +`DOCKER-USER`; other rules in `DOCKER-USER` are left alone. For traffic that arrives on the +external interface, in this order: + +1. IPsec protected traffic is accepted. +2. TCP to the ports in `docker.exposed_ports` is accepted; none by default. +3. Packets of established and related connections are accepted. +4. Everything else is dropped. + +Traffic from every other interface, including the internal one and the Docker bridges, is +not affected. The rules cover IPv4 only. + +The `vpn-router-docker` service applies the rules after `docker.service` starts and removes +them when Docker stops, so restarting Docker restores them. `systemctl restart +vpn-router-setup` applies a changed configuration while Docker runs. With `docker.mode` set to +`on`, the service fails when Docker is not installed or its daemon is not running; with +`off` it removes the rules. + ## Applying changes ```sh @@ -256,7 +313,8 @@ modules shipped with the package; the configuration file accepts any module inst ## Removal -`apt-get remove` terminates the tunnels and takes `wg-quick@wg0` down, but leaves the +`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the +tunnels and takes `wg-quick@wg0` down, but leaves the configuration in place. `strongswan` is left enabled, since it is a shared service that may be in use by something else. diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index 893bf84..2c10fd7 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,22 @@ +vpn-router (0.3.0-1) unstable; urgency=medium + + * Secure the ports Docker publishes. Rules in a chain of their own, + VPN-ROUTER-DOCKER, entered by a jump at the top of DOCKER-USER, accept + IPsec protected traffic, TCP to docker.exposed_ports and established + connections from the external interface and drop the rest of it. Other + rules in DOCKER-USER are left alone. The rules cover IPv4 only. + * Add the docker configuration section. docker.mode is auto (the default, + secures Docker when docker.service is installed), on (requires Docker) or + off (removes the rules); docker.exposed_ports lists the TCP ports and port + ranges reachable from the external interface, none by default. + * Add the vpn-router-docker service, which applies the Docker rules after + docker.service starts and removes them when it stops. vpn-router-setup + restarts it while Docker runs, so a changed configuration takes effect. + * Installing Docker before this package is recommended. When Docker is + installed later, restart vpn-router-setup to apply the rules. + + -- Sławomir Koszewski Fri, 23 Oct 2026 12:44:45 +0200 + vpn-router (0.2.0-1) unstable; urgency=medium * Change the configuration file to JSON, /etc/vpn-router/vpn-router.json. diff --git a/debian-package/debian/install b/debian-package/debian/install index 18c35a0..037774b 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -2,6 +2,7 @@ src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/ src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/ src/usr/lib/vpn-router/configure usr/lib/vpn-router/ +src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/ src/usr/lib/vpn-router/setup usr/lib/vpn-router/ src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/ diff --git a/debian-package/debian/rules b/debian-package/debian/rules index 325f1f2..3999649 100755 --- a/debian-package/debian/rules +++ b/debian-package/debian/rules @@ -4,3 +4,4 @@ override_dh_installsystemd: dh_installsystemd --name=vpn-router-setup + dh_installsystemd --name=vpn-router-docker diff --git a/debian-package/debian/vpn-router.vpn-router-docker.service b/debian-package/debian/vpn-router.vpn-router-docker.service new file mode 100644 index 0000000..f94a416 --- /dev/null +++ b/debian-package/debian/vpn-router.vpn-router-docker.service @@ -0,0 +1,14 @@ +[Unit] +Description=VPN Router rules for Docker published ports +Documentation=file:/usr/share/doc/vpn-router/README.md +After=docker.service vpn-router-setup.service +PartOf=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/lib/vpn-router/docker-rules apply +ExecStop=/usr/lib/vpn-router/docker-rules remove + +[Install] +WantedBy=docker.service diff --git a/debian-package/src/usr/lib/vpn-router/docker-rules b/debian-package/src/usr/lib/vpn-router/docker-rules new file mode 100755 index 0000000..82fe392 --- /dev/null +++ b/debian-package/src/usr/lib/vpn-router/docker-rules @@ -0,0 +1,114 @@ +#!/usr/bin/python3 +"""Secure the ports Docker publishes, with rules in the DOCKER-USER chain. + +Usage: docker-rules apply|remove + +Docker evaluates DOCKER-USER in FORWARD before its own rules, which makes it the +place to filter traffic to containers. The rules live in their own chain, +VPN-ROUTER-DOCKER, entered by one jump at the top of DOCKER-USER, so anything +else in DOCKER-USER is left alone. Traffic arriving on the external interface is +accepted when it is IPsec protected, when it is TCP to docker.exposed_ports, or +when it belongs to an established connection; all other traffic from the +external interface is dropped. Traffic from other interfaces is not affected. + +docker.mode decides whether this runs: auto applies the rules when the Docker +service is installed, on requires it, off removes the rules. The vpn-router-docker +unit runs apply after docker.service starts and remove when it stops. +""" + +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) + +import vpnrouter +from vpnrouter import fail, warn + +CHAIN = 'VPN-ROUTER-DOCKER' +USER_CHAIN = 'DOCKER-USER' + + +def iptables(*args, check=True): + result = subprocess.run(['iptables', *args], capture_output=True, text=True) + if check and result.returncode != 0: + fail(f'iptables {" ".join(args)} failed: {result.stderr.strip()}') + return result + + +def docker_installed(): + """True when systemd knows the docker.service unit.""" + result = subprocess.run( + ['systemctl', 'list-unit-files', '--no-legend', 'docker.service'], + capture_output=True, text=True) + return result.returncode == 0 and result.stdout.strip() != '' + + +def remove(): + """Take the jump and the chain out; nothing happens when they are absent.""" + while iptables('-D', USER_CHAIN, '-j', CHAIN, check=False).returncode == 0: + pass + iptables('-F', CHAIN, check=False) + iptables('-X', CHAIN, check=False) + + +def apply(cfg): + mode = cfg['docker']['mode'] + if mode == 'off': + remove() + return + + if not docker_installed(): + if mode == 'on': + fail('docker.mode is on but the docker service is not installed; ' + 'install Docker before vpn-router') + return + + if iptables('-S', USER_CHAIN, check=False).returncode != 0: + if mode == 'on': + fail(f'docker.mode is on but there is no {USER_CHAIN} chain; ' + 'is the Docker daemon running?') + warn(f'no {USER_CHAIN} chain, the Docker daemon is not running; ' + 'the rules are applied when it starts') + return + + ctx = vpnrouter.build_context(cfg) + wan = ctx['wan_iface'] + if not wan: + fail('the external interface is not known; set interfaces.external ' + 'or use mode auto or interfaces') + + ports = ','.join(item.strip() for item in cfg['docker']['exposed_ports']) + + remove() + iptables('-N', CHAIN) + iptables('-A', CHAIN, '-i', wan, '-m', 'policy', '--dir', 'in', '--pol', 'ipsec', '-j', 'ACCEPT') + if ports: + iptables('-A', CHAIN, '-i', wan, '-p', 'tcp', '-m', 'multiport', '--dports', ports, '-j', 'ACCEPT') + iptables('-A', CHAIN, '-i', wan, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT') + iptables('-A', CHAIN, '-i', wan, '-j', 'DROP') + iptables('-I', USER_CHAIN, '1', '-j', CHAIN) + + +def main(): + if len(sys.argv) != 2 or sys.argv[1] not in ('apply', 'remove'): + print('usage: docker-rules apply|remove', file=sys.stderr) + return 2 + + if sys.argv[1] == 'remove': + remove() + return 0 + + cfg = vpnrouter.load_config() + if vpnrouter.deferred(cfg): + return 0 + try: + vpnrouter.validate(cfg) + apply(cfg) + except vpnrouter.ConfigError as exc: + fail(str(exc)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index f41f5e3..b9fa644 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -299,6 +299,16 @@ def run_platform_setup(cfg, ctx): fail(f'platform module {module.__name__} failed: {exc}') +def refresh_docker_rules(): + """Apply the new settings to the Docker rules while Docker is running. + + When Docker is not running, vpn-router-docker applies them after + docker.service starts. + """ + if unit_is_active('docker.service'): + run(['systemctl', 'restart', 'vpn-router-docker.service'], check=False) + + def main(): cfg = vpnrouter.load_config() ctx, changed = vpnrouter.configure(cfg) @@ -337,6 +347,7 @@ def main(): run(['systemctl', 'restart', 'systemd-resolved'], check=False) run_platform_setup(cfg, ctx) + refresh_docker_rules() return 0 diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 7ad3b13..6d8ecb8 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -8,6 +8,7 @@ import ipaddress import json import os import pathlib +import re import shutil import subprocess import sys @@ -46,8 +47,17 @@ SCHEMA = { 'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''}, 'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, + 'docker': {'mode': 'auto', 'exposed_ports': []}, } +DOCKER_MODES = ('auto', 'on', 'off') + +#: A port or a port range as iptables multiport takes it: 443 or 8000:8100. +DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$') + +#: The multiport match takes at most 15 ports, and a range counts as two. +DOCKER_PORT_LIMIT = 15 + TYPE_NAMES = {str: 'a string', list: 'a list of strings', bool: 'a boolean', int: 'an integer'} @@ -212,6 +222,24 @@ def validate(cfg): if not 1 <= port <= 65535: raise ConfigError(f'wireguard.listen_port: {port} is not a valid port') + docker_mode = _get(cfg, 'docker', 'mode') + if docker_mode not in DOCKER_MODES: + raise ConfigError( + f'docker.mode: {docker_mode!r} is not one of {", ".join(DOCKER_MODES)}') + + ports = _get_list(cfg, 'docker', 'exposed_ports') + weight = 0 + for item in ports: + if not DOCKER_PORT.match(item) or any( + not 1 <= int(part) <= 65535 for part in item.split(':')): + raise ConfigError( + f'docker.exposed_ports: {item!r} is not a port or a port range') + weight += 2 if ':' in item else 1 + if weight > DOCKER_PORT_LIMIT: + raise ConfigError( + f'docker.exposed_ports: at most {DOCKER_PORT_LIMIT} ports are allowed, ' + 'a range counting as two') + def resolve_psk(cfg): """Return the pre-shared key, or '' when none is configured.""" diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example index 80c602b..81af712 100644 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -32,5 +32,9 @@ "enabled": false, "address": "192.168.200.1/24", "listen_port": 51820 + }, + "docker": { + "mode": "auto", + "exposed_ports": ["443"] } }