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

This commit is contained in:
slawek committed 2026-10-25 17:14:23 +01:00
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):