Compare commits

...
6 Commits
Author SHA1 Message Date
slawek 64da0cf97f fix: update interface detection code to support additional interfaces Docker adds.
Release / release (push) Successful in 17s
2026-10-23 13:43:46 +02:00
slawek 2e0cee10ac feat: added support for running the VPN router on a host with Docker Community Edition.
Release / release (push) Successful in 1m8s
2026-10-23 13:17:32 +02:00
slawek 66fcd69d40 feat: change the workflow to use owner secrets.
Release / release (push) Successful in 18s
2026-10-23 08:24:40 +02:00
slawek 19567aa273 feat: add Gitea workflow that builds package on tag push.
Release / release (push) Failing after 1m11s
2026-10-23 08:10:15 +02:00
slawek 656e1733d5 feat: change config from INI to JSON. 2026-10-23 08:04:55 +02:00
slawek b18947ed9b Converted router configuration file from .conf to JSON. 2026-10-23 07:35:43 +02:00
26 changed files with 673 additions and 406 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- '*.*.*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build package
working-directory: debian-package
run: ./build.sh
- name: Publish package
working-directory: debian-package
env:
SERVER_URL: ${{ secrets.CI_URL }}
OWNER: ${{ secrets.CI_OWNER }}
PACKAGES_TOKEN: ${{ secrets.CI_TOKEN }}
run: ./publish.sh
+28
View File
@@ -0,0 +1,28 @@
# Architecture Decision Records
## ADR-1: Docker's published ports are filtered in DOCKER-USER by the package
Status: Accepted
Context: Docker adds its own iptables rules for published ports. They are evaluated before
UFW's, so a published port is reachable from the external interface whatever UFW says. Docker
reserves the `DOCKER-USER` chain, evaluated first in `FORWARD`, for rules of the operator. The
router is an internet-facing host, and Docker is installed on it for services run alongside the
router.
Decision: The package secures Docker itself. A `docker` configuration section has `mode`
(`auto`, `on`, `off`) and `exposed_ports`. In `auto` mode the rules are applied when
`docker.service` is installed, which is what the package looks up; `on` requires Docker and
`off` removes the rules. The rules are in a chain of their own, `VPN-ROUTER-DOCKER`, entered by
one jump at the top of `DOCKER-USER`. For traffic from the external interface they accept IPsec
protected traffic, TCP to `exposed_ports` and established connections, and drop the rest. The
`vpn-router-docker` unit applies them after `docker.service` starts and removes them when it
stops; `vpn-router-setup` restarts it while Docker runs.
Consequences: Installing Docker first is recommended; `auto` mode then finds it as soon as the
package is configured. When Docker is installed after the package, the rules are applied by
restarting `vpn-router-setup`, a reboot or a restart of Docker, not by Docker's first start,
because `vpn-router-docker` is already active. A published
port is reachable from the external interface only when it is in `exposed_ports`. Other rules
in `DOCKER-USER` are not touched, because the package owns only its own chain. Restarting Docker
restores the rules. The rules cover IPv4 only.
+117 -43
View File
@@ -6,6 +6,7 @@ A Debian package that configures a Linux host or virtual machine as a VPN router
- road-warrior (P2S) access using IKEv2 EAP-TLS, with a bundled certificate authority
- optional WireGuard endpoint
- routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules
- firewall rules for the ports Docker publishes, when Docker is installed
Nothing in the package is specific to a cloud provider. It is built on the external and
internal NIC names, and a `mode` setting says how much of the rest you are supplying and
@@ -24,15 +25,19 @@ supported equally, and both end up with the same two things: a configuration fil
service that applies it.
```
/etc/vpn-router/vpn-router.conf the configuration
/etc/vpn-router/vpn-router.json the configuration
systemctl restart vpn-router-setup apply it
```
If the host runs Docker, the recommended order is Docker first and the package second. The
package secures the ports Docker publishes when it finds the Docker service; the steps for a
host that already runs Docker, and for installing Docker later, are under [Docker](#docker).
### By hand
```sh
apt-get install vpn-router # answer "none" to the platform question
$EDITOR /etc/vpn-router/vpn-router.conf
$EDITOR /etc/vpn-router/vpn-router.json
systemctl restart vpn-router-setup
```
@@ -65,10 +70,10 @@ 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.
With mode `interfaces` the `int_addr` and `int_gateway_ip` lines are dropped and read
from the internal NIC instead; with mode `auto` the interface names go too.
Alternatively write `/etc/vpn-router/vpn-router.conf` directly before or after installing.
Alternatively write `/etc/vpn-router/vpn-router.json` 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
@@ -77,47 +82,60 @@ 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
`/etc/vpn-router/vpn-router.json` is JSON, mode 0600, and is created on first install. It
is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies what
changed and asks before touching anything you altered by hand. A fully commented
reference is installed at `/usr/share/doc/vpn-router/vpn-router.conf.example`.
changed and asks before touching anything you altered by hand. A complete example is
installed at `/usr/share/doc/vpn-router/vpn-router.json.example`.
Lists are comma-separated, booleans are `true`/`false`, an empty value means unset, and a
`_b64` suffix means the value is base64-encoded.
The file is an object of sections, each an object of settings. A section or setting that
is left out takes its default, and an empty string or empty list means unset. Every value
must have the type shown below; an unknown section or setting is an error.
| Section | Setting | 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 |
| Section | Setting | Type | Default | Meaning |
|---|---|---|---|---|
| `general` | `platform` | string | `none` | `none`, `generic`, `azure` or `gcp`. `none` defers configuration entirely |
| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
| `wan` | `local_fqdn` | string | | This router's FQDN |
| `wan` | `local_id_mode` | string | `fqdn` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` |
| `local` | `cidrs` | list | | Local subnets advertised into the tunnel |
| `local` | `int_addr` | string | | This host's address on the internal network |
| `local` | `int_gateway_ip` | string | | Next hop on the internal side |
| `remote` | `addrs` | list | | Remote gateway addresses or FQDNs |
| `remote` | `id` | string | | Remote IKE identity, without a leading `@` |
| `remote` | `cidrs` | list | | Remote subnets reachable through the tunnel |
| `remote` | `psk` | string | | Pre-shared key |
| `remote` | `psk_file` | string | | Path to a file holding the key; wins over `psk` |
| `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `p2s` | `ca_name` | string | `VPN Router CA` | Common name for a locally created CA |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` |
| `wireguard` | `listen_port` | integer | `51820` | UDP port |
| `docker` | `mode` | string | `auto` | `auto` secures Docker when its service is installed, `on` requires it, `off` removes the rules |
| `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two |
A minimal site-to-site configuration in `auto` mode:
```json
{
"general": { "platform": "generic", "mode": "auto" },
"wan": { "local_fqdn": "router.example.com" },
"local": { "cidrs": ["10.0.0.0/24"] },
"remote": {
"addrs": ["peer.example.net"],
"id": "peer.example.net",
"cidrs": ["192.168.0.0/24"],
"psk": "s3cr3t"
}
}
```
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.
@@ -130,25 +148,80 @@ much the package reads from the system:
|---|---|---|
| `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 |
| `auto` | nothing | the interface names too: the external one from the routing table, the internal one as the only other NIC with an address |
`manual` is the only mode in which no detection code runs at all. `auto` is the
convenient one and can pick the wrong interface, which is a trade you make deliberately
by choosing it rather than something the package does behind your back.
Detection ignores virtual interfaces, those the kernel lists under `/sys/devices/virtual`:
loopback, bridges such as `docker0`, veth pairs and tunnels. Only the NICs of the machine
count, so Docker does not change the result. If the host has more than two NICs with an
address, `auto` stops and says so; use mode `interfaces` and name them.
The addresses strongSwan binds on always come from the external NIC, in every mode, so
they are never configured twice and cannot drift out of step with the host. If a named
interface does not exist the service stops and says so. In `interfaces` and `auto` mode
the internal gateway is taken from the routing table, falling back to the first host
address of the connected subnet with a warning when the routing table has nothing to say.
## Docker
Docker publishes container ports in its own firewall rules, which bypass UFW and can expose
ports on the external interface. Docker evaluates the `DOCKER-USER` chain before its own
rules, so the package filters there. With `docker.mode` set to `auto`, the package secures
Docker when `docker.service` is installed, and does nothing otherwise.
### On a host that already runs Docker
Installing Docker first is the recommended order. After installing the package:
1. Set `general.platform` in `/etc/vpn-router/vpn-router.json`. While it is `none`, nothing is
configured, the Docker rules included.
2. List the TCP ports the running containers must expose to the internet, for example
`"docker": { "exposed_ports": ["443"] }`. The default is none: once the rules are applied,
every published port that is not listed is unreachable from the external interface, and
remains reachable through the IPsec tunnel.
3. Apply the configuration with `systemctl restart vpn-router-setup`. Docker is running, so
this also restarts `vpn-router-docker`, which installs the rules.
4. Check the result:
```sh
iptables -S DOCKER-USER # "-j VPN-ROUTER-DOCKER" is the first rule
iptables -S VPN-ROUTER-DOCKER # the rules of the external interface
```
### When Docker is installed after the package
The Docker rules are not applied when Docker starts for the first time, so run
`systemctl restart vpn-router-setup` after installing Docker, then check as above. A reboot or a
restart of Docker applies them as well.
The rules sit in their own chain, `VPN-ROUTER-DOCKER`, entered by one jump at the top of
`DOCKER-USER`; other rules in `DOCKER-USER` are left alone. For traffic that arrives on the
external interface, in this order:
1. IPsec protected traffic is accepted.
2. TCP to the ports in `docker.exposed_ports` is accepted; none by default.
3. Packets of established and related connections are accepted.
4. Everything else is dropped.
Traffic from every other interface, including the internal one and the Docker bridges, is
not affected. The rules cover IPv4 only.
The `vpn-router-docker` service applies the rules after `docker.service` starts and removes
them when Docker stops, so restarting Docker restores them. `systemctl restart
vpn-router-setup` applies a changed configuration while Docker runs. With `docker.mode` set to
`on`, the service fails when Docker is not installed or its daemon is not running; with
`off` it removes the rules.
## Applying changes
```sh
systemctl restart vpn-router-setup
```
The service also runs at boot. With `platform = none` it does nothing and says so.
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
@@ -240,17 +313,18 @@ 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
Set `general.platform` to `example` and restart the service. The debconf question offers only the
modules shipped with the package; the configuration file accepts any module installed.
## Removal
`apt-get remove` terminates the tunnels and takes `wg-quick@wg0` down, but leaves the
`apt-get remove` stops `vpn-router-docker`, which removes the Docker rules. It terminates the
tunnels and takes `wg-quick@wg0` down, but leaves the
configuration in place. `strongswan` is left enabled, since it is a shared service that
may be in use by something else.
`apt-get purge` additionally removes the generated files and the firewall rules, restoring
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.conf` back to `ucf`
`/etc/ufw/before.rules` to its original content, and hands `vpn-router.json` back to `ucf`
before deleting it so a later reinstall starts clean. Key material is never deleted:
`/etc/vpn-router/pki`, the WireGuard key pair and everything under `/etc/swanctl` are left
alone.
View File
Whitespace-only changes.
+10 -2
View File
@@ -1,9 +1,17 @@
#!/bin/sh
set -e
# Apple container CLI on macOS, Docker CE on Linux.
case "$(uname -s)" in
Darwin) CONTAINER_RUNTIME=container ;;
Linux) CONTAINER_RUNTIME=docker ;;
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
mkdir -p out
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/'
$CONTAINER_RUNTIME build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" .
# Artifacts are streamed out as a tar archive, so no bind mount is needed.
$CONTAINER_RUNTIME run --rm vpn-router-builder tar -cf - -C /out . | tar -xf - -C out
echo "Build artifacts written to out/"
+50 -1
View File
@@ -1,4 +1,53 @@
vpn-router (1.0.0-1) unstable; urgency=medium
vpn-router (0.3.1-1) unstable; urgency=medium
* Ignore virtual interfaces when mode auto looks for the internal
interface. The Docker bridge docker0, which has an address, was counted
as a second internal interface, so vpn-router-setup and
vpn-router-docker failed on a host with Docker installed.
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 13:44:00 +0200
vpn-router (0.3.0-1) unstable; urgency=medium
* Secure the ports Docker publishes. Rules in a chain of their own,
VPN-ROUTER-DOCKER, entered by a jump at the top of DOCKER-USER, accept
IPsec protected traffic, TCP to docker.exposed_ports and established
connections from the external interface and drop the rest of it. Other
rules in DOCKER-USER are left alone. The rules cover IPv4 only.
* Add the docker configuration section. docker.mode is auto (the default,
secures Docker when docker.service is installed), on (requires Docker) or
off (removes the rules); docker.exposed_ports lists the TCP ports and port
ranges reachable from the external interface, none by default.
* Add the vpn-router-docker service, which applies the Docker rules after
docker.service starts and removes them when it stops. vpn-router-setup
restarts it while Docker runs, so a changed configuration takes effect.
* Installing Docker before this package is recommended. When Docker is
installed later, restart vpn-router-setup to apply the rules.
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 12:44:45 +0200
vpn-router (0.2.0-1) unstable; urgency=medium
* Change the configuration file to JSON, /etc/vpn-router/vpn-router.json.
Lists are arrays, booleans and the WireGuard listen port are native JSON
types, and unknown sections, unknown settings and wrong value types are
rejected.
* Replace remote.psk_b64 with remote.psk, holding the pre-shared key as a
plain string. remote.psk_file still takes precedence.
* Ship /usr/share/doc/vpn-router/vpn-router.json.example in place of
vpn-router.conf.example.
* Add platform "none" as the default, so installing the package changes
nothing on the machine until a platform is chosen.
* Add the mode setting (manual, interfaces, auto) controlling how much of
the network configuration is supplied and how much is read from the
system.
* Manage the configuration file with ucf, so dpkg-reconfigure reapplies
debconf answers without overwriting local edits.
* Apply NAT and forward rules to every local subnet, not only the first.
-- Sławomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 08:00:06 +0200
vpn-router (0.1.0-1) unstable; urgency=medium
* Initial release.
+1 -1
View File
@@ -28,7 +28,7 @@ Description: Linux VPN router with IPSec, road-warrior and WireGuard support
optional WireGuard, and the routing, NAT, MSS clamping and firewall rules a
router needs. Nothing in the package is specific to a cloud provider.
.
Settings live in /etc/vpn-router/vpn-router.conf, which is created on first
Settings live in /etc/vpn-router/vpn-router.json, 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
+2 -1
View File
@@ -2,8 +2,9 @@ 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/docker-rules 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/
src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/
+3 -3
View File
@@ -42,9 +42,9 @@ case "$1" in
# 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
ucf --three-way --debconf-ok "$CANDIDATE" /etc/vpn-router/vpn-router.json
ucfr vpn-router /etc/vpn-router/vpn-router.json
chmod 0600 /etc/vpn-router/vpn-router.json
rm -f "$CANDIDATE"
# The key now lives in the configuration file; do not keep a copy. An
+3 -3
View File
@@ -37,17 +37,17 @@ case "$1" in
# 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
ucf --purge /etc/vpn-router/vpn-router.json
fi
if command -v ucfr >/dev/null 2>&1; then
ucfr --purge vpn-router /etc/vpn-router/vpn-router.conf
ucfr --purge vpn-router /etc/vpn-router/vpn-router.json
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
/etc/vpn-router/vpn-router.json
# Key material is not package state: /etc/vpn-router/pki, the WireGuard
# key pair and everything under /etc/swanctl are left in place.
+1
View File
@@ -4,3 +4,4 @@
override_dh_installsystemd:
dh_installsystemd --name=vpn-router-setup
dh_installsystemd --name=vpn-router-docker
+3 -3
View File
@@ -9,7 +9,7 @@ Description: Platform:
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.
/etc/vpn-router/vpn-router.json when you are ready.
.
generic configures the router with no platform-specific additions.
@@ -113,8 +113,8 @@ 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. Stored base64-encoded in the configuration
file and cleared from the debconf database after installation.
configured on the remote peer. Stored in the configuration file and cleared
from the debconf database after installation.
Template: vpn-router/p2s_enabled
Type: boolean
@@ -0,0 +1,14 @@
[Unit]
Description=VPN Router rules for Docker published ports
Documentation=file:/usr/share/doc/vpn-router/README.md
After=docker.service vpn-router-setup.service
PartOf=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/lib/vpn-router/docker-rules apply
ExecStop=/usr/lib/vpn-router/docker-rules remove
[Install]
WantedBy=docker.service
+24 -4
View File
@@ -1,9 +1,21 @@
#!/bin/sh
set -e
# Apple container CLI on macOS, Docker CE on Linux.
case "$(uname -s)" in
Darwin) CONTAINER_RUNTIME=container ;;
Linux) CONTAINER_RUNTIME=docker ;;
*) printf 'ERROR: unsupported platform %s\n' "$(uname -s)" >&2; exit 1 ;;
esac
OWNER="${OWNER:-slawek}"
SERVER_URL="${SERVER_URL:-https://gitea.koszewscy.waw.pl}"
PACKAGES_TOKEN="${PACKAGES_TOKEN:-$(cat ~/.gitea-packages-token)}"
# Reads the changelog copied into the builder image, so no bind mount is needed.
changelog_field() {
container run --rm -v "$(pwd):/mnt" vpn-router-builder \
dpkg-parsechangelog --show-field "$1" -l /mnt/debian/changelog
$CONTAINER_RUNTIME run --rm vpn-router-builder \
dpkg-parsechangelog --show-field "$1" -l /build/src/debian/changelog
}
VERSION="$(changelog_field Version)"
@@ -15,6 +27,14 @@ if [ ! -f "$DEB" ]; then
exit 1
fi
curl -v --user "slawek:$(cat ~/.gitea-packages-token)" \
STATUS="$(curl -s -o /dev/null -w '%{http_code}' --user "$OWNER:$PACKAGES_TOKEN" \
"$SERVER_URL/api/v1/packages/$OWNER/debian/$PACKAGE/$VERSION")"
case "$STATUS" in
200) printf '%s %s is already published, skipping.\n' "$PACKAGE" "$VERSION"; exit 0 ;;
404) ;;
*) printf 'ERROR: version check returned HTTP %s.\n' "$STATUS" >&2; exit 1 ;;
esac
curl --fail-with-body --user "$OWNER:$PACKAGES_TOKEN" \
--upload-file "$DEB" \
"https://gitea.koszewscy.waw.pl/api/packages/slawek/debian/pool/noble/main/upload"
"$SERVER_URL/api/packages/$OWNER/debian/pool/noble/main/upload"
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/python3
"""Render configuration files and provision PKI from vpn-router.conf.
"""Render configuration files and provision PKI from vpn-router.json.
Run directly to regenerate them without starting, stopping or reloading
anything. This still writes to /etc/swanctl, /etc/systemd/resolved.conf.d and
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/python3
"""Secure the ports Docker publishes, with rules in the DOCKER-USER chain.
Usage: docker-rules apply|remove
Docker evaluates DOCKER-USER in FORWARD before its own rules, which makes it the
place to filter traffic to containers. The rules live in their own chain,
VPN-ROUTER-DOCKER, entered by one jump at the top of DOCKER-USER, so anything
else in DOCKER-USER is left alone. Traffic arriving on the external interface is
accepted when it is IPsec protected, when it is TCP to docker.exposed_ports, or
when it belongs to an established connection; all other traffic from the
external interface is dropped. Traffic from other interfaces is not affected.
docker.mode decides whether this runs: auto applies the rules when the Docker
service is installed, on requires it, off removes the rules. The vpn-router-docker
unit runs apply after docker.service starts and remove when it stops.
"""
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import vpnrouter
from vpnrouter import fail, warn
CHAIN = 'VPN-ROUTER-DOCKER'
USER_CHAIN = 'DOCKER-USER'
def iptables(*args, check=True):
result = subprocess.run(['iptables', *args], capture_output=True, text=True)
if check and result.returncode != 0:
fail(f'iptables {" ".join(args)} failed: {result.stderr.strip()}')
return result
def docker_installed():
"""True when systemd knows the docker.service unit."""
result = subprocess.run(
['systemctl', 'list-unit-files', '--no-legend', 'docker.service'],
capture_output=True, text=True)
return result.returncode == 0 and result.stdout.strip() != ''
def remove():
"""Take the jump and the chain out; nothing happens when they are absent."""
while iptables('-D', USER_CHAIN, '-j', CHAIN, check=False).returncode == 0:
pass
iptables('-F', CHAIN, check=False)
iptables('-X', CHAIN, check=False)
def apply(cfg):
mode = cfg['docker']['mode']
if mode == 'off':
remove()
return
if not docker_installed():
if mode == 'on':
fail('docker.mode is on but the docker service is not installed; '
'install Docker before vpn-router')
return
if iptables('-S', USER_CHAIN, check=False).returncode != 0:
if mode == 'on':
fail(f'docker.mode is on but there is no {USER_CHAIN} chain; '
'is the Docker daemon running?')
warn(f'no {USER_CHAIN} chain, the Docker daemon is not running; '
'the rules are applied when it starts')
return
ctx = vpnrouter.build_context(cfg)
wan = ctx['wan_iface']
if not wan:
fail('the external interface is not known; set interfaces.external '
'or use mode auto or interfaces')
ports = ','.join(item.strip() for item in cfg['docker']['exposed_ports'])
remove()
iptables('-N', CHAIN)
iptables('-A', CHAIN, '-i', wan, '-m', 'policy', '--dir', 'in', '--pol', 'ipsec', '-j', 'ACCEPT')
if ports:
iptables('-A', CHAIN, '-i', wan, '-p', 'tcp', '-m', 'multiport', '--dports', ports, '-j', 'ACCEPT')
iptables('-A', CHAIN, '-i', wan, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT')
iptables('-A', CHAIN, '-i', wan, '-j', 'DROP')
iptables('-I', USER_CHAIN, '1', '-j', CHAIN)
def main():
if len(sys.argv) != 2 or sys.argv[1] not in ('apply', 'remove'):
print('usage: docker-rules apply|remove', file=sys.stderr)
return 2
if sys.argv[1] == 'remove':
remove()
return 0
cfg = vpnrouter.load_config()
if vpnrouter.deferred(cfg):
return 0
try:
vpnrouter.validate(cfg)
apply(cfg)
except vpnrouter.ConfigError as exc:
fail(str(exc))
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -1,15 +1,15 @@
#!/usr/bin/python3
"""Write a candidate vpn-router.conf to the path given on the command line.
"""Write a candidate vpn-router.json 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
/etc/vpn-router/vpn-router.json 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
2. the current contents of /etc/vpn-router/vpn-router.json, if it exists
3. any debconf answer that was actually given
Layer 2 is what makes reconfiguration safe: a setting the administrator edited
@@ -19,12 +19,12 @@ 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.
Answers arrive as VPN_ROUTER_* environment variables and are converted to the
type of the setting they fill: comma-separated lists become arrays, "true" and
"false" become booleans and digits become integers.
"""
import base64
import json
import os
import sys
@@ -46,6 +46,7 @@ ANSWERS = {
'VPN_ROUTER_REMOTE_ADDRS': ('remote', 'addrs'),
'VPN_ROUTER_REMOTE_ID': ('remote', 'id'),
'VPN_ROUTER_REMOTE_CIDRS': ('remote', 'cidrs'),
'VPN_ROUTER_PSK': ('remote', 'psk'),
'VPN_ROUTER_P2S_ENABLED': ('p2s', 'enabled'),
'VPN_ROUTER_P2S_ADDRESS_POOL': ('p2s', 'address_pool'),
'VPN_ROUTER_P2S_CA_NAME': ('p2s', 'ca_name'),
@@ -61,25 +62,25 @@ def main():
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)
cfg = vpnrouter.load_config()
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')
value = os.environ.get(variable)
if not value:
continue
# A value that does not convert is kept as a string for validate() to report.
default = vpnrouter.SCHEMA[section][option]
if isinstance(default, bool):
value = value == 'true'
elif isinstance(default, int):
value = int(value) if value.isdigit() else value
elif isinstance(default, list):
value = [item.strip() for item in value.split(',') if item.strip()]
cfg[section][option] = value
with open(output, 'w', encoding='utf-8') as handle:
parser.write(handle)
json.dump(cfg, handle, indent=2)
handle.write('\n')
os.chmod(output, 0o600)
return 0
+15 -4
View File
@@ -139,10 +139,10 @@ def filter_block(ctx):
'# WIREGUARD RULES END',
]
if vpnrouter.p2s_ready(ctx) and ctx['local_addrs']:
if vpnrouter.p2s_ready(ctx) and ctx['local_addr_list']:
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()]:
for address in ctx['local_addr_list']:
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',
@@ -166,7 +166,7 @@ def nat_and_mangle_block(ctx):
if vpnrouter.routing_ready(ctx):
subnets = ctx['local_cidr_list']
remote = [c.strip() for c in ctx['remote_cidrs'].split(',') if c.strip()]
remote = ctx['remote_cidr_list']
lines += [
'',
'# ROUTER NAT RULES START',
@@ -299,13 +299,23 @@ def run_platform_setup(cfg, ctx):
fail(f'platform module {module.__name__} failed: {exc}')
def refresh_docker_rules():
"""Apply the new settings to the Docker rules while Docker is running.
When Docker is not running, vpn-router-docker applies them after
docker.service starts.
"""
if unit_is_active('docker.service'):
run(['systemctl', 'restart', 'vpn-router-docker.service'], check=False)
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')
f'set general.platform in {vpnrouter.CONFIG_FILE}')
return 0
if vpnrouter.routing_ready(ctx):
@@ -337,6 +347,7 @@ def main():
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
run_platform_setup(cfg, ctx)
refresh_docker_rules()
return 0
@@ -4,12 +4,11 @@ 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 json
import os
import pathlib
import re
import shutil
import subprocess
import sys
@@ -22,7 +21,7 @@ if str(LIB_DIR) not in sys.path:
import vpnrouter_platforms
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.conf')
CONFIG_FILE = pathlib.Path('/etc/vpn-router/vpn-router.json')
TEMPLATE_DIR = pathlib.Path('/usr/share/vpn-router/templates')
PKI_DIR = pathlib.Path('/etc/vpn-router/pki')
SIMPLE_CA = LIB_DIR / 'simple-ca'
@@ -39,17 +38,29 @@ 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
# section -> option -> default; the type of the default is the required type.
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'},
'local': {'cidrs': [], 'int_addr': '', 'int_gateway_ip': ''},
'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''},
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'docker': {'mode': 'auto', 'exposed_ports': []},
}
DOCKER_MODES = ('auto', 'on', 'off')
#: A port or a port range as iptables multiport takes it: 443 or 8000:8100.
DOCKER_PORT = re.compile(r'^\d{1,5}(:\d{1,5})?$')
#: The multiport match takes at most 15 ports, and a range counts as two.
DOCKER_PORT_LIMIT = 15
TYPE_NAMES = {str: 'a string', list: 'a list of strings', bool: 'a boolean',
int: 'an integer'}
ID_MODES = ('fqdn', 'public_ip', 'internal_ip')
#: How much the operator supplies, and therefore how much may be detected.
@@ -77,46 +88,29 @@ def fail(message):
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 result maps section -> option -> value. Sections and options not in
SCHEMA are kept so that validate() can report them.
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()
cfg = {section: dict(options) for section, options in SCHEMA.items()}
if path.exists():
try:
parser.read(path, encoding='utf-8')
except configparser.Error as exc:
data = json.loads(path.read_text(encoding='utf-8'))
except ValueError as exc:
fail(f'cannot parse {path}: {exc}')
return parser
if not isinstance(data, dict):
fail(f'cannot parse {path}: the top level is not an object')
for section, options in data.items():
if not isinstance(options, dict):
fail(f'cannot parse {path}: {section} is not an object')
cfg.setdefault(section, {}).update(options)
return cfg
def _split_list(value):
@@ -124,19 +118,31 @@ def _split_list(value):
def _get(cfg, section, option):
return cfg.get(section, option, fallback=SCHEMA[section][option]).strip()
value = cfg[section][option]
return value.strip() if isinstance(value, str) else value
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 _get_list(cfg, section, option):
return [item.strip() for item in cfg[section][option] if item.strip()]
def _check_networks(value, name):
for item in _split_list(value):
def _check_types(cfg):
for section, options in cfg.items():
if section not in SCHEMA:
raise ConfigError(f'{section}: unknown section')
for option, value in options.items():
if option not in SCHEMA[section]:
raise ConfigError(f'{section}.{option}: unknown setting')
expected = type(SCHEMA[section][option])
if type(value) is not expected or (
expected is list and
not all(isinstance(item, str) for item in value)):
raise ConfigError(
f'{section}.{option} must be {TYPE_NAMES[expected]}')
def _check_networks(values, name):
for item in values:
try:
ipaddress.ip_network(item, strict=False)
except ValueError:
@@ -158,6 +164,8 @@ def validate(cfg):
The exception is general.mode, which states what the operator promised to
supply, so the values that mode requires are checked for presence too.
"""
_check_types(cfg)
platform = _get(cfg, 'general', 'platform')
installed = [PLATFORM_NONE] + vpnrouter_platforms.available()
if platform and platform not in installed:
@@ -187,9 +195,10 @@ def validate(cfg):
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_networks(_get_list(cfg, 'local', 'cidrs'), 'local.cidrs')
_check_networks(_get_list(cfg, 'remote', 'cidrs'), 'remote.cidrs')
pool = _get(cfg, 'p2s', 'address_pool')
_check_networks([pool] if pool else [], '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')
@@ -210,12 +219,26 @@ def validate(cfg):
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')
if not 1 <= port <= 65535:
raise ConfigError(f'wireguard.listen_port: {port} is not a valid port')
for section in ('p2s', 'wireguard'):
_get_bool(cfg, section, 'enabled')
docker_mode = _get(cfg, 'docker', 'mode')
if docker_mode not in DOCKER_MODES:
raise ConfigError(
f'docker.mode: {docker_mode!r} is not one of {", ".join(DOCKER_MODES)}')
ports = _get_list(cfg, 'docker', 'exposed_ports')
weight = 0
for item in ports:
if not DOCKER_PORT.match(item) or any(
not 1 <= int(part) <= 65535 for part in item.split(':')):
raise ConfigError(
f'docker.exposed_ports: {item!r} is not a port or a port range')
weight += 2 if ':' in item else 1
if weight > DOCKER_PORT_LIMIT:
raise ConfigError(
f'docker.exposed_ports: at most {DOCKER_PORT_LIMIT} ports are allowed, '
'a range counting as two')
def resolve_psk(cfg):
@@ -226,14 +249,7 @@ def resolve_psk(cfg):
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}')
return cfg['remote']['psk']
def platform_module(cfg):
@@ -253,7 +269,7 @@ def platform_module(cfg):
def _join(*groups):
items = []
for group in groups:
for item in _split_list(group):
for item in group:
if item not in items:
items.append(item)
return ', '.join(items)
@@ -293,12 +309,22 @@ def detect_interfaces():
return external, candidates[0]
def _is_virtual(name):
"""True for an interface the kernel creates without a device behind it.
Loopback, bridges, veth pairs and tunnels live under /sys/devices/virtual,
which is where the Docker bridge is; a NIC of the virtual machine does not.
"""
link = pathlib.Path('/sys/class/net') / name
return '/devices/virtual/' in str(link.resolve())
def _addressed_interfaces():
"""Interface names holding at least one IPv4 address, excluding loopback."""
"""Names of the NICs holding at least one IPv4 address; virtual interfaces are left out."""
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:
if len(fields) >= 4 and fields[1] not in names and not _is_virtual(fields[1]):
names.append(fields[1])
return names
@@ -369,9 +395,8 @@ def resolve_interfaces(cfg):
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:
local_addrs = interface_addresses(ext_iface, 'interfaces.external')
if ext_iface and not local_addrs:
warn(f'{ext_iface} has no IPv4 address yet')
if mode == 'manual':
@@ -391,9 +416,10 @@ def build_context(cfg, local_id_override=None):
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')
local_fqdn = _get(cfg, 'wan', 'local_fqdn')
id_mode = _get(cfg, 'wan', 'local_id_mode') or 'fqdn'
local_cidrs = _get_list(cfg, 'local', 'cidrs')
remote_cidrs = _get_list(cfg, 'remote', 'cidrs')
if local_id_override:
local_id = local_id_override
@@ -406,32 +432,31 @@ def build_context(cfg, local_id_override=None):
# 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_addrs': ', '.join(local_addrs),
'local_addr_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']),
'local_cidrs': ', '.join(local_cidrs),
'local_cidr_list': local_cidrs,
'local_ts': _join(local_cidrs, _split_list(extras['EXTRA_LOCAL_TS'])),
'int_addr': int_addr,
'int_gateway_ip': gateway,
'remote_addrs': _get(cfg, 'remote', 'addrs'),
'remote_addrs': ', '.join(_get_list(cfg, 'remote', 'addrs')),
'remote_id': _get(cfg, 'remote', 'id'),
'remote_cidrs': _get(cfg, 'remote', 'cidrs'),
'remote_cidrs': ', '.join(remote_cidrs),
'remote_cidr_list': remote_cidrs,
'psk': resolve_psk(cfg),
'p2s_enabled': _get_bool(cfg, 'p2s', 'enabled'),
'p2s_enabled': _get(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'),
'p2s_dns': _join(local_addrs, _split_list(extras['EXTRA_P2S_DNS'])),
'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'),
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
}
@@ -1,84 +0,0 @@
# 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,40 @@
{
"general": {
"platform": "generic",
"mode": "manual"
},
"interfaces": {
"external": "eth0",
"internal": "eth1"
},
"wan": {
"local_fqdn": "router.example.com",
"local_id_mode": "fqdn"
},
"local": {
"cidrs": ["10.0.0.0/24"],
"int_addr": "10.1.1.4",
"int_gateway_ip": "10.1.1.1"
},
"remote": {
"addrs": ["peer.example.net"],
"id": "peer.example.net",
"cidrs": ["192.168.0.0/24"],
"psk": "change-me",
"psk_file": ""
},
"p2s": {
"enabled": false,
"address_pool": "172.16.0.0/24",
"ca_name": "VPN Router CA"
},
"wireguard": {
"enabled": false,
"address": "192.168.200.1/24",
"listen_port": 51820
},
"docker": {
"mode": "auto",
"exposed_ports": ["443"]
}
}
+20 -23
View File
@@ -6,29 +6,26 @@ 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
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
# Contents of /etc/vpn-router/vpn-router.json.
config = {
general = { platform = "azure", mode = "auto" }
wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode }
local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] }
remote = {
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
id = var.remote_id
cidrs = local.remote_cidr_list
psk = var.psk
}
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, listen_port = var.wireguard_listen_port }
}
}
}
+2 -33
View File
@@ -22,42 +22,11 @@ apt:
write_files:
# The configuration file. Created here before the package is installed, so
# postinst leaves it alone.
- path: /etc/vpn-router/vpn-router.conf
- path: /etc/vpn-router/vpn-router.json
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}
${jsonencode(config)}
- path: /etc/vpn-router/pki/ca_cert.pem
permissions: '0644'
+2 -33
View File
@@ -28,42 +28,11 @@ apt:
write_files:
# The configuration file. Created here before the package is installed, so
# postinst leaves it alone.
- path: /etc/vpn-router/vpn-router.conf
- path: /etc/vpn-router/vpn-router.json
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}
${jsonencode(config)}
package_update: true
+20 -23
View File
@@ -6,29 +6,26 @@ 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
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
# Contents of /etc/vpn-router/vpn-router.json.
config = {
general = { platform = "gcp", mode = "auto" }
wan = { local_fqdn = var.local_fqdn, local_id_mode = var.local_id_mode }
local = { cidrs = [for c in split(",", var.local_cidrs) : trimspace(c) if trimspace(c) != ""] }
remote = {
addrs = [for a in split(",", var.remote_addrs) : trimspace(a) if trimspace(a) != ""]
id = var.remote_id
cidrs = local.remote_cidr_list
psk = var.psk
}
p2s = { enabled = var.p2s_enabled, address_pool = var.p2s_address_pool, ca_name = "VPN Router CA" }
wireguard = { enabled = var.wireguard_enabled, address = var.wireguard_address, 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, {
+43 -37
View File
@@ -10,18 +10,19 @@ set -e
DEB="${DEB:-./vpn-router_1.0.0-1_all.deb}"
# List values are JSON arrays.
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}"
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_ADDRS="${REMOTE_ADDRS:-[\"peer.example.net\"]}"
REMOTE_ID="${REMOTE_ID:-peer.example.net}"
REMOTE_CIDRS="${REMOTE_CIDRS:-192.168.0.0/24}"
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}"
@@ -30,42 +31,47 @@ 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.
# Write the configuration before installing, so postinst leaves it alone. The
# pre-shared key goes to its own file, so it needs no JSON escaping.
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}
printf %s "$PSK" > /etc/vpn-router/psk
cat > /etc/vpn-router/vpn-router.json <<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_file": "/etc/vpn-router/psk"
},
"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