#!/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())