1066 lines
47 KiB
Python
1066 lines
47 KiB
Python
"""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
|
|
|
|
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
|
|
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')
|
|
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'},
|
|
'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': {},
|
|
'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 extended key usages a template may give.
|
|
EXTENDED_KEY_USAGES = ('server_auth', 'client_auth', 'code_signing', 'email_protection', 'time_stamping',
|
|
'ocsp_signing', 'ipsec_ike', 'ike_intermediate')
|
|
|
|
#: 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': []}
|
|
|
|
#: 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')
|
|
|
|
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')
|
|
_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')
|
|
|
|
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)}')
|
|
|
|
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, 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', EXTENDED_KEY_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)}')
|
|
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 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'),
|
|
'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']))),
|
|
'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):
|
|
"""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 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()
|
|
|
|
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))
|
|
for notice in notices:
|
|
warn(notice)
|
|
# 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
|