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:
@@ -6,3 +6,4 @@ AGENTS.md
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
__pycache__
|
__pycache__
|
||||||
.vscode
|
.vscode
|
||||||
|
tmp
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ A Debian package that configures a Linux host or virtual machine as a VPN router
|
|||||||
- optional WireGuard endpoint
|
- optional WireGuard endpoint
|
||||||
- routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules
|
- 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
|
Nothing in the package is specific to a cloud provider. It is built on the external and
|
||||||
name of the external NIC and the name of the internal one; the addresses on them are read
|
internal NIC names, and a `mode` setting says how much of the rest you are supplying and
|
||||||
from the system rather than configured again. Nothing is auto-detected, because a router
|
how much is read from the system. Detection happens only in the mode that asks for it, so
|
||||||
that guesses which interface to NAT out of is a router that can guess wrong silently.
|
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.
|
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
|
## Installing
|
||||||
|
|
||||||
The package can be installed by hand or as a step in automated provisioning. Both are
|
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
|
### By hand
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
apt-get install vpn-router
|
apt-get install vpn-router # answer "none" to the platform question
|
||||||
$EDITOR /etc/vpn-router/vpn-router.conf
|
$EDITOR /etc/vpn-router/vpn-router.conf
|
||||||
systemctl restart vpn-router-setup
|
systemctl restart vpn-router-setup
|
||||||
```
|
```
|
||||||
|
|
||||||
Accepting every default at install time is safe. Installing the package enables IP
|
Answering `none` to the platform question defers everything: no further questions are
|
||||||
forwarding and relaxes `rp_filter` through its own drop-in in `/etc/sysctl.d/`, which a
|
asked and nothing on the machine is configured. The only change an install makes is the
|
||||||
router needs, but it configures no tunnels, adds no firewall rules and does not enable the
|
package's own `/etc/sysctl.d/` drop-in, which enables IP forwarding and relaxes
|
||||||
firewall. Fill the file in when you are ready.
|
`rp_filter` as a router needs. Set `platform` in the file when you are ready.
|
||||||
|
|
||||||
### Automated
|
### 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'
|
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/external_interface string eth0
|
||||||
vpn-router vpn-router/internal_interface string eth1
|
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_fqdn string router.example.com
|
||||||
vpn-router vpn-router/local_cidrs string 10.0.0.0/24
|
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_addrs string peer.example.net
|
||||||
vpn-router vpn-router/remote_id 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
|
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
|
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.
|
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`.
|
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
|
## Configuration
|
||||||
|
|
||||||
`/etc/vpn-router/vpn-router.conf` is INI, mode 0600, and is created once on first install.
|
`/etc/vpn-router/vpn-router.conf` is INI, mode 0600, and is created on first install. It
|
||||||
The package never rewrites it, so it is safe to edit, keep in version control or template.
|
is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies what
|
||||||
A fully commented reference is installed at
|
changed and asks before touching anything you altered by hand. A fully commented
|
||||||
`/usr/share/doc/vpn-router/vpn-router.conf.example`.
|
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
|
Lists are comma-separated, booleans are `true`/`false`, an empty value means unset, and a
|
||||||
`_b64` suffix means the value is base64-encoded.
|
`_b64` suffix means the value is base64-encoded.
|
||||||
|
|
||||||
| Section | Setting | Meaning |
|
| 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` | `external` | Name of the NIC facing the untrusted network |
|
||||||
| `interfaces` | `internal` | Name of the NIC facing the protected network |
|
| `interfaces` | `internal` | Name of the NIC facing the protected network |
|
||||||
| `wan` | `local_fqdn` | This router's FQDN |
|
| `wan` | `local_fqdn` | This router's FQDN |
|
||||||
| `wan` | `local_id_mode` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` |
|
| `wan` | `local_id_mode` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` |
|
||||||
| `local` | `cidrs` | Local subnets advertised into the tunnel |
|
| `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 |
|
| `local` | `int_gateway_ip` | Next hop on the internal side |
|
||||||
| `remote` | `addrs` | Remote gateway address(es) or FQDN |
|
| `remote` | `addrs` | Remote gateway address(es) or FQDN |
|
||||||
| `remote` | `id` | Remote IKE identity, without a leading `@` |
|
| `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
|
Every feature is independent. Leaving a group empty simply means that feature is not
|
||||||
configured, and its generated files are removed.
|
configured, and its generated files are removed.
|
||||||
|
|
||||||
The two interface names are the base the rest is built on. The addresses strongSwan binds
|
### What `mode` decides
|
||||||
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
|
`mode` states how much of the network configuration you are supplying, and therefore how
|
||||||
step with the host. If a named interface does not exist the service stops and says so.
|
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
|
## 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
|
systemctl restart vpn-router-setup
|
||||||
```
|
```
|
||||||
|
|
||||||
The service also runs at boot. It regenerates the configuration files, reads the current
|
The service also runs at boot. With `platform = none` it does nothing and says so.
|
||||||
addresses of the two interfaces, applies routes, generates WireGuard keys and injects
|
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
|
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
|
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
|
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
|
feature off in the configuration stops its service and removes its files. Every step is
|
||||||
idempotent, so restarting with no changes does nothing.
|
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
|
```sh
|
||||||
/usr/lib/vpn-router/configure
|
/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
|
## Certificates
|
||||||
|
|
||||||
`/etc/vpn-router/pki` is the PKI directory, laid out the way `simple-ca` expects:
|
`/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
|
`general.platform`. The platform is never probed: the caller already knows where it is
|
||||||
deploying.
|
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
|
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
|
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.
|
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.
|
may be in use by something else.
|
||||||
|
|
||||||
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
|
`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
|
`/etc/vpn-router/pki`, the WireGuard key pair and everything under `/etc/swanctl` are left
|
||||||
alone.
|
alone.
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,45 @@
|
|||||||
set -e
|
set -e
|
||||||
. /usr/share/debconf/confmodule
|
. /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/platform || 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/external_interface || true
|
||||||
db_input high vpn-router/internal_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_fqdn || true
|
||||||
db_input high vpn-router/local_id_mode || true
|
db_input high vpn-router/local_id_mode || true
|
||||||
db_input high vpn-router/local_cidrs || 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_addrs || true
|
||||||
db_input high vpn-router/remote_id || true
|
db_input high vpn-router/remote_id || true
|
||||||
db_input high vpn-router/remote_cidrs || true
|
db_input high vpn-router/remote_cidrs || true
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Depends: ${misc:Depends},
|
|||||||
wireguard-tools,
|
wireguard-tools,
|
||||||
ufw,
|
ufw,
|
||||||
debconf,
|
debconf,
|
||||||
|
ucf,
|
||||||
openssl,
|
openssl,
|
||||||
python3,
|
python3,
|
||||||
python3-jinja2,
|
python3-jinja2,
|
||||||
|
|||||||
@@ -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/simple-ca usr/lib/vpn-router/
|
||||||
src/usr/lib/vpn-router/configure 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/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/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/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.conf.example usr/share/doc/vpn-router/
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ case "$1" in
|
|||||||
configure)
|
configure)
|
||||||
# --- Read the installer's answers ---
|
# --- Read the installer's answers ---
|
||||||
db_get vpn-router/platform; VPN_ROUTER_PLATFORM="$RET"
|
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/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$RET"
|
||||||
db_get vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$RET"
|
db_get vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$RET"
|
||||||
db_get vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$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_address; VPN_ROUTER_WG_ADDRESS="$RET"
|
||||||
db_get vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$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_INTERNAL_INTERFACE VPN_ROUTER_LOCAL_FQDN \
|
||||||
VPN_ROUTER_LOCAL_ID_MODE VPN_ROUTER_LOCAL_CIDRS \
|
VPN_ROUTER_LOCAL_ID_MODE VPN_ROUTER_LOCAL_CIDRS \
|
||||||
VPN_ROUTER_INT_GATEWAY_IP \
|
VPN_ROUTER_INT_GATEWAY_IP \
|
||||||
@@ -33,10 +36,20 @@ case "$1" in
|
|||||||
VPN_ROUTER_P2S_CA_NAME VPN_ROUTER_WG_ENABLED \
|
VPN_ROUTER_P2S_CA_NAME VPN_ROUTER_WG_ENABLED \
|
||||||
VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT
|
VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT
|
||||||
|
|
||||||
# --- Create the configuration file, only if it does not exist ---
|
# --- Hand a candidate configuration to ucf ---
|
||||||
/usr/lib/vpn-router/seed-config
|
# 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 ""
|
db_set vpn-router/psk ""
|
||||||
|
|
||||||
# Apply the sysctl drop-in shipped by this package so it takes effect
|
# Apply the sysctl drop-in shipped by this package so it takes effect
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ case "$1" in
|
|||||||
purge)
|
purge)
|
||||||
strip_ufw_blocks
|
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 \
|
rm -f /etc/swanctl/conf.d/remote-site.conf \
|
||||||
/etc/swanctl/conf.d/road-warrior.conf \
|
/etc/swanctl/conf.d/road-warrior.conf \
|
||||||
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
/etc/systemd/resolved.conf.d/p2s-forwarder.conf \
|
||||||
|
|||||||
@@ -9,8 +9,16 @@ case "$1" in
|
|||||||
if [ -d /run/systemd/system ]; then
|
if [ -d /run/systemd/system ]; then
|
||||||
swanctl --terminate --ike remote-site >/dev/null 2>&1 || true
|
swanctl --terminate --ike remote-site >/dev/null 2>&1 || true
|
||||||
swanctl --terminate --ike road-warrior >/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.
|
fi
|
||||||
systemctl disable --now wg-quick@wg0 >/dev/null 2>&1 || true
|
|
||||||
|
# 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
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -1,34 +1,62 @@
|
|||||||
Template: vpn-router/platform
|
Template: vpn-router/platform
|
||||||
Type: select
|
Type: select
|
||||||
Choices: generic, azure, gcp
|
Choices: none, generic, azure, gcp
|
||||||
Default: generic
|
Default: none
|
||||||
Description: Platform module
|
Description: Platform:
|
||||||
Platform-specific additions to load. The router works without any of them;
|
Which platform this router runs on. The choice loads platform-specific
|
||||||
a module only adds what makes sense on its platform, such as the platform
|
additions, such as the provider DNS resolver for road-warrior clients.
|
||||||
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
|
Template: vpn-router/external_interface
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
Default:
|
||||||
Description: External network interface
|
Description: External network interface:
|
||||||
Name of the interface facing the untrusted network, for example eth0 or
|
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
|
ens4. The addresses strongSwan binds on are read from it, so they are never
|
||||||
configured separately.
|
configured separately.
|
||||||
.
|
|
||||||
Leave empty to configure the router later by editing
|
|
||||||
/etc/vpn-router/vpn-router.conf.
|
|
||||||
|
|
||||||
Template: vpn-router/internal_interface
|
Template: vpn-router/internal_interface
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
Default:
|
||||||
Description: Internal network interface
|
Description: Internal network interface:
|
||||||
Name of the interface facing the protected network, for example eth1 or
|
Name of the interface facing the protected network, for example eth1 or
|
||||||
ens5. Routes for the local subnets are applied to it.
|
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
|
Template: vpn-router/local_fqdn
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
Default:
|
||||||
Description: Local router FQDN
|
Description: Local router FQDN:
|
||||||
Fully-qualified domain name of this router (for example
|
Fully-qualified domain name of this router (for example
|
||||||
router.example.com). Used as the road-warrior server identity and
|
router.example.com). Used as the road-warrior server identity and
|
||||||
certificate common name.
|
certificate common name.
|
||||||
@@ -37,7 +65,7 @@ Template: vpn-router/local_id_mode
|
|||||||
Type: select
|
Type: select
|
||||||
Choices: fqdn, public_ip, internal_ip
|
Choices: fqdn, public_ip, internal_ip
|
||||||
Default: fqdn
|
Default: fqdn
|
||||||
Description: IKE local identity mode
|
Description: IKE local identity mode:
|
||||||
How to derive the IKE identity advertised to the remote site:
|
How to derive the IKE identity advertised to the remote site:
|
||||||
.
|
.
|
||||||
fqdn - use the FQDN, which must match what the peer expects.
|
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
|
Template: vpn-router/local_cidrs
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
Default:
|
||||||
Description: Local subnet CIDR(s)
|
Description: Local subnet CIDR(s):
|
||||||
Comma-separated list of local subnet CIDRs to advertise into the
|
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).
|
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
|
Template: vpn-router/int_gateway_ip
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
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
|
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
|
Template: vpn-router/remote_addrs
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
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
|
Comma-separated list of remote gateway addresses or FQDNs for the
|
||||||
site-to-site IPSec tunnel.
|
site-to-site IPSec tunnel.
|
||||||
|
|
||||||
Template: vpn-router/remote_id
|
Template: vpn-router/remote_id
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
Default:
|
||||||
Description: Remote site IKE identity
|
Description: Remote site IKE identity:
|
||||||
IKE identity of the remote peer, without a leading @.
|
IKE identity of the remote peer, without a leading @.
|
||||||
|
|
||||||
Template: vpn-router/remote_cidrs
|
Template: vpn-router/remote_cidrs
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
Default:
|
||||||
Description: Remote subnet CIDR(s)
|
Description: Remote subnet CIDR(s):
|
||||||
Comma-separated list of remote subnet CIDRs reachable through the
|
Comma-separated list of remote subnet CIDRs reachable through the
|
||||||
site-to-site tunnel (for example 192.168.0.0/24).
|
site-to-site tunnel (for example 192.168.0.0/24).
|
||||||
|
|
||||||
Template: vpn-router/psk
|
Template: vpn-router/psk
|
||||||
Type: password
|
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
|
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
|
configured on the remote peer. Stored base64-encoded in the configuration
|
||||||
file and cleared from the debconf database after installation.
|
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
|
Template: vpn-router/p2s_address_pool
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
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).
|
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
|
||||||
|
|
||||||
Template: vpn-router/p2s_ca_name
|
Template: vpn-router/p2s_ca_name
|
||||||
Type: string
|
Type: string
|
||||||
Default: VPN Router CA
|
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
|
Common name for the certificate authority created in /etc/vpn-router/pki
|
||||||
when that directory is empty. Ignored when certificates are supplied.
|
when that directory is empty. Ignored when certificates are supplied.
|
||||||
|
|
||||||
@@ -119,12 +148,12 @@ Description: Enable WireGuard?
|
|||||||
Template: vpn-router/wg_address
|
Template: vpn-router/wg_address
|
||||||
Type: string
|
Type: string
|
||||||
Default:
|
Default:
|
||||||
Description: WireGuard interface address
|
Description: WireGuard interface address:
|
||||||
Address and prefix length for the wg0 interface (for example
|
Address and prefix length for the wg0 interface (for example
|
||||||
192.168.200.1/24).
|
192.168.200.1/24).
|
||||||
|
|
||||||
Template: vpn-router/wg_listen_port
|
Template: vpn-router/wg_listen_port
|
||||||
Type: string
|
Type: string
|
||||||
Default: 51820
|
Default: 51820
|
||||||
Description: WireGuard listen port
|
Description: WireGuard listen port:
|
||||||
UDP port that WireGuard listens on.
|
UDP port that WireGuard listens on.
|
||||||
|
|||||||
+5
-2
@@ -1,8 +1,11 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
"""Render configuration files and provision PKI from vpn-router.conf.
|
"""Render configuration files and provision PKI from vpn-router.conf.
|
||||||
|
|
||||||
Run directly to regenerate the configuration files without touching the
|
Run directly to regenerate them without starting, stopping or reloading
|
||||||
running system. 'setup' calls the same code before applying the rest.
|
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
|
import os
|
||||||
|
|||||||
+34
-18
@@ -1,9 +1,23 @@
|
|||||||
#!/usr/bin/python3
|
#!/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
|
Called from postinst, which hands the result to ucf(1). ucf compares it with
|
||||||
configuration file belongs to the operator from that point on and is never
|
/etc/vpn-router/vpn-router.conf and decides what to do about local changes, so
|
||||||
rewritten by the package.
|
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
|
Answers arrive as VPN_ROUTER_* environment variables. The pre-shared key is
|
||||||
stored base64-encoded so that arbitrary characters cannot collide with INI
|
stored base64-encoded so that arbitrary characters cannot collide with INI
|
||||||
@@ -21,6 +35,8 @@ import vpnrouter
|
|||||||
# environment variable -> (section, option)
|
# environment variable -> (section, option)
|
||||||
ANSWERS = {
|
ANSWERS = {
|
||||||
'VPN_ROUTER_PLATFORM': ('general', 'platform'),
|
'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_EXTERNAL_INTERFACE': ('interfaces', 'external'),
|
||||||
'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'),
|
'VPN_ROUTER_INTERNAL_INTERFACE': ('interfaces', 'internal'),
|
||||||
'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'),
|
'VPN_ROUTER_LOCAL_FQDN': ('wan', 'local_fqdn'),
|
||||||
@@ -40,31 +56,31 @@ ANSWERS = {
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
path = vpnrouter.CONFIG_FILE
|
if len(sys.argv) != 2:
|
||||||
if path.exists():
|
print('usage: generate-config OUTPUT', file=sys.stderr)
|
||||||
return 0
|
return 2
|
||||||
|
output = sys.argv[1]
|
||||||
|
|
||||||
parser = vpnrouter.default_parser()
|
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():
|
for variable, (section, option) in ANSWERS.items():
|
||||||
value = os.environ.get(variable)
|
if os.environ.get(variable):
|
||||||
if value is not None and value != '':
|
parser[section][option] = os.environ[variable]
|
||||||
parser[section][option] = value
|
|
||||||
|
|
||||||
psk = os.environ.get('VPN_ROUTER_PSK', '')
|
psk = os.environ.get('VPN_ROUTER_PSK', '')
|
||||||
if psk:
|
if psk:
|
||||||
parser['remote']['psk_b64'] = base64.b64encode(
|
parser['remote']['psk_b64'] = base64.b64encode(
|
||||||
psk.encode('utf-8')).decode('ascii')
|
psk.encode('utf-8')).decode('ascii')
|
||||||
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
with open(output, 'w', encoding='utf-8') as handle:
|
||||||
with open(path, 'w', encoding='utf-8') as handle:
|
|
||||||
parser.write(handle)
|
parser.write(handle)
|
||||||
os.chmod(path, 0o600)
|
os.chmod(output, 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')
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -150,14 +150,13 @@ def filter_block(ctx):
|
|||||||
lines += ['# P2S DNS RULES END']
|
lines += ['# P2S DNS RULES END']
|
||||||
|
|
||||||
if vpnrouter.routing_ready(ctx):
|
if vpnrouter.routing_ready(ctx):
|
||||||
subnet = ctx['local_subnet']
|
lines += ['', '# ROUTER FORWARD RULES START']
|
||||||
|
for subnet in ctx['local_cidr_list']:
|
||||||
lines += [
|
lines += [
|
||||||
'',
|
|
||||||
'# ROUTER FORWARD RULES START',
|
|
||||||
f'-A ufw-before-forward -s {subnet} -o {ctx["wan_iface"]} -j ACCEPT',
|
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',
|
f'-A ufw-before-forward -d {subnet} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT',
|
||||||
'# ROUTER FORWARD RULES END',
|
|
||||||
]
|
]
|
||||||
|
lines += ['# ROUTER FORWARD RULES END']
|
||||||
|
|
||||||
return ''.join(line + '\n' for line in lines)
|
return ''.join(line + '\n' for line in lines)
|
||||||
|
|
||||||
@@ -166,7 +165,8 @@ def nat_and_mangle_block(ctx):
|
|||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
if vpnrouter.routing_ready(ctx):
|
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 += [
|
lines += [
|
||||||
'',
|
'',
|
||||||
'# ROUTER NAT RULES START',
|
'# ROUTER NAT RULES START',
|
||||||
@@ -174,13 +174,15 @@ def nat_and_mangle_block(ctx):
|
|||||||
':POSTROUTING ACCEPT [0:0]',
|
':POSTROUTING ACCEPT [0:0]',
|
||||||
'-F POSTROUTING',
|
'-F POSTROUTING',
|
||||||
]
|
]
|
||||||
for cidr in [c.strip() for c in ctx['remote_cidrs'].split(',') if c.strip()]:
|
# 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')
|
lines.append(f'-A POSTROUTING -s {subnet} -d {cidr} -j RETURN')
|
||||||
lines += [
|
for subnet in subnets:
|
||||||
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE',
|
lines.append(
|
||||||
'COMMIT',
|
f'-A POSTROUTING -s {subnet} -o {ctx["wan_iface"]} -j MASQUERADE')
|
||||||
'# ROUTER NAT RULES END',
|
lines += ['COMMIT', '# ROUTER NAT RULES END']
|
||||||
]
|
|
||||||
|
|
||||||
lines += [
|
lines += [
|
||||||
'',
|
'',
|
||||||
@@ -301,6 +303,11 @@ def main():
|
|||||||
cfg = vpnrouter.load_config()
|
cfg = vpnrouter.load_config()
|
||||||
ctx, changed = vpnrouter.configure(cfg)
|
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):
|
if vpnrouter.routing_ready(ctx):
|
||||||
apply_routes(ctx)
|
apply_routes(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.cr
|
|||||||
|
|
||||||
# section -> option -> default
|
# section -> option -> default
|
||||||
SCHEMA = {
|
SCHEMA = {
|
||||||
'general': {'platform': 'generic'},
|
'general': {'platform': 'none', 'mode': 'manual'},
|
||||||
'interfaces': {'external': '', 'internal': ''},
|
'interfaces': {'external': '', 'internal': ''},
|
||||||
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'},
|
'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': ''},
|
'remote': {'addrs': '', 'id': '', 'cidrs': '', 'psk_b64': '', 'psk_file': ''},
|
||||||
'p2s': {'enabled': 'false', 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
'p2s': {'enabled': 'false', 'address_pool': '', 'ca_name': 'VPN Router CA'},
|
||||||
'wireguard': {'enabled': 'false', 'address': '', 'listen_port': '51820'},
|
'wireguard': {'enabled': 'false', 'address': '', 'listen_port': '51820'},
|
||||||
@@ -52,6 +52,17 @@ SCHEMA = {
|
|||||||
|
|
||||||
ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
|
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):
|
class ConfigError(Exception):
|
||||||
"""A setting is present but malformed."""
|
"""A setting is present but malformed."""
|
||||||
@@ -92,8 +103,13 @@ def default_parser():
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def load_config(path=CONFIG_FILE):
|
def load_config(path=None):
|
||||||
"""Read the configuration, filling in defaults for anything absent."""
|
"""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()
|
parser = default_parser()
|
||||||
if path.exists():
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
@@ -137,14 +153,35 @@ def _check_address(value, name):
|
|||||||
|
|
||||||
|
|
||||||
def validate(cfg):
|
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')
|
platform = _get(cfg, 'general', 'platform')
|
||||||
installed = vpnrouter_platforms.available()
|
installed = [PLATFORM_NONE] + vpnrouter_platforms.available()
|
||||||
if platform and platform not in installed:
|
if platform and platform not in installed:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f'general.platform: {platform!r} is not installed, expected one of '
|
f'general.platform: {platform!r} is not installed, expected one of '
|
||||||
f'{", ".join(installed)}')
|
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')
|
id_mode = _get(cfg, 'wan', 'local_id_mode')
|
||||||
if id_mode and id_mode not in ID_MODES:
|
if id_mode and id_mode not in ID_MODES:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
@@ -153,8 +190,11 @@ def validate(cfg):
|
|||||||
_check_networks(_get(cfg, 'local', 'cidrs'), 'local.cidrs')
|
_check_networks(_get(cfg, 'local', 'cidrs'), 'local.cidrs')
|
||||||
_check_networks(_get(cfg, 'remote', 'cidrs'), 'remote.cidrs')
|
_check_networks(_get(cfg, 'remote', 'cidrs'), 'remote.cidrs')
|
||||||
_check_networks(_get(cfg, 'p2s', 'address_pool'), 'p2s.address_pool')
|
_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')
|
_check_address(_get(cfg, 'local', 'int_gateway_ip'), 'local.int_gateway_ip')
|
||||||
|
|
||||||
|
# In auto mode the names are worked out later, so there is nothing to check.
|
||||||
|
if mode != 'auto':
|
||||||
for option in ('external', 'internal'):
|
for option in ('external', 'internal'):
|
||||||
name = _get(cfg, 'interfaces', option)
|
name = _get(cfg, 'interfaces', option)
|
||||||
if name and not (pathlib.Path('/sys/class/net') / name).exists():
|
if name and not (pathlib.Path('/sys/class/net') / name).exists():
|
||||||
@@ -199,6 +239,9 @@ def resolve_psk(cfg):
|
|||||||
def platform_module(cfg):
|
def platform_module(cfg):
|
||||||
"""Import the configured platform module, falling back to the default."""
|
"""Import the configured platform module, falling back to the default."""
|
||||||
name = _get(cfg, 'general', 'platform') or vpnrouter_platforms.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:
|
try:
|
||||||
return vpnrouter_platforms.load(name)
|
return vpnrouter_platforms.load(name)
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
@@ -216,6 +259,73 @@ def _join(*groups):
|
|||||||
return ', '.join(items)
|
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):
|
def interface_addresses(name, setting):
|
||||||
"""IPv4 addresses configured on an interface.
|
"""IPv4 addresses configured on an interface.
|
||||||
|
|
||||||
@@ -244,20 +354,42 @@ def interface_addresses(name, setting):
|
|||||||
return addresses
|
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):
|
def build_context(cfg, local_id_override=None):
|
||||||
"""Assemble the template context from the configuration."""
|
"""Assemble the template context from the configuration."""
|
||||||
extras = vpnrouter_platforms.context(platform_module(cfg))
|
extras = vpnrouter_platforms.context(platform_module(cfg))
|
||||||
|
|
||||||
ext_iface = _get(cfg, 'interfaces', 'external')
|
ext_iface, int_iface, local_addrs, int_addr, gateway = resolve_interfaces(cfg)
|
||||||
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 ''
|
|
||||||
|
|
||||||
local_fqdn = _get(cfg, 'wan', 'local_fqdn')
|
local_fqdn = _get(cfg, 'wan', 'local_fqdn')
|
||||||
id_mode = _get(cfg, 'wan', 'local_id_mode') or '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)
|
cidr_list = _split_list(local_cidrs)
|
||||||
|
|
||||||
return {
|
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,
|
'wan_iface': ext_iface,
|
||||||
'int_iface': int_iface,
|
'int_iface': int_iface,
|
||||||
'local_addrs': local_addrs,
|
'local_addrs': local_addrs,
|
||||||
@@ -287,10 +420,9 @@ def build_context(cfg, local_id_override=None):
|
|||||||
'local_id': local_id,
|
'local_id': local_id,
|
||||||
'local_cidrs': local_cidrs,
|
'local_cidrs': local_cidrs,
|
||||||
'local_cidr_list': cidr_list,
|
'local_cidr_list': cidr_list,
|
||||||
'local_subnet': cidr_list[0] if cidr_list else '',
|
|
||||||
'local_ts': _join(local_cidrs, extras['EXTRA_LOCAL_TS']),
|
'local_ts': _join(local_cidrs, extras['EXTRA_LOCAL_TS']),
|
||||||
'int_addr': int_addr,
|
'int_addr': int_addr,
|
||||||
'int_gateway_ip': _get(cfg, 'local', 'int_gateway_ip'),
|
'int_gateway_ip': gateway,
|
||||||
'remote_addrs': _get(cfg, 'remote', 'addrs'),
|
'remote_addrs': _get(cfg, 'remote', 'addrs'),
|
||||||
'remote_id': _get(cfg, 'remote', 'id'),
|
'remote_id': _get(cfg, 'remote', 'id'),
|
||||||
'remote_cidrs': _get(cfg, 'remote', 'cidrs'),
|
'remote_cidrs': _get(cfg, 'remote', 'cidrs'),
|
||||||
@@ -462,6 +594,12 @@ def configure(cfg=None, local_id_override=None):
|
|||||||
"""
|
"""
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
cfg = load_config()
|
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:
|
try:
|
||||||
validate(cfg)
|
validate(cfg)
|
||||||
ctx = build_context(cfg, local_id_override=local_id_override)
|
ctx = build_context(cfg, local_id_override=local_id_override)
|
||||||
|
|||||||
@@ -17,13 +17,25 @@
|
|||||||
# and leaves the host reachable and unchanged.
|
# and leaves the host reachable and unchanged.
|
||||||
|
|
||||||
[general]
|
[general]
|
||||||
# Platform module to load: generic, azure or gcp.
|
# Platform: none, generic, azure or gcp.
|
||||||
platform = generic
|
# 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]
|
[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.
|
# External faces the untrusted network; internal faces the protected one.
|
||||||
|
# Required in manual and interfaces mode; worked out for you in auto mode.
|
||||||
external =
|
external =
|
||||||
internal =
|
internal =
|
||||||
|
|
||||||
@@ -39,7 +51,10 @@ local_id_mode = fqdn
|
|||||||
[local]
|
[local]
|
||||||
# Local subnets advertised into the tunnel, comma-separated.
|
# Local subnets advertised into the tunnel, comma-separated.
|
||||||
cidrs =
|
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 =
|
int_gateway_ip =
|
||||||
|
|
||||||
[remote]
|
[remote]
|
||||||
|
|||||||
Reference in New Issue
Block a user