feat: added support for running the VPN router on a host with Docker Community Edition.
Release / release (push) Successful in 1m8s
Release / release (push) Successful in 1m8s
This commit is contained in:
1 parent
66fcd69d40
commit
2e0cee10ac
10 files changed
+279
-1
No files matched your search
+114
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user