feature: site_to_site holds any number of named site-to-site connections, which /site-to-site lists, adds, changes and removes.
Release / release (push) Successful in 12s
Release / release (push) Successful in 12s
This commit is contained in:
1 parent
a79510a02f
commit
4a5b15a559
22 files changed
+843
-351
No files matched your search
@@ -4,6 +4,8 @@ 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
|
||||
@@ -12,6 +14,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
|
||||
import jinja2
|
||||
@@ -25,12 +28,13 @@ 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')
|
||||
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'
|
||||
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')
|
||||
@@ -48,9 +52,9 @@ TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.cr
|
||||
SCHEMA = {
|
||||
'general': {'platform': 'none', 'mode': 'manual'},
|
||||
'interfaces': {'external': '', 'internal': ''},
|
||||
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'},
|
||||
'wan': {'local_fqdn': ''},
|
||||
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
|
||||
'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''},
|
||||
'site_to_site': {},
|
||||
'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'},
|
||||
@@ -64,6 +68,15 @@ SCHEMA = {
|
||||
'keep_monthly': '1 year', 'keep_yearly': '10 years'},
|
||||
}
|
||||
|
||||
#: 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', 'psk': '', 'psk_file': ''}
|
||||
|
||||
#: 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')
|
||||
|
||||
@@ -93,7 +106,7 @@ DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$')
|
||||
DOCKER_PORT_LIMIT = 15
|
||||
|
||||
TYPE_NAMES = {str: 'a string', list: 'a list of strings', bool: 'a boolean',
|
||||
int: 'an integer'}
|
||||
int: 'an integer', dict: 'an object'}
|
||||
|
||||
ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
|
||||
|
||||
@@ -118,29 +131,76 @@ def fail(message):
|
||||
sys.exit(f'vpn-router: error: {message}')
|
||||
|
||||
|
||||
def load_config(path=None):
|
||||
"""Read the configuration, filling in defaults for anything absent.
|
||||
def read_settings(path=None):
|
||||
"""The settings in the configuration file, without the defaults; {} when the file does not exist.
|
||||
|
||||
The result maps section -> option -> value. Sections and options not in
|
||||
SCHEMA are kept so that validate() can report them.
|
||||
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.
|
||||
"""
|
||||
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
|
||||
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):
|
||||
@@ -152,23 +212,37 @@ def _get(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 [item.strip() for item in cfg[section][option] if item.strip()]
|
||||
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')
|
||||
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]}')
|
||||
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):
|
||||
@@ -220,19 +294,28 @@ def validate(cfg):
|
||||
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('@'):
|
||||
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'{section}.{option}: remove the leading @; strongSwan reads an '
|
||||
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')
|
||||
|
||||
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')
|
||||
|
||||
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')
|
||||
@@ -355,15 +438,15 @@ def validate(cfg):
|
||||
'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')
|
||||
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'remote.psk_file: {psk_file} does not exist')
|
||||
raise ConfigError(f'site_to_site.{name}.psk_file: {psk_file} does not exist')
|
||||
return path.read_text(encoding='utf-8').strip()
|
||||
return cfg['remote']['psk']
|
||||
return connection['psk']
|
||||
|
||||
|
||||
def platform_module(cfg):
|
||||
@@ -447,29 +530,43 @@ def resolve_interfaces(cfg):
|
||||
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."""
|
||||
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')
|
||||
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
|
||||
identities = {'fqdn': local_fqdn, 'public_ip': public_ip or '', 'internal_ip': int_addr}
|
||||
|
||||
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 = ''
|
||||
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'])
|
||||
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, ''),
|
||||
'psk': resolve_psk(connection, name),
|
||||
}
|
||||
entry['ready'] = bool(local_addrs and connection_cidrs and all(
|
||||
entry[key] for key in ('remote_addrs', 'remote_id', 'remote_cidrs', 'local_id', 'psk')))
|
||||
connections.append(entry)
|
||||
|
||||
return {
|
||||
'platform': _get(cfg, 'general', 'platform') or PLATFORM_NONE,
|
||||
@@ -479,18 +576,12 @@ def build_context(cfg, local_id_override=None):
|
||||
'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),
|
||||
'connections': connections,
|
||||
'p2s_enabled': _get(cfg, 'p2s', 'enabled'),
|
||||
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
|
||||
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
|
||||
@@ -530,11 +621,8 @@ def build_context(cfg, local_id_override=None):
|
||||
|
||||
|
||||
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'],
|
||||
])
|
||||
"""True when any site_to_site connection is ready."""
|
||||
return any(connection['ready'] for connection in ctx['connections'])
|
||||
|
||||
|
||||
def routing_ready(ctx):
|
||||
@@ -699,7 +787,7 @@ def distribute_pki(ctx):
|
||||
return changed
|
||||
|
||||
|
||||
def configure(cfg=None, local_id_override=None):
|
||||
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:
|
||||
@@ -715,7 +803,7 @@ def configure(cfg=None, local_id_override=None):
|
||||
|
||||
try:
|
||||
validate(cfg)
|
||||
ctx = build_context(cfg, local_id_override=local_id_override)
|
||||
ctx = build_context(cfg, public_ip=public_ip)
|
||||
except ConfigError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
@@ -728,9 +816,9 @@ def configure(cfg=None, local_id_override=None):
|
||||
env = _jinja_env()
|
||||
|
||||
if s2s_ready(ctx):
|
||||
if render(env, ctx, 'remote-site.conf.j2', REMOTE_SITE, 0o600):
|
||||
if render(env, ctx, 'site-to-site.conf.j2', SITE_TO_SITE, 0o600):
|
||||
changed.add('swanctl')
|
||||
elif remove(REMOTE_SITE):
|
||||
elif remove(SITE_TO_SITE):
|
||||
changed.add('swanctl')
|
||||
|
||||
if p2s_ready(ctx):
|
||||
|
||||
Reference in new issue
Block a user