diff --git a/.gitignore b/.gitignore index 66dcddb..c7ffa0f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ AGENTS.md CLAUDE.md __pycache__ .vscode +tmp diff --git a/README.md b/README.md index 494d321..c14f752 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,16 @@ A Debian package that configures a Linux host or virtual machine as a VPN router - optional WireGuard endpoint - routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules -Nothing in the package is specific to a cloud provider. It is built on two inputs, the -name of the external NIC and the name of the internal one; the addresses on them are read -from the system rather than configured again. Nothing is auto-detected, because a router -that guesses which interface to NAT out of is a router that can guess wrong silently. +Nothing in the package is specific to a cloud provider. It is built on the external and +internal NIC names, and a `mode` setting says how much of the rest you are supplying and +how much is read from the system. Detection happens only in the mode that asks for it, so +a router never quietly guesses which interface to NAT out of unless you told it to. Platform-specific additions live in separate modules and are selected explicitly. +Installing the package configures nothing by default. The shipped platform is `none`, +which means the files are laid down and the machine is left alone until you say +otherwise. + ## Installing The package can be installed by hand or as a step in automated provisioning. Both are @@ -27,27 +31,32 @@ systemctl restart vpn-router-setup apply it ### By hand ```sh -apt-get install vpn-router +apt-get install vpn-router # answer "none" to the platform question $EDITOR /etc/vpn-router/vpn-router.conf systemctl restart vpn-router-setup ``` -Accepting every default at install time is safe. Installing the package enables IP -forwarding and relaxes `rp_filter` through its own drop-in in `/etc/sysctl.d/`, which a -router needs, but it configures no tunnels, adds no firewall rules and does not enable the -firewall. Fill the file in when you are ready. +Answering `none` to the platform question defers everything: no further questions are +asked and nothing on the machine is configured. The only change an install makes is the +package's own `/etc/sysctl.d/` drop-in, which enables IP forwarding and relaxes +`rp_filter` as a router needs. Set `platform` in the file when you are ready. ### Automated -Preseed the answers and the router comes up configured, with no follow-up command: +Preseed the answers and the router comes up configured, with no follow-up command. Note +that `platform` must be something other than `none`, and `mode` says how much you are +supplying: -```sh +```shell debconf-set-selections <<'EOF' +vpn-router vpn-router/platform select generic +vpn-router vpn-router/mode select manual vpn-router vpn-router/external_interface string eth0 vpn-router vpn-router/internal_interface string eth1 +vpn-router vpn-router/int_addr string 10.1.1.4 +vpn-router vpn-router/int_gateway_ip string 10.1.1.1 vpn-router vpn-router/local_fqdn string router.example.com vpn-router vpn-router/local_cidrs string 10.0.0.0/24 -vpn-router vpn-router/int_gateway_ip string 10.1.1.1 vpn-router vpn-router/remote_addrs string peer.example.net vpn-router vpn-router/remote_id string peer.example.net vpn-router vpn-router/remote_cidrs string 192.168.0.0/24 @@ -56,6 +65,9 @@ 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. + Alternatively write `/etc/vpn-router/vpn-router.conf` directly before or after installing. A configuration-management tool should do that and then restart `vpn-router-setup`. @@ -63,22 +75,24 @@ See [examples/](examples/) for a cloud-init template and a shell installer. ## Configuration -`/etc/vpn-router/vpn-router.conf` is INI, mode 0600, and is created once on first install. -The package never rewrites it, so it is safe to edit, keep in version control or template. -A fully commented reference is installed at -`/usr/share/doc/vpn-router/vpn-router.conf.example`. +`/etc/vpn-router/vpn-router.conf` is INI, 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`. Lists are comma-separated, booleans are `true`/`false`, an empty value means unset, and a `_b64` suffix means the value is base64-encoded. | Section | Setting | Meaning | |---|---|---| -| `general` | `platform` | Platform module: `generic`, `azure` or `gcp` | +| `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 `@` | @@ -105,10 +119,26 @@ 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. -The two interface names are the base the rest is built on. The addresses strongSwan binds -on come from the external NIC, and the address used for `local_id_mode = internal_ip` -comes from the internal one, so neither is configured twice and neither can drift out of -step with the host. If a named interface does not exist the service stops and says so. +### What `mode` decides + +`mode` states how much of the network configuration you are supplying, and therefore how +much the package reads from the system: + +| `mode` | You provide | The package works out | +|---|---|---| +| `manual` | both interface names, `int_addr`, `int_gateway_ip` | nothing | +| `interfaces` | both interface names | `int_addr` and `int_gateway_ip`, from those interfaces | +| `auto` | nothing | the interface names too, from the routing table | + +`manual` is the only mode in which no detection code runs at all. `auto` is the +convenient one and can pick the wrong interface, which is a trade you make deliberately +by choosing it rather than something the package does behind your back. + +The addresses strongSwan binds on always come from the external NIC, in every mode, so +they are never configured twice and cannot drift out of step with the host. If a named +interface does not exist the service stops and says so. In `interfaces` and `auto` mode +the internal gateway is taken from the routing table, falling back to the first host +address of the connected subnet with a warning when the routing table has nothing to say. ## Applying changes @@ -116,20 +146,28 @@ step with the host. If a named interface does not exist the service stops and sa systemctl restart vpn-router-setup ``` -The service also runs at boot. It regenerates the configuration files, reads the current -addresses of the two interfaces, applies routes, generates WireGuard keys and injects +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 feature is configured, reloading or restarting them only when their inputs changed, and restarts `systemd-resolved` only when the road-warrior DNS drop-in changed. Turning a feature off in the configuration stops its service and removes its files. Every step is idempotent, so restarting with no changes does nothing. -To regenerate the configuration files without touching the running system: +To regenerate the configuration files and PKI without starting, stopping or reloading +anything: ```sh /usr/lib/vpn-router/configure ``` +This does still write to the system: it renders into `/etc/swanctl/`, +`/etc/systemd/resolved.conf.d/` and `/etc/wireguard/`, and if road-warrior access is +enabled with an empty PKI directory it creates the certificate authority and adds it +to the host trust store. What it leaves alone is routes, firewall rules and services - +that is what `systemctl restart vpn-router-setup` adds. + ## Certificates `/etc/vpn-router/pki` is the PKI directory, laid out the way `simple-ca` expects: @@ -169,6 +207,10 @@ Platform-specific values do not belong in the core, so they are Python modules i `general.platform`. The platform is never probed: the caller already knows where it is deploying. +`none` is not a module. It is the shipped default and means "configure nothing at all", +which is how a deferred install is expressed. `generic` is the module to choose when the +router should be configured but the platform adds nothing. + The shipped modules currently add nothing. Road-warrior clients are given the router itself as their DNS server and the router forwards through its own `systemd-resolved`, so no provider resolver has to be advertised or routed through the tunnel on any platform. @@ -206,7 +248,8 @@ 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. Key material is never deleted: +`/etc/ufw/before.rules` to its original content, and hands `vpn-router.conf` 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/debian-package/debian/config b/debian-package/debian/config index d8bd128..5850d50 100755 --- a/debian-package/debian/config +++ b/debian-package/debian/config @@ -2,13 +2,45 @@ set -e . /usr/share/debconf/confmodule +# Every question has a default, so a non-interactive install with nothing +# preseeded completes without prompting. The defaults are platform "none" and +# mode "manual", which together mean: install the files, configure nothing. + db_input high vpn-router/platform || true -db_input high vpn-router/external_interface || true -db_input high vpn-router/internal_interface || true +db_go || true + +db_get vpn-router/platform +if [ "$RET" = "none" ]; then + # Configuration is deferred. Asking anything else would collect answers + # that nothing is going to apply. + exit 0 +fi + +db_input high vpn-router/mode || true +db_go || true + +# The mode says how much the operator supplies, so it decides which interface +# and address questions are worth asking. +db_get vpn-router/mode +case "$RET" in + manual) + db_input high vpn-router/external_interface || true + db_input high vpn-router/internal_interface || true + db_input high vpn-router/int_addr || true + db_input high vpn-router/int_gateway_ip || true + ;; + interfaces) + db_input high vpn-router/external_interface || true + db_input high vpn-router/internal_interface || true + ;; + auto) + ;; +esac +db_go || true + db_input high vpn-router/local_fqdn || true db_input high vpn-router/local_id_mode || true db_input high vpn-router/local_cidrs || true -db_input high vpn-router/int_gateway_ip || true db_input high vpn-router/remote_addrs || true db_input high vpn-router/remote_id || true db_input high vpn-router/remote_cidrs || true diff --git a/debian-package/debian/control b/debian-package/debian/control index d0e2d81..3bd2379 100644 --- a/debian-package/debian/control +++ b/debian-package/debian/control @@ -16,6 +16,7 @@ Depends: ${misc:Depends}, wireguard-tools, ufw, debconf, + ucf, openssl, python3, python3-jinja2, diff --git a/debian-package/debian/install b/debian-package/debian/install index d919593..2a56754 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -3,7 +3,7 @@ src/usr/lib/vpn-router/vpnrouter.py usr/lib/vpn-router/ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/ src/usr/lib/vpn-router/configure usr/lib/vpn-router/ src/usr/lib/vpn-router/setup usr/lib/vpn-router/ -src/usr/lib/vpn-router/seed-config 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/ diff --git a/debian-package/debian/postinst b/debian-package/debian/postinst index afe6c90..685df2f 100755 --- a/debian-package/debian/postinst +++ b/debian-package/debian/postinst @@ -6,6 +6,8 @@ case "$1" in configure) # --- Read the installer's answers --- db_get vpn-router/platform; VPN_ROUTER_PLATFORM="$RET" + db_get vpn-router/mode; VPN_ROUTER_MODE="$RET" + db_get vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$RET" db_get vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$RET" db_get vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$RET" db_get vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$RET" @@ -23,7 +25,8 @@ case "$1" in db_get vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$RET" db_get vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$RET" - export VPN_ROUTER_PLATFORM VPN_ROUTER_EXTERNAL_INTERFACE \ + export VPN_ROUTER_PLATFORM VPN_ROUTER_MODE VPN_ROUTER_INT_ADDR \ + VPN_ROUTER_EXTERNAL_INTERFACE \ VPN_ROUTER_INTERNAL_INTERFACE VPN_ROUTER_LOCAL_FQDN \ VPN_ROUTER_LOCAL_ID_MODE VPN_ROUTER_LOCAL_CIDRS \ VPN_ROUTER_INT_GATEWAY_IP \ @@ -33,10 +36,20 @@ case "$1" in VPN_ROUTER_P2S_CA_NAME VPN_ROUTER_WG_ENABLED \ VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT - # --- Create the configuration file, only if it does not exist --- - /usr/lib/vpn-router/seed-config + # --- Hand a candidate configuration to ucf --- + # ucf compares it against the file in /etc and decides what to do about + # local changes, prompting through debconf only for a real conflict. + # 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 + rm -f "$CANDIDATE" - # The key now lives in the configuration file; do not keep a copy. + # The key now lives in the configuration file; do not keep a copy. An + # empty answer means "leave alone" next time, so clearing it here does + # not blank the key on the next dpkg-reconfigure. db_set vpn-router/psk "" # Apply the sysctl drop-in shipped by this package so it takes effect diff --git a/debian-package/debian/postrm b/debian-package/debian/postrm index 80c0284..6b72b19 100755 --- a/debian-package/debian/postrm +++ b/debian-package/debian/postrm @@ -34,6 +34,15 @@ case "$1" in purge) strip_ufw_blocks + # 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 + fi + if command -v ucfr >/dev/null 2>&1; then + ucfr --purge vpn-router /etc/vpn-router/vpn-router.conf + 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 \ diff --git a/debian-package/debian/prerm b/debian-package/debian/prerm index 8e04499..2c14fb5 100755 --- a/debian-package/debian/prerm +++ b/debian-package/debian/prerm @@ -9,8 +9,16 @@ case "$1" in if [ -d /run/systemd/system ]; then swanctl --terminate --ike remote-site >/dev/null 2>&1 || true swanctl --terminate --ike road-warrior >/dev/null 2>&1 || true - # This package enabled wg-quick@wg0, so it takes it down again. - systemctl disable --now wg-quick@wg0 >/dev/null 2>&1 || true + fi + + # This package enabled wg-quick@wg0, so it takes it down again. Use the + # debhelper wrappers rather than systemctl, so the script behaves on a + # machine without systemd. + if command -v deb-systemd-invoke >/dev/null 2>&1; then + deb-systemd-invoke stop wg-quick@wg0.service >/dev/null 2>&1 || true + fi + if command -v deb-systemd-helper >/dev/null 2>&1; then + deb-systemd-helper disable wg-quick@wg0.service >/dev/null 2>&1 || true fi ;; esac diff --git a/debian-package/debian/templates b/debian-package/debian/templates index 1d1be74..59079e7 100644 --- a/debian-package/debian/templates +++ b/debian-package/debian/templates @@ -1,34 +1,62 @@ Template: vpn-router/platform Type: select -Choices: generic, azure, gcp -Default: generic -Description: Platform module - Platform-specific additions to load. The router works without any of them; - a module only adds what makes sense on its platform, such as the platform - DNS resolver for road-warrior clients. +Choices: none, generic, azure, gcp +Default: none +Description: Platform: + Which platform this router runs on. The choice loads platform-specific + additions, such as the provider DNS resolver for road-warrior clients. + . + 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. + . + generic configures the router with no platform-specific additions. + +Template: vpn-router/mode +Type: select +Choices: manual, interfaces, auto +Default: manual +Description: Network configuration source: + How much of the network configuration you are supplying, and therefore how + much the package works out for itself: + . + manual - you give the interface names, the internal address and the internal + gateway. Nothing is detected. + . + interfaces - you give the two interface names. The internal address and + gateway are read from them. + . + auto - you give nothing. The interfaces are identified from the routing + table and their addresses read from the system. Convenient, but it can pick + the wrong interface and configure the machine incorrectly. Template: vpn-router/external_interface Type: string Default: -Description: External network interface +Description: External network interface: Name of the interface facing the untrusted network, for example eth0 or ens4. The addresses strongSwan binds on are read from it, so they are never configured separately. - . - Leave empty to configure the router later by editing - /etc/vpn-router/vpn-router.conf. Template: vpn-router/internal_interface Type: string Default: -Description: Internal network interface +Description: Internal network interface: Name of the interface facing the protected network, for example eth1 or ens5. Routes for the local subnets are applied to it. +Template: vpn-router/int_addr +Type: string +Default: +Description: Internal interface address: + This host's own address on the internal network. Asked only in manual mode; + in the other modes it is read from the internal interface. + Template: vpn-router/local_fqdn Type: string Default: -Description: Local router FQDN +Description: Local router FQDN: Fully-qualified domain name of this router (for example router.example.com). Used as the road-warrior server identity and certificate common name. @@ -37,7 +65,7 @@ Template: vpn-router/local_id_mode Type: select Choices: fqdn, public_ip, internal_ip Default: fqdn -Description: IKE local identity mode +Description: IKE local identity mode: How to derive the IKE identity advertised to the remote site: . fqdn - use the FQDN, which must match what the peer expects. @@ -49,40 +77,41 @@ Description: IKE local identity mode Template: vpn-router/local_cidrs Type: string Default: -Description: Local subnet CIDR(s) +Description: Local subnet CIDR(s): Comma-separated list of local subnet CIDRs to advertise into the site-to-site tunnel (for example 10.0.0.0/24 or 10.0.0.0/24,10.0.1.0/24). Template: vpn-router/int_gateway_ip Type: string Default: -Description: Internal network gateway IP +Description: Internal network gateway address: Address of the next-hop gateway on the internal side, used to route the - local subnets listed above. + local subnets listed above. Asked only in manual mode; in the other modes it + is derived from the internal interface. Template: vpn-router/remote_addrs Type: string Default: -Description: Remote site WAN IP address(es) +Description: Remote site WAN IP address(es): Comma-separated list of remote gateway addresses or FQDNs for the site-to-site IPSec tunnel. Template: vpn-router/remote_id Type: string Default: -Description: Remote site IKE identity +Description: Remote site IKE identity: IKE identity of the remote peer, without a leading @. Template: vpn-router/remote_cidrs Type: string Default: -Description: Remote subnet CIDR(s) +Description: Remote subnet CIDR(s): Comma-separated list of remote subnet CIDRs reachable through the site-to-site tunnel (for example 192.168.0.0/24). Template: vpn-router/psk Type: password -Description: Pre-shared key (PSK) +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. @@ -98,13 +127,13 @@ Description: Enable road-warrior (P2S) access? Template: vpn-router/p2s_address_pool Type: string Default: -Description: Road-warrior address pool +Description: Road-warrior address pool: CIDR block assigned to road-warrior clients (for example 172.16.0.0/24). Template: vpn-router/p2s_ca_name Type: string Default: VPN Router CA -Description: Road-warrior CA name +Description: Road-warrior CA name: Common name for the certificate authority created in /etc/vpn-router/pki when that directory is empty. Ignored when certificates are supplied. @@ -119,12 +148,12 @@ Description: Enable WireGuard? Template: vpn-router/wg_address Type: string Default: -Description: WireGuard interface address +Description: WireGuard interface address: Address and prefix length for the wg0 interface (for example 192.168.200.1/24). Template: vpn-router/wg_listen_port Type: string Default: 51820 -Description: WireGuard listen port +Description: WireGuard listen port: UDP port that WireGuard listens on. diff --git a/debian-package/src/usr/lib/vpn-router/configure b/debian-package/src/usr/lib/vpn-router/configure index 2f30e29..a0e9a12 100755 --- a/debian-package/src/usr/lib/vpn-router/configure +++ b/debian-package/src/usr/lib/vpn-router/configure @@ -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 diff --git a/debian-package/src/usr/lib/vpn-router/seed-config b/debian-package/src/usr/lib/vpn-router/generate-config similarity index 50% rename from debian-package/src/usr/lib/vpn-router/seed-config rename to debian-package/src/usr/lib/vpn-router/generate-config index 2293640..66961c9 100755 --- a/debian-package/src/usr/lib/vpn-router/seed-config +++ b/debian-package/src/usr/lib/vpn-router/generate-config @@ -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 diff --git a/debian-package/src/usr/lib/vpn-router/setup b/debian-package/src/usr/lib/vpn-router/setup index cc47496..a44cfcd 100755 --- a/debian-package/src/usr/lib/vpn-router/setup +++ b/debian-package/src/usr/lib/vpn-router/setup @@ -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) diff --git a/debian-package/src/usr/lib/vpn-router/vpnrouter.py b/debian-package/src/usr/lib/vpn-router/vpnrouter.py index 2572034..09135f0 100644 --- a/debian-package/src/usr/lib/vpn-router/vpnrouter.py +++ b/debian-package/src/usr/lib/vpn-router/vpnrouter.py @@ -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) 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 index efc89a0..4710273 100644 --- 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 @@ -17,13 +17,25 @@ # and leaves the host reachable and unchanged. [general] -# Platform module to load: generic, azure or gcp. -platform = generic +# 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] -# The two inputs everything else is built on. Names only: the addresses on -# these interfaces are read from the system, never configured here. # 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 = @@ -39,7 +51,10 @@ local_id_mode = fqdn [local] # Local subnets advertised into the tunnel, comma-separated. cidrs = -# Next hop on the internal side for the subnets above. +# 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]