Converted router configuration file from .conf to JSON.
This commit is contained in:
1 parent
c423ea9347
commit
b18947ed9b
18 files changed
+285
-396
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user