"""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 base64 import binascii import configparser import ipaddress import os import pathlib 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_platforms CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.conf') 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') 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 SCHEMA = { 'general': {'platform': 'generic'}, 'interfaces': {'external': '', 'internal': ''}, 'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'}, 'local': {'cidrs': '', 'int_gateway_ip': ''}, 'remote': {'addrs': '', 'id': '', 'cidrs': '', 'psk_b64': '', 'psk_file': ''}, 'p2s': {'enabled': 'false', 'address_pool': '', 'ca_name': 'VPN Router CA'}, 'wireguard': {'enabled': 'false', 'address': '', 'listen_port': '51820'}, } ID_MODES = ('fqdn', 'public_ip', 'internal_ip') class ConfigError(Exception): """A setting is present but malformed.""" def warn(message): print(f'vpn-router: warning: {message}', file=sys.stderr) def fail(message): print(f'vpn-router: error: {message}', file=sys.stderr) sys.exit(1) def new_parser(): """A parser that tolerates operator comments and never interpolates. interpolation=None matters: the default BasicInterpolation treats '%' as syntax, which would break any value containing it. Inline comments are enabled, which configparser leaves off by default because it makes '#' and ';' unusable inside values. That is an acceptable trade here precisely because the one setting that can hold arbitrary characters, the pre-shared key, is base64-encoded, and base64 uses none of those characters. """ return configparser.ConfigParser( interpolation=None, comment_prefixes=('#', ';'), inline_comment_prefixes=('#', ';'), ) def default_parser(): parser = new_parser() for section, options in SCHEMA.items(): parser[section] = dict(options) return parser def load_config(path=CONFIG_FILE): """Read the configuration, filling in defaults for anything absent.""" parser = default_parser() if path.exists(): try: parser.read(path, encoding='utf-8') except configparser.Error as exc: fail(f'cannot parse {path}: {exc}') return parser def _split_list(value): return [item.strip() for item in value.split(',') if item.strip()] def _get(cfg, section, option): return cfg.get(section, option, fallback=SCHEMA[section][option]).strip() def _get_bool(cfg, section, option): try: return cfg.getboolean(section, option, fallback=SCHEMA[section][option] == 'true') except ValueError: raise ConfigError(f'{section}.{option} is not a boolean') def _check_networks(value, name): for item in _split_list(value): 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.""" platform = _get(cfg, 'general', 'platform') installed = 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)}') 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(cfg, 'local', 'cidrs'), 'local.cidrs') _check_networks(_get(cfg, 'remote', 'cidrs'), 'remote.cidrs') _check_networks(_get(cfg, 'p2s', 'address_pool'), 'p2s.address_pool') _check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip') 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 port: if not port.isdigit() or not 1 <= int(port) <= 65535: raise ConfigError(f'wireguard.listen_port: {port!r} is not a valid port') for section in ('p2s', 'wireguard'): _get_bool(cfg, section, 'enabled') 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() psk_b64 = _get(cfg, 'remote', 'psk_b64') if not psk_b64: return '' try: return base64.b64decode(psk_b64, validate=True).decode('utf-8') except (binascii.Error, UnicodeDecodeError) as exc: raise ConfigError(f'remote.psk_b64 is not valid base64 text: {exc}') def platform_module(cfg): """Import the configured platform module, falling back to the default.""" name = _get(cfg, 'general', 'platform') or 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 _split_list(group): if item not in items: items.append(item) return ', '.join(items) def interface_addresses(name, setting): """IPv4 addresses configured on an interface. The interface name is configuration; its addresses are read from the system. Looking up the addresses of a named interface is deterministic - unlike working out which interface to use, which is a guess the package deliberately does not make. """ if not name: return [] if not (pathlib.Path('/sys/class/net') / name).exists(): raise ConfigError(f'{setting}: interface {name!r} does not exist') result = subprocess.run( ['ip', '-o', '-4', 'addr', 'show', 'dev', name], capture_output=True, text=True, ) if result.returncode != 0: raise ConfigError(f'{setting}: cannot read addresses of {name!r}') addresses = [] for line in result.stdout.splitlines(): fields = line.split() if len(fields) >= 4: addresses.append(fields[3].split('/')[0]) return addresses def build_context(cfg, local_id_override=None): """Assemble the template context from the configuration.""" extras = vpnrouter_platforms.context(platform_module(cfg)) ext_iface = _get(cfg, 'interfaces', 'external') int_iface = _get(cfg, 'interfaces', 'internal') ext_addrs = interface_addresses(ext_iface, 'interfaces.external') int_addrs = interface_addresses(int_iface, 'interfaces.internal') if ext_iface and not ext_addrs: warn(f'{ext_iface} has no IPv4 address yet') local_addrs = ', '.join(ext_addrs) int_addr = int_addrs[0] if int_addrs else '' local_fqdn = _get(cfg, 'wan', 'local_fqdn') id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn' local_cidrs = _get(cfg, 'local', 'cidrs') if local_id_override: local_id = local_id_override elif id_mode == 'fqdn': local_id = f'@{local_fqdn}' if local_fqdn else '' 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 = '' cidr_list = _split_list(local_cidrs) return { 'platform': _get(cfg, 'general', 'platform') or 'generic', 'wan_iface': ext_iface, 'int_iface': int_iface, 'local_addrs': local_addrs, 'local_addr_list': _split_list(local_addrs), 'local_fqdn': local_fqdn, 'local_id_mode': id_mode, 'local_id': local_id, 'local_cidrs': local_cidrs, 'local_cidr_list': cidr_list, 'local_subnet': cidr_list[0] if cidr_list else '', 'local_ts': _join(local_cidrs, extras['EXTRA_LOCAL_TS']), 'int_addr': int_addr, 'int_gateway_ip': _get(cfg, 'local', 'int_gateway_ip'), 'remote_addrs': _get(cfg, 'remote', 'addrs'), 'remote_id': _get(cfg, 'remote', 'id'), 'remote_cidrs': _get(cfg, 'remote', 'cidrs'), 'psk': resolve_psk(cfg), 'p2s_enabled': _get_bool(cfg, 'p2s', 'enabled'), 'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'), 'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'), 'p2s_dns': _join(local_addrs, extras['EXTRA_P2S_DNS']), 'wg_enabled': _get_bool(cfg, 'wireguard', 'enabled'), 'wg_address': _get(cfg, 'wireguard', 'address'), 'wg_listen_port': _get(cfg, 'wireguard', 'listen_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 _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 server certificate when the PKI directory is empty.""" PKI_DIR.mkdir(parents=True, exist_ok=True) os.chmod(PKI_DIR, 0o700) if any(PKI_DIR.iterdir()): return if not ctx['p2s_ca_name']: warn('p2s.ca_name is empty, not creating a local CA') return if not SIMPLE_CA.is_file(): fail(f'{SIMPLE_CA} is missing') _simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name']) _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'. """ if cfg is None: cfg = load_config() try: validate(cfg) ctx = build_context(cfg, local_id_override=local_id_override) except ConfigError as exc: fail(str(exc)) changed = set() if p2s_ready(ctx): 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') if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644): changed.add('resolved') else: if remove(ROAD_WARRIOR): changed.add('swanctl') if 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') return ctx, changed