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