From b18947ed9b9a93c66470f247c2bc8c6622b29ab1 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Fri, 23 Oct 2026 07:35:43 +0200 Subject: [PATCH] Converted router configuration file from .conf to JSON. --- README.md | 93 ++++++----- app/README.md | 0 debian-package/debian/control | 2 +- debian-package/debian/install | 2 +- debian-package/debian/postinst | 6 +- debian-package/debian/postrm | 6 +- debian-package/debian/templates | 6 +- .../src/usr/lib/vpn-router/configure | 2 +- .../src/usr/lib/vpn-router/generate-config | 45 ++--- debian-package/src/usr/lib/vpn-router/setup | 8 +- .../src/usr/lib/vpn-router/vpnrouter.py | 155 ++++++++---------- .../doc/vpn-router/vpn-router.conf.example | 84 ---------- .../doc/vpn-router/vpn-router.json.example | 36 ++++ examples/azure/router.tf | 43 +++-- examples/cloud-init-with-pki.yaml.tpl | 35 +--- examples/cloud-init.yaml.tpl | 35 +--- examples/gcp/router.tf | 43 +++-- examples/install.sh | 80 ++++----- 18 files changed, 285 insertions(+), 396 deletions(-) create mode 100644 app/README.md delete mode 100644 debian-package/src/usr/share/doc/vpn-router/vpn-router.conf.example create mode 100644 debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example diff --git a/README.md b/README.md index 1f6c5be..52ce439 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ supported equally, and both end up with the same two things: a configuration fil service that applies it. ``` -/etc/vpn-router/vpn-router.conf the configuration +/etc/vpn-router/vpn-router.json the configuration systemctl restart vpn-router-setup apply it ``` @@ -32,7 +32,7 @@ systemctl restart vpn-router-setup apply it ```sh apt-get install vpn-router # answer "none" to the platform question -$EDITOR /etc/vpn-router/vpn-router.conf +$EDITOR /etc/vpn-router/vpn-router.json systemctl restart vpn-router-setup ``` @@ -65,10 +65,10 @@ EOF DEBIAN_FRONTEND=noninteractive apt-get install -y vpn-router ``` -With `mode = interfaces` the `int_addr` and `int_gateway_ip` lines are dropped and read -from the internal NIC instead; with `mode = auto` the interface names go too. +With mode `interfaces` the `int_addr` and `int_gateway_ip` lines are dropped and read +from the internal NIC instead; with mode `auto` the interface names go too. -Alternatively write `/etc/vpn-router/vpn-router.conf` directly before or after installing. +Alternatively write `/etc/vpn-router/vpn-router.json` directly before or after installing. A configuration-management tool should do that and then restart `vpn-router-setup`. See [examples/](examples/) for a cloud-init template and a shell installer, and @@ -77,47 +77,58 @@ that deploy a router on Azure or GCP. ## Configuration -`/etc/vpn-router/vpn-router.conf` is INI, mode 0600, and is created on first install. It +`/etc/vpn-router/vpn-router.json` is JSON, mode 0600, and is created on first install. It is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies what -changed and asks before touching anything you altered by hand. A fully commented -reference is installed at `/usr/share/doc/vpn-router/vpn-router.conf.example`. +changed and asks before touching anything you altered by hand. A complete example is +installed at `/usr/share/doc/vpn-router/vpn-router.json.example`. -Lists are comma-separated, booleans are `true`/`false`, an empty value means unset, and a -`_b64` suffix means the value is base64-encoded. +The file is an object of sections, each an object of settings. A section or setting that +is left out takes its default, and an empty string or empty list means unset. Every value +must have the type shown below; an unknown section or setting is an error. -| Section | Setting | Meaning | -|---|---|---| -| `general` | `platform` | `none`, `generic`, `azure` or `gcp`. `none` defers configuration entirely | -| `general` | `mode` | How much you supply: `manual`, `interfaces` or `auto` | -| `interfaces` | `external` | Name of the NIC facing the untrusted network | -| `interfaces` | `internal` | Name of the NIC facing the protected network | -| `wan` | `local_fqdn` | This router's FQDN | -| `wan` | `local_id_mode` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` | -| `local` | `cidrs` | Local subnets advertised into the tunnel | -| `local` | `int_addr` | This host's address on the internal network | -| `local` | `int_gateway_ip` | Next hop on the internal side | -| `remote` | `addrs` | Remote gateway address(es) or FQDN | -| `remote` | `id` | Remote IKE identity, without a leading `@` | -| `remote` | `cidrs` | Remote subnets reachable through the tunnel | -| `remote` | `psk_b64` | Pre-shared key, base64-encoded | -| `remote` | `psk_file` | Path to a file holding the raw key; wins over `psk_b64` | -| `p2s` | `enabled` | Road-warrior access | -| `p2s` | `address_pool` | Pool assigned to road-warrior clients | -| `p2s` | `ca_name` | Common name for a locally created CA | -| `wireguard` | `enabled` | WireGuard endpoint | -| `wireguard` | `address` | Address and prefix for `wg0` | -| `wireguard` | `listen_port` | UDP port, default 51820 | +| Section | Setting | Type | Default | Meaning | +|---|---|---|---|---| +| `general` | `platform` | string | `none` | `none`, `generic`, `azure` or `gcp`. `none` defers configuration entirely | +| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` | +| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network | +| `interfaces` | `internal` | string | | Name of the NIC facing the protected network | +| `wan` | `local_fqdn` | string | | This router's FQDN | +| `wan` | `local_id_mode` | string | `fqdn` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` | +| `local` | `cidrs` | list | | Local subnets advertised into the tunnel | +| `local` | `int_addr` | string | | This host's address on the internal network | +| `local` | `int_gateway_ip` | string | | Next hop on the internal side | +| `remote` | `addrs` | list | | Remote gateway addresses or FQDNs | +| `remote` | `id` | string | | Remote IKE identity, without a leading `@` | +| `remote` | `cidrs` | list | | Remote subnets reachable through the tunnel | +| `remote` | `psk` | string | | Pre-shared key | +| `remote` | `psk_file` | string | | Path to a file holding the key; wins over `psk` | +| `p2s` | `enabled` | boolean | `false` | Road-warrior access | +| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients | +| `p2s` | `ca_name` | string | `VPN Router CA` | Common name for a locally created CA | +| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint | +| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` | +| `wireguard` | `listen_port` | integer | `51820` | UDP port | + +A minimal site-to-site configuration in `auto` mode: + +```json +{ + "general": { "platform": "generic", "mode": "auto" }, + "wan": { "local_fqdn": "router.example.com" }, + "local": { "cidrs": ["10.0.0.0/24"] }, + "remote": { + "addrs": ["peer.example.net"], + "id": "peer.example.net", + "cidrs": ["192.168.0.0/24"], + "psk": "s3cr3t" + } +} +``` WireGuard peers are added by hand in `/etc/wireguard/wg0.conf`. The package owns the `[Interface]` section of that file and rewrites it when the settings above change, but it carries every `[Peer]` section across untouched. -Encode the pre-shared key with: - -```sh -printf %s 'the key' | base64 -w0 -``` - Every feature is independent. Leaving a group empty simply means that feature is not configured, and its generated files are removed. @@ -148,7 +159,7 @@ address of the connected subnet with a warning when the routing table has nothin systemctl restart vpn-router-setup ``` -The service also runs at boot. With `platform = none` it does nothing and says so. +The service also runs at boot. With platform `none` it does nothing and says so. Otherwise it regenerates the configuration files, resolves the interfaces and their addresses as `mode` directs, applies routes, generates WireGuard keys and injects firewall rules. It then starts `strongswan` and `wg-quick@wg0` if the corresponding @@ -240,7 +251,7 @@ EXTRA_P2S_DNS = '192.0.2.53' EXTRA_LOCAL_TS = '192.0.2.53/32' ``` -Set `platform = example` and restart the service. The debconf question offers only the +Set `general.platform` to `example` and restart the service. The debconf question offers only the modules shipped with the package; the configuration file accepts any module installed. ## Removal @@ -250,7 +261,7 @@ configuration in place. `strongswan` is left enabled, since it is a shared servi may be in use by something else. `apt-get purge` additionally removes the generated files and the firewall rules, restoring -`/etc/ufw/before.rules` to its original content, and hands `vpn-router.conf` back to `ucf` +`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf` before deleting it so a later reinstall starts clean. Key material is never deleted: `/etc/vpn-router/pki`, the WireGuard key pair and everything under `/etc/swanctl` are left alone. diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..e69de29 diff --git a/debian-package/debian/control b/debian-package/debian/control index 3bd2379..adb231c 100644 --- a/debian-package/debian/control +++ b/debian-package/debian/control @@ -28,7 +28,7 @@ Description: Linux VPN router with IPSec, road-warrior and WireGuard support optional WireGuard, and the routing, NAT, MSS clamping and firewall rules a router needs. Nothing in the package is specific to a cloud provider. . - Settings live in /etc/vpn-router/vpn-router.conf, which is created on first + Settings live in /etc/vpn-router/vpn-router.json, which is created on first install and owned by the administrator afterwards. Editing it and restarting vpn-router-setup applies the change; the same service reconciles the system at every boot. It is built on two inputs, the external and internal interface diff --git a/debian-package/debian/install b/debian-package/debian/install index 2a56754..18c35a0 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -6,4 +6,4 @@ src/usr/lib/vpn-router/setup usr/lib/vpn-router/ src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/ src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/ -src/usr/share/doc/vpn-router/vpn-router.conf.example usr/share/doc/vpn-router/ +src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/ diff --git a/debian-package/debian/postinst b/debian-package/debian/postinst index 685df2f..37d3aaf 100755 --- a/debian-package/debian/postinst +++ b/debian-package/debian/postinst @@ -42,9 +42,9 @@ case "$1" in # This is what makes dpkg-reconfigure apply without destroying edits. CANDIDATE="$(mktemp)" /usr/lib/vpn-router/generate-config "$CANDIDATE" - ucf --three-way --debconf-ok "$CANDIDATE" /etc/vpn-router/vpn-router.conf - ucfr vpn-router /etc/vpn-router/vpn-router.conf - chmod 0600 /etc/vpn-router/vpn-router.conf + ucf --three-way --debconf-ok "$CANDIDATE" /etc/vpn-router/vpn-router.json + ucfr vpn-router /etc/vpn-router/vpn-router.json + chmod 0600 /etc/vpn-router/vpn-router.json rm -f "$CANDIDATE" # The key now lives in the configuration file; do not keep a copy. An diff --git a/debian-package/debian/postrm b/debian-package/debian/postrm index 6b72b19..fcbe1fb 100755 --- a/debian-package/debian/postrm +++ b/debian-package/debian/postrm @@ -37,17 +37,17 @@ case "$1" in # Let ucf forget the file before it is removed, or a reinstall finds a # stale hash and declines to lay the file down again. if command -v ucf >/dev/null 2>&1; then - ucf --purge /etc/vpn-router/vpn-router.conf + ucf --purge /etc/vpn-router/vpn-router.json fi if command -v ucfr >/dev/null 2>&1; then - ucfr --purge vpn-router /etc/vpn-router/vpn-router.conf + ucfr --purge vpn-router /etc/vpn-router/vpn-router.json fi rm -f /etc/swanctl/conf.d/remote-site.conf \ /etc/swanctl/conf.d/road-warrior.conf \ /etc/systemd/resolved.conf.d/p2s-forwarder.conf \ /etc/wireguard/wg0.conf \ - /etc/vpn-router/vpn-router.conf + /etc/vpn-router/vpn-router.json # Key material is not package state: /etc/vpn-router/pki, the WireGuard # key pair and everything under /etc/swanctl are left in place. diff --git a/debian-package/debian/templates b/debian-package/debian/templates index 59079e7..e4be953 100644 --- a/debian-package/debian/templates +++ b/debian-package/debian/templates @@ -9,7 +9,7 @@ Description: Platform: none defers configuration entirely: the package installs its files and changes nothing on the machine. Choose it when the router will be configured later by hand or by a configuration-management tool, then set the platform in - /etc/vpn-router/vpn-router.conf when you are ready. + /etc/vpn-router/vpn-router.json when you are ready. . generic configures the router with no platform-specific additions. @@ -113,8 +113,8 @@ Template: vpn-router/psk Type: password Description: Pre-shared key (PSK): Pre-shared key for the site-to-site IKEv2 tunnel. Must match the value - configured on the remote peer. Stored base64-encoded in the configuration - file and cleared from the debconf database after installation. + configured on the remote peer. Stored in the configuration file and cleared + from the debconf database after installation. Template: vpn-router/p2s_enabled Type: boolean diff --git a/debian-package/src/usr/lib/vpn-router/configure b/debian-package/src/usr/lib/vpn-router/configure index a0e9a12..86ee727 100755 --- a/debian-package/src/usr/lib/vpn-router/configure +++ b/debian-package/src/usr/lib/vpn-router/configure @@ -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 diff --git a/debian-package/src/usr/lib/vpn-router/generate-config b/debian-package/src/usr/lib/vpn-router/generate-config index 66961c9..2d444fb 100755 --- a/debian-package/src/usr/lib/vpn-router/generate-config +++ b/debian-package/src/usr/lib/vpn-router/generate-config @@ -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 diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index a44cfcd..f41f5e3 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -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): diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 09135f0..7ad3b13 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -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'), } diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.conf.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.conf.example deleted file mode 100644 index 4710273..0000000 --- a/debian-package/src/usr/share/doc/vpn-router/vpn-router.conf.example +++ /dev/null @@ -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 diff --git a/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example new file mode 100644 index 0000000..80c602b --- /dev/null +++ b/debian-package/src/usr/share/doc/vpn-router/vpn-router.json.example @@ -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 + } +} diff --git a/examples/azure/router.tf b/examples/azure/router.tf index 948db08..1bd31cd 100644 --- a/examples/azure/router.tf +++ b/examples/azure/router.tf @@ -6,29 +6,26 @@ locals { supply_pki = var.ca_cert_file != "" cloud_init_vars = { - hostname = var.name - fqdn = var.local_fqdn - repo_url = var.repo_url - repo_gpg_key = data.http.repo_gpg_key.response_body - ubuntu_codename = var.ubuntu_codename - platform = "azure" - mode = "auto" - external_interface = "" - internal_interface = "" - local_id_mode = var.local_id_mode - local_cidrs = var.local_cidrs - int_addr = "" - int_gateway_ip = "" - remote_addrs = var.remote_addrs - remote_id = var.remote_id - remote_cidrs = var.remote_cidrs - psk_b64 = base64encode(var.psk) - p2s_enabled = var.p2s_enabled - p2s_address_pool = var.p2s_address_pool - p2s_ca_name = "VPN Router CA" - wg_enabled = var.wireguard_enabled - wg_address = var.wireguard_address - wg_listen_port = var.wireguard_listen_port + hostname = var.name + fqdn = var.local_fqdn + repo_url = var.repo_url + repo_gpg_key = data.http.repo_gpg_key.response_body + ubuntu_codename = var.ubuntu_codename + + # Contents of /etc/vpn-router/vpn-router.json. + config = { + general = { platform = "azure", mode = "auto" } + wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode } + local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] } + remote = { + addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""] + id = var.remote_id + cidrs = local.remote_cidr_list + psk = var.psk + } + p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" } + wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port } + } } } diff --git a/examples/cloud-init-with-pki.yaml.tpl b/examples/cloud-init-with-pki.yaml.tpl index 895b9e0..ca0c007 100644 --- a/examples/cloud-init-with-pki.yaml.tpl +++ b/examples/cloud-init-with-pki.yaml.tpl @@ -22,42 +22,11 @@ apt: write_files: # The configuration file. Created here before the package is installed, so # postinst leaves it alone. - - path: /etc/vpn-router/vpn-router.conf + - path: /etc/vpn-router/vpn-router.json permissions: '0600' owner: root:root content: | - [general] - platform = ${platform} - mode = ${mode} - - [interfaces] - external = ${external_interface} - internal = ${internal_interface} - - [wan] - local_fqdn = ${fqdn} - local_id_mode = ${local_id_mode} - - [local] - cidrs = ${local_cidrs} - int_addr = ${int_addr} - int_gateway_ip = ${int_gateway_ip} - - [remote] - addrs = ${remote_addrs} - id = ${remote_id} - cidrs = ${remote_cidrs} - psk_b64 = ${psk_b64} - - [p2s] - enabled = ${p2s_enabled} - address_pool = ${p2s_address_pool} - ca_name = ${p2s_ca_name} - - [wireguard] - enabled = ${wg_enabled} - address = ${wg_address} - listen_port = ${wg_listen_port} + ${jsonencode(config)} - path: /etc/vpn-router/pki/ca_cert.pem permissions: '0644' diff --git a/examples/cloud-init.yaml.tpl b/examples/cloud-init.yaml.tpl index 269f277..40a57d4 100644 --- a/examples/cloud-init.yaml.tpl +++ b/examples/cloud-init.yaml.tpl @@ -28,42 +28,11 @@ apt: write_files: # The configuration file. Created here before the package is installed, so # postinst leaves it alone. - - path: /etc/vpn-router/vpn-router.conf + - path: /etc/vpn-router/vpn-router.json permissions: '0600' owner: root:root content: | - [general] - platform = ${platform} - mode = ${mode} - - [interfaces] - external = ${external_interface} - internal = ${internal_interface} - - [wan] - local_fqdn = ${fqdn} - local_id_mode = ${local_id_mode} - - [local] - cidrs = ${local_cidrs} - int_addr = ${int_addr} - int_gateway_ip = ${int_gateway_ip} - - [remote] - addrs = ${remote_addrs} - id = ${remote_id} - cidrs = ${remote_cidrs} - psk_b64 = ${psk_b64} - - [p2s] - enabled = ${p2s_enabled} - address_pool = ${p2s_address_pool} - ca_name = ${p2s_ca_name} - - [wireguard] - enabled = ${wg_enabled} - address = ${wg_address} - listen_port = ${wg_listen_port} + ${jsonencode(config)} package_update: true diff --git a/examples/gcp/router.tf b/examples/gcp/router.tf index 6cd738d..65e54ac 100644 --- a/examples/gcp/router.tf +++ b/examples/gcp/router.tf @@ -6,29 +6,26 @@ locals { supply_pki = var.ca_cert_file != "" cloud_init_vars = { - hostname = var.name - fqdn = var.local_fqdn - repo_url = var.repo_url - repo_gpg_key = data.http.repo_gpg_key.response_body - ubuntu_codename = var.ubuntu_codename - platform = "gcp" - mode = "auto" - external_interface = "" - internal_interface = "" - local_id_mode = var.local_id_mode - local_cidrs = var.local_cidrs - int_addr = "" - int_gateway_ip = "" - remote_addrs = var.remote_addrs - remote_id = var.remote_id - remote_cidrs = var.remote_cidrs - psk_b64 = base64encode(var.psk) - p2s_enabled = var.p2s_enabled - p2s_address_pool = var.p2s_address_pool - p2s_ca_name = "VPN Router CA" - wg_enabled = var.wireguard_enabled - wg_address = var.wireguard_address - wg_listen_port = var.wireguard_listen_port + hostname = var.name + fqdn = var.local_fqdn + repo_url = var.repo_url + repo_gpg_key = data.http.repo_gpg_key.response_body + ubuntu_codename = var.ubuntu_codename + + # Contents of /etc/vpn-router/vpn-router.json. + config = { + general = { platform = "gcp", mode = "auto" } + wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode } + local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] } + remote = { + addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""] + id = var.remote_id + cidrs = local.remote_cidr_list + psk = var.psk + } + p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" } + wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port } + } } cloud_init_rendered = local.supply_pki ? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, { diff --git a/examples/install.sh b/examples/install.sh index 13a162d..6f2a199 100755 --- a/examples/install.sh +++ b/examples/install.sh @@ -10,18 +10,19 @@ set -e DEB="${DEB:-./vpn-router_1.0.0-1_all.deb}" +# List values are JSON arrays. PLATFORM="${PLATFORM:-generic}" MODE="${MODE:-manual}" EXTERNAL_INTERFACE="${EXTERNAL_INTERFACE:-eth0}" INTERNAL_INTERFACE="${INTERNAL_INTERFACE:-eth1}" LOCAL_FQDN="${LOCAL_FQDN:-router.example.com}" LOCAL_ID_MODE="${LOCAL_ID_MODE:-fqdn}" -LOCAL_CIDRS="${LOCAL_CIDRS:-10.0.0.0/24}" +LOCAL_CIDRS="${LOCAL_CIDRS:-[\"10.0.0.0/24\"]}" INT_ADDR="${INT_ADDR:-10.1.1.4}" INT_GATEWAY_IP="${INT_GATEWAY_IP:-10.1.1.1}" -REMOTE_ADDRS="${REMOTE_ADDRS:-peer.example.net}" +REMOTE_ADDRS="${REMOTE_ADDRS:-[\"peer.example.net\"]}" REMOTE_ID="${REMOTE_ID:-peer.example.net}" -REMOTE_CIDRS="${REMOTE_CIDRS:-192.168.0.0/24}" +REMOTE_CIDRS="${REMOTE_CIDRS:-[\"192.168.0.0/24\"]}" PSK="${PSK:-change-me}" P2S_ENABLED="${P2S_ENABLED:-false}" P2S_ADDRESS_POOL="${P2S_ADDRESS_POOL:-172.16.0.0/24}" @@ -30,42 +31,47 @@ WG_ENABLED="${WG_ENABLED:-false}" WG_ADDRESS="${WG_ADDRESS:-}" WG_LISTEN_PORT="${WG_LISTEN_PORT:-51820}" -# Write the configuration before installing, so postinst leaves it alone. +# Write the configuration before installing, so postinst leaves it alone. The +# pre-shared key goes to its own file, so it needs no JSON escaping. install -d -m 0755 /etc/vpn-router umask 077 -cat > /etc/vpn-router/vpn-router.conf < /etc/vpn-router/psk +cat > /etc/vpn-router/vpn-router.json <