Compare commits

...
7 Commits
Author SHA1 Message Date
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
17 changed files with 1012 additions and 18 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.
+143 -4
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 - road-warrior (P2S) access using IKEv2 EAP-TLS, with a bundled certificate authority
- optional WireGuard endpoint - optional WireGuard endpoint
- routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules - routing and NAT for a protected subnet, TCP MSS clamping, and UFW firewall rules
- 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 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 internal NIC names, and a `mode` setting says how much of the rest you are supplying and
@@ -28,6 +29,10 @@ service that applies it.
systemctl restart vpn-router-setup apply it 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 ### By hand
```sh ```sh
@@ -92,13 +97,13 @@ must have the type shown below; an unknown section or setting is an error.
| `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` | | `general` | `mode` | string | `manual` | How much you supply: `manual`, `interfaces` or `auto` |
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network | | `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network | | `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
| `wan` | `local_fqdn` | string | | This router's FQDN | | `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` | | `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` | `cidrs` | list | | Local subnets advertised into the tunnel |
| `local` | `int_addr` | string | | This host's address on the internal network | | `local` | `int_addr` | string | | This host's address on the internal network |
| `local` | `int_gateway_ip` | string | | Next hop on the internal side | | `local` | `int_gateway_ip` | string | | Next hop on the internal side |
| `remote` | `addrs` | list | | Remote gateway addresses or FQDNs | | `remote` | `addrs` | list | | Remote gateway addresses or FQDNs |
| `remote` | `id` | string | | Remote IKE identity, without a leading `@` | | `remote` | `id` | string | | Remote IKE identity, an FQDN or an IPv4 or IPv6 address |
| `remote` | `cidrs` | list | | Remote subnets reachable through the tunnel | | `remote` | `cidrs` | list | | Remote subnets reachable through the tunnel |
| `remote` | `psk` | string | | Pre-shared key | | `remote` | `psk` | string | | Pre-shared key |
| `remote` | `psk_file` | string | | Path to a file holding the key; wins over `psk` | | `remote` | `psk_file` | string | | Path to a file holding the key; wins over `psk` |
@@ -108,6 +113,11 @@ must have the type shown below; an unknown section or setting is an error.
| `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint | | `wireguard` | `enabled` | boolean | `false` | WireGuard endpoint |
| `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` | | `wireguard` | `address` | string | | Address and prefix for `wg0`, for example `192.168.200.1/24` |
| `wireguard` | `listen_port` | integer | `51820` | UDP port | | `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: A minimal site-to-site configuration in `auto` mode:
@@ -125,6 +135,11 @@ A minimal site-to-site configuration in `auto` mode:
} }
``` ```
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 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 `[Interface]` section of that file and rewrites it when the settings above change, but it
carries every `[Peer]` section across untouched. carries every `[Peer]` section across untouched.
@@ -141,18 +156,73 @@ much the package reads from the system:
|---|---|---| |---|---|---|
| `manual` | both interface names, `int_addr`, `int_gateway_ip` | nothing | | `manual` | both interface names, `int_addr`, `int_gateway_ip` | nothing |
| `interfaces` | both interface names | `int_addr` and `int_gateway_ip`, from those interfaces | | `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 `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 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. 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 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 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 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 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. 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 ## Applying changes
```sh ```sh
@@ -254,9 +324,78 @@ EXTRA_LOCAL_TS = '192.0.2.53/32'
Set `general.platform` to `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. 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 |
| `ipsec` | The output of `swanctl --list-conns` and `swanctl --list-sas`, as lists of lines |
| `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 ## 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 configuration in place. `strongswan` is left enabled, since it is a shared service that
may be in use by something else. may be in use by something else.
+10 -2
View File
@@ -1,9 +1,17 @@
#!/bin/sh #!/bin/sh
set -e 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 mkdir -p out
container build --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) -t vpn-router-builder "$@" . $CONTAINER_RUNTIME 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/' # 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/" echo "Build artifacts written to out/"
+83 -1
View File
@@ -1,4 +1,86 @@
vpn-router (1.0.0-1) unstable; urgency=medium 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. * Initial release.
+2 -2
View File
@@ -9,8 +9,8 @@ Rules-Requires-Root: no
Package: vpn-router Package: vpn-router
Architecture: all Architecture: all
Depends: ${misc:Depends}, Depends: ${misc:Depends},
strongswan-swanctl, strongswan-swanctl (>= 5.0),
charon-systemd, charon-systemd (>= 5.0),
libstrongswan-extra-plugins, libstrongswan-extra-plugins,
libcharon-extra-plugins, libcharon-extra-plugins,
wireguard-tools, wireguard-tools,
+2
View File
@@ -2,7 +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/vpnrouter.py usr/lib/vpn-router/
src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/
src/usr/lib/vpn-router/configure usr/lib/vpn-router/ src/usr/lib/vpn-router/configure usr/lib/vpn-router/
src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/
src/usr/lib/vpn-router/setup usr/lib/vpn-router/ src/usr/lib/vpn-router/setup usr/lib/vpn-router/
src/usr/lib/vpn-router/web usr/lib/vpn-router/
src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/ src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/
src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/ src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/
src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/ src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/
+2
View File
@@ -4,3 +4,5 @@
override_dh_installsystemd: override_dh_installsystemd:
dh_installsystemd --name=vpn-router-setup dh_installsystemd --name=vpn-router-setup
dh_installsystemd --name=vpn-router-docker
dh_installsystemd --name=vpn-router-web
@@ -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
@@ -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 #!/bin/sh
set -e 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() { changelog_field() {
container run --rm -v "$(pwd):/mnt" vpn-router-builder \ $CONTAINER_RUNTIME run --rm vpn-router-builder \
dpkg-parsechangelog --show-field "$1" -l /mnt/debian/changelog dpkg-parsechangelog --show-field "$1" -l /build/src/debian/changelog
} }
VERSION="$(changelog_field Version)" VERSION="$(changelog_field Version)"
@@ -15,6 +27,14 @@ if [ ! -f "$DEB" ]; then
exit 1 exit 1
fi 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" \ --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"
+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())
@@ -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. would be a silent and security-relevant mistake.
""" """
import json
import os import os
import pathlib import pathlib
import re
import socket import socket
import subprocess import subprocess
import sys import sys
@@ -24,13 +26,18 @@ from vpnrouter import fail
BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules') BEFORE_RULES = pathlib.Path('/etc/ufw/before.rules')
UFW_DEFAULTS = pathlib.Path('/etc/default/ufw') UFW_DEFAULTS = pathlib.Path('/etc/default/ufw')
WG_DIR = pathlib.Path('/etc/wireguard') WG_DIR = pathlib.Path('/etc/wireguard')
WEB_STATE = pathlib.Path('/run/vpn-router/web.json')
FILTER_ANCHOR = '# End required lines' 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 = ( BLOCKS = (
('# IPSEC RULES START', '# IPSEC RULES END'), ('# IPSEC RULES START', '# IPSEC RULES END'),
('# WIREGUARD RULES START', '# WIREGUARD RULES END'), ('# WIREGUARD RULES START', '# WIREGUARD RULES END'),
('# P2S DNS RULES START', '# P2S DNS 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 FORWARD RULES START', '# ROUTER FORWARD RULES END'),
('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'), ('# ROUTER NAT RULES START', '# ROUTER NAT RULES END'),
('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'), ('# ROUTER MSS RULES START', '# ROUTER MSS RULES END'),
@@ -149,6 +156,14 @@ def filter_block(ctx):
] ]
lines += ['# P2S DNS RULES END'] 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): if vpnrouter.routing_ready(ctx):
lines += ['', '# ROUTER FORWARD RULES START'] lines += ['', '# ROUTER FORWARD RULES START']
for subnet in ctx['local_cidr_list']: for subnet in ctx['local_cidr_list']:
@@ -299,6 +314,58 @@ def run_platform_setup(cfg, ctx):
fail(f'platform module {module.__name__} failed: {exc}') 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 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.
"""
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', 'vpn-router-web.service'], check=False)
def main(): def main():
cfg = vpnrouter.load_config() cfg = vpnrouter.load_config()
ctx, changed = vpnrouter.configure(cfg) ctx, changed = vpnrouter.configure(cfg)
@@ -337,6 +404,8 @@ def main():
run(['systemctl', 'restart', 'systemd-resolved'], check=False) run(['systemctl', 'restart', 'systemd-resolved'], check=False)
run_platform_setup(cfg, ctx) run_platform_setup(cfg, ctx)
refresh_docker_rules()
refresh_web(cfg, ctx)
return 0 return 0
@@ -8,6 +8,7 @@ import ipaddress
import json import json
import os import os
import pathlib import pathlib
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -46,8 +47,21 @@ SCHEMA = {
'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''}, 'remote': {'addrs': [], 'id': '', 'cidrs': [], 'psk': '', 'psk_file': ''},
'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'}, 'p2s': {'enabled': False, 'address_pool': '', 'ca_name': 'VPN Router CA'},
'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820}, 'wireguard': {'enabled': False, 'address': '', 'listen_port': 51820},
'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', TYPE_NAMES = {str: 'a string', list: 'a list of strings', bool: 'a boolean',
int: 'an integer'} int: 'an integer'}
@@ -180,6 +194,12 @@ def validate(cfg):
raise ConfigError( raise ConfigError(
f'{section}.{option} is required in mode {mode or "manual"}') 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') id_mode = _get(cfg, 'wan', 'local_id_mode')
if id_mode and id_mode not in ID_MODES: if id_mode and id_mode not in ID_MODES:
raise ConfigError( raise ConfigError(
@@ -212,6 +232,32 @@ def validate(cfg):
if not 1 <= port <= 65535: if not 1 <= port <= 65535:
raise ConfigError(f'wireguard.listen_port: {port} is not a valid port') raise ConfigError(f'wireguard.listen_port: {port} is not a valid port')
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): def resolve_psk(cfg):
"""Return the pre-shared key, or '' when none is configured.""" """Return the pre-shared key, or '' when none is configured."""
@@ -281,12 +327,22 @@ def detect_interfaces():
return external, candidates[0] 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(): 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 = [] names = []
for line in _ip_route('-o', '-4', 'addr', 'show').splitlines(): for line in _ip_route('-o', '-4', 'addr', 'show').splitlines():
fields = line.split() 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]) names.append(fields[1])
return names return names
@@ -386,7 +442,7 @@ def build_context(cfg, local_id_override=None):
if local_id_override: if local_id_override:
local_id = local_id_override local_id = local_id_override
elif id_mode == 'fqdn': elif id_mode == 'fqdn':
local_id = f'@{local_fqdn}' if local_fqdn else '' local_id = local_fqdn
elif id_mode == 'internal_ip': elif id_mode == 'internal_ip':
local_id = int_addr local_id = int_addr
else: else:
@@ -421,6 +477,8 @@ def build_context(cfg, local_id_override=None):
'wg_enabled': _get(cfg, 'wireguard', 'enabled'), 'wg_enabled': _get(cfg, 'wireguard', 'enabled'),
'wg_address': _get(cfg, 'wireguard', 'address'), 'wg_address': _get(cfg, 'wireguard', 'address'),
'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'), 'wg_listen_port': _get(cfg, 'wireguard', 'listen_port'),
'web_enabled': _get(cfg, 'web', 'enabled'),
'web_port': _get(cfg, 'web', 'port'),
} }
+355
View File
@@ -0,0 +1,355 @@
#!/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 re
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'
SA_LINE = re.compile(r'^(\S+): #\d+, (\w+),')
_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 established(connection):
"""The number of IKE SAs of the connection, or None when swanctl cannot be asked."""
result = run('swanctl', '--list-sas')
if result is None or result.returncode != 0:
return None
return sum(1 for line in result.stdout.splitlines()
if (match := SA_LINE.match(line)) and match.group(1) == connection and match.group(2) == '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 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=lines_of('ip', '-4', 'route', 'show')),
'ipsec': {
'connections': lines_of('swanctl', '--list-conns'),
'sas': lines_of('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())
@@ -32,5 +32,14 @@
"enabled": false, "enabled": false,
"address": "192.168.200.1/24", "address": "192.168.200.1/24",
"listen_port": 51820 "listen_port": 51820
},
"docker": {
"mode": "auto",
"exposed_ports": ["443"]
},
"web": {
"enabled": true,
"port": 8080,
"jwt_secret": ""
} }
} }
@@ -14,7 +14,7 @@ connections {
} }
remote { remote {
id = @{{ remote_id }} id = {{ remote_id }}
auth = psk auth = psk
} }
@@ -40,7 +40,7 @@ connections {
secrets { secrets {
ike-psk { ike-psk {
id-1 = {{ local_id }} id-1 = {{ local_id }}
id-2 = @{{ remote_id }} id-2 = {{ remote_id }}
secret = "{{ psk }}" secret = "{{ psk }}"
} }
} }