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'),
}