21 KiB
vpn-router
A Debian package that configures a Linux host or virtual machine as a VPN router:
- site-to-site IKEv2 IPSec with pre-shared key authentication (strongSwan swanctl)
- road-warrior (P2S) access using IKEv2 EAP-TLS, with a bundled certificate authority
- optional WireGuard endpoint
- routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules
- firewall rules for the ports Docker publishes, when Docker is installed
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 supported equally, and both end up with the same two things: a configuration file and a service that applies it.
/etc/vpn-router/vpn-router.json the configuration
systemctl restart vpn-router-setup apply it
If the host runs Docker, the recommended order is Docker first and the package second. The package secures the ports Docker publishes when it finds the Docker service; the steps for a host that already runs Docker, and for installing Docker later, are under Docker.
By hand
apt-get install vpn-router # answer "none" to the platform question
$EDITOR /etc/vpn-router/vpn-router.json
systemctl restart vpn-router-setup
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. Note
that platform must be something other than none, and mode says how much you are
supplying:
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/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
vpn-router vpn-router/psk password s3cr3t
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.json directly before or after installing.
A configuration-management tool should do that and then restart vpn-router-setup.
See examples/ for a cloud-init template and a shell installer, and examples/azure/ / examples/gcp/ for full Terraform examples that deploy a router on Azure or GCP.
Configuration
/etc/vpn-router/vpn-router.json is JSON, mode 0600, and is created on first install. It
is managed with ucf, so it is yours to edit: a later dpkg-reconfigure applies what
changed and asks before touching anything you altered by hand. A complete example is
installed at /usr/share/doc/vpn-router/vpn-router.json.example.
The file is an object of sections, each an object of settings. A section or setting that is left out takes its default, and an empty string or empty list means unset. Every value must have the type shown below; an unknown section or setting is an error.
| Section | Setting | Type | Default | Meaning |
|---|---|---|---|---|
general |
platform |
string | none |
none, generic, azure or gcp. none defers configuration entirely |
general |
mode |
string | manual |
How much you supply: manual, interfaces or auto |
interfaces |
external |
string | Name of the NIC facing the untrusted network | |
interfaces |
internal |
string | Name of the NIC facing the protected network | |
wan |
local_fqdn |
string | This router's name or IP address, used as its IKE identity in fqdn mode |
|
wan |
local_id_mode |
string | fqdn |
IKE identity source: fqdn, public_ip or internal_ip |
local |
cidrs |
list | Local subnets advertised into the tunnel | |
local |
int_addr |
string | This host's address on the internal network | |
local |
int_gateway_ip |
string | Next hop on the internal side | |
remote |
addrs |
list | Remote gateway addresses or FQDNs | |
remote |
id |
string | Remote IKE identity, an FQDN or an IPv4 or IPv6 address | |
remote |
cidrs |
list | Remote subnets reachable through the tunnel | |
remote |
psk |
string | Pre-shared key | |
remote |
psk_file |
string | Path to a file holding the key; wins over psk |
|
p2s |
enabled |
boolean | false |
Road-warrior access |
p2s |
address_pool |
string | CIDR assigned to road-warrior clients | |
p2s |
ca_name |
string | VPN Router CA |
Common name for a locally created CA |
wireguard |
enabled |
boolean | false |
WireGuard endpoint |
wireguard |
address |
string | Address and prefix for wg0, for example 192.168.200.1/24 |
|
wireguard |
listen_port |
integer | 51820 |
UDP port |
docker |
mode |
string | auto |
auto secures Docker when its service is installed, on requires it, off removes the rules |
docker |
exposed_ports |
list | TCP ports and port ranges (8000:8100) reachable from the external interface; at most 15, a range counting as two |
|
web |
enabled |
boolean | true |
The status web service on the internal address |
web |
port |
integer | 8080 |
TCP port of the status web service |
web |
jwt_secret |
string | Secret, at least 32 characters, that signs the tokens for /config; without it /config is not served |
A minimal site-to-site configuration in auto mode:
{
"general": { "platform": "generic", "mode": "auto" },
"wan": { "local_fqdn": "router.example.com" },
"local": { "cidrs": ["10.0.0.0/24"] },
"remote": {
"addrs": ["peer.example.net"],
"id": "peer.example.net",
"cidrs": ["192.168.0.0/24"],
"psk": "s3cr3t"
}
}
The router's IKE identity (wan.local_fqdn in fqdn mode) and the remote one (remote.id) are
an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configuration as given;
strongSwan reads an address as an IP identity and a name as an FQDN, so a leading @ is not needed
and is refused. Road-warrior access needs a name in wan.local_fqdn.
WireGuard peers are added by hand in /etc/wireguard/wg0.conf. The package owns the
[Interface] section of that file and rewrites it when the settings above change, but it
carries every [Peer] section across untouched.
Every feature is independent. Leaving a group empty simply means that feature is not configured, and its generated files are removed.
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: the external one from the routing table, the internal one as the only other NIC with an address |
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.
Detection ignores virtual interfaces, those the kernel lists under /sys/devices/virtual:
loopback, bridges such as docker0, veth pairs and tunnels. Only the NICs of the machine
count, so Docker does not change the result. If the host has more than two NICs with an
address, auto stops and says so; use mode interfaces and name them.
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.
Docker
Docker publishes container ports in its own firewall rules, which bypass UFW and can expose
ports on the external interface. Docker evaluates the DOCKER-USER chain before its own
rules, so the package filters there. With docker.mode set to auto, the package secures
Docker when docker.service is installed, and does nothing otherwise.
On a host that already runs Docker
Installing Docker first is the recommended order. After installing the package:
-
Set
general.platformin/etc/vpn-router/vpn-router.json. While it isnone, nothing is configured, the Docker rules included. -
List the TCP ports the running containers must expose to the internet, for example
"docker": { "exposed_ports": ["443"] }. The default is none: once the rules are applied, every published port that is not listed is unreachable from the external interface, and remains reachable through the IPsec tunnel. -
Apply the configuration with
systemctl restart vpn-router-setup. Docker is running, so this also restartsvpn-router-docker, which installs the rules. -
Check the result:
iptables -S DOCKER-USER # "-j VPN-ROUTER-DOCKER" is the first rule iptables -S VPN-ROUTER-DOCKER # the rules of the external interface
When Docker is installed after the package
The Docker rules are not applied when Docker starts for the first time, so run
systemctl restart vpn-router-setup after installing Docker, then check as above. A reboot or a
restart of Docker applies them as well.
The rules sit in their own chain, VPN-ROUTER-DOCKER, entered by one jump at the top of
DOCKER-USER; other rules in DOCKER-USER are left alone. For traffic that arrives on the
external interface, in this order:
- IPsec protected traffic is accepted.
- TCP to the ports in
docker.exposed_portsis accepted; none by default. - Packets of established and related connections are accepted.
- Everything else is dropped.
Traffic from every other interface, including the internal one and the Docker bridges, is not affected. The rules cover IPv4 only.
vpn-router-setup starts after docker.service, so Docker is up when it looks for it; the
ordering has no effect on a host without Docker. The vpn-router-docker service applies the
rules after docker.service starts and removes
them when Docker stops, so restarting Docker restores them. systemctl restart vpn-router-setup applies a changed configuration while Docker runs. With docker.mode set to
on, the service fails when Docker is not installed or its daemon is not running; with
off it removes the rules.
Applying changes
systemctl restart vpn-router-setup
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 and PKI without starting, stopping or reloading anything:
/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:
ca_cert.pem ca_key.pem ca_bundle.pem <label>_cert.pem <label>_key.pem crl.pem
<label> is the first component of the router FQDN, so router.example.com gives
router_cert.pem.
If road-warrior access is enabled and this directory is empty, a certificate authority and a server certificate are created there automatically. To use your own instead, place the files in that layout before enabling the feature; nothing is generated or overwritten when material is already present.
Either way, the package copies what each consumer needs into place: the CA into
/etc/swanctl/x509ca/, the server certificate and key into /etc/swanctl/x509/ and
/etc/swanctl/private/, a CRL into /etc/swanctl/x509crl/, and the CA into the system
trust store.
Issue a client certificate with the bundled tool:
/usr/lib/vpn-router/simple-ca make-cert --ca-dir /etc/vpn-router/pki alice
/usr/lib/vpn-router/simple-ca make-pfx --ca-dir /etc/vpn-router/pki \
--password s3cr3t /etc/vpn-router/pki/alice_cert.pem
simple-ca comes from a separate project, https://gitea.koszewscy.waw.pl/slawek/simple-ca,
and also supports make-crl and revoke-cert.
Platform modules
Platform-specific values do not belong in the core, so they are Python modules in
/usr/lib/vpn-router/vpnrouter_platforms/, imported at run time and selected by
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.
The modules exist as the documented place for platform specifics if that changes.
A module may define any of the following, and takes the default for anything it omits:
| Name | Type | Effect |
|---|---|---|
EXTRA_P2S_DNS |
str |
added to the road-warrior pool dns list |
EXTRA_LOCAL_TS |
str |
added to local_ts on both connections |
apply(ctx) |
function | called once the system is configured, to apply platform-specific state; must be idempotent |
ctx is the template context, so a module reads what it needs from it -
ctx['wan_iface'], ctx['int_iface'], ctx['local_cidrs'] and the rest.
Adding a platform is adding one file. Modules are discovered by listing the package, so nothing in the core needs editing:
# /usr/lib/vpn-router/vpnrouter_platforms/example.py
"""Example provider."""
EXTRA_P2S_DNS = '192.0.2.53'
EXTRA_LOCAL_TS = '192.0.2.53/32'
Set general.platform to example and restart the service. The debconf question offers only the
modules shipped with the package; the configuration file accepts any module installed.
Status web service
The vpn-router-web service shows the state of the router on its internal address and
web.port (8080 by default). It uses only the Python standard library, serves HTTP without
authentication, and shows no configuration: no key, identity, address or subnet.
| Page | Content |
|---|---|
/ |
The status for people: plain text for curl and wget, a page styled with Tailwind CSS for a browser (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
/status |
The same status as JSON |
/config |
The configuration with its secrets masked, and what the router resolved and runs, as JSON; needs a token |
The status holds the state of vpn-router-setup, of strongswan when site-to-site or road-warrior
access is configured, and of wg-quick@wg0 when WireGuard is; the package version; whether
Docker is installed, and when it is, the state of docker.service and of vpn-router-docker
(the Docker rules), which do not change status; whether the
site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and
the WireGuard peers with the age of their last handshake and their traffic. status is ok when
all these services are active, and degraded otherwise.
curl http://10.0.0.4:8080/
curl http://10.0.0.4:8080/status
The /config page
/config shows what / and /status leave out, for a client such as manage-ui:
| Key | Content |
|---|---|
configuration |
vpn-router.json with the defaults applied and the secrets masked (psk, jwt_secret, and any option named like a password, token or key) |
network |
The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as ip -j -4 route prints them |
ipsec |
connections and sas: one object per connection and per IKE SA, read from swanctl --list-conns --pretty and swanctl --list-sas --pretty, with the connection's name under name and swanctl's keys and string values |
wireguard |
The public key and port of wg0, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
docker |
docker.exposed_ports and the rules of the VPN-ROUTER-DOCKER chain |
The page needs a JWT in an Authorization: Bearer header, signed with HS256 by web.jwt_secret.
The token must have an exp claim, not more than ten minutes from now (a minute of clock
difference is allowed); nbf is checked when present. Other algorithms are refused, with 401 and
WWW-Authenticate: Bearer. While web.jwt_secret is not set the page is not served (404). The
token and the page travel over plain HTTP on the internal network, and the token can be used
until it expires, so keep the lifetime short. A client makes a token from the secret with only
HMAC-SHA256; with Python:
import base64, hashlib, hmac, json, time
def b64(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
secret = 'the web.jwt_secret value'
head = b64(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
body = b64(json.dumps({'exp': int(time.time()) + 300}).encode())
sign = b64(hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest())
print(f'{head}.{body}.{sign}')
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:8080/config
vpn-router-setup opens web.port on the internal interface in the firewall, writes the address,
the port, the secret and the facts for /config to /run/vpn-router/web.json (readable by root
only) and restarts the service, so a changed web section
applies with systemctl restart vpn-router-setup. The service ends without an error when
web.enabled is false, and keeps trying to start until the router has an internal address.
Removal
apt-get remove stops vpn-router-docker, which removes the Docker rules. It terminates the
tunnels and takes wg-quick@wg0 down, but leaves the
configuration in place. strongswan is left enabled, since it is a shared service that
may be in use by something else.
apt-get purge additionally removes the generated files and the firewall rules, restoring
/etc/ufw/before.rules to its original content, and hands vpn-router.json back to ucf
before deleting it so a later reinstall starts clean. Key material is never deleted:
/etc/vpn-router/pki, the WireGuard key pair and everything under /etc/swanctl are left
alone.
Building
cd debian-package
./build.sh
The build runs in a container and writes the package to debian-package/out/.
./publish.sh uploads it to the configured repository.