Defer configuration by default and manage vpn-router.conf with ucf

Add platform = none as the shipped default so installing the package
changes nothing on the machine until a platform is chosen. Add a mode
setting (manual/interfaces/auto) controlling how much of the network
configuration is supplied versus detected from the system. Manage
/etc/vpn-router/vpn-router.conf with ucf instead of writing it once,
so dpkg-reconfigure can safely reapply debconf answers without
clobbering local edits. Extend NAT/forward rules to all local subnets,
not just the first.
This commit is contained in:
slawek committed 2026-08-23 17:15:49 +02:00
1 parent 083ad9a596
commit 7cb2f1b8dd
14 files changed
+439 -124

No files matched your search

+5 -2
View File
@@ -1,8 +1,11 @@
#!/usr/bin/python3
"""Render configuration files and provision PKI from vpn-router.conf.
Run directly to regenerate the configuration files without touching the
running system. 'setup' calls the same code before applying the rest.
Run directly to regenerate them without starting, stopping or reloading
anything. This still writes to /etc/swanctl, /etc/systemd/resolved.conf.d and
/etc/wireguard, and may create the certificate authority and add it to the host
trust store; what it leaves alone is routes, firewall rules and services.
'setup' calls the same code before applying those.
"""
import os
@@ -1,9 +1,23 @@
#!/usr/bin/python3
"""Create /etc/vpn-router/vpn-router.conf from the installer's answers.
"""Write a candidate vpn-router.conf to the path given on the command line.
Runs once, from postinst, and only when the file does not exist. The
configuration file belongs to the operator from that point on and is never
rewritten by the package.
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
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
3. any debconf answer that was actually given
Layer 2 is what makes reconfiguration safe: a setting the administrator edited
by hand, and that debconf has nothing to say about, is carried through
unchanged, so ucf only sees a diff where something really changed. An empty
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
@@ -21,6 +35,8 @@ import vpnrouter
# environment variable -> (section, option)
ANSWERS = {
'VPN_ROUTER_PLATFORM': ('general', 'platform'),
'VPN_ROUTER_MODE': ('general', 'mode'),
'VPN_ROUTER_INT_ADDR': ('local', 'int_addr'),
'VPN_ROUTER_EXTERNAL_INTERFACE': ('interfaces', 'external'),
'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'),
'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'),
@@ -40,31 +56,31 @@ ANSWERS = {
def main():
path = vpnrouter.CONFIG_FILE
if path.exists():
return 0
if len(sys.argv) != 2:
print('usage: generate-config OUTPUT', file=sys.stderr)
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)
for variable, (section, option) in ANSWERS.items():
value = os.environ.get(variable)
if value is not None and value != '':
parser[section][option] = value
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')
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as handle:
with open(output, 'w', encoding='utf-8') as handle:
parser.write(handle)
os.chmod(path, 0o600)
os.chown(path, 0, 0)
print(f'vpn-router: wrote {path}')
print('vpn-router: edit it and run "systemctl restart vpn-router-setup" '
'to apply changes')
os.chmod(output, 0o600)
return 0
+23 -16
View File
@@ -150,14 +150,13 @@ def filter_block(ctx):
lines += ['# P2S DNS RULES END']
if vpnrouter.routing_ready(ctx):
subnet = ctx['local_subnet']
lines += [
'',
'# ROUTER FORWARD RULES START',
f'-A ufw-before-forward -s {subnet} -o {ctx["wan_iface"]} -j ACCEPT',
f'-A ufw-before-forward -d {subnet} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT',
'# ROUTER FORWARD RULES END',
]
lines += ['', '# ROUTER FORWARD RULES START']
for subnet in ctx['local_cidr_list']:
lines += [
f'-A ufw-before-forward -s {subnet} -o {ctx["wan_iface"]} -j ACCEPT',
f'-A ufw-before-forward -d {subnet} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT',
]
lines += ['# ROUTER FORWARD RULES END']
return ''.join(line + '\n' for line in lines)
@@ -166,7 +165,8 @@ def nat_and_mangle_block(ctx):
lines = []
if vpnrouter.routing_ready(ctx):
subnet = ctx['local_subnet']
subnets = ctx['local_cidr_list']
remote = [c.strip() for c in ctx['remote_cidrs'].split(',') if c.strip()]
lines += [
'',
'# ROUTER NAT RULES START',
@@ -174,13 +174,15 @@ def nat_and_mangle_block(ctx):
':POSTROUTING ACCEPT [0:0]',
'-F POSTROUTING',
]
for cidr in [c.strip() for c in ctx['remote_cidrs'].split(',') if c.strip()]:
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
lines += [
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE',
'COMMIT',
'# ROUTER NAT RULES END',
]
# Every RETURN precedes every MASQUERADE, so tunnel-bound traffic
# escapes NAT whichever local subnet it came from.
for subnet in subnets:
for cidr in remote:
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
for subnet in subnets:
lines.append(
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE')
lines += ['COMMIT', '# ROUTER NAT RULES END']
lines += [
'',
@@ -301,6 +303,11 @@ def main():
cfg = vpnrouter.load_config()
ctx, changed = vpnrouter.configure(cfg)
if ctx is None:
print('vpn-router: platform is none, configuration deferred; '
'set general.platform in /etc/vpn-router/vpn-router.conf')
return 0
if vpnrouter.routing_ready(ctx):
apply_routes(ctx)
@@ -41,10 +41,10 @@ TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.cr
# section -> option -> default
SCHEMA = {
'general': {'platform': 'generic'},
'general': {'platform': 'none', 'mode': 'manual'},
'interfaces': {'external': '', 'internal': ''},
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'},
'local': {'cidrs': '', 'int_gateway_ip': ''},
'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'},
@@ -52,6 +52,17 @@ SCHEMA = {
ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
#: How much the operator supplies, and therefore how much may be detected.
#: manual - names and addresses given; nothing is detected
#: interfaces - names given; addresses and gateway derived from them
#: auto - nothing given; the interfaces are worked out too, accepting
#: that the result may be wrong
MODES = ('manual', 'interfaces', 'auto')
#: platform = none means configuration is deferred: files are installed and
#: nothing else happens until the operator says otherwise.
PLATFORM_NONE = 'none'
class ConfigError(Exception):
"""A setting is present but malformed."""
@@ -92,8 +103,13 @@ def default_parser():
return parser
def load_config(path=CONFIG_FILE):
"""Read the configuration, filling in defaults for anything absent."""
def load_config(path=None):
"""Read the configuration, filling in defaults for anything absent.
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()
if path.exists():
try:
@@ -137,14 +153,35 @@ def _check_address(value, name):
def validate(cfg):
"""Reject values that are present but malformed. Absent is not an error."""
"""Reject values that are present but malformed. Absent is not an error.
The exception is general.mode, which states what the operator promised to
supply, so the values that mode requires are checked for presence too.
"""
platform = _get(cfg, 'general', 'platform')
installed = vpnrouter_platforms.available()
installed = [PLATFORM_NONE] + 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)}')
mode = _get(cfg, 'general', 'mode')
if mode and mode not in MODES:
raise ConfigError(
f'general.mode: {mode!r} is not one of {", ".join(MODES)}')
if not deferred(cfg):
required = {
'manual': (('interfaces', 'external'), ('interfaces', 'internal'),
('local', 'int_addr'), ('local', 'int_gateway_ip')),
'interfaces': (('interfaces', 'external'), ('interfaces', 'internal')),
'auto': (),
}[mode or 'manual']
for section, option in required:
if not _get(cfg, section, option):
raise ConfigError(
f'{section}.{option} is required in mode {mode or "manual"}')
id_mode = _get(cfg, 'wan', 'local_id_mode')
if id_mode and id_mode not in ID_MODES:
raise ConfigError(
@@ -153,13 +190,16 @@ def validate(cfg):
_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_addr'), 'local.int_addr')
_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')
# In auto mode the names are worked out later, so there is nothing to check.
if mode != 'auto':
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:
@@ -199,6 +239,9 @@ def resolve_psk(cfg):
def platform_module(cfg):
"""Import the configured platform module, falling back to the default."""
name = _get(cfg, 'general', 'platform') or vpnrouter_platforms.DEFAULT
if name == PLATFORM_NONE:
# Not normally reached: configure() returns before this when deferred.
name = vpnrouter_platforms.DEFAULT
try:
return vpnrouter_platforms.load(name)
except ModuleNotFoundError:
@@ -216,6 +259,73 @@ def _join(*groups):
return ', '.join(items)
def deferred(cfg):
"""True when platform is none, meaning nothing is to be configured yet."""
return (_get(cfg, 'general', 'platform') or PLATFORM_NONE) == PLATFORM_NONE
def _ip_route(*args):
result = subprocess.run(['ip', *args], capture_output=True, text=True)
return result.stdout if result.returncode == 0 else ''
def detect_interfaces():
"""Work out the external and internal interface names.
Only reached in auto mode, where the operator has accepted that a wrong
guess is possible. The external interface is whichever one carries the
default route; the internal one is the only other addressed interface.
"""
tokens = _ip_route('route', 'get', '1.1.1.1').split()
external = ''
for index, token in enumerate(tokens):
if token == 'dev' and index + 1 < len(tokens):
external = tokens[index + 1]
break
if not external:
raise ConfigError('auto mode: cannot determine the external interface')
candidates = [name for name in _addressed_interfaces() if name != external]
if len(candidates) != 1:
raise ConfigError(
f'auto mode: expected one internal interface, found {len(candidates)}; '
'use mode = interfaces and name them')
return external, candidates[0]
def _addressed_interfaces():
"""Interface names holding at least one IPv4 address, excluding loopback."""
names = []
for line in _ip_route('-o', '-4', 'addr', 'show').splitlines():
fields = line.split()
if len(fields) >= 4 and fields[1] != 'lo' and fields[1] not in names:
names.append(fields[1])
return names
def detect_gateway(iface, address):
"""Derive the next hop on the internal side.
The routing table is the honest source. When it has nothing to say, fall
back to the first host address of the connected subnet, which is a
convention rather than a fact - so say so.
"""
for line in _ip_route('-o', 'route', 'show', 'dev', iface).splitlines():
fields = line.split()
if 'via' in fields:
return fields[fields.index('via') + 1]
for line in _ip_route('-o', '-4', 'addr', 'show', 'dev', iface).splitlines():
fields = line.split()
if len(fields) >= 4 and fields[3].split('/')[0] == address:
network = ipaddress.ip_interface(fields[3]).network
gateway = str(next(network.hosts()))
warn(f'{iface}: no gateway in the routing table, assuming {gateway} '
f'as the first host of {network}')
return gateway
return ''
def interface_addresses(name, setting):
"""IPv4 addresses configured on an interface.
@@ -244,20 +354,42 @@ def interface_addresses(name, setting):
return addresses
def resolve_interfaces(cfg):
"""Return (ext_iface, int_iface, local_addrs, int_addr, int_gateway_ip).
What is read from the system and what is taken verbatim depends entirely on
general.mode. In manual mode nothing below touches the system at all.
"""
mode = _get(cfg, 'general', 'mode') or 'manual'
ext_iface = _get(cfg, 'interfaces', 'external')
int_iface = _get(cfg, 'interfaces', 'internal')
int_addr = _get(cfg, 'local', 'int_addr')
gateway = _get(cfg, 'local', 'int_gateway_ip')
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:
warn(f'{ext_iface} has no IPv4 address yet')
if mode == 'manual':
return ext_iface, int_iface, local_addrs, int_addr, gateway
int_addrs = interface_addresses(int_iface, 'interfaces.internal')
int_addr = int_addrs[0] if int_addrs else ''
if int_iface and not int_addr:
warn(f'{int_iface} has no IPv4 address yet')
gateway = detect_gateway(int_iface, int_addr) if int_addr else ''
return ext_iface, int_iface, local_addrs, int_addr, gateway
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 ''
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'
@@ -277,7 +409,8 @@ def build_context(cfg, local_id_override=None):
cidr_list = _split_list(local_cidrs)
return {
'platform': _get(cfg, 'general', 'platform') or 'generic',
'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,
@@ -287,10 +420,9 @@ def build_context(cfg, local_id_override=None):
'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'),
'int_gateway_ip': gateway,
'remote_addrs': _get(cfg, 'remote', 'addrs'),
'remote_id': _get(cfg, 'remote', 'id'),
'remote_cidrs': _get(cfg, 'remote', 'cidrs'),
@@ -462,6 +594,12 @@ def configure(cfg=None, local_id_override=None):
"""
if cfg is None:
cfg = load_config()
# platform = none: configuration is deferred. Files are installed and
# nothing is rendered, provisioned or removed until a platform is chosen.
if deferred(cfg):
return None, set()
try:
validate(cfg)
ctx = build_context(cfg, local_id_override=local_id_override)