Reengineed the code. Generalized the package. Cloud configurators are modules.

This commit is contained in:
slawek committed 2026-08-10 09:02:45 +02:00
1 parent be4bb34bb0
commit 083ad9a596
40 files changed
+2367 -650

No files matched your search

@@ -0,0 +1,503 @@
"""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 base64
import binascii
import configparser
import ipaddress
import os
import pathlib
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_platforms
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.conf')
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')
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
SCHEMA = {
'general': {'platform': 'generic'},
'interfaces': {'external': '', 'internal': ''},
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'},
'local': {'cidrs': '', 'int_gateway_ip': ''},
'remote': {'addrs': '', 'id': '', 'cidrs': '', 'psk_b64': '', 'psk_file': ''},
'p2s': {'enabled': 'false', 'address_pool': '', 'ca_name': 'VPN Router CA'},
'wireguard': {'enabled': 'false', 'address': '', 'listen_port': '51820'},
}
ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
class ConfigError(Exception):
"""A setting is present but malformed."""
def warn(message):
print(f'vpn-router: warning: {message}', file=sys.stderr)
def fail(message):
print(f'vpn-router: error: {message}', file=sys.stderr)
sys.exit(1)
def new_parser():
"""A parser that tolerates operator comments and never interpolates.
interpolation=None matters: the default BasicInterpolation treats '%' as
syntax, which would break any value containing it.
Inline comments are enabled, which configparser leaves off by default
because it makes '#' and ';' unusable inside values. That is an acceptable
trade here precisely because the one setting that can hold arbitrary
characters, the pre-shared key, is base64-encoded, and base64 uses none of
those characters.
"""
return configparser.ConfigParser(
interpolation=None,
comment_prefixes=('#', ';'),
inline_comment_prefixes=('#', ';'),
)
def default_parser():
parser = new_parser()
for section, options in SCHEMA.items():
parser[section] = dict(options)
return parser
def load_config(path=CONFIG_FILE):
"""Read the configuration, filling in defaults for anything absent."""
parser = default_parser()
if path.exists():
try:
parser.read(path, encoding='utf-8')
except configparser.Error as exc:
fail(f'cannot parse {path}: {exc}')
return parser
def _split_list(value):
return [item.strip() for item in value.split(',') if item.strip()]
def _get(cfg, section, option):
return cfg.get(section, option, fallback=SCHEMA[section][option]).strip()
def _get_bool(cfg, section, option):
try:
return cfg.getboolean(section, option,
fallback=SCHEMA[section][option] == 'true')
except ValueError:
raise ConfigError(f'{section}.{option} is not a boolean')
def _check_networks(value, name):
for item in _split_list(value):
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."""
platform = _get(cfg, 'general', 'platform')
installed = 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)}')
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(cfg, 'local', 'cidrs'), 'local.cidrs')
_check_networks(_get(cfg, 'remote', 'cidrs'), 'remote.cidrs')
_check_networks(_get(cfg, 'p2s', 'address_pool'), 'p2s.address_pool')
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
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 port:
if not port.isdigit() or not 1 <= int(port) <= 65535:
raise ConfigError(f'wireguard.listen_port: {port!r} is not a valid port')
for section in ('p2s', 'wireguard'):
_get_bool(cfg, section, 'enabled')
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()
psk_b64 = _get(cfg, 'remote', 'psk_b64')
if not psk_b64:
return ''
try:
return base64.b64decode(psk_b64, validate=True).decode('utf-8')
except (binascii.Error, UnicodeDecodeError) as exc:
raise ConfigError(f'remote.psk_b64 is not valid base64 text: {exc}')
def platform_module(cfg):
"""Import the configured platform module, falling back to the default."""
name = _get(cfg, 'general', 'platform') or 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 _split_list(group):
if item not in items:
items.append(item)
return ', '.join(items)
def interface_addresses(name, setting):
"""IPv4 addresses configured on an interface.
The interface name is configuration; its addresses are read from the
system. Looking up the addresses of a named interface is deterministic -
unlike working out which interface to use, which is a guess the package
deliberately does not make.
"""
if not name:
return []
if not (pathlib.Path('/sys/class/net') / name).exists():
raise ConfigError(f'{setting}: interface {name!r} does not exist')
result = subprocess.run(
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
capture_output=True, text=True,
)
if result.returncode != 0:
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
addresses = []
for line in result.stdout.splitlines():
fields = line.split()
if len(fields) >= 4:
addresses.append(fields[3].split('/')[0])
return addresses
def build_context(cfg, local_id_override=None):
"""Assemble the template context from the configuration."""
extras = vpnrouter_platforms.context(platform_module(cfg))
ext_iface = _get(cfg, 'interfaces', 'external')
int_iface = _get(cfg, 'interfaces', 'internal')
ext_addrs = interface_addresses(ext_iface, 'interfaces.external')
int_addrs = interface_addresses(int_iface, 'interfaces.internal')
if ext_iface and not ext_addrs:
warn(f'{ext_iface} has no IPv4 address yet')
local_addrs = ', '.join(ext_addrs)
int_addr = int_addrs[0] if int_addrs else ''
local_fqdn = _get(cfg, 'wan', 'local_fqdn')
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
local_cidrs = _get(cfg, 'local', 'cidrs')
if local_id_override:
local_id = local_id_override
elif id_mode == 'fqdn':
local_id = f'@{local_fqdn}' if local_fqdn else ''
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 = ''
cidr_list = _split_list(local_cidrs)
return {
'platform': _get(cfg, 'general', 'platform') or 'generic',
'wan_iface': ext_iface,
'int_iface': int_iface,
'local_addrs': local_addrs,
'local_addr_list': _split_list(local_addrs),
'local_fqdn': local_fqdn,
'local_id_mode': id_mode,
'local_id': local_id,
'local_cidrs': local_cidrs,
'local_cidr_list': cidr_list,
'local_subnet': cidr_list[0] if cidr_list else '',
'local_ts': _join(local_cidrs, extras['EXTRA_LOCAL_TS']),
'int_addr': int_addr,
'int_gateway_ip': _get(cfg, 'local', 'int_gateway_ip'),
'remote_addrs': _get(cfg, 'remote', 'addrs'),
'remote_id': _get(cfg, 'remote', 'id'),
'remote_cidrs': _get(cfg, 'remote', 'cidrs'),
'psk': resolve_psk(cfg),
'p2s_enabled': _get_bool(cfg, 'p2s', 'enabled'),
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
'p2s_dns': _join(local_addrs, extras['EXTRA_P2S_DNS']),
'wg_enabled': _get_bool(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'),
'wg_listen_port': _get(cfg, 'wireguard', 'listen_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 _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 server certificate when the PKI directory is empty."""
PKI_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PKI_DIR, 0o700)
if any(PKI_DIR.iterdir()):
return
if not ctx['p2s_ca_name']:
warn('p2s.ca_name is empty, not creating a local CA')
return
if not SIMPLE_CA.is_file():
fail(f'{SIMPLE_CA} is missing')
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
_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'.
"""
if cfg is None:
cfg = load_config()
try:
validate(cfg)
ctx = build_context(cfg, local_id_override=local_id_override)
except ConfigError as exc:
fail(str(exc))
changed = set()
if p2s_ready(ctx):
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')
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
changed.add('resolved')
else:
if remove(ROAD_WARRIOR):
changed.add('swanctl')
if 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')
return ctx, changed