"""Shared library for the vpn-router package. Everything derivable from the configuration file alone, with no running network required. Used by both 'configure' and 'setup'. """ import ipaddress import json import os import pathlib import re import shutil import subprocess import sys import jinja2 LIB_DIR = pathlib.Path(__file__).resolve().parent if str(LIB_DIR) not in sys.path: sys.path.insert(0, str(LIB_DIR)) import vpnrouter_ldap import vpnrouter_platforms from vpnrouter_net import ConfigError, detect_interfaces, interface_addresses, ip_output CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json') TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates') PKI_DIR = pathlib.Path('/etc/vpn-router/pki') SIMPLE_CA = LIB_DIR / 'simple-ca' SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d') REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf' ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf' P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf') WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf') OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf') OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf') USER_CERT_DIR = PKI_DIR / 'users' CA_CERT_DEST = pathlib.Path('/etc/swanctl/x509ca/ca.pem') SERVER_CERT = pathlib.Path('/etc/swanctl/x509/server.pem') SERVER_KEY = pathlib.Path('/etc/swanctl/private/server.key') CRL_DEST = pathlib.Path('/etc/swanctl/x509crl/crl.pem') TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.crt') # section -> option -> default; the type of the default is the required type. SCHEMA = { 'general': {'platform': 'none', 'mode': 'manual'}, 'interfaces': {'external': '', 'internal': ''}, 'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'}, 'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''}, 'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''}, 'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'docker': {'mode': 'auto', 'exposed_ports': []}, 'web': {'enabled': True, 'port': 7081, 'database': '', 'trusted_proxies': []}, } DOCKER_MODES = ('auto', 'on', 'off') OPENVPN_PROTOCOLS = ('udp', 'tcp') #: The tun device of the OpenVPN server. OPENVPN_DEVICE = 'ovpn0' #: Days a certificate revocation list is valid; the router only reads its own lists. CRL_DAYS = 3650 #: 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'} ID_MODES = ('fqdn', 'public_ip', 'internal_ip') #: How much the operator supplies, and therefore how much may be detected. #: manual - names and addresses given; nothing is detected #: interfaces - names given; addresses and gateway derived from them #: auto - nothing given; the interfaces are worked out too, accepting #: that the result may be wrong MODES = ('manual', 'interfaces', 'auto') #: platform = none means configuration is deferred: files are installed and #: nothing else happens until the operator says otherwise. PLATFORM_NONE = 'none' def warn(message): print(f'vpn-router: warning: {message}', file=sys.stderr) def fail(message): """End with status 1; SystemExit carries the message, which Python prints to standard error.""" sys.exit(f'vpn-router: error: {message}') def load_config(path=None): """Read the configuration, filling in defaults for anything absent. The result maps section -> option -> value. Sections and options not in SCHEMA are kept so that validate() can report them. The path is resolved when called, not when this function is defined, so CONFIG_FILE stays overridable. """ path = pathlib.Path(path) if path else CONFIG_FILE cfg = {section: dict(options) for section, options in SCHEMA.items()} if path.exists(): try: data = json.loads(path.read_text(encoding='utf-8')) except ValueError as exc: fail(f'cannot parse {path}: {exc}') if not isinstance(data, dict): fail(f'cannot parse {path}: the top level is not an object') for section, options in data.items(): if not isinstance(options, dict): fail(f'cannot parse {path}: {section} is not an object') cfg.setdefault(section, {}).update(options) return cfg def _split_list(value): return [item.strip() for item in value.split(',') if item.strip()] def _get(cfg, section, option): value = cfg[section][option] return value.strip() if isinstance(value, str) else value def _get_list(cfg, section, option): return [item.strip() for item in cfg[section][option] if item.strip()] def _check_types(cfg): for section, options in cfg.items(): if section not in SCHEMA: raise ConfigError(f'{section}: unknown section') for option, value in options.items(): if option not in SCHEMA[section]: raise ConfigError(f'{section}.{option}: unknown setting') expected = type(SCHEMA[section][option]) if type(value) is not expected or ( expected is list and not all(isinstance(item, str) for item in value)): raise ConfigError( f'{section}.{option} must be {TYPE_NAMES[expected]}') def _check_networks(values, name): for item in values: try: ipaddress.ip_network(item, strict=False) except ValueError: raise ConfigError(f'{name}: {item!r} is not a valid CIDR') def _check_address(value, name): if not value: return try: ipaddress.ip_address(value) except ValueError: raise ConfigError(f'{name}: {value!r} is not a valid IP address') def validate(cfg): """Reject values that are present but malformed. Absent is not an error. The exception is general.mode, which states what the operator promised to supply, so the values that mode requires are checked for presence too. """ _check_types(cfg) platform = _get(cfg, 'general', 'platform') installed = [PLATFORM_NONE] + vpnrouter_platforms.available() if platform and platform not in installed: raise ConfigError( f'general.platform: {platform!r} is not installed, expected one of ' f'{", ".join(installed)}') mode = _get(cfg, 'general', 'mode') if mode and mode not in MODES: raise ConfigError( f'general.mode: {mode!r} is not one of {", ".join(MODES)}') if not deferred(cfg): required = { 'manual': (('interfaces', 'external'), ('interfaces', 'internal'), ('local', 'int_addr'), ('local', 'int_gateway_ip')), 'interfaces': (('interfaces', 'external'), ('interfaces', 'internal')), 'auto': (), }[mode or 'manual'] for section, option in required: if not _get(cfg, section, option): raise ConfigError( f'{section}.{option} is required in mode {mode or "manual"}') for section, option in (('wan', 'local_fqdn'), ('remote', 'id')): if str(_get(cfg, section, option)).startswith('@'): raise ConfigError( f'{section}.{option}: remove the leading @; strongSwan reads an ' 'address as an IP identity and a name as an FQDN') id_mode = _get(cfg, 'wan', 'local_id_mode') if id_mode and id_mode not in ID_MODES: raise ConfigError( f'wan.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}') _check_networks(_get_list(cfg, 'local', 'cidrs'), 'local.cidrs') _check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs') pool = _get(cfg, 'p2s', 'address_pool') _check_networks([pool] if pool else [], 'p2s.address_pool') openvpn_pool = _get(cfg, 'openvpn', 'address_pool') if openvpn_pool: try: if ipaddress.ip_network(openvpn_pool).version != 4: raise ValueError except ValueError: raise ConfigError(f'openvpn.address_pool: {openvpn_pool!r} is not an IPv4 network') _check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr') _check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip') # In auto mode the names are worked out later, so there is nothing to check. if mode != 'auto': for option in ('external', 'internal'): name = _get(cfg, 'interfaces', option) if name and not (pathlib.Path('/sys/class/net') / name).exists(): raise ConfigError( f'interfaces.{option}: interface {name!r} does not exist') wg_address = _get(cfg, 'wireguard', 'address') if wg_address: try: ipaddress.ip_interface(wg_address) except ValueError: raise ConfigError( f'wireguard.address: {wg_address!r} is not an address with prefix') port = _get(cfg, 'wireguard', 'listen_port') if not 1 <= port <= 65535: raise ConfigError(f'wireguard.listen_port: {port} is not a valid port') openvpn_port = _get(cfg, 'openvpn', 'port') if not 1 <= openvpn_port <= 65535: raise ConfigError(f'openvpn.port: {openvpn_port} is not a valid port') protocol = _get(cfg, 'openvpn', 'protocol') if protocol not in OPENVPN_PROTOCOLS: raise ConfigError( f'openvpn.protocol: {protocol!r} is not one of {", ".join(OPENVPN_PROTOCOLS)}') web_port = _get(cfg, 'web', 'port') if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535: raise ConfigError(f'web.port: {web_port!r} is not a valid port') for item in _get_list(cfg, 'web', 'trusted_proxies'): _check_address(item, 'web.trusted_proxies') 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.""" psk_file = _get(cfg, 'remote', 'psk_file') if psk_file: path = pathlib.Path(psk_file) if not path.is_file(): raise ConfigError(f'remote.psk_file: {psk_file} does not exist') return path.read_text(encoding='utf-8').strip() return cfg['remote']['psk'] def platform_module(cfg): """Import the configured platform module, falling back to the default.""" name = _get(cfg, 'general', 'platform') or vpnrouter_platforms.DEFAULT if name == PLATFORM_NONE: # Not normally reached: configure() returns before this when deferred. name = vpnrouter_platforms.DEFAULT try: return vpnrouter_platforms.load(name) except ModuleNotFoundError: warn(f'platform {name!r} is not installed, using ' f'{vpnrouter_platforms.DEFAULT}') return vpnrouter_platforms.load(vpnrouter_platforms.DEFAULT) def _join(*groups): items = [] for group in groups: for item in group: if item not in items: items.append(item) return ', '.join(items) def deferred(cfg): """True when platform is none, meaning nothing is to be configured yet.""" return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE def detect_gateway(iface, address): """Derive the next hop on the internal side. The routing table is the honest source. When it has nothing to say, fall back to the first host address of the connected subnet, which is a convention rather than a fact - so say so. """ for line in ip_output('-o', 'route', 'show', 'dev', iface).splitlines(): fields = line.split() if 'via' in fields: return fields[fields.index('via') + 1] for line in ip_output('-o', '-4', 'addr', 'show', 'dev', iface).splitlines(): fields = line.split() if len(fields) >= 4 and fields[3].split('/')[0] == address: network = ipaddress.ip_interface(fields[3]).network gateway = str(next(network.hosts())) warn(f'{iface}: no gateway in the routing table, assuming {gateway} ' f'as the first host of {network}') return gateway return '' def resolve_interfaces(cfg): """Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip). What is read from the system and what is taken verbatim depends entirely on general.mode. In manual mode nothing below touches the system at all. """ mode = _get(cfg, 'general', 'mode') or 'manual' ext_iface = _get(cfg, 'interfaces', 'external') int_iface = _get(cfg, 'interfaces', 'internal') int_addr = _get(cfg, 'local', 'int_addr') gateway = _get(cfg, 'local', 'int_gateway_ip') if mode == 'auto': ext_iface, int_iface = detect_interfaces() local_addrs = interface_addresses(ext_iface, 'interfaces.external') if ext_iface and not local_addrs: warn(f'{ext_iface} has no IPv4 address yet') if mode == 'manual': return ext_iface, int_iface, local_addrs, int_addr, gateway int_addrs = interface_addresses(int_iface, 'interfaces.internal') int_addr = int_addrs[0] if int_addrs else '' if int_iface and not int_addr: warn(f'{int_iface} has no IPv4 address yet') gateway = detect_gateway(int_iface, int_addr) if int_addr else '' return ext_iface, int_iface, local_addrs, int_addr, gateway def build_context(cfg, local_id_override=None): """Assemble the template context from the configuration.""" extras = vpnrouter_platforms.context(platform_module(cfg)) ext_iface, int_iface, local_addrs, int_addr, gateway = resolve_interfaces(cfg) local_fqdn = _get(cfg, 'wan', 'local_fqdn') id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn' local_cidrs = _get_list(cfg, 'local', 'cidrs') remote_cidrs = _get_list(cfg, 'remote', 'cidrs') openvpn_pool = _get(cfg, 'openvpn', 'address_pool') openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None if local_id_override: local_id = local_id_override elif id_mode == 'fqdn': local_id = local_fqdn elif id_mode == 'internal_ip': local_id = int_addr else: # public_ip is resolved once the network is up, and the caller then # re-renders with local_id_override set. local_id = '' return { 'platform': _get(cfg, 'general', 'platform') or PLATFORM_NONE, 'mode': _get(cfg, 'general', 'mode') or 'manual', 'wan_iface': ext_iface, 'int_iface': int_iface, 'local_addrs': ', '.join(local_addrs), 'local_addr_list': local_addrs, 'local_fqdn': local_fqdn, 'local_id_mode': id_mode, 'local_id': local_id, 'local_cidrs': ', '.join(local_cidrs), 'local_cidr_list': local_cidrs, 'local_ts': _join(local_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])), 'int_addr': int_addr, 'int_gateway_ip': gateway, 'remote_addrs': ', '.join(_get_list(cfg, 'remote', 'addrs')), 'remote_id': _get(cfg, 'remote', 'id'), 'remote_cidrs': ', '.join(remote_cidrs), 'remote_cidr_list': remote_cidrs, 'psk': resolve_psk(cfg), 'p2s_enabled': _get(cfg, 'p2s', 'enabled'), 'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'), 'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'), 'p2s_dns': _join(local_addrs, _split_list(extras['EXTRA_P2S_DNS'])), 'wg_enabled': _get(cfg, 'wireguard', 'enabled'), 'wg_address': _get(cfg, 'wireguard', 'address'), 'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'), 'openvpn_enabled': _get(cfg, 'openvpn', 'enabled'), 'openvpn_pool': openvpn_pool, 'openvpn_network': str(openvpn_network.network_address) if openvpn_network else '', 'openvpn_netmask': str(openvpn_network.netmask) if openvpn_network else '', 'openvpn_routes': [(str(network.network_address), str(network.netmask)) for network in (ipaddress.ip_network(cidr, strict=False) for cidr in local_cidrs)], 'openvpn_port': _get(cfg, 'openvpn', 'port'), 'openvpn_protocol': _get(cfg, 'openvpn', 'protocol'), 'openvpn_device': OPENVPN_DEVICE, 'openvpn_ldap_conf': str(OPENVPN_LDAP), 'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))), 'ca_cert': str(CA_CERT_DEST), 'server_cert': str(SERVER_CERT), 'server_key': str(SERVER_KEY), 'crl': str(CRL_DEST), 'ldap_uri': vpnrouter_ldap.URI, 'ldap_users': vpnrouter_ldap.USERS, 'web_enabled': _get(cfg, 'web', 'enabled'), 'web_port': _get(cfg, 'web', 'port'), } def s2s_ready(ctx): return all([ ctx['local_addrs'], ctx['local_fqdn'], ctx['local_cidrs'], ctx['remote_addrs'], ctx['remote_id'], ctx['remote_cidrs'], ctx['psk'], ctx['local_id'], ]) def routing_ready(ctx): """Routing and NAT need both interfaces named, plus the subnets involved.""" return all([ctx['local_cidrs'], ctx['int_gateway_ip'], ctx['int_iface'], ctx['wan_iface']]) def p2s_ready(ctx): return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn']) def wg_ready(ctx): return bool(ctx['wg_enabled'] and ctx['wg_address']) def openvpn_ready(ctx): return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn']) def _jinja_env(): return jinja2.Environment( loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)), keep_trailing_newline=True, undefined=jinja2.StrictUndefined, autoescape=False, ) def render(env, ctx, template_name, dest, mode): """Render a template, writing only when the content changes.""" content = env.get_template(template_name).render(ctx) dest = pathlib.Path(dest) if dest.exists() and dest.read_text(encoding='utf-8') == content: os.chmod(dest, mode) return False dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(content, encoding='utf-8') os.chmod(dest, mode) return True def existing_peers(path): """Return the [Peer] sections of an existing WireGuard config. Peers are added by the operator by hand, so re-rendering the [Interface] section must not discard them. """ path = pathlib.Path(path) if not path.is_file(): return '' lines = path.read_text(encoding='utf-8').splitlines(keepends=True) for index, line in enumerate(lines): if line.strip().lower() == '[peer]': return ''.join(lines[index:]) return '' def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600): """Render wg0.conf, carrying any operator-defined peers across.""" content = env.get_template('wg0.conf.j2').render(ctx) peers = existing_peers(dest) if peers: content = content.rstrip('\n') + '\n\n' + peers dest = pathlib.Path(dest) if dest.exists() and dest.read_text(encoding='utf-8') == content: os.chmod(dest, mode) return False dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(content, encoding='utf-8') os.chmod(dest, mode) return True def remove(path): """Remove a generated file. Returns True when something was removed.""" path = pathlib.Path(path) if path.exists(): path.unlink() return True return False def install_file(src, dest, mode): """Copy src to dest when the content differs. Returns True on change.""" src, dest = pathlib.Path(src), pathlib.Path(dest) if not src.is_file(): return False if dest.exists() and dest.read_bytes() == src.read_bytes(): os.chmod(dest, mode) return False dest.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(src, dest) os.chmod(dest, mode) return True def _simple_ca(*args): result = subprocess.run( [sys.executable, str(SIMPLE_CA), *args], capture_output=True, text=True, ) if result.returncode != 0: fail(f'simple-ca {" ".join(args)} failed: {result.stderr.strip()}') return result.stdout def provision_pki(ctx): """Create the CA and CRL when the PKI directory is empty, and the server certificate once wan.local_fqdn is set. A CA of the router's own (ca_key.pem present) without a CRL gets one, and without a certificate for wan.local_fqdn gets one too; nothing present is overwritten. """ PKI_DIR.mkdir(parents=True, exist_ok=True) os.chmod(PKI_DIR, 0o700) if not SIMPLE_CA.is_file(): fail(f'{SIMPLE_CA} is missing') if not any(PKI_DIR.iterdir()): if not ctx['p2s_ca_name']: warn('p2s.ca_name is empty, not creating a local CA') return _simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name']) if not (PKI_DIR / 'ca_key.pem').is_file(): return if not (PKI_DIR / 'crl.pem').is_file(): _simple_ca('make-crl', '--ca-dir', str(PKI_DIR), '--days', str(CRL_DAYS)) label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else '' if label and not (PKI_DIR / f'{label}_cert.pem').is_file(): _simple_ca('make-cert', '--ca-dir', str(PKI_DIR), ctx['local_fqdn'], ctx['local_fqdn']) def distribute_pki(ctx): """Copy PKI material to each consumer. Returns True when anything changed.""" label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else '' bundle = PKI_DIR / 'ca_bundle.pem' ca_src = bundle if bundle.is_file() else PKI_DIR / 'ca_cert.pem' changed = False changed |= install_file(ca_src, CA_CERT_DEST, 0o644) if label: changed |= install_file(PKI_DIR / f'{label}_cert.pem', SERVER_CERT, 0o644) changed |= install_file(PKI_DIR / f'{label}_key.pem', SERVER_KEY, 0o600) changed |= install_file(PKI_DIR / 'crl.pem', CRL_DEST, 0o644) if install_file(PKI_DIR / 'ca_cert.pem', TRUST_ANCHOR, 0o644): changed = True if shutil.which('update-ca-certificates'): subprocess.run(['update-ca-certificates'], capture_output=True, text=True) return changed def configure(cfg=None, local_id_override=None): """Render configuration files and provision PKI. Returns (ctx, changed) where changed is a set naming what needs reloading: 'swanctl', 'resolved', 'wireguard', 'openvpn'. """ if cfg is None: cfg = load_config() # platform = none: configuration is deferred. Files are installed and # nothing is rendered, provisioned or removed until a platform is chosen. if deferred(cfg): return None, set() try: validate(cfg) ctx = build_context(cfg, local_id_override=local_id_override) except ConfigError as exc: fail(str(exc)) changed = set() provision_pki(ctx) if distribute_pki(ctx): changed.add('swanctl') env = _jinja_env() if s2s_ready(ctx): if render(env, ctx, 'remote-site.conf.j2', REMOTE_SITE, 0o600): changed.add('swanctl') elif remove(REMOTE_SITE): changed.add('swanctl') if p2s_ready(ctx): if render(env, ctx, 'road-warrior.conf.j2', ROAD_WARRIOR, 0o600): changed.add('swanctl') elif remove(ROAD_WARRIOR): changed.add('swanctl') if p2s_ready(ctx) or openvpn_ready(ctx): if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644): changed.add('resolved') elif remove(P2S_FORWARDER): changed.add('resolved') if wg_ready(ctx): if render_wg_conf(env, ctx): changed.add('wireguard') elif remove(WG_CONF): changed.add('wireguard') if openvpn_ready(ctx): if render(env, ctx, 'openvpn-server.conf.j2', OPENVPN_CONF, 0o600): changed.add('openvpn') if render(env, ctx, 'openvpn-ldap.conf.j2', OPENVPN_LDAP, 0o600): changed.add('openvpn') else: for path in (OPENVPN_CONF, OPENVPN_LDAP): if remove(path): changed.add('openvpn') return ctx, changed