"""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 contextlib import fcntl import ipaddress import json import os import pathlib import re import shutil import sys import tempfile import urllib.parse import jinja2 import ldap 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_pki import vpnrouter_platforms import vpnrouter_status from vpnrouter_net import DEFAULT_LISTEN, LISTEN_CHOICES, LOOPBACK, ConfigError, detect_interfaces, interface_addresses, ip_output CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json') CONFIG_LOCK = pathlib.Path('/run/vpn-router/config.lock') TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates') SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d') SITE_TO_SITE = SWANCTL_CONF_D / 'site-to-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') #: The router's WireGuard public key, which setup writes with its private key. WG_PUBLIC_KEY = pathlib.Path('/etc/wireguard/wg0.pub') #: The lines around the [Peer] sections of the users' WireGuard peers in WG_CONF, which setup writes from the directory. WG_PEERS_BEGIN = "# BEGIN WireGuard peers of the users of the router's directory" WG_PEERS_END = "# END WireGuard peers of the users of the router's directory" OPENVPN_CONF = pathlib.Path('/etc/openvpn/server/vpn-router.conf') OPENVPN_LDAP = pathlib.Path('/etc/openvpn/server/vpn-router-ldap.conf') # 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': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''}, 'site_to_site': {}, 'p2s': {'enabled': False, 'address_pool': ''}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'openvpn': {'enabled': False, 'address_pool': '', 'port': 1194, 'protocol': 'udp'}, 'docker': {'mode': 'auto', 'exposed_ports': []}, 'web': {'enabled': True, 'listen': DEFAULT_LISTEN, 'port': 7081, 'database': '', 'trusted_proxies': [], 'status_content': 'basic', 'shell': False, 'shell_idle_minutes': 15, 'shell_auth_timeout': 15}, 'ssh': {'ldap_login': False}, 'https': {'enabled': True, 'email': '', 'web_port': 7444, 'manager_port': 7443, 'caddy_source': 'ubuntu'}, 'pki': {'root_name': 'VPN Router Root CA', 'root_days': 7300, 'ca_days': 3650, 'key': 'rsa-4096', 'crl_days': 7, 'root_crl_days': 180, 'publish': ['http'], 'p2s_name': 'VPN Router P2S CA', 's2s_name': 'VPN Router Site-to-Site CA', 'issuing_cas': {}, 'templates': {}, 'extended_key_usages': {}, 'root_key_warning': True}, 'metrics': {'enabled': True, 'listen': 'internal', 'port': 9464, 'https_port': 9465, 'service_account': 'metrics', 'otlp_endpoint': '', 'otlp_username': '', 'otlp_password': '', 'keep_details': '24 hours', 'keep_hourly': '1 month', 'keep_daily': '3 months', 'keep_monthly': '1 year', 'keep_yearly': '10 years'}, 'backup': {'enabled': False, 'schedule': 'daily', 'target': 'local', 'path': '/var/backups/vpn-router', 'keep_hourly': 24, 'keep_daily': 7, 'keep_weekly': 4, 'keep_monthly': 12, 'keep_yearly': 0, 'metrics': False, 'rclone_remote': '', 'rclone_mode': 'sync', 'pbs_repository': '', 'pbs_password': '', 'pbs_fingerprint': '', 'pbs_namespace': '', 'pbs_keyfile': ''}, } #: site_to_site maps a connection name to its settings: option -> default, the type of the default being the required type. CONNECTION = {'addrs': [], 'id': '', 'cidrs': [], 'local_cidrs': [], 'local_id_mode': 'fqdn', 'auth': 'psk', 'psk': '', 'psk_file': '', 'remote_ca': ''} #: How a site_to_site connection authenticates: a pre-shared key, or certificates. CONNECTION_AUTH = ('psk', 'certificate') #: The name of a site_to_site connection, which also names its swanctl connection, child and secret. CONNECTION_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$') #: The site_to_site connection the install answers fill. FIRST_CONNECTION = 'remote-site' #: The retention settings of the metrics, from the details to the yearly summaries. METRICS_KEEP = ('keep_details', 'keep_hourly', 'keep_daily', 'keep_monthly', 'keep_yearly') #: A retention period as PostgreSQL reads an interval: a count and a unit. METRICS_INTERVAL = re.compile(r'^[1-9]\d{0,4} (hour|day|month|year)s?$') #: How often backups run, as systemd OnCalendar shorthands, from the most to the least often. BACKUP_SCHEDULES = ('hourly', 'daily', 'weekly') #: Where backups go: an archive in backup.path, or a Proxmox Backup Server. BACKUP_TARGETS = ('local', 'pbs') #: How rclone follows backup.path in the cloud: sync mirrors it, copy deletes nothing remotely. RCLONE_MODES = ('sync', 'copy') #: The retention settings of local backups, from the hourly to the yearly ones. BACKUP_KEEP = ('keep_hourly', 'keep_daily', 'keep_weekly', 'keep_monthly', 'keep_yearly') #: A remote of rclone.conf and an optional path: remote:path. RCLONE_REMOTE = re.compile(r'^[\w.][\w. -]*:\S*$') #: A PBS repository: [[auth-id@]server[:port]:]datastore. PBS_REPOSITORY = re.compile(r'^(\S+:)?[A-Za-z0-9_][A-Za-z0-9._-]*$') #: The SHA-256 fingerprint of a server certificate, as PBS shows it. PBS_FINGERPRINT = re.compile(r'^([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$') #: A PBS namespace: up to seven names joined by /. PBS_NAMESPACE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9._-]*(/[A-Za-z0-9_][A-Za-z0-9._-]*){0,6}$') #: The port of Let's Encrypt's HTTP challenge, which Caddy answers while HTTPS is on. ACME_HTTP_PORT = 80 #: The default port of HTTPS, where Caddy publishes the CA files when pki.publish has https-default. HTTPS_DEFAULT_PORT = 443 #: Where vpn-router-setup installs Caddy from, https.caddy_source: the Ubuntu archive or Caddy's own repository. CADDY_SOURCES = ('ubuntu', 'upstream') #: Caddy's own repository, which https.caddy_source upstream configures: the source, its signing key and their URLs. CADDY_APT_SOURCE = pathlib.Path('/etc/apt/sources.list.d/caddy-stable.sources') CADDY_KEYRING = pathlib.Path('/etc/apt/keyrings/caddy-stable.asc') CADDY_KEY_URL = 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' CADDY_REPOSITORY = 'https://dl.cloudsmith.io/public/caddy/stable/deb/debian' #: The key types of the CAs, pki.key, and of the templates. PKI_KEYS = ('rsa-2048', 'rsa-3072', 'rsa-4096', 'ec-p256', 'ec-p384') #: Where the CA certificates and CRLs are published, pki.publish, as the URLs in the certificates list them. PKI_PUBLISH = ('http', 'http-80', 'https', 'https-default') #: The key usages a template may give, as the arguments of cryptography's KeyUsage. KEY_USAGES = ('digital_signature', 'content_commitment', 'key_encipherment', 'data_encipherment', 'key_agreement') #: The built-in extended key usages a template may give, name -> OID; pki.extended_key_usages adds custom ones. EXTENDED_KEY_USAGES = { 'server_auth': '1.3.6.1.5.5.7.3.1', 'client_auth': '1.3.6.1.5.5.7.3.2', 'code_signing': '1.3.6.1.5.5.7.3.3', 'email_protection': '1.3.6.1.5.5.7.3.4', 'time_stamping': '1.3.6.1.5.5.7.3.8', 'ocsp_signing': '1.3.6.1.5.5.7.3.9', 'ipsec_ike': '1.3.6.1.5.5.7.3.17', 'ike_intermediate': '1.3.6.1.5.5.8.2.2', } #: The name of a custom extended key usage of pki.extended_key_usages. EKU_NAME = re.compile(r'^[a-z][a-z0-9_-]{0,30}$') #: An object identifier in dotted form, of a custom extended key usage or a certificate policy. OID = re.compile(r'^[0-2](\.(0|[1-9][0-9]*)){1,63}$') #: What the common name of a template's certificates is: a DNS name, any name, or an e-mail address. TEMPLATE_SUBJECTS = ('dns', 'name', 'email') #: The subject alternative names a template's certificates may have. SAN_TYPES = ('dns', 'ip', 'email') #: A certificate template: option -> default, the type of the default being the required type. TEMPLATE = {'key': 'rsa-3072', 'days': 365, 'key_usage': ['digital_signature'], 'extended_key_usage': [], 'subject': 'name', 'sans': [], 'policies': []} #: The built-in templates; pki.templates changes their options and adds templates of its own. PKI_TEMPLATES = { 'vpn-server': {'key_usage': ['digital_signature', 'key_encipherment'], 'extended_key_usage': ['server_auth', 'ike_intermediate'], 'subject': 'dns', 'sans': ['dns', 'ip']}, 'vpn-user': {'extended_key_usage': ['client_auth'], 'subject': 'name', 'sans': ['dns', 'email']}, 'site-to-site': {'key_usage': ['digital_signature', 'key_encipherment'], 'extended_key_usage': ['server_auth', 'client_auth'], 'subject': 'dns', 'sans': ['dns', 'ip']}, 'server': {'key_usage': ['digital_signature', 'key_encipherment'], 'extended_key_usage': ['server_auth'], 'subject': 'dns', 'sans': ['dns', 'ip']}, 'client': {'extended_key_usage': ['client_auth'], 'subject': 'name', 'sans': ['dns', 'email']}, } #: The templates only the router issues: its VPN server certificate and the users' certificates. ROUTER_TEMPLATES = ('vpn-server', 'vpn-user') #: The built-in issuing CAs: name -> the pki option of the common name and the templates the CA issues. BUILTIN_CAS = {'p2s': ('p2s_name', ['vpn-server', 'vpn-user']), 's2s': ('s2s_name', ['site-to-site'])} #: A general-purpose issuing CA of pki.issuing_cas: option -> default, the type of the default being the required type. ISSUING_CA = {'name': '', 'templates': [], 'acme': False} #: The name of an issuing CA or a template; an issuing CA's name also names its files. PKI_NAME = re.compile(r'^[a-z][a-z0-9-]{0,30}$') #: The longest common name of a CA, in characters. CA_NAME_MAX = 64 #: What / and /status of the web service show, web.status_content, from the least to the most. STATUS_CONTENTS = ('basic', 'standard', 'full') #: The longest web.shell_idle_minutes and web.shell_auth_timeout, a day. SHELL_IDLE_MAX = 1440 DOCKER_MODES = ('auto', 'on', 'off') OPENVPN_PROTOCOLS = ('udp', 'tcp') #: The tun device of the OpenVPN server. OPENVPN_DEVICE = 'ovpn0' #: 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', dict: 'an object'} 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 read_settings(path=None): """The settings in the configuration file, without the defaults; {} when the file does not exist. Raises ConfigError when the file is not JSON or not an object of sections. """ path = pathlib.Path(path) if path else CONFIG_FILE try: data = json.loads(path.read_text(encoding='utf-8')) except FileNotFoundError: return {} except ValueError as exc: raise ConfigError(f'cannot parse {path}: {exc}') if not isinstance(data, dict): raise ConfigError(f'cannot parse {path}: the top level is not an object') for section, options in data.items(): if not isinstance(options, dict): raise ConfigError(f'cannot parse {path}: {section} is not an object') return data def with_defaults(data): """The configuration of the settings of read_settings, with the defaults for anything absent. The result maps section -> option -> value, and site_to_site maps each connection name to its settings filled from CONNECTION. Sections, options and connections that are not objects are kept so that validate() can report them. """ cfg = {section: dict(options) for section, options in SCHEMA.items()} for section, options in data.items(): cfg.setdefault(section, {}).update(options) cfg['site_to_site'] = {name: {**CONNECTION, **options} if isinstance(options, dict) else options for name, options in cfg['site_to_site'].items()} return cfg def load_config(path=None): """Read the configuration, filling in defaults for anything absent (see with_defaults). The path is resolved when called, not when this function is defined, so CONFIG_FILE stays overridable. """ try: return with_defaults(read_settings(path)) except ConfigError as exc: fail(str(exc)) @contextlib.contextmanager def config_lock(): """Hold the lock of the configuration file from reading it to writing it with save_config.""" CONFIG_LOCK.parent.mkdir(parents=True, exist_ok=True) with open(CONFIG_LOCK, 'a', encoding='utf-8') as handle: fcntl.flock(handle, fcntl.LOCK_EX) yield def save_config(data): """Write the settings to the configuration file, indented, with mode 0600, replacing it in one step. The caller holds config_lock. """ descriptor, name = tempfile.mkstemp(dir=CONFIG_FILE.parent, prefix='.vpn-router.json.') try: with os.fdopen(descriptor, 'w', encoding='utf-8') as handle: handle.write(json.dumps(data, indent=2) + '\n') os.chmod(name, 0o600) os.replace(name, CONFIG_FILE) except OSError: os.unlink(name) raise 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 _clean(values): return [item.strip() for item in values if item.strip()] def _get_list(cfg, section, option): return _clean(cfg[section][option]) def _check_options(options, defaults, prefix): for option, value in options.items(): if option not in defaults: raise ConfigError(f'{prefix}.{option}: unknown setting') expected = type(defaults[option]) if type(value) is not expected or ( expected is list and not all(isinstance(item, str) for item in value)): raise ConfigError( f'{prefix}.{option} must be {TYPE_NAMES[expected]}') def _check_types(cfg): for section, options in cfg.items(): if section not in SCHEMA: raise ConfigError(f'{section}: unknown section') if section != 'site_to_site': _check_options(options, SCHEMA[section], section) continue for name, connection in options.items(): if not isinstance(connection, dict): raise ConfigError(f'site_to_site.{name} must be {TYPE_NAMES[dict]}') _check_options(connection, CONNECTION, f'site_to_site.{name}') 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"}') identities = [('wan.local_fqdn', _get(cfg, 'wan', 'local_fqdn'))] for name, connection in cfg['site_to_site'].items(): prefix = f'site_to_site.{name}' if not CONNECTION_NAME.match(name): raise ConfigError( f'{prefix}: a connection name starts with a lowercase letter and has at most 31 ' 'lowercase letters, digits and hyphens') identities.append((f'{prefix}.id', connection['id'].strip())) id_mode = connection['local_id_mode'].strip() if id_mode and id_mode not in ID_MODES: raise ConfigError( f'{prefix}.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}') _check_networks(_clean(connection['cidrs']), f'{prefix}.cidrs') _check_networks(_clean(connection['local_cidrs']), f'{prefix}.local_cidrs') if connection['auth'] not in CONNECTION_AUTH: raise ConfigError(f'{prefix}.auth: {connection["auth"]!r} is not one of {", ".join(CONNECTION_AUTH)}') if connection['remote_ca'].strip() and not vpnrouter_pki.is_ca_certificate(connection['remote_ca']): raise ConfigError(f'{prefix}.remote_ca is not the PEM certificate of a CA') for option, value in identities: if value.startswith('@'): raise ConfigError( f'{option}: remove the leading @; strongSwan reads an ' 'address as an IP identity and a name as an FQDN') _check_networks(_get_list(cfg, 'local', 'cidrs'), 'local.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') for system, option in (('p2s', 'address_pool'), ('openvpn', 'address_pool'), ('wireguard', 'address')): if _get(cfg, system, 'enabled') and not _get(cfg, system, option): raise ConfigError(f'{system}.enabled needs {system}.{option}') _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: interface = ipaddress.ip_interface(wg_address) except ValueError: raise ConfigError( f'wireguard.address: {wg_address!r} is not an address with prefix') # The router's own address on wg0, from which the peers' addresses of the same network are told apart. network = interface.network if network.num_addresses > 2 and interface.ip in (network.network_address, network.broadcast_address): raise ConfigError( f'wireguard.address: {wg_address} is the network or broadcast address of {network}; give the ' f'router\'s own address in it, such as {next(network.hosts())}/{network.prefixlen}') 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') web_listen = _get(cfg, 'web', 'listen') if web_listen not in LISTEN_CHOICES: raise ConfigError( f'web.listen: {web_listen!r} is not one of {", ".join(LISTEN_CHOICES)}') status_content = _get(cfg, 'web', 'status_content') if status_content not in STATUS_CONTENTS: raise ConfigError( f'web.status_content: {status_content!r} is not one of {", ".join(STATUS_CONTENTS)}') shell_idle = _get(cfg, 'web', 'shell_idle_minutes') if not 1 <= shell_idle <= SHELL_IDLE_MAX: raise ConfigError(f'web.shell_idle_minutes: {shell_idle} is not between 1 and {SHELL_IDLE_MAX}') shell_auth = _get(cfg, 'web', 'shell_auth_timeout') if not 0 <= shell_auth <= SHELL_IDLE_MAX: raise ConfigError(f'web.shell_auth_timeout: {shell_auth} is not between 0 and {SHELL_IDLE_MAX}') https_ports = [] for option in ('web_port', 'manager_port'): port = _get(cfg, 'https', option) if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: raise ConfigError(f'https.{option}: {port!r} is not a valid port') if port == ACME_HTTP_PORT: raise ConfigError(f'https.{option}: {port} is the port of the HTTP challenge') https_ports.append(port) if https_ports[0] == https_ports[1]: raise ConfigError('https.web_port and https.manager_port must differ') for item in _get_list(cfg, 'web', 'trusted_proxies'): _check_address(item, 'web.trusted_proxies') caddy_source = _get(cfg, 'https', 'caddy_source') if caddy_source not in CADDY_SOURCES: raise ConfigError(f'https.caddy_source: {caddy_source!r} is not one of {", ".join(CADDY_SOURCES)}') for option in ('root_name', 'p2s_name', 's2s_name'): value = _get(cfg, 'pki', option) if not value or len(value) > CA_NAME_MAX or not value.isprintable(): raise ConfigError(f'pki.{option} must have 1 to {CA_NAME_MAX} printable characters') for option in ('root_days', 'ca_days', 'crl_days', 'root_crl_days'): if not 1 <= _get(cfg, 'pki', option) <= 36500: raise ConfigError(f'pki.{option} must be 1 to 36500 days') if _get(cfg, 'pki', 'key') not in PKI_KEYS: raise ConfigError(f'pki.key: {_get(cfg, "pki", "key")!r} is not one of {", ".join(PKI_KEYS)}') publish = _get_list(cfg, 'pki', 'publish') for item in publish: if item not in PKI_PUBLISH: raise ConfigError(f'pki.publish: {item!r} is not one of {", ".join(PKI_PUBLISH)}') if len(set(publish)) != len(publish): raise ConfigError('pki.publish names an endpoint twice') if publish and not _get(cfg, 'wan', 'local_fqdn'): raise ConfigError('pki.publish needs wan.local_fqdn, the name in the published URLs') if 'http' in publish and not _get(cfg, 'web', 'enabled'): raise ConfigError('pki.publish: http is served by the web service, which needs web.enabled') if set(publish) - {'http'} and not _get(cfg, 'https', 'enabled'): raise ConfigError('pki.publish: http-80, https and https-default are served by Caddy, which needs https.enabled') for name, oid in cfg['pki']['extended_key_usages'].items(): prefix = f'pki.extended_key_usages.{name}' if not EKU_NAME.match(name) or name in EXTENDED_KEY_USAGES: raise ConfigError(f'{prefix}: an extended key usage name starts with a lowercase letter, has at most 31 ' 'lowercase letters, digits, hyphens and underscores, and is not a built-in one') if not isinstance(oid, str) or not OID.match(oid): raise ConfigError(f'{prefix} must be an OID in dotted form, such as 1.3.6.1.4.1.311.20.2.2') usages = extended_key_usages(cfg) for name, template in cfg['pki']['templates'].items(): prefix = f'pki.templates.{name}' if not PKI_NAME.match(name): raise ConfigError(f'{prefix}: a template name starts with a lowercase letter and has at most 31 lowercase ' 'letters, digits and hyphens') if not isinstance(template, dict): raise ConfigError(f'{prefix} must be {TYPE_NAMES[dict]}') _check_options(template, TEMPLATE, prefix) templates = pki_templates(cfg) for name, template in templates.items(): prefix = f'pki.templates.{name}' if template['key'] not in PKI_KEYS: raise ConfigError(f'{prefix}.key: {template["key"]!r} is not one of {", ".join(PKI_KEYS)}') if not 1 <= template['days'] <= 36500: raise ConfigError(f'{prefix}.days must be 1 to 36500 days') for option, allowed in (('key_usage', KEY_USAGES), ('extended_key_usage', tuple(usages)), ('sans', SAN_TYPES)): for item in template[option]: if item not in allowed: raise ConfigError(f'{prefix}.{option}: {item!r} is not one of {", ".join(allowed)}') for item in template['policies']: if not OID.match(item): raise ConfigError(f'{prefix}.policies: {item!r} is not an OID in dotted form, such as 1.3.6.1.4.1.99999.1') if not template['key_usage']: raise ConfigError(f'{prefix}.key_usage needs at least one usage') if template['subject'] not in TEMPLATE_SUBJECTS: raise ConfigError(f'{prefix}.subject: {template["subject"]!r} is not one of {", ".join(TEMPLATE_SUBJECTS)}') for name, authority in cfg['pki']['issuing_cas'].items(): prefix = f'pki.issuing_cas.{name}' if not PKI_NAME.match(name) or name in BUILTIN_CAS or name == 'root': raise ConfigError(f'{prefix}: an issuing CA name starts with a lowercase letter and has at most 31 ' f'lowercase letters, digits and hyphens, and is not root, {" or ".join(BUILTIN_CAS)}') if not isinstance(authority, dict): raise ConfigError(f'{prefix} must be {TYPE_NAMES[dict]}') _check_options(authority, ISSUING_CA, prefix) for name, authority in issuing_cas(cfg).items(): prefix = f'pki.issuing_cas.{name}' if authority['builtin']: continue common_name = authority['name'].strip() if not common_name or len(common_name) > CA_NAME_MAX or not common_name.isprintable(): raise ConfigError(f'{prefix}.name must have 1 to {CA_NAME_MAX} printable characters') if not authority['templates']: raise ConfigError(f'{prefix}.templates needs at least one template') for template in authority['templates']: if template not in templates: raise ConfigError(f'{prefix}.templates: {template!r} is not a template') if template in ROUTER_TEMPLATES: raise ConfigError(f'{prefix}.templates: {template} is issued by the P2S CA only') if authority['acme'] and not _get(cfg, 'https', 'enabled'): raise ConfigError(f'{prefix}.acme: the ACME server is Caddy\'s, which needs https.enabled') taken = {web_port: 'web.port', ACME_HTTP_PORT: 'the HTTP challenge', https_ports[0]: 'https.web_port', https_ports[1]: 'https.manager_port'} if 'https-default' in publish: if HTTPS_DEFAULT_PORT in taken: raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of ' f'{taken[HTTPS_DEFAULT_PORT]}') if _get(cfg, 'openvpn', 'protocol') == 'tcp' and _get(cfg, 'openvpn', 'port') == HTTPS_DEFAULT_PORT: raise ConfigError(f'pki.publish: https-default takes port {HTTPS_DEFAULT_PORT}, the port of openvpn.port') taken[HTTPS_DEFAULT_PORT] = 'pki.publish https-default' for option in ('port', 'https_port'): port = _get(cfg, 'metrics', option) if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: raise ConfigError(f'metrics.{option}: {port!r} is not a valid port') if port in taken: raise ConfigError(f'metrics.{option}: {port} is the port of {taken[port]}') taken[port] = f'metrics.{option}' metrics_listen = _get(cfg, 'metrics', 'listen') if metrics_listen not in LISTEN_CHOICES: raise ConfigError( f'metrics.listen: {metrics_listen!r} is not one of {", ".join(LISTEN_CHOICES)}') endpoint = _get(cfg, 'metrics', 'otlp_endpoint') if endpoint and urllib.parse.urlsplit(endpoint).scheme not in ('http', 'https'): raise ConfigError(f'metrics.otlp_endpoint: {endpoint!r} is not an http or https URL') if bool(_get(cfg, 'metrics', 'otlp_username')) != bool(_get(cfg, 'metrics', 'otlp_password')): raise ConfigError('metrics.otlp_username and metrics.otlp_password are given together') for option in METRICS_KEEP: keep = _get(cfg, 'metrics', option) if not METRICS_INTERVAL.match(keep): raise ConfigError( f'metrics.{option}: {keep!r} is not a count and a unit, such as "24 hours" or "3 months"') schedule = _get(cfg, 'backup', 'schedule') if schedule not in BACKUP_SCHEDULES: raise ConfigError( f'backup.schedule: {schedule!r} is not one of {", ".join(BACKUP_SCHEDULES)}') target = _get(cfg, 'backup', 'target') if target not in BACKUP_TARGETS: raise ConfigError(f'backup.target: {target!r} is not one of {", ".join(BACKUP_TARGETS)}') for option in ('path', 'pbs_keyfile'): value = _get(cfg, 'backup', option) if value and not value.startswith('/'): raise ConfigError(f'backup.{option}: {value!r} is not an absolute path') if not _get(cfg, 'backup', 'path'): raise ConfigError('backup.path is required') for option in BACKUP_KEEP: if _get(cfg, 'backup', option) < 0: raise ConfigError(f'backup.{option} must not be negative') if target == 'local' and not any(_get(cfg, 'backup', option) for option in backup_keeps(schedule)): raise ConfigError( f'backup: with the {schedule} schedule, at least one of {", ".join(backup_keeps(schedule))} ' 'is above 0') remote = _get(cfg, 'backup', 'rclone_remote') if remote and not RCLONE_REMOTE.match(remote): raise ConfigError(f'backup.rclone_remote: {remote!r} is not a remote and a path, such as azure:backups/router') rclone_mode = _get(cfg, 'backup', 'rclone_mode') if rclone_mode not in RCLONE_MODES: raise ConfigError(f'backup.rclone_mode: {rclone_mode!r} is not one of {", ".join(RCLONE_MODES)}') if target == 'pbs': repository = _get(cfg, 'backup', 'pbs_repository') if not repository or not _get(cfg, 'backup', 'pbs_password'): raise ConfigError('backup.pbs_repository and backup.pbs_password are required with the pbs target') if not PBS_REPOSITORY.match(repository): raise ConfigError( f'backup.pbs_repository: {repository!r} is not of the form user@realm!token@host:datastore') fingerprint = _get(cfg, 'backup', 'pbs_fingerprint') if fingerprint and not PBS_FINGERPRINT.match(fingerprint): raise ConfigError(f'backup.pbs_fingerprint: {fingerprint!r} is not 32 hexadecimal pairs joined by colons') namespace = _get(cfg, 'backup', 'pbs_namespace') if namespace and not PBS_NAMESPACE.match(namespace): raise ConfigError(f'backup.pbs_namespace: {namespace!r} is not up to seven names joined by /') 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 first, _, last = item.partition(':') if _get(cfg, 'https', 'enabled'): caddy_ports = (ACME_HTTP_PORT, *https_ports, *([HTTPS_DEFAULT_PORT] if 'https-default' in publish else [])) for port in caddy_ports: if int(first) <= port <= int(last or first): raise ConfigError( f'docker.exposed_ports: {item!r} takes port {port}, which Caddy serves while ' 'https.enabled is true') 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 backup_keeps(schedule): """The retention settings of local backups that apply to the schedule: none finer than it.""" return BACKUP_KEEP[BACKUP_SCHEDULES.index(schedule):] def pki_templates(cfg): """The certificate templates: the built-in ones (PKI_TEMPLATES) and those of pki.templates, each filled from TEMPLATE.""" custom = cfg['pki']['templates'] return {name: {**TEMPLATE, **PKI_TEMPLATES.get(name, {}), **(custom.get(name) if isinstance(custom.get(name), dict) else {})} for name in [*PKI_TEMPLATES, *(name for name in custom if name not in PKI_TEMPLATES)]} def extended_key_usages(cfg): """The extended key usages templates may give, name -> OID: the built-in ones (EXTENDED_KEY_USAGES) and those of pki.extended_key_usages.""" custom = cfg['pki']['extended_key_usages'] return {**EXTENDED_KEY_USAGES, **{name: oid for name, oid in custom.items() if isinstance(oid, str)}} def issuing_cas(cfg): """The issuing CAs, name -> {name, templates, acme, builtin}: p2s and s2s (BUILTIN_CAS) with their common names from the pki section, then those of pki.issuing_cas, each filled from ISSUING_CA.""" found = {name: {'name': _get(cfg, 'pki', option), 'templates': list(templates), 'acme': False, 'builtin': True} for name, (option, templates) in BUILTIN_CAS.items()} for name, options in cfg['pki']['issuing_cas'].items(): if isinstance(options, dict): found[name] = {**ISSUING_CA, **options, 'builtin': False} return found def pki_base_urls(cfg): """The base URLs, ending in /, under which the CA certificates and CRLs are published, in the order of pki.publish.""" fqdn = _get(cfg, 'wan', 'local_fqdn') bases = { 'http': f'http://{fqdn}:{_get(cfg, "web", "port")}/pki/', 'http-80': f'http://{fqdn}/pki/', 'https': f'https://{fqdn}:{_get(cfg, "https", "web_port")}/pki/', 'https-default': f'https://{fqdn}/pki/', } return [bases[item] for item in _get_list(cfg, 'pki', 'publish') if item in bases] if fqdn else [] def resolve_psk(connection, name): """Return the pre-shared key of the site_to_site connection: psk_file wins, else psk; '' when none is configured.""" psk_file = connection['psk_file'].strip() if psk_file: path = pathlib.Path(psk_file) if not path.is_file(): raise ConfigError(f'site_to_site.{name}.psk_file: {psk_file} does not exist') return path.read_text(encoding='utf-8').strip() return connection['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, public_ip=None): """Assemble the template context from the configuration. public_ip is the address wan.local_fqdn resolves to, the local identity of the site_to_site connections in mode public_ip; setup resolves it once the network is up and renders again, and without it such a connection is not ready. """ 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') local_cidrs = _get_list(cfg, 'local', 'cidrs') openvpn_pool = _get(cfg, 'openvpn', 'address_pool') openvpn_network = ipaddress.ip_network(openvpn_pool) if openvpn_pool else None identities = {'fqdn': local_fqdn, 'public_ip': public_ip or '', 'internal_ip': int_addr} connections = [] for name, connection in cfg['site_to_site'].items(): id_mode = connection['local_id_mode'].strip() or 'fqdn' remote_cidrs = _clean(connection['cidrs']) connection_cidrs = _clean(connection['local_cidrs']) auth = connection['auth'] entry = { 'name': name, 'remote_addrs': ', '.join(_clean(connection['addrs'])), 'remote_id': connection['id'].strip(), 'remote_cidrs': ', '.join(remote_cidrs), 'remote_cidr_list': remote_cidrs, 'local_ts': _join(connection_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])), 'local_cidr_list': connection_cidrs, 'local_id_mode': id_mode, 'local_id': identities.get(id_mode, ''), 'auth': auth, 'psk': resolve_psk(connection, name) if auth == 'psk' else '', 'remote_cacert': vpnrouter_pki.remote_ca_file(name) if connection['remote_ca'].strip() else vpnrouter_pki.SWANCTL_CA_FILES['s2s'], } required = ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', *(('psk',) if auth == 'psk' else ())) entry['ready'] = bool(local_addrs and connection_cidrs and all(entry[key] for key in required)) connections.append(entry) 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_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, 'connections': connections, 'p2s_enabled': _get(cfg, 'p2s', 'enabled'), 'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'), 'public_ip': public_ip or '', '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'), 'wg_peers_begin': WG_PEERS_BEGIN, 'wg_peers_end': WG_PEERS_END, '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), 'openvpn_status': vpnrouter_status.OPENVPN_STATUS, 'openvpn_status_seconds': vpnrouter_status.OPENVPN_STATUS_SECONDS, 'p2s_dns_list': _split_list(_join(local_addrs, _split_list(extras['EXTRA_P2S_DNS']))), 'openvpn_ca': str(vpnrouter_pki.OPENVPN_CA), 'openvpn_crl': str(vpnrouter_pki.OPENVPN_CRL), 'server_cert': str(vpnrouter_pki.VPN_SERVER_CERT), 'server_key': str(vpnrouter_pki.VPN_SERVER_KEY), 'server_certificate': False, 'ldap_uri': vpnrouter_ldap.URI, 'ldap_users': vpnrouter_ldap.USERS, 'web_enabled': _get(cfg, 'web', 'enabled'), 'web_port': _get(cfg, 'web', 'port'), 'https_enabled': _get(cfg, 'https', 'enabled'), 'https_email': _get(cfg, 'https', 'email'), 'https_web_port': _get(cfg, 'https', 'web_port'), 'https_manager_port': _get(cfg, 'https', 'manager_port'), 'pki_publish': _get_list(cfg, 'pki', 'publish'), 'pki_acme': [name for name, authority in issuing_cas(cfg).items() if authority['acme']], 'metrics_enabled': _get(cfg, 'metrics', 'enabled'), 'metrics_port': _get(cfg, 'metrics', 'port'), 'metrics_https_port': _get(cfg, 'metrics', 'https_port'), 'metrics_service_account': _get(cfg, 'metrics', 'service_account'), 'loopback': LOOPBACK, } def s2s_ready(ctx): """True when any site_to_site connection is ready.""" return any(connection['ready'] for connection in ctx['connections']) 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): """Road-warrior access needs its settings and the router's VPN server certificate, which the P2S CA issues.""" return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'] and ctx['server_certificate']) def wg_ready(ctx): return bool(ctx['wg_enabled'] and ctx['wg_address']) def openvpn_ready(ctx): """OpenVPN needs its settings and the router's VPN server certificate, which the P2S CA issues.""" return bool(ctx['openvpn_enabled'] and ctx['openvpn_pool'] and ctx['local_fqdn'] and ctx['server_certificate']) def https_ready(ctx): """Caddy serves HTTPS once it is enabled and wan.local_fqdn names the certificate.""" return bool(ctx['https_enabled'] and ctx['local_fqdn']) def metrics_ready(ctx): """The metrics service collects, keeps and serves the metrics while metrics.enabled is true.""" return bool(ctx['metrics_enabled']) 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): """The block of the users' peers and the [Peer] sections added by hand of an existing WireGuard config, each '' without one. The block lies between WG_PEERS_BEGIN and WG_PEERS_END; the [Peer] sections outside it are the operator's, which re-rendering the file keeps. """ path = pathlib.Path(path) if not path.is_file(): return '', '' text = path.read_text(encoding='utf-8') block = '' begin, end = text.find(WG_PEERS_BEGIN), text.find(WG_PEERS_END) if begin != -1 and end > begin: block = text[begin:end + len(WG_PEERS_END)] text = text[:begin] + text[end + len(WG_PEERS_END):] lines = text.splitlines(keepends=True) for index, line in enumerate(lines): if line.strip().lower() == '[peer]': return block, ''.join(lines[index:]) return block, '' def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600): """Render wg0.conf: the [Interface], the block of the users' peers of ctx's wg_peers, the block the file holds while wg_peers is None, and the peers the operator added by hand, carried across.""" content = env.get_template('wg0.conf.j2').render(ctx).rstrip('\n') + '\n' block, own = existing_peers(dest) if ctx['wg_peers'] is not None: block = env.get_template('wg0-peers.conf.j2').render(ctx).strip('\n') if ctx['wg_peers'] else '' for part in (block, own): if part.strip(): content += '\n' + part.strip('\n') + '\n' 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 configure(cfg=None, public_ip=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, public_ip=public_ip) except ConfigError as exc: fail(str(exc)) changed = set() # Without the directory, which holds the PKI, the copies of the last run stay and what needs none is configured. with vpnrouter_pki.locked(): try: notices = vpnrouter_pki.provision(cfg, ctx) if vpnrouter_pki.distribute(cfg): changed.add('swanctl') except vpnrouter_pki.Failure as failure: fail(str(failure)) except ldap.LDAPError as exc: notices = [f'the PKI is left as it is: the directory cannot be reached: {exc}'] for notice in notices: warn(notice) # The WireGuard peers of the users; None keeps those of wg0.conf while the directory cannot be reached. ctx['wg_peers'] = None if wg_ready(ctx): try: ctx['wg_peers'] = vpnrouter_ldap.wireguard_peers() except ldap.LDAPError as exc: warn(f'the WireGuard peers of the users are left as they are: the directory cannot be reached: {exc}') # What needs the router's own certificates. ctx['server_certificate'] = vpnrouter_pki.VPN_SERVER_CERT.is_file() for connection in ctx['connections']: if connection['auth'] == 'certificate': connection['ready'] = connection['ready'] and vpnrouter_pki.S2S_CERT.is_file() env = _jinja_env() if s2s_ready(ctx): if render(env, ctx, 'site-to-site.conf.j2', SITE_TO_SITE, 0o600): changed.add('swanctl') elif remove(SITE_TO_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