Compare commits

..
10 Commits
Author SHA1 Message Date
slawek 90e7c79840 fix: replace webservice swanctl call human readble output with structured one.
Release / release (push) Successful in 18s
2026-10-23 20:44:17 +02:00
slawek a6b25b8bb4 fix: router setup hangs on race condition with Docker.
Release / release (push) Successful in 18s
2026-10-23 20:03:17 +02:00
slawek 7163f167e0 feature: Added an internally only exposed status web service.
Release / release (push) Successful in 17s
2026-10-23 19:46:26 +02:00
slawek 5402256ef6 fix: removed unnecressary peer ID handling that is no longer required for >=5.0 StrongSwan.
Release / release (push) Successful in 17s
2026-10-23 17:31:22 +02:00
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
30 changed files with 1372 additions and 412 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
+77
View File
@@ -0,0 +1,77 @@
# 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.
## ADR-2: A status web service of the standard library, bound to the internal address
Status: Accepted
Context: The router has no way to see its state without a shell. A check from the protected
network, by a person or by a monitor, needs a page or an endpoint. The router is an
internet-facing host, so what the service shows and where it listens matter.
Decision: The `vpn-router-web` unit runs one Python script that uses only the standard library, in
the way of a small test server. It binds to the address of the internal interface and to
`web.port`, on by default at 8080. `/` is for people, in plain text for `curl` and `wget` and as
a Tailwind CSS page for a browser, and `/status` is the same status as JSON. It shows the
state of the services, the package version and the state of the site-to-site, road-warrior and
WireGuard features, and nothing from the configuration. `vpn-router-setup` resolves the
interface address, writes it with the port and the features to `/run/vpn-router/web.json`,
opens the port on the internal interface in UFW and restarts the service, so the service does
not need the package's configuration code, which needs Jinja2.
Consequences: Nothing outside the internal network can reach it, since it listens on the internal
address only and the firewall opens the port on the internal interface only. It has no
authentication, which is acceptable because it is read only and shows no configuration. It runs
as root, because `swanctl` and `wg` need it, with `NoNewPrivileges`, `ProtectSystem=strict`,
`ProtectHome` and `PrivateTmp`. The Tailwind style comes from a CDN, so a browser on a network
without internet shows the unstyled page, and `curl` is unaffected. A change of the internal
address is picked up at the next run of `vpn-router-setup`.
## ADR-3: /config is authenticated with a short-lived JWT signed with a shared secret
Status: Accepted
Context: A client such as manage-ui needs the configuration and the resolved state of the
router, which `/` and `/status` must not show. The service has only the standard library, listens
on plain HTTP on the internal network, and its configuration file is readable by root only.
Decision: `/config` needs a JWT in an `Authorization: Bearer` header. The service verifies HS256
tokens with `hmac` and `hashlib`, with the secret `web.jwt_secret`, and accepts no other
algorithm, because verifying RS256 or ES256 needs a library outside the standard library. The
token must carry `exp`, not more than ten minutes ahead, so that the client makes a fresh token for
each use and a leaked one expires. Without a secret the page is not served. The secrets in the
configuration shown are masked by name (`psk`, and options with `secret`, `password`, `token` or a
`_key` ending in the name).
Consequences: The client and the router share one secret, and whoever holds it can make tokens,
so it is long and random; it is checked to have at least 32 characters. The secret is in the
configuration file and in `/run/vpn-router/web.json`, both readable by root only. The token and
the answer cross the network unencrypted, so the page is for a trusted internal network; HTTPS
would need a certificate that the package does not have. The masking is by option name, so a new
secret option needs a name that matches.
+195 -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,68 @@ 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 name or IP address, used as its IKE identity in `fqdn` mode |
| `wan` | `local_id_mode` | string | `fqdn` | IKE identity source: `fqdn`, `public_ip` or `internal_ip` |
| `local` | `cidrs` | list | | Local subnets advertised into the tunnel |
| `local` | `int_addr` | string | | This host's address on the internal network |
| `local` | `int_gateway_ip` | string | | Next hop on the internal side |
| `remote` | `addrs` | list | | Remote gateway addresses or FQDNs |
| `remote` | `id` | string | | Remote IKE identity, an FQDN or an IPv4 or IPv6 address |
| `remote` | `cidrs` | list | | Remote subnets reachable through the tunnel |
| `remote` | `psk` | string | | Pre-shared key |
| `remote` | `psk_file` | string | | Path to a file holding the key; wins over `psk` |
| `p2s` | `enabled` | boolean | `false` | Road-warrior access |
| `p2s` | `address_pool` | string | | CIDR assigned to road-warrior clients |
| `p2s` | `ca_name` | string | `VPN Router CA` | Common name for a locally created CA |
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` |
| `wireguard` | `listen_port` | integer | `51820` | UDP port |
| `docker` | `mode` | string | `auto` | `auto` secures Docker when its service is installed, `on` requires it, `off` removes the rules |
| `docker` | `exposed_ports` | list | | TCP ports and port ranges (`8000:8100`) reachable from the external interface; at most 15, a range counting as two |
| `web` | `enabled` | boolean | `true` | The status web service on the internal address |
| `web` | `port` | integer | `8080` | TCP port of the status web service |
| `web` | `jwt_secret` | string | | Secret, at least 32 characters, that signs the tokens for `/config`; without it `/config` is not served |
A minimal site-to-site configuration in `auto` mode:
```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"
}
}
```
The router's IKE identity (`wan.local_fqdn` in `fqdn` mode) and the remote one (`remote.id`) are
an FQDN or an IPv4 or IPv6 address. They are written to the strongSwan configuration as given;
strongSwan reads an address as an IP identity and a name as an FQDN, so a leading `@` is not needed
and is refused. Road-warrior access needs a name in `wan.local_fqdn`.
WireGuard peers are added by hand in `/etc/wireguard/wg0.conf`. The package owns the
`[Interface]` section of that file and rewrites it when the settings above change, but it
carries every `[Peer]` section across untouched.
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 +156,82 @@ 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.
`vpn-router-setup` starts after `docker.service`, so Docker is up when it looks for it; the
ordering has no effect on a host without Docker. The `vpn-router-docker` service applies the
rules after `docker.service` starts and removes
them when Docker stops, so restarting Docker restores them. `systemctl restart
vpn-router-setup` applies a changed configuration while Docker runs. With `docker.mode` set to
`on`, the service fails when Docker is not installed or its daemon is not running; with
`off` it removes the rules.
## Applying changes
```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 +323,86 @@ 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.
## Status web service
The `vpn-router-web` service shows the state of the router on its internal address and
`web.port` (8080 by default). It uses only the Python standard library, serves HTTP without
authentication, and shows no configuration: no key, identity, address or subnet.
| Page | Content |
| --- | --- |
| `/` | The status for people: plain text for `curl` and `wget`, a page styled with Tailwind CSS for a browser (the style sheet is loaded from a CDN, so the browser needs internet access; the text is readable without it) |
| `/status` | The same status as JSON |
| `/config` | The configuration with its secrets masked, and what the router resolved and runs, as JSON; needs a token |
The status holds the state of `vpn-router-setup`, of `strongswan` when site-to-site or road-warrior
access is configured, and of `wg-quick@wg0` when WireGuard is; the package version; whether
Docker is installed, and when it is, the state of `docker.service` and of `vpn-router-docker`
(the Docker rules), which do not change `status`; whether the
site-to-site tunnel is established; the number of connected road-warrior (IPsec P2S) clients; and
the WireGuard peers with the age of their last handshake and their traffic. `status` is `ok` when
all these services are active, and `degraded` otherwise.
```bash
curl http://10.0.0.4:8080/
curl http://10.0.0.4:8080/status
```
### The /config page
`/config` shows what `/` and `/status` leave out, for a client such as manage-ui:
| Key | Content |
| --- | --- |
| `configuration` | `vpn-router.json` with the defaults applied and the secrets masked (`psk`, `jwt_secret`, and any option named like a password, token or key) |
| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them |
| `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values |
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
The page needs a JWT in an `Authorization: Bearer` header, signed with HS256 by `web.jwt_secret`.
The token must have an `exp` claim, not more than ten minutes from now (a minute of clock
difference is allowed); `nbf` is checked when present. Other algorithms are refused, with 401 and
`WWW-Authenticate: Bearer`. While `web.jwt_secret` is not set the page is not served (404). The
token and the page travel over plain HTTP on the internal network, and the token can be used
until it expires, so keep the lifetime short. A client makes a token from the secret with only
HMAC-SHA256; with Python:
```python
import base64, hashlib, hmac, json, time
def b64(data):
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
secret = 'the web.jwt_secret value'
head = b64(json.dumps({'alg': 'HS256', 'typ': 'JWT'}).encode())
body = b64(json.dumps({'exp': int(time.time()) + 300}).encode())
sign = b64(hmac.new(secret.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest())
print(f'{head}.{body}.{sign}')
```
```bash
curl -H "Authorization: Bearer $TOKEN" http://10.0.0.4:8080/config
```
`vpn-router-setup` opens `web.port` on the internal interface in the firewall, writes the address,
the port, the secret and the facts for `/config` to `/run/vpn-router/web.json` (readable by root
only) and restarts the service, so a changed `web` section
applies with `systemctl restart vpn-router-setup`. The service ends without an error when
`web.enabled` is `false`, and keeps trying to start until the router has an internal address.
## Removal
`apt-get remove` 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/"
+108 -1
View File
@@ -1,4 +1,111 @@
vpn-router (1.0.0-1) unstable; urgency=medium
vpn-router (0.4.2-1) unstable; urgency=medium
* /config gives the IPsec connections and SAs as objects, read from
swanctl --pretty, and the routes as objects from ip -j, instead of
lines of text. The site-to-site and road-warrior state of /status is
counted from the same swanctl objects.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 21:30:00 +0200
vpn-router (0.4.1-1) unstable; urgency=medium
* Fix the start of vpn-router-setup hanging on a host that runs Docker.
vpn-router-setup restarted vpn-router-docker and waited for it, but that
unit was ordered after vpn-router-setup, so each waited for the other,
the installation of the package never finished and vpn-router-web was
never enabled or started. vpn-router-docker is no longer ordered after
vpn-router-setup, so vpn-router-setup still waits for it and the Docker
rules are in place when it ends. The restart of vpn-router-web is queued
without waiting.
* Start vpn-router-setup after docker.service, so that Docker is up when
the setup looks for it and does not start in parallel with it. The
ordering applies only when Docker is installed.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 20:10:00 +0200
vpn-router (0.4.0-1) unstable; urgency=medium
* Add the vpn-router-web service, a small status web service that uses only
the Python standard library. It listens on the internal address and on
web.port, 8080 by default. / shows the state of the router for people,
as plain text for curl and wget and as a Tailwind CSS page for a
browser, and /status shows the same as JSON: the state of the services,
the package version, whether Docker is installed, and the state of
site-to-site, road-warrior and WireGuard, with no configuration data.
* Add the web configuration section. web.enabled (true by default) and
web.port. vpn-router-setup opens the port on the internal interface in
UFW and restarts the service.
* Add /config to the status web service, for a client such as manage-ui. It
shows the configuration with its secrets masked, the interfaces,
addresses, identities and routes the router resolved, the IPsec
connections and SAs, the WireGuard peers (no keys) and the Docker rules.
It needs a JWT signed with HS256 by web.jwt_secret, with an exp claim
within ten minutes, and is not served while web.jwt_secret is not set.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 19:00:00 +0200
vpn-router (0.3.2-1) unstable; urgency=medium
* Accept an IPv4 or IPv6 address as remote.id and as wan.local_fqdn. The
identities are written to the strongSwan configuration as given, without
an @ added, and strongSwan reads an address as an IP identity and a name
as an FQDN. A value that starts with @ is refused with an error, since
strongSwan 5.0 and later need no @ to avoid resolving a name.
Road-warrior access still needs a name in wan.local_fqdn.
* Depend on strongswan-swanctl and charon-systemd 5.0 or later.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 18:00:00 +0200
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.
+3 -3
View File
@@ -9,8 +9,8 @@ Rules-Requires-Root: no
Package: vpn-router
Architecture: all
Depends: ${misc:Depends},
strongswan-swanctl,
charon-systemd,
strongswan-swanctl (>= 5.0),
charon-systemd (>= 5.0),
libstrongswan-extra-plugins,
libcharon-extra-plugins,
wireguard-tools,
@@ -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
+3 -1
View File
@@ -2,8 +2,10 @@ 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/web 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.
+2
View File
@@ -4,3 +4,5 @@
override_dh_installsystemd:
dh_installsystemd --name=vpn-router-setup
dh_installsystemd --name=vpn-router-docker
dh_installsystemd --name=vpn-router-web
+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
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
@@ -2,7 +2,7 @@
Description=VPN Router Setup
Documentation=file:/usr/share/doc/vpn-router/README.md
Wants=network-online.target
After=network-online.target
After=network-online.target docker.service
[Service]
Type=oneshot
@@ -0,0 +1,18 @@
[Unit]
Description=VPN Router status web service
Documentation=file:/usr/share/doc/vpn-router/README.md
After=network-online.target vpn-router-setup.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/lib/vpn-router/web
Restart=on-failure
RestartSec=15
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
+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
+76 -4
View File
@@ -10,8 +10,10 @@ firewall and NAT rules are written against them, so choosing the wrong one
would be a silent and security-relevant mistake.
"""
import json
import os
import pathlib
import re
import socket
import subprocess
import sys
@@ -24,13 +26,18 @@ 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')
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
FILTER_ANCHOR = '# End required lines'
#: Configuration options whose values are masked wherever the configuration is shown.
SECRET_OPTION = re.compile(r'(^psk$)|secret|password|token|_key$')
BLOCKS = (
('# IPSEC RULES START', '# IPSEC RULES END'),
('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
('# P2S DNS RULES START', '# P2S DNS RULES END'),
('# WEB RULES START', '# WEB 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'),
@@ -139,16 +146,24 @@ 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',
]
lines += ['# P2S DNS RULES END']
if ctx['web_enabled'] and ctx['int_iface']:
lines += [
'',
'# WEB RULES START',
f'-A ufw-before-input -i {ctx["int_iface"]} -p tcp --dport {ctx["web_port"]} -j ACCEPT',
'# WEB RULES END',
]
if vpnrouter.routing_ready(ctx):
lines += ['', '# ROUTER FORWARD RULES START']
for subnet in ctx['local_cidr_list']:
@@ -166,7 +181,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 +314,68 @@ 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. The restart is waited for, so the rules are in place
when this setup ends; vpn-router-docker is therefore not ordered after
vpn-router-setup.
"""
if unit_is_active('docker.service'):
run(['systemctl', 'restart', 'vpn-router-docker.service'], check=False)
def refresh_web(cfg, ctx):
"""Hand the web service what it needs and restart it.
The service takes the address to bind to, the port, the features of the
router, the secret that signs its tokens, the configuration with its
secrets masked and the facts resolved from the system from a file here, so
that it needs nothing but the standard library and the configuration code.
The file is readable by root only. The service ends quietly when
web.enabled is false. The restart is queued, not waited for, because the
unit is ordered after vpn-router-setup.
"""
masked = {
section: {option: '********' if SECRET_OPTION.search(option) and value else value
for option, value in options.items()}
for section, options in cfg.items()
}
WEB_STATE.parent.mkdir(parents=True, exist_ok=True)
WEB_STATE.touch(mode=0o600)
WEB_STATE.chmod(0o600)
WEB_STATE.write_text(json.dumps({
'enabled': bool(ctx['web_enabled']),
'bind': ctx['int_addr'],
'port': ctx['web_port'],
'jwt_secret': cfg['web']['jwt_secret'],
'site_to_site': bool(vpnrouter.s2s_ready(ctx)),
'p2s': bool(vpnrouter.p2s_ready(ctx)),
'wireguard': bool(vpnrouter.wg_ready(ctx)),
'configuration': masked,
'network': {
'external_interface': ctx['wan_iface'],
'internal_interface': ctx['int_iface'],
'external_addresses': ctx['local_addr_list'],
'internal_address': ctx['int_addr'],
'internal_gateway': ctx['int_gateway_ip'],
'local_identity': ctx['local_id'],
'remote_identity': ctx['remote_id'],
'local_cidrs': ctx['local_cidr_list'],
'remote_cidrs': ctx['remote_cidr_list'],
},
}) + '\n', encoding='utf-8')
run(['systemctl', 'restart', '--no-block', 'vpn-router-web.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 +407,8 @@ def main():
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
run_platform_setup(cfg, ctx)
refresh_docker_rules()
refresh_web(cfg, ctx)
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,33 @@ 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': []},
'web': {'enabled': True, 'port': 8080, 'jwt_secret': ''},
}
#: The shortest secret that signs the tokens of the status web service.
WEB_SECRET_MIN = 32
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 +92,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 +122,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 +168,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:
@@ -182,14 +194,21 @@ def validate(cfg):
raise ConfigError(
f'{section}.{option} is required in mode {mode or "manual"}')
for section, option in (('wan', 'local_fqdn'), ('remote', 'id')):
if str(_get(cfg, section, option)).startswith('@'):
raise ConfigError(
f'{section}.{option}: remove the leading @; strongSwan reads an '
'address as an IP identity and a name as an FQDN')
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_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 +229,34 @@ 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')
web_port = _get(cfg, 'web', 'port')
if isinstance(web_port, bool) or not isinstance(web_port, int) or not 1 <= web_port <= 65535:
raise ConfigError(f'web.port: {web_port!r} is not a valid port')
web_secret = _get(cfg, 'web', 'jwt_secret')
if web_secret and len(web_secret) < WEB_SECRET_MIN:
raise ConfigError(f'web.jwt_secret: use at least {WEB_SECRET_MIN} characters')
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 +267,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 +287,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 +327,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 +413,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,14 +434,15 @@ 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
elif id_mode == 'fqdn':
local_id = f'@{local_fqdn}' if local_fqdn else ''
local_id = local_fqdn
elif id_mode == 'internal_ip':
local_id = int_addr
else:
@@ -406,34 +450,35 @@ 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'),
'web_enabled': _get(cfg, 'web', 'enabled'),
'web_port': _get(cfg, 'web', 'port'),
}
+402
View File
@@ -0,0 +1,402 @@
#!/usr/bin/python3
"""Serve the status of the router on its internal address.
Run by vpn-router-web.service. It needs nothing but the Python standard library.
Pages:
/ the status for people: plain text for curl and wget, a styled page
(Tailwind CSS from a CDN) for a browser
/status the status as JSON
/config the configuration without its secrets, and what the router resolved and
runs, as JSON; needs an HS256 JWT in an Authorization: Bearer header
that is signed with web.jwt_secret and expires within ten minutes; not
served (404) while web.jwt_secret is not set
/ and /status show only the status: whether the services run, the package version, whether
Docker is installed, and the state of site-to-site, road-warrior (IPsec P2S) and
WireGuard. No configuration data, key or address is shown.
The address to bind to, the port, the secret and the configuration shown by /config
are not read from the configuration here: vpn-router-setup resolves them, with the
interfaces, and writes them to /run/vpn-router/web.json, readable by root only, before
it restarts this service. The service ends with
success when the file says the service is disabled, and with failure, to be
started again, when the file is missing or the router has no internal address yet.
"""
import base64
import hashlib
import hmac
import json
import os
import signal
import subprocess
import sys
import threading
import time
from html import escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
STATE_FILE = '/run/vpn-router/web.json'
COMMAND_TIMEOUT = 5
CACHE_SECONDS = 2
#: Seconds of clock difference accepted between a token and this router.
LEEWAY = 60
#: The longest a token may be valid, from now, in seconds.
MAX_LIFETIME = 600
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
_cache = {'at': 0.0, 'data': None}
_lock = threading.Lock()
def run(*args):
"""The finished process of a command, or None when it cannot be run."""
try:
return subprocess.run(args, capture_output=True, text=True, timeout=COMMAND_TIMEOUT)
except (OSError, subprocess.TimeoutExpired):
return None
def unit_state(unit):
result = run('systemctl', 'is-active', unit)
return result.stdout.strip() if result and result.stdout.strip() else 'unknown'
def package_version():
result = run('dpkg-query', '-W', '-f=${Version}', 'vpn-router')
return result.stdout.strip() if result and result.returncode == 0 else None
def docker_status():
"""Whether Docker is installed, and the state of its service and of the Docker rules when it is."""
result = run('systemctl', 'list-unit-files', '--no-legend', 'docker.service')
if result is None or result.returncode != 0 or not result.stdout.strip():
return {'installed': False}
return {
'installed': True,
'service': unit_state('docker.service'),
'rules': unit_state('vpn-router-docker.service'),
}
def swanctl(command):
"""The entries of a swanctl list command, or None when swanctl cannot be asked.
The --pretty output has one item per line: "name {" opens a section, "key = [" a list
whose items follow one per line, "}" and "]" close them, and "key = value" is a pair
whose value may contain spaces. Every "... event {" section at the top holds one
connection or SA; each becomes an entry with its name under "name". The closing
"... reply {" section is left out. Values stay strings, as swanctl prints them.
"""
result = run('swanctl', command, '--pretty')
if result is None or result.returncode != 0:
return None
events = []
stack = []
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
if line in ('}', ']'):
stack.pop()
elif not stack:
section = {}
if line.endswith(' event {'):
events.append(section)
stack.append(section)
elif isinstance(stack[-1], list):
stack[-1].append(line)
elif line.endswith(' {'):
stack[-1][line[:-2]] = section = {}
stack.append(section)
elif line.endswith(' = ['):
stack[-1][line[:-4]] = items = []
stack.append(items)
else:
key, _, value = line.partition(' = ')
stack[-1][key] = value
return [dict(body, name=name) for event in events for name, body in event.items()]
def established(connection):
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
sas = swanctl('--list-sas')
if sas is None:
return None
return sum(1 for sa in sas if sa['name'] == connection and sa.get('state') == 'ESTABLISHED')
def wireguard_peers():
"""One entry per WireGuard peer, with its handshake age and traffic, or None when wg0 is down."""
result = run('wg', 'show', 'wg0', 'dump')
if result is None or result.returncode != 0:
return None
now = int(time.time())
peers = []
for line in result.stdout.splitlines()[1:]:
fields = line.split('\t')
if len(fields) < 7:
continue
handshake = int(fields[4])
peers.append({
'handshake_seconds_ago': now - handshake if handshake else None,
'received_bytes': int(fields[5]),
'sent_bytes': int(fields[6]),
})
return peers
def collect(state):
"""The status document. state says which features the router has."""
units = {'vpn-router-setup': unit_state('vpn-router-setup.service')}
if state.get('site_to_site') or state.get('p2s'):
units['strongswan'] = unit_state('strongswan.service')
if state.get('wireguard'):
units['wg-quick@wg0'] = unit_state('wg-quick@wg0.service')
status = {
'status': 'ok' if all(value == 'active' for value in units.values()) else 'degraded',
'version': package_version(),
'time': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
'units': units,
'docker': docker_status(),
}
if state.get('site_to_site'):
count = established('remote-site')
status['site_to_site'] = {'established': None if count is None else count > 0}
if state.get('p2s'):
status['p2s'] = {'clients': established('road-warrior')}
if state.get('wireguard'):
status['wireguard'] = {'peers': wireguard_peers()}
return status
def b64decode(part):
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
def verify(token, secret):
"""The claims of a token signed with HS256 by the secret, or None when it is not acceptable.
Only HS256 is accepted, so a token cannot choose a weaker algorithm. The token
must expire (exp), and not later than MAX_LIFETIME seconds from now, so that a
stolen token is of little use; LEEWAY seconds of clock difference are allowed.
"""
try:
header, payload, signature = token.split('.')
if json.loads(b64decode(header)).get('alg') != 'HS256':
return None
expected = hmac.new(secret.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64decode(signature)):
return None
claims = json.loads(b64decode(payload))
now = time.time()
expires = claims['exp']
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not now - LEEWAY <= expires <= now + MAX_LIFETIME + LEEWAY:
return None
not_before = claims.get('nbf')
if not_before is not None and (isinstance(not_before, bool) or not isinstance(not_before, (int, float)) or not_before > now + LEEWAY):
return None
except (ValueError, KeyError, TypeError, AttributeError):
return None
return claims
def lines_of(*args):
"""The output lines of a command, or None when it fails."""
result = run(*args)
return result.stdout.splitlines() if result and result.returncode == 0 else None
def routes():
"""The IPv4 routes as ip -j prints them, or None when ip fails."""
result = run('ip', '-j', '-4', 'route', 'show')
if result is None or result.returncode != 0:
return None
try:
return json.loads(result.stdout)
except ValueError:
return None
def wireguard_details():
"""The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
result = run('wg', 'show', 'wg0', 'dump')
if result is None or result.returncode != 0:
return None
rows = [line.split('\t') for line in result.stdout.splitlines()]
now = int(time.time())
return {
'public_key': rows[0][1] if rows and len(rows[0]) > 2 else None,
'listen_port': int(rows[0][2]) if rows and len(rows[0]) > 2 else None,
'peers': [{
'public_key': row[0],
'endpoint': None if row[2] == '(none)' else row[2],
'allowed_ips': row[3].split(','),
'handshake_seconds_ago': now - int(row[4]) if int(row[4]) else None,
'received_bytes': int(row[5]),
'sent_bytes': int(row[6]),
} for row in rows[1:] if len(row) >= 7],
}
def details(state):
"""What /config shows: the configuration without its secrets, and what the router resolved and runs."""
configuration = state.get('configuration', {})
return {
'configuration': configuration,
'network': dict(state.get('network', {}), routes=routes()),
'ipsec': {
'connections': swanctl('--list-conns'),
'sas': swanctl('--list-sas'),
},
'wireguard': wireguard_details(),
'docker': {
'exposed_ports': configuration.get('docker', {}).get('exposed_ports', []),
'rules': lines_of('iptables', '-S', 'VPN-ROUTER-DOCKER'),
},
}
def text_of(status):
"""The status for people, as plain text."""
lines = ['VPN Router', f'Status: {status["status"]}', f'Version: {status["version"] or "unknown"}', '']
lines += [f'{name}: {value}' for name, value in status['units'].items()]
docker = status['docker']
lines.append('Docker: not installed' if not docker['installed']
else f'Docker: installed, service {docker["service"]}, rules {docker["rules"]}')
if 'site_to_site' in status:
value = status['site_to_site']['established']
lines.append(f'Site to site: {"unknown" if value is None else "established" if value else "not established"}')
if 'p2s' in status:
clients = status['p2s']['clients']
lines.append(f'Road-warrior (IPsec P2S): {"unknown" if clients is None else f"{clients} connected"}')
if 'wireguard' in status:
peers = status['wireguard']['peers']
if peers is None:
lines.append('WireGuard: down')
else:
lines.append(f'WireGuard: {len(peers)} peers')
for number, peer in enumerate(peers, 1):
age = peer['handshake_seconds_ago']
lines.append(f' peer {number}: {"no handshake yet" if age is None else f"handshake {age} s ago"}, '
f'received {peer["received_bytes"]} B, sent {peer["sent_bytes"]} B')
return '\n'.join(lines) + '\n'
def html_of(status):
"""The status for a browser, as a page styled with Tailwind CSS."""
colour = 'bg-emerald-600' if status['status'] == 'ok' else 'bg-amber-600'
body = ''.join(f'<li>{escape(line)}</li>' for line in text_of(status).splitlines()[4:] if line)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VPN Router</title>
<script src="{TAILWIND}"></script>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
<div class="mx-auto max-w-2xl px-4 py-10">
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-lg">
<div class="{colour} px-6 py-4 text-white">
<h1 class="text-xl font-semibold">VPN Router</h1>
<p class="mt-1 text-sm">Status: {escape(status['status'])}, version {escape(status['version'] or 'unknown')}</p>
</div>
<ul class="space-y-2 px-6 py-6 font-mono text-sm">{body}</ul>
<p class="px-6 pb-6 text-xs text-slate-500">{escape(status['time'])} - <a class="underline" href="/status">JSON</a></p>
</div>
</div>
</body>
</html>
"""
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def status(self):
with _lock:
if _cache['data'] is None or time.monotonic() - _cache['at'] > CACHE_SECONDS:
_cache['data'] = collect(self.server.state)
_cache['at'] = time.monotonic()
return _cache['data']
def answer(self, send_body):
path = self.path.split('?', 1)[0]
extra = []
if path == '/config':
secret = self.server.state.get('jwt_secret')
header = self.headers.get('Authorization', '')
if not secret:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
elif not header.startswith('Bearer ') or verify(header[7:].strip(), secret) is None:
body, kind, code = b'Unauthorized\n', 'text/plain; charset=utf-8', 401
extra.append(('WWW-Authenticate', 'Bearer'))
else:
body, kind, code = json.dumps(details(self.server.state), indent=2).encode() + b'\n', 'application/json', 200
elif path == '/status':
body, kind, code = json.dumps(self.status(), indent=2).encode() + b'\n', 'application/json', 200
elif path == '/':
agent = self.headers.get('User-Agent', '').lower()
if 'curl' in agent or 'wget' in agent:
body, kind = text_of(self.status()).encode(), 'text/plain; charset=utf-8'
else:
body, kind = html_of(self.status()).encode(), 'text/html; charset=utf-8'
code = 200
else:
body, kind, code = b'Not found\n', 'text/plain; charset=utf-8', 404
self.send_response(code)
self.send_header('Content-Type', kind)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
for name, value in extra:
self.send_header(name, value)
self.end_headers()
if send_body:
self.wfile.write(body)
def do_GET(self):
self.answer(True)
def do_HEAD(self):
self.answer(False)
def log_message(self, format, *args):
sys.stderr.write('%s - - [%s] %s\n' % (self.client_address[0], self.log_date_time_string(), format % args))
def main():
try:
with open(STATE_FILE, encoding='utf-8') as handle:
state = json.load(handle)
except (OSError, ValueError) as exc:
print(f'vpn-router-web: cannot read {STATE_FILE}: {exc}', file=sys.stderr)
return 1
if not state.get('enabled'):
print('vpn-router-web: the web service is disabled in the configuration')
return 0
if not state.get('bind'):
print('vpn-router-web: the router has no internal address yet', file=sys.stderr)
return 1
server = ThreadingHTTPServer((state['bind'], state['port']), Handler)
server.state = state
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
print(f'vpn-router-web: serving on http://{state["bind"]}:{state["port"]}')
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -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,45 @@
{
"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"]
},
"web": {
"enabled": true,
"port": 8080,
"jwt_secret": ""
}
}
@@ -14,7 +14,7 @@ connections {
}
remote {
id = @{{ remote_id }}
id = {{ remote_id }}
auth = psk
}
@@ -40,7 +40,7 @@ connections {
secrets {
ike-psk {
id-1 = {{ local_id }}
id-2 = @{{ remote_id }}
id-2 = {{ remote_id }}
secret = "{{ psk }}"
}
}
+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