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:
1 parent
083ad9a596
commit
7cb2f1b8dd
14 files changed
+439
-124
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user