5 Commits
Author SHA1 Message Date
slawek c8f54ca865 Add GCP deployment examples: create Terraform configurations for GCP, including network setup, instance definitions, and routing, along with a README for usage instructions. 2026-08-27 21:23:47 +02:00
slawek 245d0dfd96 Enhance Azure example configurations: add subscription_id and tenant_id variables, update terraform.tfvars.example, and refine .gitignore for tfplan files 2026-08-23 22:38:48 +02:00
slawek dac5cea5ee Add Azure deployment examples and enhance configuration management
- Update .gitignore to exclude Terraform files
- Enhance README with Azure deployment instructions
- Refactor publish.sh to use a container for changelog parsing
- Add Azure example files including Terraform configurations
- Create cloud-init templates for PKI and default configurations
- Implement workload VM setup for testing routing
2026-08-23 22:10:29 +02:00
slawek 7cb2f1b8dd 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.
2026-08-23 17:15:49 +02:00
slawek 083ad9a596 Reengineed the code. Generalized the package. Cloud configurators are modules. 2026-08-10 09:02:45 +02:00
61 changed files with 3796 additions and 658 deletions
+8
View File
@@ -4,3 +4,11 @@ out
.*.md
AGENTS.md
CLAUDE.md
__pycache__
.vscode
tmp
.terraform*
*tfplan*
*.tfstate
*.tfstate.*
*.auto.tfvars
+264 -2
View File
@@ -1,4 +1,266 @@
# Linux Cloud Router for Ubuntu/Debian Linux
# vpn-router
This project provides an Ubuntu/Debian Linux-based cloud router solution that can be deployed on various cloud platforms. It offers routing and S2S and P2S VPN capabilities, making it an ideal choice for organizations looking to establish secure and efficient network connectivity in the cloud.
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
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.conf the configuration
systemctl restart vpn-router-setup apply it
```
### By hand
```sh
apt-get install vpn-router # answer "none" to the platform question
$EDITOR /etc/vpn-router/vpn-router.conf
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:
```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/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.conf` directly before or after installing.
A configuration-management tool should do that and then restart `vpn-router-setup`.
See [examples/](examples/) for a cloud-init template and a shell installer, and
[examples/azure/](examples/azure/) / [examples/gcp/](examples/gcp/) for full Terraform examples
that deploy a router on Azure or GCP.
## Configuration
`/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` | `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 `@` |
| `remote` | `cidrs` | Remote subnets reachable through the tunnel |
| `remote` | `psk_b64` | Pre-shared key, base64-encoded |
| `remote` | `psk_file` | Path to a file holding the raw key; wins over `psk_b64` |
| `p2s` | `enabled` | Road-warrior access |
| `p2s` | `address_pool` | Pool assigned to road-warrior clients |
| `p2s` | `ca_name` | Common name for a locally created CA |
| `wireguard` | `enabled` | WireGuard endpoint |
| `wireguard` | `address` | Address and prefix for `wg0` |
| `wireguard` | `listen_port` | UDP port, default 51820 |
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.
Encode the pre-shared key with:
```sh
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.
### 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
```sh
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:
```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:
```
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:
```sh
/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:
```python
# /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 `platform = example` and restart the service. The debconf question offers only the
modules shipped with the package; the configuration file accepts any module installed.
## Removal
`apt-get remove` 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.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.
## Building
```sh
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.
-28
View File
@@ -1,28 +0,0 @@
#cloud-config
apt:
sources:
cloud-router:
source: "deb [signed-by=/etc/apt/keyrings/cloud-router.gpg] ${repo_url} ${ubuntu_codename} main"
key: |
${indent(8, trimspace(repo_gpg_key))}
debconf_selections: |
cloud-router cloud-router/local_addrs string ${local_addrs}
cloud-router cloud-router/local_fqdn string ${fqdn}
cloud-router cloud-router/local_id_mode select ${local_id_mode}
cloud-router cloud-router/local_cidrs string ${local_cidrs}
cloud-router cloud-router/remote_addrs string ${remote_addrs}
cloud-router cloud-router/remote_id string ${remote_id}
cloud-router cloud-router/psk password ${psk}
cloud-router cloud-router/remote_cidrs string ${remote_cidrs}
cloud-router cloud-router/router_int_gateway_ip string ${router_int_gateway_ip}
cloud-router cloud-router/p2s_address_pool string ${p2s_address_pool}
cloud-router cloud-router/wg_enabled boolean ${wg_enabled}
cloud-router cloud-router/wg_address string ${wg_address}
cloud-router cloud-router/wg_listen_port string ${wg_listen_port}
package_update: true
packages:
- cloud-router
+3
View File
@@ -8,12 +8,15 @@ RUN apt-get update \
build-essential \
dpkg-dev \
debhelper \
lintian \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY . src/
RUN cd src && dpkg-buildpackage -us -uc -b
RUN lintian --no-tag-display-limit /build/*.changes || true
RUN mkdir /out \
&& find /build -maxdepth 1 \( -name '*.deb' -o -name '*.buildinfo' -o -name '*.changes' \) \
-exec install -o "$HOST_UID" -g "$HOST_GID" -m 0644 {} /out/ \;
+2 -2
View File
@@ -3,7 +3,7 @@ set -e
mkdir -p out
container build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t cloud-router-builder "$@" .
container run --rm -v "$(pwd)/out:/mnt" cloud-router-builder sh -c 'cp -p /out/* /mnt/'
container build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
container run --rm -v "$(pwd)/out:/mnt" vpn-router-builder sh -c 'cp -p /out/* /mnt/'
echo "Build artifacts written to out/"
+1 -1
View File
@@ -1,4 +1,4 @@
cloud-router (1.0.0-1) unstable; urgency=medium
vpn-router (1.0.0-1) unstable; urgency=medium
* Initial release.
+55 -14
View File
@@ -2,22 +2,63 @@
set -e
. /usr/share/debconf/confmodule
db_input high cloud-router/local_addrs || true
db_input high cloud-router/local_fqdn || true
db_input high cloud-router/local_id_mode || true
db_input high cloud-router/local_cidrs || true
db_input high cloud-router/remote_addrs || true
db_input high cloud-router/remote_id || true
db_input high cloud-router/psk || true
db_input high cloud-router/remote_cidrs || true
db_input high cloud-router/router_int_gateway_ip || true
db_input high cloud-router/p2s_address_pool || true
db_input high cloud-router/wg_enabled || true
# 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_go || true
db_get cloud-router/wg_enabled
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/remote_addrs || true
db_input high vpn-router/remote_id || true
db_input high vpn-router/remote_cidrs || true
db_input high vpn-router/psk || true
db_input high vpn-router/p2s_enabled || true
db_input high vpn-router/wg_enabled || true
db_go || true
db_get vpn-router/p2s_enabled
if [ "$RET" = "true" ]; then
db_input high cloud-router/wg_address || true
db_input high cloud-router/wg_listen_port || true
db_input high vpn-router/p2s_address_pool || true
db_input high vpn-router/p2s_ca_name || true
db_go || true
fi
db_get vpn-router/wg_enabled
if [ "$RET" = "true" ]; then
db_input high vpn-router/wg_address || true
db_input high vpn-router/wg_listen_port || true
db_go || true
fi
+22 -11
View File
@@ -1,4 +1,4 @@
Source: cloud-router
Source: vpn-router
Section: net
Priority: optional
Maintainer: Sławomir Koszewski <slawek@koszewscy.waw.pl>
@@ -6,7 +6,7 @@ Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Rules-Requires-Root: no
Package: cloud-router
Package: vpn-router
Architecture: all
Depends: ${misc:Depends},
strongswan-swanctl,
@@ -16,14 +16,25 @@ Depends: ${misc:Depends},
wireguard-tools,
ufw,
debconf,
ucf,
openssl,
python3-jinja2
Description: Linux cloud router with IPSec and optional WireGuard
Configures a Linux host as a cloud router providing site-to-site IKEv2
IPSec (strongSwan swanctl) and road-warrior P2S VPN (EAP-TLS). WireGuard
is optionally enabled. Includes a PKI helper library (simple-ca.sh) for
managing the road-warrior certificate authority.
python3,
python3-jinja2,
iproute2,
systemd-resolved
Description: Linux VPN router with IPSec, road-warrior and WireGuard support
Configures a Linux host or virtual machine as a VPN router: site-to-site
IKEv2 IPSec (strongSwan swanctl), optional road-warrior access using EAP-TLS,
optional WireGuard, and the routing, NAT, MSS clamping and firewall rules a
router needs. Nothing in the package is specific to a cloud provider.
.
Site-specific values are collected via debconf at install time and written
to /etc/default/cloud-router. A one-shot systemd service (cloud-router-setup)
applies UFW rules and WireGuard keys on first boot.
Settings live in /etc/vpn-router/vpn-router.conf, which is created on first
install and owned by the administrator afterwards. Editing it and restarting
vpn-router-setup applies the change; the same service reconciles the system
at every boot. It is built on two inputs, the external and internal interface
names; the addresses on them are read from the system rather than configured
again, and nothing is auto-detected.
.
Platform-specific additions are provided by modules under
/usr/lib/vpn-router, selected explicitly by the platform setting. Modules for
Azure and GCP are included.
+1 -1
View File
@@ -1,5 +1,5 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: cloud-router
Upstream-Name: vpn-router
Upstream-Contact: Sławomir Koszewski <slawek@koszewscy.waw.pl>
Files: *
+4 -10
View File
@@ -1,10 +1,4 @@
etc/cloud-router
etc/cloud-router/pki
etc/wireguard
etc/swanctl/conf.d
etc/swanctl/x509ca
etc/swanctl/x509
etc/swanctl/private
etc/systemd/resolved.conf.d
usr/lib/cloud-router
usr/share/cloud-router/templates
etc/vpn-router
usr/lib/vpn-router
usr/lib/vpn-router/vpnrouter_platforms
usr/share/vpn-router/templates
+9 -4
View File
@@ -1,4 +1,9 @@
src/etc/sysctl.d/99-cloud-router.conf etc/sysctl.d/
src/opt/cloud-router/bin/simple-ca opt/cloud-router/bin/
src/usr/lib/cloud-router/configure usr/lib/cloud-router/
src/usr/share/cloud-router/templates/* usr/share/cloud-router/templates/
src/etc/sysctl.d/99-vpn-router.conf etc/sysctl.d/
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/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/
+50 -36
View File
@@ -4,47 +4,61 @@ set -e
case "$1" in
configure)
# ── Read debconf answers ──────────────────────────────────────────────
db_get cloud-router/local_addrs; CLOUD_ROUTER_LOCAL_ADDRS="$RET"
db_get cloud-router/local_fqdn; CLOUD_ROUTER_LOCAL_FQDN="$RET"
db_get cloud-router/local_id_mode; CLOUD_ROUTER_LOCAL_ID_MODE="$RET"
db_get cloud-router/local_cidrs; CLOUD_ROUTER_LOCAL_CIDRS="$RET"
db_get cloud-router/remote_addrs; CLOUD_ROUTER_REMOTE_ADDRS="$RET"
db_get cloud-router/remote_id; CLOUD_ROUTER_REMOTE_ID="$RET"
db_get cloud-router/psk; CLOUD_ROUTER_PSK="$RET"
db_get cloud-router/remote_cidrs; CLOUD_ROUTER_REMOTE_CIDRS="$RET"
db_get cloud-router/router_int_gateway_ip; CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP="$RET"
db_get cloud-router/p2s_address_pool; CLOUD_ROUTER_P2S_ADDRESS_POOL="$RET"
db_get cloud-router/wg_enabled; CLOUD_ROUTER_WG_ENABLED="$RET"
db_get cloud-router/wg_address; CLOUD_ROUTER_WG_ADDRESS="$RET"
db_get cloud-router/wg_listen_port; CLOUD_ROUTER_WG_LISTEN_PORT="$RET"
# --- 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"
db_get vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$RET"
db_get vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$RET"
db_get vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$RET"
db_get vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$RET"
db_get vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$RET"
db_get vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$RET"
db_get vpn-router/psk; VPN_ROUTER_PSK="$RET"
db_get vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$RET"
db_get vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$RET"
db_get vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$RET"
db_get vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$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"
# ── Render configuration files via Jinja2 templates ─────────────────
export CLOUD_ROUTER_LOCAL_ADDRS CLOUD_ROUTER_LOCAL_FQDN \
CLOUD_ROUTER_LOCAL_ID_MODE CLOUD_ROUTER_LOCAL_CIDRS \
CLOUD_ROUTER_REMOTE_ADDRS CLOUD_ROUTER_REMOTE_ID \
CLOUD_ROUTER_PSK CLOUD_ROUTER_REMOTE_CIDRS \
CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP CLOUD_ROUTER_P2S_ADDRESS_POOL \
CLOUD_ROUTER_WG_ENABLED CLOUD_ROUTER_WG_ADDRESS \
CLOUD_ROUTER_WG_LISTEN_PORT
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 \
VPN_ROUTER_REMOTE_ADDRS VPN_ROUTER_REMOTE_ID \
VPN_ROUTER_REMOTE_CIDRS VPN_ROUTER_PSK \
VPN_ROUTER_P2S_ENABLED VPN_ROUTER_P2S_ADDRESS_POOL \
VPN_ROUTER_P2S_CA_NAME VPN_ROUTER_WG_ENABLED \
VPN_ROUTER_WG_ADDRESS VPN_ROUTER_WG_LISTEN_PORT
/usr/lib/cloud-router/configure
# --- 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"
db_set cloud-router/psk ""
# 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 system settings ─────────────────────────────────────────────
sysctl --system
netplan apply
systemctl daemon-reload
systemctl restart systemd-resolved
# Apply the sysctl drop-in shipped by this package so it takes effect
# without waiting for a reboot.
sysctl --system >/dev/null
# ── UFW: ensure SSH is allowed then enable ────────────────────────────
ufw allow 22/tcp
ufw --force enable
ufw reload
# ── strongSwan ────────────────────────────────────────────────────────
systemctl enable --now strongswan
# The firewall is not touched here. Enabling it, allowing SSH and
# setting the forward policy all belong to vpn-router-setup, which does
# them only once the configuration says this host is a router.
;;
esac
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
set -e
BEFORE_RULES=/etc/ufw/before.rules
# Remove the rule blocks this package inserted, together with the single blank
# line that precedes each one, so before.rules returns to its original content.
strip_ufw_blocks() {
[ -f "$BEFORE_RULES" ] || return 0
grep -q '^# ROUTER MSS RULES START$\|^# IPSEC RULES START$' "$BEFORE_RULES" || return 0
tmp="$(mktemp)"
awk '
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES START$/ {
skip = 1; pending = 0; next
}
/^# (IPSEC|WIREGUARD|P2S DNS|ROUTER FORWARD|ROUTER NAT|ROUTER MSS) RULES END$/ {
skip = 0; next
}
skip { next }
/^$/ { pending++; next }
{
while (pending > 0) { print ""; pending-- }
print
}
END { while (pending > 0) { print ""; pending-- } }
' "$BEFORE_RULES" > "$tmp"
cat "$tmp" > "$BEFORE_RULES"
rm -f "$tmp"
}
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 \
/etc/wireguard/wg0.conf \
/etc/vpn-router/vpn-router.conf
# Key material is not package state: /etc/vpn-router/pki, the WireGuard
# key pair and everything under /etc/swanctl are left in place.
rmdir --ignore-fail-on-non-empty /etc/vpn-router 2>/dev/null || true
;;
esac
#DEBHELPER#
+17 -1
View File
@@ -3,7 +3,23 @@ set -e
case "$1" in
remove|deconfigure)
systemctl disable --now strongswan || true
# Stop offering the tunnels, but leave strongSwan itself enabled: it is
# a shared service and may be in use by something other than this
# package. The connection files are removed on purge.
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
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
+3
View File
@@ -1,3 +1,6 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_installsystemd:
dh_installsystemd --name=vpn-router-setup
+135 -56
View File
@@ -1,80 +1,159 @@
Template: cloud-router/local_addrs
Type: string
Description: Local WAN IP address(es)
Comma-separated list of local WAN IP addresses that strongSwan binds on
for the site-to-site and road-warrior tunnels (e.g. 10.1.2.3).
Template: vpn-router/platform
Type: select
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: cloud-router/local_fqdn
Type: string
Description: Local router FQDN
Fully-qualified domain name of this router (e.g. router.example.com).
Used as the road-warrior server identity and certificate CN.
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: cloud-router/local_id_mode
Template: vpn-router/external_interface
Type: string
Default:
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.
Template: vpn-router/internal_interface
Type: string
Default:
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:
Fully-qualified domain name of this router (for example
router.example.com). Used as the road-warrior server identity and
certificate common name.
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 (default; requires matching on remote side)
public_ip — resolve the public IP from DNS at first boot
internal_ip — use the local WAN IP address
.
fqdn - use the FQDN, which must match what the peer expects.
.
public_ip - resolve the FQDN and use the address it returns.
.
internal_ip - use the internal NIC address.
Template: cloud-router/local_cidrs
Template: vpn-router/local_cidrs
Type: string
Description: Local subnet CIDR(s)
Default:
Description: Local subnet CIDR(s):
Comma-separated list of local subnet CIDRs to advertise into the
site-to-site tunnel (e.g. 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: cloud-router/remote_addrs
Template: vpn-router/int_gateway_ip
Type: string
Description: Remote site WAN IP address(es)
Comma-separated list of remote site WAN IP addresses for the
Default:
Description: Internal network gateway address:
Address of the next-hop gateway on the internal side, used to route the
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):
Comma-separated list of remote gateway addresses or FQDNs for the
site-to-site IPSec tunnel.
Template: cloud-router/remote_id
Template: vpn-router/remote_id
Type: string
Description: Remote site IKE identity
IKE identity of the remote peer (FQDN, without leading @).
Default:
Description: Remote site IKE identity:
IKE identity of the remote peer, without a leading @.
Template: cloud-router/psk
Template: vpn-router/remote_cidrs
Type: string
Default:
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)
Pre-shared key for the site-to-site IKEv2 tunnel. Must match the
value configured on the remote peer.
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.
Template: cloud-router/remote_cidrs
Type: string
Description: Remote subnet CIDR(s)
Comma-separated list of remote subnet CIDRs for the site-to-site
tunnel (e.g. 192.168.0.0/24).
Template: cloud-router/router_int_gateway_ip
Type: string
Description: Internal network gateway IP
IP address of the next-hop gateway on the internal NIC (eth1).
Used in the netplan route for the local subnet.
Template: cloud-router/p2s_address_pool
Type: string
Description: Road-warrior address pool
CIDR block assigned to road-warrior VPN clients (e.g. 172.16.0.0/24).
Template: cloud-router/wg_enabled
Template: vpn-router/p2s_enabled
Type: boolean
Default: false
Description: Enable WireGuard VPN?
If true, WireGuard is configured on wg0 and its UFW rules are installed.
Description: Enable road-warrior (P2S) access?
If enabled, this router accepts IKEv2 EAP-TLS connections from individual
clients. Certificates are taken from /etc/vpn-router/pki, and a local
certificate authority is created there if that directory is empty.
Template: cloud-router/wg_address
Template: vpn-router/p2s_address_pool
Type: string
Default: 10.0.1.1/24
Description: WireGuard interface address
IP address and prefix length for the wg0 interface (e.g. 10.0.1.1/24).
Only used when WireGuard is enabled.
Default:
Description: Road-warrior address pool:
CIDR block assigned to road-warrior clients (for example 172.16.0.0/24).
Template: cloud-router/wg_listen_port
Template: vpn-router/p2s_ca_name
Type: string
Default: VPN Router CA
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.
Template: vpn-router/wg_enabled
Type: boolean
Default: false
Description: Enable WireGuard?
If enabled, WireGuard is configured on wg0, a key pair is generated, and
the matching firewall rule is installed. Peers are added by editing
/etc/wireguard/wg0.conf.
Template: vpn-router/wg_address
Type: string
Default:
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
UDP port that WireGuard listens on. Only used when WireGuard is enabled.
Description: WireGuard listen port:
UDP port that WireGuard listens on.
@@ -0,0 +1,13 @@
[Unit]
Description=VPN Router Setup
Documentation=file:/usr/share/doc/vpn-router/README.md
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/lib/vpn-router/setup
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
Regular → Executable
+18 -1
View File
@@ -1,3 +1,20 @@
#!/bin/sh
set -e
changelog_field() {
container run --rm -v "$(pwd):/mnt" vpn-router-builder \
dpkg-parsechangelog --show-field "$1" -l /mnt/debian/changelog
}
VERSION="$(changelog_field Version)"
PACKAGE="$(changelog_field Source)"
DEB="out/${PACKAGE}_${VERSION}_all.deb"
if [ ! -f "$DEB" ]; then
printf 'ERROR: %s does not exist. Run ./build.sh first.\n' "$DEB" >&2
exit 1
fi
curl -v --user "slawek:$(cat ~/.gitea-packages-token)" \
--upload-file out/cloud-router_1.0.0-1_all.deb \
--upload-file "$DEB" \
"https://gitea.koszewscy.waw.pl/api/packages/slawek/debian/pool/noble/main/upload"
@@ -1,208 +0,0 @@
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Sławomir Koszewski
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""simple-ca — minimal CA for cloud-router IKEv2 PKI.
Directory layout:
/etc/swanctl/x509ca/ca.pem CA certificate (strongSwan trust anchor)
/etc/swanctl/private/ca.key CA private key
/etc/swanctl/x509/server.pem server certificate
/etc/swanctl/private/server.key server private key
/etc/cloud-router/pki/{name}_cert.pem user/road-warrior certificate
/etc/cloud-router/pki/{name}_key.pem user/road-warrior private key
/etc/cloud-router/pki/{name}.pfx PKCS#12 bundle for client distribution
"""
import argparse
import subprocess
import sys
from pathlib import Path
CA_CERT = Path('/etc/swanctl/x509ca/ca.pem')
CA_KEY = Path('/etc/swanctl/private/ca.key')
SERVER_CERT = Path('/etc/swanctl/x509/server.pem')
SERVER_KEY = Path('/etc/swanctl/private/server.key')
PKI_DIR = Path('/etc/cloud-router/pki')
def _run(*args, stdin=None):
result = subprocess.run(list(args), input=stdin, capture_output=True)
if result.returncode != 0:
sys.stderr.buffer.write(result.stderr)
sys.exit(1)
return result.stdout
def _san_prefix(s):
if '@' in s:
return f'email:{s}'
parts = s.split('.')
if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
return f'IP:{s}'
return f'DNS:{s}'
def cmd_make_ca(args):
if CA_CERT.exists() and CA_KEY.exists():
print('Root CA already exists, skipping.')
return
print('Generating root CA certificate and key...')
_run(
'openssl', 'req',
'-x509',
'-newkey', 'rsa:4096',
'-keyout', str(CA_KEY),
'-out', str(CA_CERT),
'-days', str(args.days),
'-noenc',
'-subj', f'/CN={args.name}',
'-text',
'-addext', 'basicConstraints=critical,CA:TRUE,pathlen:1',
'-addext', 'keyUsage=critical,keyCertSign,cRLSign',
)
CA_KEY.chmod(0o600)
print(f'Root CA created.')
print(f' Certificate : {CA_CERT}')
print(f' Private key : {CA_KEY}')
def cmd_make_cert(args):
name = args.subject.split('.')[0]
if args.type == 'server':
cert_path = SERVER_CERT
key_path = SERVER_KEY
else:
cert_path = PKI_DIR / f'{name}_cert.pem'
key_path = PKI_DIR / f'{name}_key.pem'
if cert_path.exists() and key_path.exists():
print(f'Certificate already exists at {cert_path}, skipping.')
return
if not CA_CERT.exists() or not CA_KEY.exists():
print('ERROR: Root CA not found. Run: simple-ca make-ca <name>', file=sys.stderr)
sys.exit(1)
req_args = [
'openssl', 'req',
'-newkey', 'rsa:4096',
'-keyout', str(key_path),
'-noenc',
'-subj', f'/CN={args.subject}',
'-addext', 'basicConstraints=critical,CA:FALSE',
]
if args.type == 'server':
sans = [f'DNS:{args.subject}'] + [_san_prefix(s) for s in args.san]
req_args += [
'-addext', 'keyUsage=critical,digitalSignature,keyEncipherment',
'-addext', 'extendedKeyUsage=serverAuth,clientAuth',
'-addext', f'subjectAltName={",".join(sans)}',
]
else:
sans = [_san_prefix(s) for s in args.san]
req_args += [
'-addext', 'keyUsage=critical,digitalSignature,nonRepudiation',
'-addext', 'extendedKeyUsage=clientAuth,emailProtection',
]
if sans:
req_args += ['-addext', f'subjectAltName={",".join(sans)}']
x509_args = [
'openssl', 'x509',
'-req',
'-CA', str(CA_CERT),
'-CAkey', str(CA_KEY),
'-copy_extensions', 'copyall',
'-days', str(args.days),
'-text',
'-out', str(cert_path),
]
csr = _run(*req_args)
_run(*x509_args, stdin=csr)
key_path.chmod(0o600)
print('Certificate created.')
print(f' Certificate : {cert_path}')
print(f' Private key : {key_path}')
def cmd_make_pfx(args):
name = args.name
cert_path = PKI_DIR / f'{name}_cert.pem'
key_path = PKI_DIR / f'{name}_key.pem'
pfx_path = PKI_DIR / f'{name}.pfx'
if not cert_path.exists() or not key_path.exists():
print(f'ERROR: Certificate or key not found for {name!r} in {PKI_DIR}.', file=sys.stderr)
sys.exit(1)
if pfx_path.exists():
print(f'ERROR: {pfx_path} already exists.', file=sys.stderr)
sys.exit(1)
password = args.password or 'changeit'
_run(
'openssl', 'pkcs12',
'-export',
'-out', str(pfx_path),
'-inkey', str(key_path),
'-in', str(cert_path),
'-certfile', str(CA_CERT),
'-password', f'pass:{password}',
)
print(f'PKCS#12 created: {pfx_path}')
def main():
ap = argparse.ArgumentParser(
prog='simple-ca',
description='Minimal CA for cloud-router IKEv2 PKI.',
)
sub = ap.add_subparsers(dest='command', required=True)
p_ca = sub.add_parser('make-ca', help='Create root CA')
p_ca.add_argument('name', help='CA common name')
p_ca.add_argument('--days', type=int, default=3650, metavar='N')
p_ca.set_defaults(func=cmd_make_ca)
p_cert = sub.add_parser('make-cert', help='Issue a certificate')
p_cert.add_argument('subject', help='Subject CN (FQDN for server, username for user)')
p_cert.add_argument('san', nargs='*', help='Additional SANs: IP, DNS, or email')
p_cert.add_argument('--type', choices=['server', 'user'], default='server',
help='Certificate type (default: server)')
p_cert.add_argument('--days', type=int, default=365, metavar='N')
p_cert.set_defaults(func=cmd_make_cert)
p_pfx = sub.add_parser('make-pfx', help='Export PKCS#12 bundle for a user certificate')
p_pfx.add_argument('name', help='Certificate name (without extension)')
p_pfx.add_argument('--password', metavar='PASS',
help='Export password (default: changeit)')
p_pfx.set_defaults(func=cmd_make_pfx)
args = ap.parse_args()
args.func(args)
if __name__ == '__main__':
main()
-257
View File
@@ -1,257 +0,0 @@
#!/usr/bin/python3
"""Render cloud-router configuration files and configure the system."""
import os
import sys
import socket
import pathlib
import subprocess
import jinja2
TEMPLATE_DIR = pathlib.Path('/usr/share/cloud-router/templates')
def _require(name):
val = os.environ.get(name)
if val is None:
print(f'ERROR: environment variable {name} is not set', file=sys.stderr)
sys.exit(1)
return val
def build_context():
local_addrs = _require('CLOUD_ROUTER_LOCAL_ADDRS')
local_fqdn = _require('CLOUD_ROUTER_LOCAL_FQDN')
local_id_mode = _require('CLOUD_ROUTER_LOCAL_ID_MODE')
local_cidrs = _require('CLOUD_ROUTER_LOCAL_CIDRS')
remote_addrs = _require('CLOUD_ROUTER_REMOTE_ADDRS')
remote_id = _require('CLOUD_ROUTER_REMOTE_ID')
psk = _require('CLOUD_ROUTER_PSK')
remote_cidrs = _require('CLOUD_ROUTER_REMOTE_CIDRS')
router_int_gateway_ip = _require('CLOUD_ROUTER_ROUTER_INT_GATEWAY_IP')
p2s_address_pool = _require('CLOUD_ROUTER_P2S_ADDRESS_POOL')
wg_enabled = _require('CLOUD_ROUTER_WG_ENABLED')
wg_address = _require('CLOUD_ROUTER_WG_ADDRESS')
wg_listen_port = _require('CLOUD_ROUTER_WG_LISTEN_PORT')
local_subnet = local_cidrs.split(',')[0].strip()
p2s_server_name = local_fqdn.split('.')[0]
if local_id_mode == 'fqdn':
local_id = f'@{local_fqdn}'
elif local_id_mode == 'public_ip':
try:
local_id = socket.getaddrinfo(local_fqdn, None, socket.AF_INET)[0][4][0]
except socket.gaierror as exc:
print(f'ERROR: cannot resolve {local_fqdn}: {exc}', file=sys.stderr)
sys.exit(1)
elif local_id_mode == 'internal_ip':
local_id = local_addrs
else:
local_id = f'@{local_fqdn}'
return {
'local_addrs': local_addrs,
'local_fqdn': local_fqdn,
'local_id_mode': local_id_mode,
'local_cidrs': local_cidrs,
'local_subnet': local_subnet,
'remote_addrs': remote_addrs,
'remote_id': remote_id,
'psk': psk,
'remote_cidrs': remote_cidrs,
'router_int_gateway_ip': router_int_gateway_ip,
'p2s_address_pool': p2s_address_pool,
'p2s_server_name': p2s_server_name,
'wg_enabled': wg_enabled,
'wg_address': wg_address,
'wg_listen_port': wg_listen_port,
'local_id': local_id,
}
def render(jinja_env, ctx, template_name, dest, mode):
content = jinja_env.get_template(template_name).render(ctx)
dest = pathlib.Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding='utf-8')
os.chmod(dest, mode)
os.chown(dest, 0, 0)
def detect_wan_iface():
result = subprocess.run(
['ip', 'route', 'get', '1.1.1.1'],
capture_output=True, text=True,
)
if result.returncode != 0:
print('ERROR: ip route get 1.1.1.1 failed', file=sys.stderr)
sys.exit(1)
tokens = result.stdout.split()
for i, tok in enumerate(tokens):
if tok == 'dev' and i + 1 < len(tokens):
return tokens[i + 1]
print('ERROR: unable to detect WAN interface', file=sys.stderr)
sys.exit(1)
def setup_wireguard(ctx):
if ctx['wg_enabled'] != 'true':
return
wg_dir = pathlib.Path('/etc/wireguard')
wg_dir.mkdir(mode=0o700, exist_ok=True)
key_file = wg_dir / 'wg0.key'
if not key_file.exists() or key_file.stat().st_size == 0:
result = subprocess.run(['wg', 'genkey'], capture_output=True, check=True)
key_file.write_bytes(result.stdout)
os.chmod(key_file, 0o600)
pub_result = subprocess.run(
['wg', 'pubkey'],
input=key_file.read_bytes(),
capture_output=True, check=True,
)
pub_file = wg_dir / 'wg0.pub'
pub_file.write_bytes(pub_result.stdout)
os.chmod(pub_file, 0o644)
def _insert_after(content, marker, block):
"""Insert block after the first line that exactly matches marker."""
lines = content.splitlines(keepends=True)
result = []
for line in lines:
result.append(line)
if line.rstrip('\n') == marker:
result.append(block)
return ''.join(result)
def _insert_after_first_commit(content, block):
"""Insert block after the first COMMIT line (end of *filter table)."""
lines = content.splitlines(keepends=True)
result = []
inserted = False
for line in lines:
result.append(line)
if not inserted and line.rstrip('\n') == 'COMMIT':
result.append(block)
inserted = True
return ''.join(result)
def _wg_block(ctx):
return (
'\n'
'# WIREGUARD RULES START\n'
f'-A ufw-before-input -p udp --dport {ctx["wg_listen_port"]} -j ACCEPT\n'
'# WIREGUARD RULES END\n'
)
def setup_ufw(ctx, wan_iface):
# ── DEFAULT_FORWARD_POLICY ────────────────────────────────────────────────
ufw_defaults = pathlib.Path('/etc/default/ufw')
if ufw_defaults.exists():
lines = ufw_defaults.read_text().splitlines(keepends=True)
lines = [
'DEFAULT_FORWARD_POLICY="ACCEPT"\n'
if ln.startswith('DEFAULT_FORWARD_POLICY=') else ln
for ln in lines
]
ufw_defaults.write_text(''.join(lines))
# ── before.rules ─────────────────────────────────────────────────────────
before_rules = pathlib.Path('/etc/ufw/before.rules')
if not before_rules.exists():
print(f'ERROR: {before_rules} does not exist', file=sys.stderr)
sys.exit(1)
content = before_rules.read_text()
# Idempotency: handle dpkg-reconfigure re-runs
if '# IPSEC RULES START' in content:
if ctx['wg_enabled'] == 'true' and '# WIREGUARD RULES START' not in content:
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
before_rules.write_text(content)
return
# Filter table additions (IPSEC, P2S DNS, FORWARD)
filter_block = (
'\n'
'# IPSEC RULES START\n'
'-A ufw-before-input -p udp --dport 500 -j ACCEPT\n'
'-A ufw-before-input -p udp --dport 4500 -j ACCEPT\n'
'-A ufw-before-input -p esp -j ACCEPT\n'
'-A ufw-before-input -m policy --dir in --pol ipsec -j ACCEPT\n'
'-A ufw-before-output -m policy --dir out --pol ipsec -j ACCEPT\n'
'-A ufw-before-forward -m policy --dir in --pol ipsec -j ACCEPT\n'
'-A ufw-before-forward -m policy --dir out --pol ipsec -j ACCEPT\n'
'# IPSEC RULES END\n'
'\n'
'# P2S DNS RULES START\n'
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p udp --dport 53 -j ACCEPT\n'
f'-A ufw-before-input -s {ctx["p2s_address_pool"]} -d {ctx["local_addrs"]} -p tcp --dport 53 -j ACCEPT\n'
'# P2S DNS RULES END\n'
'\n'
'# ROUTER FORWARD RULES START\n'
f'-A ufw-before-forward -s {ctx["local_subnet"]} -o {wan_iface} -j ACCEPT\n'
f'-A ufw-before-forward -d {ctx["local_subnet"]} -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n'
'# ROUTER FORWARD RULES END\n'
)
content = _insert_after(content, '# End required lines', filter_block)
# NAT table (inserted after the filter table's COMMIT)
nat_lines = [
'\n# ROUTER NAT RULES START\n',
'*nat\n',
':POSTROUTING ACCEPT [0:0]\n',
'-F POSTROUTING\n',
]
for cidr in ctx['remote_cidrs'].split(','):
nat_lines.append(
f'-A POSTROUTING -s {ctx["local_subnet"]} -d {cidr.strip()} -j RETURN\n'
)
nat_lines.append(
f'-A POSTROUTING -s {ctx["local_subnet"]} -o {wan_iface} -j MASQUERADE\n'
)
nat_lines.append('COMMIT\n# ROUTER NAT RULES END\n')
content = _insert_after_first_commit(content, ''.join(nat_lines))
if ctx['wg_enabled'] == 'true':
content = _insert_after(content, '# P2S DNS RULES END', _wg_block(ctx))
before_rules.write_text(content)
def main():
ctx = build_context()
loader = jinja2.FileSystemLoader(str(TEMPLATE_DIR))
jinja_env = jinja2.Environment(
loader=loader,
keep_trailing_newline=True,
undefined=jinja2.StrictUndefined,
autoescape=False,
)
render(jinja_env, ctx, 'cloud-router.default.j2',
'/etc/default/cloud-router', 0o644)
render(jinja_env, ctx, 'remote-site.conf.j2',
'/etc/swanctl/conf.d/remote-site.conf', 0o600)
render(jinja_env, ctx, 'road-warrior.conf.j2',
'/etc/swanctl/conf.d/road-warrior.conf', 0o600)
render(jinja_env, ctx, 'p2s-forwarder.conf.j2',
'/etc/systemd/resolved.conf.d/p2s-forwarder.conf', 0o644)
render(jinja_env, ctx, '90-cloud-router.yaml.j2',
'/etc/netplan/90-cloud-router.yaml', 0o600)
if ctx['wg_enabled'] == 'true':
render(jinja_env, ctx, 'wg0.conf.j2',
'/etc/wireguard/wg0.conf', 0o600)
wan_iface = detect_wan_iface()
setup_wireguard(ctx)
setup_ufw(ctx, wan_iface)
if __name__ == '__main__':
main()
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/python3
"""Render configuration files and provision PKI from vpn-router.conf.
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
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter
def main():
vpnrouter.configure()
return 0
if __name__ == '__main__':
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/python3
"""Write a candidate vpn-router.conf to the path given on the command line.
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
syntax.
"""
import base64
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
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'),
'VPN_ROUTER_LOCAL_ID_MODE': ('wan', 'local_id_mode'),
'VPN_ROUTER_LOCAL_CIDRS': ('local', 'cidrs'),
'VPN_ROUTER_INT_GATEWAY_IP': ('local', 'int_gateway_ip'),
'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'),
'VPN_ROUTER_REMOTE_ID': ('remote', 'id'),
'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'),
'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'),
'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'),
'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'),
'VPN_ROUTER_WG_ENABLED': ('wireguard', 'enabled'),
'VPN_ROUTER_WG_ADDRESS': ('wireguard', 'address'),
'VPN_ROUTER_WG_LISTEN_PORT': ('wireguard', 'listen_port'),
}
def main():
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():
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')
with open(output, 'w', encoding='utf-8') as handle:
parser.write(handle)
os.chmod(output, 0o600)
return 0
if __name__ == '__main__':
sys.exit(main())
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/python3
"""Apply the configuration to the running system.
Regenerates the configuration files first, then does everything that needs
the live system: routes, DNS-derived identity, WireGuard keys, firewall rules
and service reloads. Idempotent - a second run changes nothing.
Interface names come from the configuration and are never guessed. The
firewall and NAT rules are written against them, so choosing the wrong one
would be a silent and security-relevant mistake.
"""
import os
import pathlib
import socket
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter
from vpnrouter import fail
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard')
FILTER_ANCHOR = '# End required lines'
BLOCKS = (
('# IPSEC RULES START', '# IPSEC RULES END'),
('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
('# P2S DNS RULES START', '# P2S DNS RULES END'),
('# ROUTER FORWARD RULES START', '# ROUTER FORWARD RULES END'),
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
)
def run(args, check=True):
result = subprocess.run(args, capture_output=True, text=True)
if check and result.returncode != 0:
fail(f'{" ".join(args)} failed: {result.stderr.strip()}')
return result
def apply_routes(ctx):
"""Route the protected subnets via the configured internal interface.
validate() has already confirmed the interface exists.
"""
gateway = ctx['int_gateway_ip']
iface = ctx['int_iface']
for cidr in ctx['local_cidr_list']:
run(['ip', 'route', 'replace', cidr, 'via', gateway, 'dev', iface])
def resolve_public_ip(fqdn):
try:
return socket.getaddrinfo(fqdn, None, socket.AF_INET)[0][4][0]
except socket.gaierror as exc:
fail(f'cannot resolve {fqdn} for local_id_mode=public_ip: {exc}')
def setup_wireguard(ctx):
"""Generate the WireGuard key pair when it does not exist."""
if not ctx['wg_enabled']:
return
WG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
key_file = WG_DIR / 'wg0.key'
pub_file = WG_DIR / 'wg0.pub'
if not key_file.exists() or key_file.stat().st_size == 0:
result = run(['wg', 'genkey'])
key_file.write_text(result.stdout)
os.chmod(key_file, 0o600)
result = subprocess.run(['wg', 'pubkey'], input=key_file.read_text(),
capture_output=True, text=True)
if result.returncode != 0:
fail(f'wg pubkey failed: {result.stderr.strip()}')
pub_file.write_text(result.stdout)
os.chmod(pub_file, 0o644)
def strip_blocks(text):
"""Remove every block this package owns, and the blank line before it."""
lines = text.splitlines(keepends=True)
starts = {start for start, _ in BLOCKS}
ends = {end for _, end in BLOCKS}
out = []
skipping = False
for line in lines:
stripped = line.strip()
if not skipping and stripped in starts:
while out and out[-1].strip() == '':
out.pop()
skipping = True
continue
if skipping:
if stripped in ends:
skipping = False
continue
out.append(line)
return ''.join(out)
def is_configured(ctx):
"""True once any feature is actually configured.
While nothing is configured the package leaves the firewall alone: an
install that has not been filled in yet must not start blocking traffic on
a host that was working before.
"""
return any((vpnrouter.s2s_ready(ctx), vpnrouter.routing_ready(ctx),
vpnrouter.p2s_ready(ctx), vpnrouter.wg_ready(ctx)))
def filter_block(ctx):
lines = [
'',
'# IPSEC RULES START',
'-A ufw-before-input -p udp --dport 500 -j ACCEPT',
'-A ufw-before-input -p udp --dport 4500 -j ACCEPT',
'-A ufw-before-input -p esp -j ACCEPT',
'-A ufw-before-input -m policy --dir in --pol ipsec -j ACCEPT',
'-A ufw-before-output -m policy --dir out --pol ipsec -j ACCEPT',
'-A ufw-before-forward -m policy --dir in --pol ipsec -j ACCEPT',
'-A ufw-before-forward -m policy --dir out --pol ipsec -j ACCEPT',
'# IPSEC RULES END',
]
if vpnrouter.wg_ready(ctx):
lines += [
'',
'# WIREGUARD RULES START',
f'-A ufw-before-input -p udp --dport {ctx["wg_listen_port"]} -j ACCEPT',
'# WIREGUARD RULES END',
]
if vpnrouter.p2s_ready(ctx) and ctx['local_addrs']:
pool = ctx['p2s_address_pool']
lines += ['', '# P2S DNS RULES START']
for address in [a.strip() for a in ctx['local_addrs'].split(',') if a.strip()]:
lines += [
f'-A ufw-before-input -s {pool} -d {address} -p udp --dport 53 -j ACCEPT',
f'-A ufw-before-input -s {pool} -d {address} -p tcp --dport 53 -j ACCEPT',
]
lines += ['# P2S DNS RULES END']
if vpnrouter.routing_ready(ctx):
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)
def nat_and_mangle_block(ctx):
lines = []
if vpnrouter.routing_ready(ctx):
subnets = ctx['local_cidr_list']
remote = [c.strip() for c in ctx['remote_cidrs'].split(',') if c.strip()]
lines += [
'',
'# ROUTER NAT RULES START',
'*nat',
':POSTROUTING ACCEPT [0:0]',
'-F POSTROUTING',
]
# 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 += [
'',
'# ROUTER MSS RULES START',
'*mangle',
':FORWARD ACCEPT [0:0]',
'-A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu',
'COMMIT',
'# ROUTER MSS RULES END',
]
return ''.join(line + '\n' for line in lines)
def _insert_after_line(text, marker, block):
lines = text.splitlines(keepends=True)
for index, line in enumerate(lines):
if line.strip() == marker:
lines.insert(index + 1, block)
return ''.join(lines)
return None
def setup_ufw(ctx):
"""Rewrite the package's blocks in before.rules. Returns True on change."""
if not BEFORE_RULES.is_file():
fail(f'{BEFORE_RULES} does not exist')
# Nothing configured: remove anything left from an earlier configuration
# and touch nothing else.
if not is_configured(ctx):
original = BEFORE_RULES.read_text(encoding='utf-8')
text = strip_blocks(original)
if text == original:
return False
BEFORE_RULES.write_text(text, encoding='utf-8')
return True
if UFW_DEFAULTS.is_file():
text = UFW_DEFAULTS.read_text(encoding='utf-8')
updated = ''.join(
'DEFAULT_FORWARD_POLICY="ACCEPT"\n'
if line.startswith('DEFAULT_FORWARD_POLICY=') else line
for line in text.splitlines(keepends=True)
)
if updated != text:
UFW_DEFAULTS.write_text(updated, encoding='utf-8')
original = BEFORE_RULES.read_text(encoding='utf-8')
text = strip_blocks(original)
result = _insert_after_line(text, FILTER_ANCHOR, filter_block(ctx))
if result is None:
fail(f'{BEFORE_RULES}: anchor "{FILTER_ANCHOR}" not found')
text = result
result = _insert_after_line(text, 'COMMIT', nat_and_mangle_block(ctx))
if result is None:
fail(f'{BEFORE_RULES}: no COMMIT line found')
text = result
if text == original:
return False
BEFORE_RULES.write_text(text, encoding='utf-8')
return True
def unit_is_active(unit):
return run(['systemctl', 'is-active', '--quiet', unit],
check=False).returncode == 0
def manage_strongswan(ctx, changed):
"""Make sure the IPsec daemon is running and holding the current config.
strongswan.service loads swanctl configuration itself on start
(ExecStartPost) and re-reads it on reload, so starting it is enough on a
cold boot and a reload is the right response to a configuration change.
"""
wanted = vpnrouter.s2s_ready(ctx) or vpnrouter.p2s_ready(ctx)
if not wanted:
return
if not unit_is_active('strongswan'):
run(['systemctl', 'enable', '--now', 'strongswan'], check=False)
elif 'swanctl' in changed:
run(['systemctl', 'reload', 'strongswan'], check=False)
def manage_wireguard(ctx, changed):
"""Bring wg0 up when WireGuard is configured, and down when it is not."""
unit = 'wg-quick@wg0'
if not vpnrouter.wg_ready(ctx):
if unit_is_active(unit):
run(['systemctl', 'disable', '--now', unit], check=False)
return
if not unit_is_active(unit):
run(['systemctl', 'enable', '--now', unit], check=False)
elif 'wireguard' in changed:
run(['systemctl', 'restart', unit], check=False)
def run_platform_setup(cfg, ctx):
"""Let the platform module apply its own state, if it defines any."""
module = vpnrouter.platform_module(cfg)
apply = getattr(module, 'apply', None)
if apply is None:
return
try:
apply(ctx)
except Exception as exc:
fail(f'platform module {module.__name__} failed: {exc}')
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)
if ctx['local_id_mode'] == 'public_ip' and ctx['local_fqdn']:
address = resolve_public_ip(ctx['local_fqdn'])
ctx, more = vpnrouter.configure(cfg, local_id_override=address)
changed |= more
setup_wireguard(ctx)
rules_changed = setup_ufw(ctx)
if is_configured(ctx):
status = run(['ufw', 'status'], check=False)
if not status.stdout.startswith('Status: active'):
# Only ever enable the firewall after making sure SSH survives it.
run(['ufw', 'allow', '22/tcp'], check=False)
run(['ufw', '--force', 'enable'])
elif rules_changed:
run(['ufw', 'reload'])
elif rules_changed:
run(['ufw', 'reload'], check=False)
manage_strongswan(ctx, changed)
manage_wireguard(ctx, changed)
if 'resolved' in changed:
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
run_platform_setup(cfg, ctx)
return 0
if __name__ == '__main__':
sys.exit(main())
+611
View File
@@ -0,0 +1,611 @@
#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Sławomir Koszewski
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# This module requires Python 3.8+ and OpenSSL to be installed on the system.
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
import tempfile
OPENSSL = "openssl"
class Config:
FILE = "simple-ca.json"
def __init__(self, ca_dir: str):
self._path = os.path.join(ca_dir, self.FILE)
self._data: dict = {}
if not os.path.isfile(self._path):
return
try:
with open(self._path, "r") as f:
self._data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
print(f"WARNING: could not read {self._path}: {e}", file=sys.stderr)
def get(self, key, default=None):
return self._data.get(key, default)
def update(self, patch: dict):
file_missing = not os.path.isfile(self._path)
changed = any(self._data.get(k) != v for k, v in patch.items())
if file_missing or changed:
self._data.update(patch)
self._save()
def append_history(self, ca_key: str, entry: dict):
history = self._data.get("history", {})
history.setdefault(ca_key, []).append(entry)
self._data["history"] = history
self._save()
def revoke_in_history(self, ca_key: str, serial: str, revoked_at: str):
"""Mark a certificate as revoked. Returns True if newly revoked,
False if already revoked, None if serial not found."""
history = self._data.get("history", {})
for entry in history.get(ca_key, []):
if entry.get("serial") == serial:
if "revoked" in entry:
return False
entry["revoked"] = revoked_at
self._data["history"] = history
self._save()
return True
return None
def _save(self):
with open(self._path, "w") as f:
json.dump(self._data, f, indent=2)
f.write("\n")
_config: Config
def _now() -> str:
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _err(msg):
print(f"ERROR: {msg}", file=sys.stderr)
def _rebuild_ca_bundle(ca_dir):
bundle_path = os.path.join(ca_dir, "ca_bundle.pem")
parts = []
root = os.path.join(ca_dir, "ca_cert.pem")
if os.path.isfile(root):
with open(root, "rb") as f:
parts.append(f.read())
for name in sorted(_config.get("subordinates", [])):
sub_cert = os.path.join(ca_dir, name, "ca_cert.pem")
if os.path.isfile(sub_cert):
with open(sub_cert, "rb") as f:
parts.append(f.read())
with open(bundle_path, "wb") as f:
f.write(b"".join(parts))
def _read_serial(cert_path) -> str:
result = subprocess.run(
[OPENSSL, "x509", "-in", cert_path, "-noout", "-serial"],
capture_output=True, text=True,
)
return result.stdout.strip().split("=", 1)[-1]
def _read_expiry(cert_path) -> str:
"""Return the certificate notAfter date as an ISO 8601 UTC string."""
result = subprocess.run(
[OPENSSL, "x509", "-in", cert_path, "-noout", "-enddate"],
capture_output=True, text=True,
)
raw = result.stdout.strip().split("=", 1)[-1]
raw = " ".join(raw.split()) # normalize whitespace ("May 4" -> "May 4")
dt = datetime.datetime.strptime(raw, "%b %d %H:%M:%S %Y GMT")
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def _iso_to_asn1(iso: str) -> str:
"""Convert "2026-05-24T14:28:10Z" -> "260524142810Z" for OpenSSL index.txt."""
dt = datetime.datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ")
return dt.strftime("%y%m%d%H%M%SZ")
_IP_RE = re.compile(r"^[0-9]{1,3}(\.[0-9]{1,3}){3}$")
_DNS_RE = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*$")
def _is_ip(value):
return bool(_IP_RE.match(value))
def _is_dns(value):
return bool(_DNS_RE.match(value))
def _pipe(cmd1, cmd2):
p1 = subprocess.Popen(cmd1, stdout=subprocess.PIPE)
p2 = subprocess.Popen(cmd2, stdin=p1.stdout)
p1.stdout.close()
p2.communicate()
p1.wait()
return p1.returncode == 0 and p2.returncode == 0
def make_ca(ca_dir, ca_name, days=3650, issuing_ca=None, ca_publish_base_url=None):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"Certificate directory {ca_dir} does not exist.")
return False
if not ca_name:
_err("CA name is required.")
return False
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
if issuing_ca:
_err(
f"Cannot create issuing CA '{ca_name}' without existing root CA "
"certificate and key. Please create the root CA first."
)
return False
print(f"Generating CA certificate '{ca_name}' and key...")
# Path length constraint of 1: allows one level of issuing CAs.
cmd = [
OPENSSL, "req",
"-x509",
"-newkey", "rsa:4096",
"-keyout", root_ca_key_path,
"-out", root_ca_cert_path,
"-days", str(days),
"-noenc",
"-subj", f"/CN={ca_name}",
"-text",
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:1",
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
]
if subprocess.run(cmd).returncode != 0:
_err("Failed to generate CA certificate and key.")
return False
_rebuild_ca_bundle(ca_dir)
patch = {"name": ca_name, "created": _now()}
if ca_publish_base_url:
patch["ca_publish_base_url"] = ca_publish_base_url
_config.update(patch)
return True
issuing_ca_dir = os.path.join(ca_dir, issuing_ca)
issuing_ca_cert = os.path.join(issuing_ca_dir, "ca_cert.pem")
issuing_ca_key = os.path.join(issuing_ca_dir, "ca_key.pem")
if not os.path.isfile(issuing_ca_cert) or not os.path.isfile(issuing_ca_key):
print(f"Generating issuing CA certificate '{ca_name}' and key...")
os.makedirs(issuing_ca_dir, exist_ok=True)
req_cmd = [
OPENSSL, "req",
"-newkey", "rsa:4096",
"-keyout", issuing_ca_key,
"-noenc",
"-subj", f"/CN={ca_name}",
"-addext", "basicConstraints=critical,CA:TRUE,pathlen:0",
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
]
if ca_publish_base_url:
req_cmd += [
"-addext", f"authorityInfoAccess=caIssuers;URI:{ca_publish_base_url}/ca_cert.crt",
"-addext", f"crlDistributionPoints=URI:{ca_publish_base_url}/crl.pem",
]
x509_cmd = [
OPENSSL, "x509",
"-req",
"-CA", root_ca_cert_path,
"-CAkey", root_ca_key_path,
"-copy_extensions", "copyall",
"-days", str(days),
"-text",
"-out", issuing_ca_cert,
]
if not _pipe(req_cmd, x509_cmd):
_err("Failed to generate issuing CA certificate and key.")
return False
patch = {"ca_publish_base_url": ca_publish_base_url} if ca_publish_base_url else {}
subs = _config.get("subordinates", [])
if issuing_ca not in subs:
patch["subordinates"] = subs + [issuing_ca]
_config.update(patch)
_rebuild_ca_bundle(ca_dir)
return True
def make_cert(cert_subject_name, sans=None, ca_dir=None, cert_dir=None,
issuing_ca=None, days=365, ca_publish_base_url=None):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"CA directory {ca_dir} does not exist.")
return False
if not cert_subject_name:
_err("Subject name is required.")
return False
if not _is_dns(cert_subject_name):
_err(f"Invalid subject name '{cert_subject_name}'. Must be a valid DNS name.")
return False
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
cert_dir = cert_dir or signing_dir
if not os.path.isdir(cert_dir):
_err(f"Certificate directory {cert_dir} does not exist.")
return False
ca_cert_path = os.path.join(signing_dir, "ca_cert.pem")
ca_key_path = os.path.join(signing_dir, "ca_key.pem")
if not os.path.isfile(ca_cert_path) or not os.path.isfile(ca_key_path):
_err(
f"Signing CA certificate and key not found in {signing_dir}. "
"Please set up a signing CA first."
)
return False
aia_url = cdp_url = ""
if ca_publish_base_url:
if issuing_ca:
aia_url = f"{ca_publish_base_url}/{issuing_ca}/ca_cert.crt"
cdp_url = f"{ca_publish_base_url}/{issuing_ca}/crl.pem"
else:
aia_url = f"{ca_publish_base_url}/ca_cert.crt"
cdp_url = f"{ca_publish_base_url}/crl.pem"
cert_name = cert_subject_name.split(".", 1)[0]
san_entries = [f"DNS:{cert_subject_name}"]
for entry in sans or []:
if _is_ip(entry):
san_entries.append(f"IP:{entry}")
elif _is_dns(entry):
san_entries.append(f"DNS:{entry}")
else:
_err(f"Invalid SAN entry '{entry}'")
return False
sans_ext = "subjectAltName=" + ",".join(san_entries)
print(f"Generating server certificate for '{cert_subject_name}' with SANs:")
for san in san_entries:
print(f" - {san}")
cert_out = os.path.join(cert_dir, f"{cert_name}_cert.pem")
key_out = os.path.join(cert_dir, f"{cert_name}_key.pem")
if not os.path.isfile(cert_out) or not os.path.isfile(key_out):
print("Generating server certificate and key...")
req_cmd = [
OPENSSL, "req",
"-newkey", "rsa:4096",
"-keyout", key_out,
"-noenc",
"-subj", f"/CN={cert_subject_name}",
"-addext", "basicConstraints=critical,CA:FALSE",
"-addext", "keyUsage=critical,digitalSignature,keyEncipherment",
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext", sans_ext,
]
if aia_url:
req_cmd += ["-addext", f"authorityInfoAccess=caIssuers;URI:{aia_url}"]
if cdp_url:
req_cmd += ["-addext", f"crlDistributionPoints=URI:{cdp_url}"]
x509_cmd = [
OPENSSL, "x509",
"-req",
"-CA", ca_cert_path,
"-CAkey", ca_key_path,
"-copy_extensions", "copyall",
"-days", str(days),
"-text",
"-out", cert_out,
]
if not _pipe(req_cmd, x509_cmd):
_err("Failed to generate server certificate and key.")
return False
_config.append_history(issuing_ca or "ca", {
"name": cert_subject_name,
"serial": _read_serial(cert_out),
"created": _now(),
"expires": _read_expiry(cert_out),
})
return True
def make_pfx(cert_path, ca_dir, issuing_ca=None, password=None, apple_openssl=False):
if issuing_ca == "ca":
_err("--issuing-ca cannot be 'ca' as it is reserved for the root CA.")
return False
cert_dir = os.path.dirname(cert_path)
cert_basename = os.path.basename(cert_path)
cert_name = cert_basename[:-len("_cert.pem")] if cert_basename.endswith("_cert.pem") else cert_basename
key_path = os.path.join(cert_dir, f"{cert_name}_key.pem")
if not cert_dir or not os.path.isdir(cert_dir):
_err(f"Certificate directory {cert_dir} does not exist.")
return False
if not ca_dir or not os.path.isdir(ca_dir):
_err(f"CA directory {ca_dir} does not exist.")
return False
if not os.path.isfile(cert_path) or not os.path.isfile(key_path):
_err("Server certificate or key not found.")
return False
root_ca_cert_path = os.path.join(ca_dir, "ca_cert.pem")
root_ca_key_path = os.path.join(ca_dir, "ca_key.pem")
if not os.path.isfile(root_ca_cert_path) or not os.path.isfile(root_ca_key_path):
_err(f"CA certificate or key not found in {ca_dir}.")
return False
if issuing_ca:
issuing_ca_cert_path = os.path.join(ca_dir, issuing_ca, "ca_cert.pem")
if not os.path.isfile(issuing_ca_cert_path):
_err(f"Issuing CA certificate not found: {issuing_ca_cert_path}.")
return False
if not password:
password = "changeit"
pfx_path = os.path.join(cert_dir, f"{cert_name}.pfx")
if os.path.isfile(pfx_path):
print("PKCS#12 (PFX) file already exists, aborting generation.")
return False
print("Generating PKCS#12 (PFX) file...", end="")
chain_bytes = b""
with open(root_ca_cert_path, "rb") as f:
chain_bytes += f.read()
if issuing_ca:
with open(issuing_ca_cert_path, "rb") as f:
chain_bytes += f.read()
chain_fd, chain_file = tempfile.mkstemp()
try:
with os.fdopen(chain_fd, "wb") as f:
f.write(chain_bytes)
cmd = [
"/usr/bin/openssl" if apple_openssl else OPENSSL, "pkcs12",
"-export", "-out", pfx_path,
"-inkey", key_path,
"-in", cert_path,
"-certfile", chain_file,
"-password", f"pass:{password}",
]
if subprocess.run(cmd).returncode != 0:
_err("Failed to generate PKCS#12 (PFX) file.")
return False
finally:
if os.path.exists(chain_file):
os.remove(chain_file)
print("done.")
return True
def make_crl(ca_dir, issuing_ca=None, days=30):
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
ca_cert = os.path.join(signing_dir, "ca_cert.pem")
ca_key = os.path.join(signing_dir, "ca_key.pem")
if not os.path.isfile(ca_cert) or not os.path.isfile(ca_key):
_err(f"CA certificate or key not found in {signing_dir}.")
return False
crl_path = os.path.join(signing_dir, "crl.pem")
history_key = issuing_ca or "ca"
revoked_entries = [
e for e in _config.get("history", {}).get(history_key, [])
if "revoked" in e and "expires" in e
]
with tempfile.TemporaryDirectory() as tmp:
index_txt = os.path.join(tmp, "index.txt")
crlnumber = os.path.join(tmp, "crlnumber")
cnf_path = os.path.join(tmp, "openssl.cnf")
with open(index_txt, "w") as f:
for e in revoked_entries:
expires_asn1 = _iso_to_asn1(e["expires"])
revoked_asn1 = _iso_to_asn1(e["revoked"])
f.write(f"R\t{expires_asn1}\t{revoked_asn1}\t{e['serial']}\tunknown\t/CN={e['name']}\n")
with open(crlnumber, "w") as f:
f.write("01\n")
with open(cnf_path, "w") as f:
f.write(
"[ ca ]\ndefault_ca = CA_default\n\n"
"[ CA_default ]\n"
f"database = {index_txt}\n"
f"crlnumber = {crlnumber}\n"
f"certificate = {ca_cert}\n"
f"private_key = {ca_key}\n"
f"default_crl_days = {days}\n"
"default_md = sha256\n\n"
"[ crl_ext ]\n"
"authorityKeyIdentifier = keyid:always\n"
)
if subprocess.run([OPENSSL, "ca", "-gencrl", "-config", cnf_path, "-out", crl_path]).returncode != 0:
_err("Failed to generate CRL.")
return False
print(f"CRL written to {crl_path}")
return True
def revoke_cert(cert_path, ca_dir, issuing_ca=None):
if not os.path.isfile(cert_path):
_err(f"Certificate not found: {cert_path}")
return False
signing_dir = os.path.join(ca_dir, issuing_ca) if issuing_ca else ca_dir
if not os.path.isdir(signing_dir):
_err(f"CA directory not found: {signing_dir}")
return False
ca_key = issuing_ca or "ca"
serial = _read_serial(cert_path)
result = _config.revoke_in_history(ca_key, serial, _now())
if result is None:
_err(f"Certificate with serial {serial} not found in history for CA '{ca_key}'.")
return False
if result is False:
print(f"Certificate {cert_path} (serial {serial}) is already revoked.")
return True
print(f"Certificate {cert_path} (serial {serial}) marked as revoked.")
return True
def _build_parser():
parser = argparse.ArgumentParser(
description="Simple CA for creating and managing test certificates."
)
sub = parser.add_subparsers(dest="command", required=True)
p_ca = sub.add_parser("make-ca", help="Create a root or issuing CA.")
p_ca.add_argument("--days", type=int, default=None, help="Validity period in days (default: 3650)")
p_ca.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_ca.add_argument("--ca-publish-base-url", default=None,
help="Base URL for AIA and CRL distribution point extensions")
p_ca.add_argument("--ca-dir", help="Directory to store the CA files")
p_ca.add_argument("--openssl", default=None, metavar="PATH",
help=f"Path to the openssl binary (default: {OPENSSL})")
p_ca.add_argument("ca_name", help="Name of the CA")
p_cert = sub.add_parser("make-cert", help="Create a server/client certificate.")
p_cert.add_argument("--ca-dir", help="Directory of the CA")
p_cert.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_cert.add_argument("--days", type=int, default=None, help="Validity period in days (default: 365)")
p_cert.add_argument("--cert-dir", help="Directory to store the certificate files")
p_cert.add_argument("--openssl", default=None, metavar="PATH",
help=f"Path to the openssl binary (default: {OPENSSL})")
p_cert.add_argument("subject_name", help="Subject name for the certificate")
p_cert.add_argument("sans", nargs="*", help="Subject Alternative Names (SANs) for the certificate")
p_pfx = sub.add_parser("make-pfx", help="Create a PKCS#12 (PFX) bundle.")
p_pfx.add_argument("--issuing-ca", default=None, help="Specify the issuing CA")
p_pfx.add_argument("--ca-dir", help="Directory of the CA")
p_pfx.add_argument("--password", help="Password for the PFX file")
p_pfx.add_argument("--apple-openssl", action="store_true", default=False,
help="Use Apple's bundled /usr/bin/openssl for PKCS12 generation")
p_pfx.add_argument("path", help="Path to the certificate file")
p_crl = sub.add_parser("make-crl", help="Generate a CRL for a CA.")
p_crl.add_argument("--ca-dir", help="Directory of the CA")
p_crl.add_argument("--issuing-ca", default=None, help="Generate CRL for this issuing CA")
p_crl.add_argument("--days", type=int, default=None, help="CRL validity in days (default: 30)")
p_rev = sub.add_parser("revoke-cert", help="Revoke a certificate.")
p_rev.add_argument("--ca-dir", help="Directory of the CA")
p_rev.add_argument("--issuing-ca", default=None, help="Issuing CA that signed the certificate")
p_rev.add_argument("cert_path", help="Path to the certificate file to revoke")
return parser
def main(argv=None):
global OPENSSL, _config
parser = _build_parser()
args = parser.parse_args(argv)
ca_dir = args.ca_dir or os.environ.get("SIMPLE_CA_DIR") or os.getcwd()
_config = Config(ca_dir)
OPENSSL = getattr(args, "openssl", None) or _config.get("openssl", OPENSSL)
issuing_ca = args.issuing_ca or _config.get("issuing_ca")
ca_publish_base_url = getattr(args, "ca_publish_base_url", None) or _config.get("ca_publish_base_url")
days_cfg = _config.get("days", {})
if args.command == "make-ca":
days = args.days or days_cfg.get("ca", 3650)
ok = make_ca(
ca_dir, args.ca_name,
days=days,
issuing_ca=issuing_ca,
ca_publish_base_url=ca_publish_base_url,
)
elif args.command == "make-cert":
days = args.days or days_cfg.get("cert", 365)
ok = make_cert(
args.subject_name,
sans=args.sans,
ca_dir=ca_dir,
cert_dir=getattr(args, "cert_dir", None),
issuing_ca=issuing_ca,
days=days,
ca_publish_base_url=ca_publish_base_url,
)
elif args.command == "make-pfx":
ok = make_pfx(
args.path, ca_dir,
issuing_ca=issuing_ca,
password=args.password,
apple_openssl=args.apple_openssl,
)
elif args.command == "make-crl":
days = args.days or days_cfg.get("crl", 30)
ok = make_crl(ca_dir, issuing_ca=issuing_ca, days=days)
elif args.command == "revoke-cert":
ok = revoke_cert(args.cert_path, ca_dir, issuing_ca=issuing_ca)
else:
parser.error(f"Unknown command: {args.command}")
ok = False
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,641 @@
"""Shared library for the vpn-router package.
Everything derivable from the configuration file alone, with no running
network required. Used by both 'configure' and 'setup'.
"""
import base64
import binascii
import configparser
import ipaddress
import os
import pathlib
import shutil
import subprocess
import sys
import jinja2
LIB_DIR = pathlib.Path(__file__).resolve().parent
if str(LIB_DIR) not in sys.path:
sys.path.insert(0, str(LIB_DIR))
import vpnrouter_platforms
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.conf')
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
SIMPLE_CA = LIB_DIR / 'simple-ca'
SWANCTL_CONF_D = pathlib.Path('/etc/swanctl/conf.d')
REMOTE_SITE = SWANCTL_CONF_D / 'remote-site.conf'
ROAD_WARRIOR = SWANCTL_CONF_D / 'road-warrior.conf'
P2S_FORWARDER = pathlib.Path('/etc/systemd/resolved.conf.d/p2s-forwarder.conf')
WG_CONF = pathlib.Path('/etc/wireguard/wg0.conf')
CA_CERT_DEST = pathlib.Path('/etc/swanctl/x509ca/ca.pem')
SERVER_CERT = pathlib.Path('/etc/swanctl/x509/server.pem')
SERVER_KEY = pathlib.Path('/etc/swanctl/private/server.key')
CRL_DEST = pathlib.Path('/etc/swanctl/x509crl/crl.pem')
TRUST_ANCHOR = pathlib.Path('/usr/local/share/ca-certificates/vpn-router-ca.crt')
# section -> option -> default
SCHEMA = {
'general': {'platform': 'none', 'mode': 'manual'},
'interfaces': {'external': '', 'internal': ''},
'wan': {'local_fqdn': '', 'local_id_mode': 'fqdn'},
'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'},
}
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."""
def warn(message):
print(f'vpn-router: warning: {message}', file=sys.stderr)
def fail(message):
print(f'vpn-router: error: {message}', file=sys.stderr)
sys.exit(1)
def new_parser():
"""A parser that tolerates operator comments and never interpolates.
interpolation=None matters: the default BasicInterpolation treats '%' as
syntax, which would break any value containing it.
Inline comments are enabled, which configparser leaves off by default
because it makes '#' and ';' unusable inside values. That is an acceptable
trade here precisely because the one setting that can hold arbitrary
characters, the pre-shared key, is base64-encoded, and base64 uses none of
those characters.
"""
return configparser.ConfigParser(
interpolation=None,
comment_prefixes=('#', ';'),
inline_comment_prefixes=('#', ';'),
)
def default_parser():
parser = new_parser()
for section, options in SCHEMA.items():
parser[section] = dict(options)
return parser
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:
parser.read(path, encoding='utf-8')
except configparser.Error as exc:
fail(f'cannot parse {path}: {exc}')
return parser
def _split_list(value):
return [item.strip() for item in value.split(',') if item.strip()]
def _get(cfg, section, option):
return cfg.get(section, option, fallback=SCHEMA[section][option]).strip()
def _get_bool(cfg, section, option):
try:
return cfg.getboolean(section, option,
fallback=SCHEMA[section][option] == 'true')
except ValueError:
raise ConfigError(f'{section}.{option} is not a boolean')
def _check_networks(value, name):
for item in _split_list(value):
try:
ipaddress.ip_network(item, strict=False)
except ValueError:
raise ConfigError(f'{name}: {item!r} is not a valid CIDR')
def _check_address(value, name):
if not value:
return
try:
ipaddress.ip_address(value)
except ValueError:
raise ConfigError(f'{name}: {value!r} is not a valid IP address')
def validate(cfg):
"""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 = [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(
f'wan.local_id_mode: {id_mode!r} is not one of {", ".join(ID_MODES)}')
_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')
# 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:
try:
ipaddress.ip_interface(wg_address)
except ValueError:
raise ConfigError(
f'wireguard.address: {wg_address!r} is not an address with prefix')
port = _get(cfg, 'wireguard', 'listen_port')
if port:
if not port.isdigit() or not 1 <= int(port) <= 65535:
raise ConfigError(f'wireguard.listen_port: {port!r} is not a valid port')
for section in ('p2s', 'wireguard'):
_get_bool(cfg, section, 'enabled')
def resolve_psk(cfg):
"""Return the pre-shared key, or '' when none is configured."""
psk_file = _get(cfg, 'remote', 'psk_file')
if psk_file:
path = pathlib.Path(psk_file)
if not path.is_file():
raise ConfigError(f'remote.psk_file: {psk_file} does not exist')
return path.read_text(encoding='utf-8').strip()
psk_b64 = _get(cfg, 'remote', 'psk_b64')
if not psk_b64:
return ''
try:
return base64.b64decode(psk_b64, validate=True).decode('utf-8')
except (binascii.Error, UnicodeDecodeError) as exc:
raise ConfigError(f'remote.psk_b64 is not valid base64 text: {exc}')
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:
warn(f'platform {name!r} is not installed, using '
f'{vpnrouter_platforms.DEFAULT}')
return vpnrouter_platforms.load(vpnrouter_platforms.DEFAULT)
def _join(*groups):
items = []
for group in groups:
for item in _split_list(group):
if item not in items:
items.append(item)
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.
The interface name is configuration; its addresses are read from the
system. Looking up the addresses of a named interface is deterministic -
unlike working out which interface to use, which is a guess the package
deliberately does not make.
"""
if not name:
return []
if not (pathlib.Path('/sys/class/net') / name).exists():
raise ConfigError(f'{setting}: interface {name!r} does not exist')
result = subprocess.run(
['ip', '-o', '-4', 'addr', 'show', 'dev', name],
capture_output=True, text=True,
)
if result.returncode != 0:
raise ConfigError(f'{setting}: cannot read addresses of {name!r}')
addresses = []
for line in result.stdout.splitlines():
fields = line.split()
if len(fields) >= 4:
addresses.append(fields[3].split('/')[0])
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, 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'
local_cidrs = _get(cfg, 'local', 'cidrs')
if local_id_override:
local_id = local_id_override
elif id_mode == 'fqdn':
local_id = f'@{local_fqdn}' if local_fqdn else ''
elif id_mode == 'internal_ip':
local_id = int_addr
else:
# public_ip is resolved once the network is up, and the caller then
# re-renders with local_id_override set.
local_id = ''
cidr_list = _split_list(local_cidrs)
return {
'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,
'local_addr_list': _split_list(local_addrs),
'local_fqdn': local_fqdn,
'local_id_mode': id_mode,
'local_id': local_id,
'local_cidrs': local_cidrs,
'local_cidr_list': cidr_list,
'local_ts': _join(local_cidrs, extras['EXTRA_LOCAL_TS']),
'int_addr': int_addr,
'int_gateway_ip': gateway,
'remote_addrs': _get(cfg, 'remote', 'addrs'),
'remote_id': _get(cfg, 'remote', 'id'),
'remote_cidrs': _get(cfg, 'remote', 'cidrs'),
'psk': resolve_psk(cfg),
'p2s_enabled': _get_bool(cfg, 'p2s', 'enabled'),
'p2s_address_pool': _get(cfg, 'p2s', 'address_pool'),
'p2s_ca_name': _get(cfg, 'p2s', 'ca_name'),
'p2s_dns': _join(local_addrs, extras['EXTRA_P2S_DNS']),
'wg_enabled': _get_bool(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'),
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
}
def s2s_ready(ctx):
return all([
ctx['local_addrs'], ctx['local_fqdn'], ctx['local_cidrs'],
ctx['remote_addrs'], ctx['remote_id'], ctx['remote_cidrs'],
ctx['psk'], ctx['local_id'],
])
def routing_ready(ctx):
"""Routing and NAT need both interfaces named, plus the subnets involved."""
return all([ctx['local_cidrs'], ctx['int_gateway_ip'],
ctx['int_iface'], ctx['wan_iface']])
def p2s_ready(ctx):
return bool(ctx['p2s_enabled'] and ctx['p2s_address_pool'] and ctx['local_fqdn'])
def wg_ready(ctx):
return bool(ctx['wg_enabled'] and ctx['wg_address'])
def _jinja_env():
return jinja2.Environment(
loader=jinja2.FileSystemLoader(str(TEMPLATE_DIR)),
keep_trailing_newline=True,
undefined=jinja2.StrictUndefined,
autoescape=False,
)
def render(env, ctx, template_name, dest, mode):
"""Render a template, writing only when the content changes."""
content = env.get_template(template_name).render(ctx)
dest = pathlib.Path(dest)
if dest.exists() and dest.read_text(encoding='utf-8') == content:
os.chmod(dest, mode)
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding='utf-8')
os.chmod(dest, mode)
return True
def existing_peers(path):
"""Return the [Peer] sections of an existing WireGuard config.
Peers are added by the operator by hand, so re-rendering the [Interface]
section must not discard them.
"""
path = pathlib.Path(path)
if not path.is_file():
return ''
lines = path.read_text(encoding='utf-8').splitlines(keepends=True)
for index, line in enumerate(lines):
if line.strip().lower() == '[peer]':
return ''.join(lines[index:])
return ''
def render_wg_conf(env, ctx, dest=WG_CONF, mode=0o600):
"""Render wg0.conf, carrying any operator-defined peers across."""
content = env.get_template('wg0.conf.j2').render(ctx)
peers = existing_peers(dest)
if peers:
content = content.rstrip('\n') + '\n\n' + peers
dest = pathlib.Path(dest)
if dest.exists() and dest.read_text(encoding='utf-8') == content:
os.chmod(dest, mode)
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding='utf-8')
os.chmod(dest, mode)
return True
def remove(path):
"""Remove a generated file. Returns True when something was removed."""
path = pathlib.Path(path)
if path.exists():
path.unlink()
return True
return False
def install_file(src, dest, mode):
"""Copy src to dest when the content differs. Returns True on change."""
src, dest = pathlib.Path(src), pathlib.Path(dest)
if not src.is_file():
return False
if dest.exists() and dest.read_bytes() == src.read_bytes():
os.chmod(dest, mode)
return False
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dest)
os.chmod(dest, mode)
return True
def _simple_ca(*args):
result = subprocess.run(
[sys.executable, str(SIMPLE_CA), *args],
capture_output=True, text=True,
)
if result.returncode != 0:
fail(f'simple-ca {" ".join(args)} failed: {result.stderr.strip()}')
return result.stdout
def provision_pki(ctx):
"""Create the CA and server certificate when the PKI directory is empty."""
PKI_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(PKI_DIR, 0o700)
if any(PKI_DIR.iterdir()):
return
if not ctx['p2s_ca_name']:
warn('p2s.ca_name is empty, not creating a local CA')
return
if not SIMPLE_CA.is_file():
fail(f'{SIMPLE_CA} is missing')
_simple_ca('make-ca', '--ca-dir', str(PKI_DIR), ctx['p2s_ca_name'])
_simple_ca('make-cert', '--ca-dir', str(PKI_DIR),
ctx['local_fqdn'], ctx['local_fqdn'])
def distribute_pki(ctx):
"""Copy PKI material to each consumer. Returns True when anything changed."""
label = ctx['local_fqdn'].split('.', 1)[0] if ctx['local_fqdn'] else ''
bundle = PKI_DIR / 'ca_bundle.pem'
ca_src = bundle if bundle.is_file() else PKI_DIR / 'ca_cert.pem'
changed = False
changed |= install_file(ca_src, CA_CERT_DEST, 0o644)
if label:
changed |= install_file(PKI_DIR / f'{label}_cert.pem', SERVER_CERT, 0o644)
changed |= install_file(PKI_DIR / f'{label}_key.pem', SERVER_KEY, 0o600)
changed |= install_file(PKI_DIR / 'crl.pem', CRL_DEST, 0o644)
if install_file(PKI_DIR / 'ca_cert.pem', TRUST_ANCHOR, 0o644):
changed = True
if shutil.which('update-ca-certificates'):
subprocess.run(['update-ca-certificates'],
capture_output=True, text=True)
return changed
def configure(cfg=None, local_id_override=None):
"""Render configuration files and provision PKI.
Returns (ctx, changed) where changed is a set naming what needs reloading:
'swanctl', 'resolved', 'wireguard'.
"""
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)
except ConfigError as exc:
fail(str(exc))
changed = set()
if p2s_ready(ctx):
provision_pki(ctx)
if distribute_pki(ctx):
changed.add('swanctl')
env = _jinja_env()
if s2s_ready(ctx):
if render(env, ctx, 'remote-site.conf.j2', REMOTE_SITE, 0o600):
changed.add('swanctl')
elif remove(REMOTE_SITE):
changed.add('swanctl')
if p2s_ready(ctx):
if render(env, ctx, 'road-warrior.conf.j2', ROAD_WARRIOR, 0o600):
changed.add('swanctl')
if render(env, ctx, 'p2s-forwarder.conf.j2', P2S_FORWARDER, 0o644):
changed.add('resolved')
else:
if remove(ROAD_WARRIOR):
changed.add('swanctl')
if remove(P2S_FORWARDER):
changed.add('resolved')
if wg_ready(ctx):
if render_wg_conf(env, ctx):
changed.add('wireguard')
elif remove(WG_CONF):
changed.add('wireguard')
return ctx, changed
@@ -0,0 +1,53 @@
"""Platform modules for vpn-router.
A platform module supplies the values and behaviour that only make sense on one
kind of host. The core is complete without any of them; a module only adds
what the core deliberately refuses to know about, such as a provider's DNS
resolver address.
Modules are discovered by listing this package, so adding a platform means
adding one file here. Nothing in the core has to be edited.
A module may define any of:
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) called once the system has been configured, to apply
platform-specific state. Must be idempotent. ctx is the
template context, carrying wan_iface, int_iface,
local_cidrs and everything else derived from the
configuration.
Anything a module does not define takes its default, so a module that only
needs one constant is one line long.
"""
import importlib
import pkgutil
DEFAULT = 'generic'
#: Context keys a module may contribute, with the value used when it does not.
CONTEXT_DEFAULTS = {
'EXTRA_P2S_DNS': '',
'EXTRA_LOCAL_TS': '',
}
def available():
"""Names of the installed platform modules."""
return sorted(module.name for module in pkgutil.iter_modules(__path__))
def load(name):
"""Import a platform module by name.
Raises ModuleNotFoundError when no such module is installed.
"""
return importlib.import_module(f'{__name__}.{name}')
def context(module):
"""Read a module's context contributions, applying defaults."""
return {key: getattr(module, key, default)
for key, default in CONTEXT_DEFAULTS.items()}
@@ -0,0 +1,11 @@
"""Microsoft Azure.
No platform-specific configuration is currently required.
The Azure platform resolver 168.63.129.16 is deliberately *not* advertised to
road-warrior clients and *not* added to the traffic selectors. Clients are
given the router itself as their DNS server, and the router forwards through
its own systemd-resolved via the p2s-forwarder drop-in. That resolves
Azure-internal names without routing the wireserver address through the tunnel
and without a second resolver in the pool.
"""
@@ -0,0 +1,8 @@
"""Google Cloud Platform.
No platform-specific configuration is currently required.
As on Azure, road-warrior clients are given the router itself as their DNS
server and the router forwards through its own systemd-resolved, so the
metadata resolver does not belong in the pool or in the traffic selectors.
"""
@@ -0,0 +1,6 @@
"""Generic platform: no additions.
The default, and the correct choice on any host that needs nothing
platform-specific. It defines nothing, which is the point: the core is
complete on its own.
"""
@@ -1,7 +0,0 @@
network:
version: 2
ethernets:
eth1:
routes:
- to: {{ local_subnet }}
via: {{ router_int_gateway_ip }}
@@ -1,14 +0,0 @@
LOCAL_ADDRS="{{ local_addrs }}"
LOCAL_FQDN="{{ local_fqdn }}"
LOCAL_ID_MODE="{{ local_id_mode }}"
LOCAL_CIDRS="{{ local_cidrs }}"
LOCAL_SUBNET="{{ local_subnet }}"
REMOTE_ADDRS="{{ remote_addrs }}"
REMOTE_ID="{{ remote_id }}"
REMOTE_CIDRS="{{ remote_cidrs }}"
ROUTER_INT_GATEWAY_IP="{{ router_int_gateway_ip }}"
P2S_ADDRESS_POOL="{{ p2s_address_pool }}"
P2S_SERVER_NAME="{{ p2s_server_name }}"
WG_ENABLED="{{ wg_enabled }}"
WG_ADDRESS="{{ wg_address }}"
WG_LISTEN_PORT="{{ wg_listen_port }}"
@@ -1,2 +0,0 @@
[Resolve]
DNSStubListenerExtra={{ local_addrs }}
@@ -0,0 +1,84 @@
# Example vpn-router configuration.
#
# This file is documentation. The live configuration is
# /etc/vpn-router/vpn-router.conf, which is generated on first install and
# owned by the operator afterwards. Copy settings from here as needed.
#
# Format: INI. Lists are comma-separated, booleans are true/false, an empty
# value means unset, and a _b64 suffix means the value is base64-encoded.
#
# Comments start with # or ; and may follow a value on the same line. That
# means # and ; cannot appear inside a value - which is why the pre-shared
# key, the one setting that can hold arbitrary text, is base64-encoded.
#
# Apply any change with: systemctl restart vpn-router-setup
#
# Every feature is optional. A file with nothing filled in configures nothing
# and leaves the host reachable and unchanged.
[general]
# 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]
# 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 =
[wan]
# This router's fully-qualified domain name.
local_fqdn =
# Source of the local IKE identity: fqdn | public_ip | internal_ip
# fqdn - use local_fqdn (must match what the peer expects)
# public_ip - resolve local_fqdn at boot and use the address
# internal_ip - use the address on the internal interface
local_id_mode = fqdn
[local]
# Local subnets advertised into the tunnel, comma-separated.
cidrs =
# 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]
# Remote gateway address(es) or FQDN, comma-separated.
addrs =
# Remote IKE identity, without a leading '@'.
id =
# Remote subnets reachable through the tunnel, comma-separated.
cidrs =
# Pre-shared key, base64-encoded: printf %s "$PSK" | base64 -w0
psk_b64 =
# Path to a file containing the raw PSK. Takes precedence over psk_b64.
psk_file =
[p2s]
# Road-warrior access.
enabled = false
# Address pool handed to road-warrior clients.
address_pool =
# CN for the CA created when /etc/vpn-router/pki is empty.
ca_name = VPN Router CA
[wireguard]
enabled = false
# Address and prefix length for wg0, for example 192.168.200.1/24.
address =
listen_port = 51820
@@ -0,0 +1,4 @@
[Resolve]
{% for address in local_addr_list -%}
DNSStubListenerExtra={{ address }}
{% endfor -%}
@@ -21,7 +21,7 @@ connections {
children {
site2site {
mode = tunnel
local_ts = {{ local_cidrs }}
local_ts = {{ local_ts }}
remote_ts = {{ remote_cidrs }}
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
@@ -21,7 +21,7 @@ connections {
children {
road-warrior {
mode = tunnel
local_ts = {{ local_cidrs }}
local_ts = {{ local_ts }}
remote_ts = dynamic
esp_proposals = aes256-sha256,aes256-sha1
life_time = 3600
@@ -38,6 +38,6 @@ connections {
pools {
rw-pool {
addrs = {{ p2s_address_pool }}
dns = {{ local_addrs }}
dns = {{ p2s_dns }}
}
}
+45
View File
@@ -0,0 +1,45 @@
# Azure example
Deploys `vpn-router` on an Azure VM: a two-NIC router (external/WAN and internal/protected),
a demo protected `workload` subnet routed through it, and cloud-init that installs and
configures the package on first boot. See the repository's top-level [README](../../README.md)
for what the package itself does.
## Prerequisites
- An Azure subscription and `az login` (or another form of Azure credentials Terraform can pick
up).
- An SSH key pair for `admin_ssh_public_key`.
- A build of the `vpn-router` package published to the repository at `repo_url` (default: the
project's Gitea Debian registry). See [../../debian-package](../../debian-package) for
`build.sh`/`publish.sh`.
- That repository must allow anonymous reads, since the router pulls the package with no
credentials configured. On Gitea this means the `debian` package registry under the
`slawek` account is set to public/anonymous-read; `publish.sh` still authenticates to
upload, only reads are anonymous. This is a one-time change made on the Gitea side, outside
this repository.
## Usage
```sh
cp terraform.tfvars.example terraform.tfvars
$EDITOR terraform.tfvars
export TF_VAR_psk='change-me' # keep secrets out of tracked files
```
`platform = azure` and `mode = auto` are hard-coded into the cloud-init template call in
`router.tf`: the router has exactly two NICs, the external one carries the default route (it is
marked `primary` and holds the public IP), which is exactly the case `mode = auto` is designed
to detect reliably - see the top-level README's "What mode decides" section.
## Verifying
```sh
ssh <admin_username>@$(terraform output -raw router_public_ip)
systemctl status vpn-router-setup
swanctl --list-sas
```
Set `deploy_workload_vm = true` and re-apply to add a VM on the `workload` subnet (no public
IP - reach it via the router or Azure Bastion) for confirming that its traffic to `remote_cidrs`
actually flows through the router.
+104
View File
@@ -0,0 +1,104 @@
resource "azurerm_resource_group" "this" {
name = "rg-${var.name}"
location = var.location
}
resource "azurerm_virtual_network" "this" {
name = "vnet-${var.name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
address_space = [var.vnet_address_space]
}
resource "azurerm_subnet" "ext" {
name = "ext"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [var.ext_subnet_cidr]
}
resource "azurerm_subnet" "int" {
name = "int"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [var.int_subnet_cidr]
}
resource "azurerm_subnet" "workload" {
name = "workload"
resource_group_name = azurerm_resource_group.this.name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = [var.workload_subnet_cidr]
}
resource "azurerm_dns_a_record" "router_ext" {
count = var.dns_zone_id != null ? 1 : 0
name = trimsuffix(var.local_fqdn, ".${split("/", var.dns_zone_id)[length(split("/", var.dns_zone_id)) - 1]}")
zone_name = split("/", var.dns_zone_id)[length(split("/", var.dns_zone_id)) - 1]
resource_group_name = split("/", var.dns_zone_id)[4]
ttl = 300
records = [azurerm_public_ip.ext.ip_address]
}
resource "azurerm_network_security_group" "router_ext" {
name = "nsg-${var.name}-ext"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
security_rule {
name = "Allow-SSH-TCP-22"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "Allow-IKE-UDP-500"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Udp"
source_port_range = "*"
destination_port_range = "500"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "Allow-IPsec-NAT-T-UDP-4500"
priority = 120
direction = "Inbound"
access = "Allow"
protocol = "Udp"
source_port_range = "*"
destination_port_range = "4500"
source_address_prefix = "*"
destination_address_prefix = "*"
}
dynamic "security_rule" {
for_each = var.wireguard_enabled ? [1] : []
content {
name = "Allow-WireGuard-UDP"
priority = 130
direction = "Inbound"
access = "Allow"
protocol = "Udp"
source_port_range = "*"
destination_port_range = tostring(var.wireguard_listen_port)
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
}
resource "azurerm_network_interface_security_group_association" "router_ext" {
network_interface_id = azurerm_network_interface.ext.id
network_security_group_id = azurerm_network_security_group.router_ext.id
}
+9
View File
@@ -0,0 +1,9 @@
output "router_public_ip" {
description = "Public IP address of the router VM."
value = azurerm_public_ip.ext.ip_address
}
output "router_internal_ip" {
description = "Private IP address of the router's internal NIC - the next hop to use in routes configured outside this Terraform run (another route table, or an on-prem device) that need to reach remote_cidrs through this router."
value = azurerm_network_interface.int.private_ip_address
}
+6
View File
@@ -0,0 +1,6 @@
provider "azurerm" {
features {}
subscription_id = var.subscription_id
tenant_id = var.tenant_id != "" ? var.tenant_id : null
}
+111
View File
@@ -0,0 +1,111 @@
data "http" "repo_gpg_key" {
url = "${var.repo_url}/repository.key"
}
locals {
supply_pki = var.ca_cert_file != ""
cloud_init_vars = {
hostname = var.name
fqdn = var.local_fqdn
repo_url = var.repo_url
repo_gpg_key = data.http.repo_gpg_key.response_body
ubuntu_codename = var.ubuntu_codename
platform = "azure"
mode = "auto"
external_interface = ""
internal_interface = ""
local_id_mode = var.local_id_mode
local_cidrs = var.local_cidrs
int_addr = ""
int_gateway_ip = ""
remote_addrs = var.remote_addrs
remote_id = var.remote_id
remote_cidrs = var.remote_cidrs
psk_b64 = base64encode(var.psk)
p2s_enabled = var.p2s_enabled
p2s_address_pool = var.p2s_address_pool
p2s_ca_name = "VPN Router CA"
wg_enabled = var.wireguard_enabled
wg_address = var.wireguard_address
wg_listen_port = var.wireguard_listen_port
}
}
resource "azurerm_public_ip" "ext" {
name = "pip-${var.name}-ext"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_network_interface" "ext" {
name = "${var.name}-ext-nic"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
ip_forwarding_enabled = true
ip_configuration {
name = "ext"
subnet_id = azurerm_subnet.ext.id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.ext.id
}
}
resource "azurerm_network_interface" "int" {
name = "${var.name}-int-nic"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
ip_forwarding_enabled = true
ip_configuration {
name = "int"
subnet_id = azurerm_subnet.int.id
private_ip_address_allocation = "Dynamic"
}
}
resource "azurerm_linux_virtual_machine" "router" {
name = var.name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
size = var.vm_size
admin_username = var.admin_username
admin_password = var.admin_password != "" ? var.admin_password : null
disable_password_authentication = var.admin_password == ""
network_interface_ids = [
azurerm_network_interface.ext.id,
azurerm_network_interface.int.id,
]
admin_ssh_key {
username = var.admin_username
public_key = var.admin_ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "StandardSSD_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server"
version = "latest"
}
custom_data = base64encode(
local.supply_pki
? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
label = split(".", var.local_fqdn)[0]
ca_cert = file(var.ca_cert_file)
server_cert = file(var.server_cert_file)
server_key = file(var.server_key_file)
}))
: templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
)
}
+25
View File
@@ -0,0 +1,25 @@
locals {
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""]
}
resource "azurerm_route_table" "router" {
name = "rt-${var.name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
}
resource "azurerm_route" "to_remote" {
for_each = toset(local.remote_cidr_list)
name = "to-${replace(each.value, "/", "-")}"
resource_group_name = azurerm_resource_group.this.name
route_table_name = azurerm_route_table.router.name
address_prefix = each.value
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_network_interface.int.private_ip_address
}
resource "azurerm_subnet_route_table_association" "workload" {
subnet_id = azurerm_subnet.workload.id
route_table_id = azurerm_route_table.router.id
}
+19
View File
@@ -0,0 +1,19 @@
# Copy to terraform.tfvars (or a git-ignored *.auto.tfvars) and adjust.
# Keep secrets - psk here - out of any tracked file: set them with
# TF_VAR_psk or in a git-ignored *.auto.tfvars instead.
subscription_id = "00000000-0000-0000-0000-000000000000"
location = "polandcentral"
name = "vpn-router-example"
admin_ssh_public_key = "ssh-ed25519 AAAA... you@example.com"
local_fqdn = "router.example.com"
local_cidrs = "10.0.3.0/24"
remote_addrs = "peer.example.net"
remote_id = "peer.example.net"
remote_cidrs = "192.168.0.0/24"
p2s_enabled = false
wireguard_enabled = false
deploy_workload_vm = false
+181
View File
@@ -0,0 +1,181 @@
variable "subscription_id" {
description = "Azure subscription ID to deploy into."
type = string
}
variable "tenant_id" {
description = "Azure AD tenant ID. Leave empty to use the tenant of the credentials Terraform is running with."
type = string
default = ""
}
variable "location" {
description = "Azure region to deploy into."
type = string
}
variable "name" {
description = "Base name used to derive resource names."
type = string
default = "vpn-router-example"
}
variable "admin_ssh_public_key" {
description = "SSH public key installed for the admin_username on both VMs."
type = string
}
variable "admin_username" {
description = "Admin username on both VMs."
type = string
default = "azureuser"
}
variable "admin_password" {
description = "Admin password for the router VM. SSH key auth is always configured; leaving this empty additionally disables password authentication, setting it enables password auth alongside the key."
type = string
default = ""
sensitive = true
}
variable "vm_size" {
description = "VM size for the router."
type = string
default = "Standard_B2ls_v2"
}
variable "local_fqdn" {
description = "FQDN of the router, used as the road-warrior/IKE identity and, when dns_zone_id is set, as the name of the A record created for it."
type = string
}
variable "dns_zone_id" {
description = "Resource ID of an existing Azure DNS zone to create local_fqdn's A record in, pointing at the router's public IP. local_fqdn must be a name within that zone. Leave null to skip - local_fqdn is then just a label with nothing making it resolve."
type = string
nullable = true
default = null
}
variable "local_id_mode" {
description = "IKE local identity source: fqdn, public_ip or internal_ip."
type = string
default = "fqdn"
}
variable "local_cidrs" {
description = "Local subnet CIDR(s) advertised into the site-to-site tunnel. Should include the workload subnet CIDR."
type = string
}
variable "remote_addrs" {
description = "Remote gateway address(es) or FQDN for the site-to-site tunnel."
type = string
}
variable "remote_id" {
description = "Remote peer's IKE identity, without a leading @."
type = string
}
variable "remote_cidrs" {
description = "Remote subnet CIDR(s) reachable through the site-to-site tunnel."
type = string
}
variable "psk" {
description = "Pre-shared key for the site-to-site IKEv2 tunnel."
type = string
sensitive = true
}
variable "p2s_enabled" {
description = "Enable road-warrior (P2S) access."
type = bool
default = false
}
variable "p2s_address_pool" {
description = "CIDR block assigned to road-warrior clients."
type = string
default = ""
}
variable "ca_cert_file" {
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
type = string
default = ""
}
variable "server_cert_file" {
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
type = string
default = ""
}
variable "server_key_file" {
description = "Path to an existing server private key PEM, paired with ca_cert_file."
type = string
default = ""
sensitive = true
}
variable "wireguard_enabled" {
description = "Enable the WireGuard endpoint."
type = bool
default = false
}
variable "wireguard_address" {
description = "Address and prefix length for the wg0 interface."
type = string
default = ""
}
variable "wireguard_listen_port" {
description = "UDP port WireGuard listens on."
type = number
default = 51820
}
variable "deploy_workload_vm" {
description = "Deploy a bare VM on the workload subnet, for manually verifying routing through the router."
type = bool
default = false
}
variable "repo_url" {
description = "Base URL of the Debian package repository the router pulls vpn-router from."
type = string
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
}
variable "ubuntu_codename" {
description = "Ubuntu release codename of the router VM's image, used to select the apt repo component."
type = string
default = "noble"
}
variable "vnet_address_space" {
description = "Address space of the example VNet."
type = string
default = "10.0.0.0/16"
}
variable "ext_subnet_cidr" {
description = "CIDR of the router's external (WAN-facing) subnet."
type = string
default = "10.0.1.0/24"
}
variable "int_subnet_cidr" {
description = "CIDR of the router's internal (protected-network-facing) subnet."
type = string
default = "10.0.2.0/24"
}
variable "workload_subnet_cidr" {
description = "CIDR of the demo protected workload subnet, routed through the router."
type = string
default = "10.0.3.0/24"
}
+14
View File
@@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.9"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = ">= 4.0, < 5.0"
}
http = {
source = "hashicorp/http"
version = ">= 3.4, < 4.0"
}
}
}
+44
View File
@@ -0,0 +1,44 @@
# Bare VM on the workload subnet, for manually verifying that its traffic to
# remote_cidrs flows through the router. Off by default so a plain apply
# stays to just the router.
resource "azurerm_network_interface" "workload" {
count = var.deploy_workload_vm ? 1 : 0
name = "${var.name}-workload-nic"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
ip_configuration {
name = "workload"
subnet_id = azurerm_subnet.workload.id
private_ip_address_allocation = "Dynamic"
}
}
resource "azurerm_linux_virtual_machine" "workload" {
count = var.deploy_workload_vm ? 1 : 0
name = "${var.name}-workload"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
size = var.vm_size
admin_username = var.admin_username
disable_password_authentication = true
network_interface_ids = [azurerm_network_interface.workload[0].id]
admin_ssh_key {
username = var.admin_username
public_key = var.admin_ssh_public_key
}
os_disk {
caching = "ReadWrite"
storage_account_type = "StandardSSD_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server"
version = "latest"
}
}
+84
View File
@@ -0,0 +1,84 @@
#cloud-config
#
# Example: install and configure vpn-router during first boot, supplying an
# existing CA and server certificate instead of letting the package generate
# one. See cloud-init.yaml.tpl for the variant that generates its own CA.
#
# Template variables are Terraform templatefile() placeholders. Adapt or drop
# them for whatever renders this file. <label> is the first component of the
# FQDN, for example "router" in router.example.com.
hostname: ${hostname}
fqdn: ${fqdn}
manage_etc_hosts: false
apt:
sources:
vpn-router:
source: "deb [signed-by=/etc/apt/keyrings/vpn-router.asc] ${repo_url} ${ubuntu_codename} main"
key: |
${indent(8, trimspace(repo_gpg_key))}
write_files:
# The configuration file. Created here before the package is installed, so
# postinst leaves it alone.
- path: /etc/vpn-router/vpn-router.conf
permissions: '0600'
owner: root:root
content: |
[general]
platform = ${platform}
mode = ${mode}
[interfaces]
external = ${external_interface}
internal = ${internal_interface}
[wan]
local_fqdn = ${fqdn}
local_id_mode = ${local_id_mode}
[local]
cidrs = ${local_cidrs}
int_addr = ${int_addr}
int_gateway_ip = ${int_gateway_ip}
[remote]
addrs = ${remote_addrs}
id = ${remote_id}
cidrs = ${remote_cidrs}
psk_b64 = ${psk_b64}
[p2s]
enabled = ${p2s_enabled}
address_pool = ${p2s_address_pool}
ca_name = ${p2s_ca_name}
[wireguard]
enabled = ${wg_enabled}
address = ${wg_address}
listen_port = ${wg_listen_port}
- path: /etc/vpn-router/pki/ca_cert.pem
permissions: '0644'
owner: root:root
content: |
${indent(6, trimspace(ca_cert))}
- path: /etc/vpn-router/pki/${label}_cert.pem
permissions: '0644'
owner: root:root
content: |
${indent(6, trimspace(server_cert))}
- path: /etc/vpn-router/pki/${label}_key.pem
permissions: '0600'
owner: root:root
content: |
${indent(6, trimspace(server_key))}
package_update: true
packages:
- vpn-router
# Nothing further is required: installing the package starts
# vpn-router-setup, which applies the configuration written above.
+74
View File
@@ -0,0 +1,74 @@
#cloud-config
#
# Example: install and configure vpn-router during first boot.
#
# This is one way to reach the state described in README.md, not an interface
# the package depends on. It writes the configuration file directly, which
# keeps everything in one place and works the same whether or not debconf is
# involved.
#
# Template variables are Terraform templatefile() placeholders. Adapt or drop
# them for whatever renders this file.
#
# This variant lets the package generate its own CA on first boot. See
# cloud-init-with-pki.yaml.tpl for the variant that supplies existing PKI
# material instead.
hostname: ${hostname}
fqdn: ${fqdn}
manage_etc_hosts: false
apt:
sources:
vpn-router:
source: "deb [signed-by=/etc/apt/keyrings/vpn-router.asc] ${repo_url} ${ubuntu_codename} main"
key: |
${indent(8, trimspace(repo_gpg_key))}
write_files:
# The configuration file. Created here before the package is installed, so
# postinst leaves it alone.
- path: /etc/vpn-router/vpn-router.conf
permissions: '0600'
owner: root:root
content: |
[general]
platform = ${platform}
mode = ${mode}
[interfaces]
external = ${external_interface}
internal = ${internal_interface}
[wan]
local_fqdn = ${fqdn}
local_id_mode = ${local_id_mode}
[local]
cidrs = ${local_cidrs}
int_addr = ${int_addr}
int_gateway_ip = ${int_gateway_ip}
[remote]
addrs = ${remote_addrs}
id = ${remote_id}
cidrs = ${remote_cidrs}
psk_b64 = ${psk_b64}
[p2s]
enabled = ${p2s_enabled}
address_pool = ${p2s_address_pool}
ca_name = ${p2s_ca_name}
[wireguard]
enabled = ${wg_enabled}
address = ${wg_address}
listen_port = ${wg_listen_port}
package_update: true
packages:
- vpn-router
# Nothing further is required: installing the package starts
# vpn-router-setup, which applies the configuration written above.
+48
View File
@@ -0,0 +1,48 @@
# GCP example
Deploys `vpn-router` on a GCE instance: a two-NIC router (external/WAN and internal/protected,
each its own VPC network), a demo protected `workload` subnet in the internal network routed
through it, and cloud-init that installs and configures the package on first boot. See the
repository's top-level [README](../../README.md) for what the package itself does, and
[../azure/](../azure/) for the equivalent Azure example - the two share the same design.
## Prerequisites
- A GCP project and credentials Terraform can pick up (`gcloud auth application-default login`
or a service account).
- An SSH key pair for `admin_ssh_public_key`.
- A build of the `vpn-router` package published to the repository at `repo_url` (default: the
project's Gitea Debian registry). See [../../debian-package](../../debian-package) for
`build.sh`/`publish.sh`. That repository must allow anonymous reads, since the router pulls
the package with no credentials configured.
## Usage
```sh
cp terraform.tfvars.example terraform.tfvars
$EDITOR terraform.tfvars
export TF_VAR_psk='change-me' # keep secrets out of tracked files
```
`platform = gcp` and `mode = auto` are hard-coded into the cloud-init template call in
`router.tf`: the router has exactly two NICs, and on GCP the system default route always goes
out NIC0 (the external one here) while a secondary NIC only reaches its own subnet unless
policy-routed - exactly the case `mode = auto` is designed to detect reliably. See the
top-level README's "What mode decides" section.
Unlike the Azure example, `ext` and `internal` are two separate VPC networks (GCP instances
attach one NIC per network), with `int` and `workload` as two subnets inside the same
`internal` network. GCP routes apply network-wide rather than per-subnet, so `routes.tf`
needs no separate subnet association - the route in `internal` already covers both subnets.
## Verifying
```sh
ssh <admin_username>@$(terraform output -raw router_public_ip)
systemctl status vpn-router-setup
swanctl --list-sas
```
Set `deploy_workload_vm = true` and re-apply to add a VM on the `workload` subnet (no public
IP - reach it via the router or IAP) for confirming that its traffic to `remote_cidrs` actually
flows through the router.
+78
View File
@@ -0,0 +1,78 @@
resource "google_dns_record_set" "router_ext" {
count = var.dns_managed_zone != "" ? 1 : 0
name = "${var.local_fqdn}."
type = "A"
ttl = 300
managed_zone = var.dns_managed_zone
project = var.dns_project != "" ? var.dns_project : var.project_id
rrdatas = [google_compute_address.ext.address]
}
resource "google_compute_network" "ext" {
name = "${var.name}-ext"
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "ext" {
name = "${var.name}-ext"
network = google_compute_network.ext.id
region = var.region
ip_cidr_range = var.ext_subnet_cidr
}
resource "google_compute_network" "internal" {
name = "${var.name}-int"
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "int" {
name = "${var.name}-int"
network = google_compute_network.internal.id
region = var.region
ip_cidr_range = var.int_subnet_cidr
}
resource "google_compute_subnetwork" "workload" {
name = "${var.name}-workload"
network = google_compute_network.internal.id
region = var.region
ip_cidr_range = var.workload_subnet_cidr
}
resource "google_compute_firewall" "router_ext" {
name = "${var.name}-ext-allow-router"
network = google_compute_network.ext.name
allow {
protocol = "tcp"
ports = ["22"]
}
allow {
protocol = "udp"
ports = ["500", "4500"]
}
dynamic "allow" {
for_each = var.wireguard_enabled ? [1] : []
content {
protocol = "udp"
ports = [tostring(var.wireguard_listen_port)]
}
}
source_ranges = ["0.0.0.0/0"]
target_tags = ["${var.name}-router"]
}
resource "google_compute_firewall" "internal_allow_all" {
name = "${var.name}-int-allow-internal"
network = google_compute_network.internal.name
allow {
protocol = "all"
}
source_ranges = [var.int_subnet_cidr, var.workload_subnet_cidr]
}
+9
View File
@@ -0,0 +1,9 @@
output "router_public_ip" {
description = "Public IP address of the router VM."
value = google_compute_address.ext.address
}
output "router_internal_ip" {
description = "Private IP address of the router's internal NIC - the next hop to use in routes configured outside this Terraform run (another route, or an on-prem device) that need to reach remote_cidrs through this router."
value = google_compute_instance.router.network_interface[1].network_ip
}
+5
View File
@@ -0,0 +1,5 @@
provider "google" {
project = var.project_id
region = var.region
zone = var.zone
}
+79
View File
@@ -0,0 +1,79 @@
data "http" "repo_gpg_key" {
url = "${var.repo_url}/repository.key"
}
locals {
supply_pki = var.ca_cert_file != ""
cloud_init_vars = {
hostname = var.name
fqdn = var.local_fqdn
repo_url = var.repo_url
repo_gpg_key = data.http.repo_gpg_key.response_body
ubuntu_codename = var.ubuntu_codename
platform = "gcp"
mode = "auto"
external_interface = ""
internal_interface = ""
local_id_mode = var.local_id_mode
local_cidrs = var.local_cidrs
int_addr = ""
int_gateway_ip = ""
remote_addrs = var.remote_addrs
remote_id = var.remote_id
remote_cidrs = var.remote_cidrs
psk_b64 = base64encode(var.psk)
p2s_enabled = var.p2s_enabled
p2s_address_pool = var.p2s_address_pool
p2s_ca_name = "VPN Router CA"
wg_enabled = var.wireguard_enabled
wg_address = var.wireguard_address
wg_listen_port = var.wireguard_listen_port
}
cloud_init_rendered = local.supply_pki ? templatefile("${path.module}/../cloud-init-with-pki.yaml.tpl", merge(local.cloud_init_vars, {
label = split(".", var.local_fqdn)[0]
ca_cert = file(var.ca_cert_file)
server_cert = file(var.server_cert_file)
server_key = file(var.server_key_file)
})) : templatefile("${path.module}/../cloud-init.yaml.tpl", local.cloud_init_vars)
}
resource "google_compute_address" "ext" {
name = "${var.name}-ext"
region = var.region
}
resource "google_compute_instance" "router" {
name = var.name
machine_type = var.machine_type
zone = var.zone
tags = ["${var.name}-router"]
can_ip_forward = true
boot_disk {
initialize_params {
image = "ubuntu-os-cloud/ubuntu-2404-lts-amd64"
}
}
network_interface {
network = google_compute_network.ext.id
subnetwork = google_compute_subnetwork.ext.id
access_config {
nat_ip = google_compute_address.ext.address
}
}
network_interface {
network = google_compute_network.internal.id
subnetwork = google_compute_subnetwork.int.id
}
metadata = {
ssh-keys = "${var.admin_username}:${var.admin_ssh_public_key}"
user-data = local.cloud_init_rendered
}
}
+13
View File
@@ -0,0 +1,13 @@
locals {
remote_cidr_list = [for c in split(",", var.remote_cidrs) : trimspace(c) if trimspace(c) != ""]
}
resource "google_compute_route" "to_remote" {
for_each = toset(local.remote_cidr_list)
name = "${var.name}-to-${replace(replace(each.value, "/", "-"), ".", "-")}"
network = google_compute_network.internal.name
dest_range = each.value
next_hop_instance = google_compute_instance.router.self_link
priority = 100
}
+20
View File
@@ -0,0 +1,20 @@
# Copy to terraform.tfvars (or a git-ignored *.auto.tfvars) and adjust.
# Keep secrets - psk here - out of any tracked file: set them with
# TF_VAR_psk or in a git-ignored *.auto.tfvars instead.
project_id = "my-gcp-project"
region = "europe-central2"
zone = "europe-central2-a"
name = "vpn-router-example"
admin_ssh_public_key = "ssh-ed25519 AAAA... you@example.com"
local_fqdn = "router.example.com"
local_cidrs = "10.0.3.0/24"
remote_addrs = "peer.example.net"
remote_id = "peer.example.net"
remote_cidrs = "192.168.0.0/24"
p2s_enabled = false
wireguard_enabled = false
deploy_workload_vm = false
+171
View File
@@ -0,0 +1,171 @@
variable "project_id" {
description = "GCP project to deploy into."
type = string
}
variable "region" {
description = "GCP region to deploy into."
type = string
}
variable "zone" {
description = "GCP zone to deploy the VMs into."
type = string
}
variable "name" {
description = "Base name used to derive resource names."
type = string
default = "vpn-router-example"
}
variable "admin_username" {
description = "Admin username created on both VMs via SSH key metadata."
type = string
default = "vpnrouter"
}
variable "admin_ssh_public_key" {
description = "SSH public key installed for admin_username on both VMs."
type = string
}
variable "machine_type" {
description = "Machine type for the router."
type = string
default = "e2-small"
}
variable "local_fqdn" {
description = "FQDN of the router, used as the road-warrior/IKE identity and, when dns_managed_zone is set, as the name of the A record created for it."
type = string
}
variable "dns_managed_zone" {
description = "Name of an existing Cloud DNS managed zone to create local_fqdn's A record in, pointing at the router's public IP. local_fqdn must be a name within that zone's DNS name. Leave empty to skip - local_fqdn is then just a label with nothing making it resolve."
type = string
default = ""
}
variable "dns_project" {
description = "Project that owns dns_managed_zone. Leave empty to use project_id."
type = string
default = ""
}
variable "local_id_mode" {
description = "IKE local identity source: fqdn, public_ip or internal_ip."
type = string
default = "fqdn"
}
variable "local_cidrs" {
description = "Local subnet CIDR(s) advertised into the site-to-site tunnel. Should include the workload subnet CIDR."
type = string
}
variable "remote_addrs" {
description = "Remote gateway address(es) or FQDN for the site-to-site tunnel."
type = string
}
variable "remote_id" {
description = "Remote peer's IKE identity, without a leading @."
type = string
}
variable "remote_cidrs" {
description = "Remote subnet CIDR(s) reachable through the site-to-site tunnel."
type = string
}
variable "psk" {
description = "Pre-shared key for the site-to-site IKEv2 tunnel."
type = string
sensitive = true
}
variable "p2s_enabled" {
description = "Enable road-warrior (P2S) access."
type = bool
default = false
}
variable "p2s_address_pool" {
description = "CIDR block assigned to road-warrior clients."
type = string
default = ""
}
variable "ca_cert_file" {
description = "Path to an existing CA certificate PEM to supply instead of letting the package generate one. Leave empty to auto-generate."
type = string
default = ""
}
variable "server_cert_file" {
description = "Path to an existing server certificate PEM, paired with ca_cert_file."
type = string
default = ""
}
variable "server_key_file" {
description = "Path to an existing server private key PEM, paired with ca_cert_file."
type = string
default = ""
sensitive = true
}
variable "wireguard_enabled" {
description = "Enable the WireGuard endpoint."
type = bool
default = false
}
variable "wireguard_address" {
description = "Address and prefix length for the wg0 interface."
type = string
default = ""
}
variable "wireguard_listen_port" {
description = "UDP port WireGuard listens on."
type = number
default = 51820
}
variable "deploy_workload_vm" {
description = "Deploy a bare VM on the workload subnet, for manually verifying routing through the router."
type = bool
default = false
}
variable "repo_url" {
description = "Base URL of the Debian package repository the router pulls vpn-router from."
type = string
default = "https://gitea.koszewscy.waw.pl/api/packages/slawek/debian"
}
variable "ubuntu_codename" {
description = "Ubuntu release codename of the router VM's image, used to select the apt repo component."
type = string
default = "noble"
}
variable "ext_subnet_cidr" {
description = "CIDR of the router's external (WAN-facing) subnet, in its own VPC network."
type = string
default = "10.0.1.0/24"
}
variable "int_subnet_cidr" {
description = "CIDR of the router's internal (protected-network-facing) subnet, in its own VPC network."
type = string
default = "10.0.2.0/24"
}
variable "workload_subnet_cidr" {
description = "CIDR of the demo protected workload subnet, in the same VPC network as int_subnet_cidr and routed through the router."
type = string
default = "10.0.3.0/24"
}
+14
View File
@@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.9"
required_providers {
google = {
source = "hashicorp/google"
version = ">= 7.0, < 8.0"
}
http = {
source = "hashicorp/http"
version = ">= 3.4, < 4.0"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
# Bare VM on the workload subnet, for manually verifying that its traffic to
# remote_cidrs flows through the router. Off by default so a plain apply
# stays to just the router.
resource "google_compute_instance" "workload" {
count = var.deploy_workload_vm ? 1 : 0
name = "${var.name}-workload"
machine_type = var.machine_type
zone = var.zone
boot_disk {
initialize_params {
image = "ubuntu-os-cloud/ubuntu-2404-lts-amd64"
}
}
network_interface {
network = google_compute_network.internal.id
subnetwork = google_compute_subnetwork.workload.id
}
metadata = {
ssh-keys = "${var.admin_username}:${var.admin_ssh_public_key}"
}
}
+77
View File
@@ -0,0 +1,77 @@
#!/bin/sh
#
# Example: unattended install and configuration of vpn-router on an existing
# host. Run as root. Adjust the values below, or set them in the environment.
#
# This is the same end state as the cloud-init example and as configuring the
# router by hand; only the way the file is written differs.
set -e
DEB="${DEB:-./vpn-router_1.0.0-1_all.deb}"
PLATFORM="${PLATFORM:-generic}"
MODE="${MODE:-manual}"
EXTERNAL_INTERFACE="${EXTERNAL_INTERFACE:-eth0}"
INTERNAL_INTERFACE="${INTERNAL_INTERFACE:-eth1}"
LOCAL_FQDN="${LOCAL_FQDN:-router.example.com}"
LOCAL_ID_MODE="${LOCAL_ID_MODE:-fqdn}"
LOCAL_CIDRS="${LOCAL_CIDRS:-10.0.0.0/24}"
INT_ADDR="${INT_ADDR:-10.1.1.4}"
INT_GATEWAY_IP="${INT_GATEWAY_IP:-10.1.1.1}"
REMOTE_ADDRS="${REMOTE_ADDRS:-peer.example.net}"
REMOTE_ID="${REMOTE_ID:-peer.example.net}"
REMOTE_CIDRS="${REMOTE_CIDRS:-192.168.0.0/24}"
PSK="${PSK:-change-me}"
P2S_ENABLED="${P2S_ENABLED:-false}"
P2S_ADDRESS_POOL="${P2S_ADDRESS_POOL:-172.16.0.0/24}"
P2S_CA_NAME="${P2S_CA_NAME:-VPN Router CA}"
WG_ENABLED="${WG_ENABLED:-false}"
WG_ADDRESS="${WG_ADDRESS:-}"
WG_LISTEN_PORT="${WG_LISTEN_PORT:-51820}"
# Write the configuration before installing, so postinst leaves it alone.
install -d -m 0755 /etc/vpn-router
umask 077
cat > /etc/vpn-router/vpn-router.conf <<EOF
[general]
platform = ${PLATFORM}
mode = ${MODE}
[interfaces]
external = ${EXTERNAL_INTERFACE}
internal = ${INTERNAL_INTERFACE}
[wan]
local_fqdn = ${LOCAL_FQDN}
local_id_mode = ${LOCAL_ID_MODE}
[local]
cidrs = ${LOCAL_CIDRS}
int_addr = ${INT_ADDR}
int_gateway_ip = ${INT_GATEWAY_IP}
[remote]
addrs = ${REMOTE_ADDRS}
id = ${REMOTE_ID}
cidrs = ${REMOTE_CIDRS}
psk_b64 = $(printf %s "$PSK" | base64 -w0)
[p2s]
enabled = ${P2S_ENABLED}
address_pool = ${P2S_ADDRESS_POOL}
ca_name = ${P2S_CA_NAME}
[wireguard]
enabled = ${WG_ENABLED}
address = ${WG_ADDRESS}
listen_port = ${WG_LISTEN_PORT}
EOF
umask 022
DEBIAN_FRONTEND=noninteractive apt-get install -y "$DEB"
# Installing starts vpn-router-setup, which applies the file above. Restart it
# explicitly after any later edit.
systemctl restart vpn-router-setup
systemctl --no-pager status vpn-router-setup