Converted router configuration file from .conf to JSON.

This commit is contained in:
slawek committed 2026-10-23 07:35:43 +02:00
1 parent c423ea9347
commit b18947ed9b
18 files changed
+285 -396

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/python3
"""Render configuration files and provision PKI from vpn-router.conf.
"""Render configuration files and provision PKI from vpn-router.json.
Run directly to regenerate them without starting, stopping or reloading
anything. This still writes to /etc/swanctl, /etc/systemd/resolved.conf.d and
@@ -1,15 +1,15 @@
#!/usr/bin/python3
"""Write a candidate vpn-router.conf to the path given on the command line.
"""Write a candidate vpn-router.json to the path given on the command line.
Called from postinst, which hands the result to ucf(1). ucf compares it with
/etc/vpn-router/vpn-router.conf and decides what to do about local changes, so
/etc/vpn-router/vpn-router.json and decides what to do about local changes, so
this script never writes to /etc and never has to worry about destroying an
administrator's edits.
The candidate is built in three layers, each overriding the one before:
1. the shipped defaults
2. the current contents of /etc/vpn-router/vpn-router.conf, if it exists
2. the current contents of /etc/vpn-router/vpn-router.json, if it exists
3. any debconf answer that was actually given
Layer 2 is what makes reconfiguration safe: a setting the administrator edited
@@ -19,12 +19,12 @@ answer means "leave alone", which is why clearing the pre-shared key from the
debconf database after installation does not blank it on the next
dpkg-reconfigure.
Answers arrive as VPN_ROUTER_* environment variables. The pre-shared key is
stored base64-encoded so that arbitrary characters cannot collide with INI
syntax.
Answers arrive as VPN_ROUTER_* environment variables and are converted to the
type of the setting they fill: comma-separated lists become arrays, "true" and
"false" become booleans and digits become integers.
"""
import base64
import json
import os
import sys
@@ -46,6 +46,7 @@ ANSWERS = {
'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'),
'VPN_ROUTER_REMOTE_ID': ('remote', 'id'),
'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'),
'VPN_ROUTER_PSK': ('remote', 'psk'),
'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'),
'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'),
'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'),
@@ -61,25 +62,25 @@ def main():
return 2
output = sys.argv[1]
parser = vpnrouter.default_parser()
current = vpnrouter.load_config()
for section in parser.sections():
for option in parser[section]:
if current.has_option(section, option):
parser[section][option] = current.get(section, option)
cfg = vpnrouter.load_config()
for variable, (section, option) in ANSWERS.items():
if os.environ.get(variable):
parser[section][option] = os.environ[variable]
psk = os.environ.get('VPN_ROUTER_PSK', '')
if psk:
parser['remote']['psk_b64'] = base64.b64encode(
psk.encode('utf-8')).decode('ascii')
value = os.environ.get(variable)
if not value:
continue
# A value that does not convert is kept as a string for validate() to report.
default = vpnrouter.SCHEMA[section][option]
if isinstance(default, bool):
value = value == 'true'
elif isinstance(default, int):
value = int(value) if value.isdigit() else value
elif isinstance(default, list):
value = [item.strip() for item in value.split(',') if item.strip()]
cfg[section][option] = value
with open(output, 'w', encoding='utf-8') as handle:
parser.write(handle)
json.dump(cfg, handle, indent=2)
handle.write('\n')
os.chmod(output, 0o600)
return 0
+4 -4
View File
@@ -139,10 +139,10 @@ def filter_block(ctx):
'# WIREGUARD RULES END',
]
if vpnrouter.p2s_ready(ctx) and ctx['local_addrs']:
if vpnrouter.p2s_ready(ctx) and ctx['local_addr_list']:
pool = ctx['p2s_address_pool']
lines += ['', '# P2S DNS RULES START']
for address in [a.strip() for a in ctx['local_addrs'].split(',') if a.strip()]:
for address in ctx['local_addr_list']:
lines += [
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
@@ -166,7 +166,7 @@ def nat_and_mangle_block(ctx):
if vpnrouter.routing_ready(ctx):
subnets = ctx['local_cidr_list']
remote = [c.strip() for c in ctx['remote_cidrs'].split(',') if c.strip()]
remote = ctx['remote_cidr_list']
lines += [
'',
'# ROUTER NAT RULES START',
@@ -305,7 +305,7 @@ def main():
if ctx is None:
print('vpn-router: platform is none, configuration deferred; '
'set general.platform in /etc/vpn-router/vpn-router.conf')
f'set general.platform in {vpnrouter.CONFIG_FILE}')
return 0
if vpnrouter.routing_ready(ctx):
@@ -4,10 +4,8 @@ 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 json
import os
import pathlib
import shutil
@@ -22,7 +20,7 @@ if str(LIB_DIR) not in sys.path:
import vpnrouter_platforms
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.conf')
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'
@@ -39,17 +37,20 @@ 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
# 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_b64': '', 'psk_file': ''},
'p2s': {'enabled': 'false', 'address_pool': '', 'ca_name': 'VPN Router CA'},
'wireguard': {'enabled': 'false', 'address': '', 'listen_port': '51820'},
'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},
}
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.
@@ -77,46 +78,29 @@ def fail(message):
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=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
parser = default_parser()
cfg = {section: dict(options) for section, options in SCHEMA.items()}
if path.exists():
try:
parser.read(path, encoding='utf-8')
except configparser.Error as exc:
data = json.loads(path.read_text(encoding='utf-8'))
except ValueError as exc:
fail(f'cannot parse {path}: {exc}')
return parser
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):
@@ -124,19 +108,31 @@ def _split_list(value):
def _get(cfg, section, option):
return cfg.get(section, option, fallback=SCHEMA[section][option]).strip()
value = cfg[section][option]
return value.strip() if isinstance(value, str) else value
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 _get_list(cfg, section, option):
return [item.strip() for item in cfg[section][option] if item.strip()]
def _check_networks(value, name):
for item in _split_list(value):
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:
@@ -158,6 +154,8 @@ def validate(cfg):
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:
@@ -187,9 +185,10 @@ def validate(cfg):
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_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')
_check_address(_get(cfg, 'local', 'int_addr'), 'local.int_addr')
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
@@ -210,12 +209,8 @@ def validate(cfg):
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')
if not 1 <= port <= 65535:
raise ConfigError(f'wireguard.listen_port: {port} is not a valid port')
def resolve_psk(cfg):
@@ -226,14 +221,7 @@ def resolve_psk(cfg):
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}')
return cfg['remote']['psk']
def platform_module(cfg):
@@ -253,7 +241,7 @@ def platform_module(cfg):
def _join(*groups):
items = []
for group in groups:
for item in _split_list(group):
for item in group:
if item not in items:
items.append(item)
return ', '.join(items)
@@ -369,9 +357,8 @@ def resolve_interfaces(cfg):
if mode == 'auto':
ext_iface, int_iface = detect_interfaces()
ext_addrs = interface_addresses(ext_iface, 'interfaces.external')
local_addrs = ', '.join(ext_addrs)
if ext_iface and not ext_addrs:
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':
@@ -391,9 +378,10 @@ def build_context(cfg, local_id_override=None):
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(cfg, 'local', 'cidrs')
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')
if local_id_override:
local_id = local_id_override
@@ -406,32 +394,31 @@ def build_context(cfg, local_id_override=None):
# re-renders with local_id_override set.
local_id = ''
cidr_list = _split_list(local_cidrs)
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': local_addrs,
'local_addr_list': _split_list(local_addrs),
'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': local_cidrs,
'local_cidr_list': cidr_list,
'local_ts': _join(local_cidrs, extras['EXTRA_LOCAL_TS']),
'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': _get(cfg, 'remote', 'addrs'),
'remote_addrs': ', '.join(_get_list(cfg, 'remote', 'addrs')),
'remote_id': _get(cfg, 'remote', 'id'),
'remote_cidrs': _get(cfg, 'remote', 'cidrs'),
'remote_cidrs': ', '.join(remote_cidrs),
'remote_cidr_list': remote_cidrs,
'psk': resolve_psk(cfg),
'p2s_enabled': _get_bool(cfg, 'p2s', 'enabled'),
'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, extras['EXTRA_P2S_DNS']),
'wg_enabled': _get_bool(cfg, 'wireguard', 'enabled'),
'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'),
}
@@ -1,84 +0,0 @@
# Example vpn-router configuration.
#
# This file is documentation. The live configuration is
# /etc/vpn-router/vpn-router.conf, which is generated on first install and
# owned by the operator afterwards. Copy settings from here as needed.
#
# Format: INI. Lists are comma-separated, booleans are true/false, an empty
# value means unset, and a _b64 suffix means the value is base64-encoded.
#
# Comments start with # or ; and may follow a value on the same line. That
# means # and ; cannot appear inside a value - which is why the pre-shared
# key, the one setting that can hold arbitrary text, is base64-encoded.
#
# Apply any change with: systemctl restart vpn-router-setup
#
# Every feature is optional. A file with nothing filled in configures nothing
# and leaves the host reachable and unchanged.
[general]
# Platform: none, generic, azure or gcp.
# none - configuration is deferred. Files are installed and nothing on
# this machine is changed. Set a real platform when ready.
# generic - configure, with no platform-specific additions.
platform = none
# How much of the network configuration you supply, and therefore how much is
# read from the system:
# manual - interface names, int_addr and int_gateway_ip are all given
# below. Nothing is detected.
# interfaces - the two interface names are given; int_addr and
# int_gateway_ip are read from them.
# auto - nothing is given. The interfaces are identified from the
# routing table. Convenient, and able to get it wrong.
mode = manual
[interfaces]
# External faces the untrusted network; internal faces the protected one.
# Required in manual and interfaces mode; worked out for you in auto mode.
external =
internal =
[wan]
# This router's fully-qualified domain name.
local_fqdn =
# Source of the local IKE identity: fqdn | public_ip | internal_ip
# fqdn - use local_fqdn (must match what the peer expects)
# public_ip - resolve local_fqdn at boot and use the address
# internal_ip - use the address on the internal interface
local_id_mode = fqdn
[local]
# Local subnets advertised into the tunnel, comma-separated.
cidrs =
# This host's own address on the internal network, and the next hop on that
# side for the subnets above. Both are required in manual mode and read from
# the internal interface in the other two.
int_addr =
int_gateway_ip =
[remote]
# Remote gateway address(es) or FQDN, comma-separated.
addrs =
# Remote IKE identity, without a leading '@'.
id =
# Remote subnets reachable through the tunnel, comma-separated.
cidrs =
# Pre-shared key, base64-encoded: printf %s "$PSK" | base64 -w0
psk_b64 =
# Path to a file containing the raw PSK. Takes precedence over psk_b64.
psk_file =
[p2s]
# Road-warrior access.
enabled = false
# Address pool handed to road-warrior clients.
address_pool =
# CN for the CA created when /etc/vpn-router/pki is empty.
ca_name = VPN Router CA
[wireguard]
enabled = false
# Address and prefix length for wg0, for example 192.168.200.1/24.
address =
listen_port = 51820
@@ -0,0 +1,36 @@
{
"general": {
"platform": "generic",
"mode": "manual"
},
"interfaces": {
"external": "eth0",
"internal": "eth1"
},
"wan": {
"local_fqdn": "router.example.com",
"local_id_mode": "fqdn"
},
"local": {
"cidrs": ["10.0.0.0/24"],
"int_addr": "10.1.1.4",
"int_gateway_ip": "10.1.1.1"
},
"remote": {
"addrs": ["peer.example.net"],
"id": "peer.example.net",
"cidrs": ["192.168.0.0/24"],
"psk": "change-me",
"psk_file": ""
},
"p2s": {
"enabled": false,
"address_pool": "172.16.0.0/24",
"ca_name": "VPN Router CA"
},
"wireguard": {
"enabled": false,
"address": "192.168.200.1/24",
"listen_port": 51820
}
}