Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7163f167e0 | ||
|
|
5402256ef6 | ||
|
|
64da0cf97f | ||
|
|
2e0cee10ac | ||
|
|
66fcd69d40 | ||
|
|
19567aa273 | ||
|
|
656e1733d5 |
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -28,6 +29,10 @@ service that applies 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
|
||||
|
||||
```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` |
|
||||
| `interfaces` | `external` | string | | Name of the NIC facing the untrusted network |
|
||||
| `interfaces` | `internal` | string | | Name of the NIC facing the protected network |
|
||||
| `wan` | `local_fqdn` | string | | This router's FQDN |
|
||||
| `wan` | `local_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, 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` | `psk` | string | | Pre-shared key |
|
||||
| `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` | `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:
|
||||
|
||||
@@ -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
|
||||
`[Interface]` section of that file and rewrites it when the settings above change, but it
|
||||
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 |
|
||||
| `interfaces` | both interface names | `int_addr` and `int_gateway_ip`, from those interfaces |
|
||||
| `auto` | nothing | the interface names too, from the routing table |
|
||||
| `auto` | nothing | the interface names too: the external one from the routing table, the internal one as the only other NIC with an address |
|
||||
|
||||
`manual` is the only mode in which no detection code runs at all. `auto` is the
|
||||
convenient one and can pick the wrong interface, which is a trade you make deliberately
|
||||
by choosing it rather than something the package does behind your back.
|
||||
|
||||
Detection ignores virtual interfaces, those the kernel lists under `/sys/devices/virtual`:
|
||||
loopback, bridges such as `docker0`, veth pairs and tunnels. Only the NICs of the machine
|
||||
count, so Docker does not change the result. If the host has more than two NICs with an
|
||||
address, `auto` stops and says so; use mode `interfaces` and name them.
|
||||
|
||||
The addresses strongSwan binds on always come from the external NIC, in every mode, so
|
||||
they are never configured twice and cannot drift out of step with the host. If a named
|
||||
interface does not exist the service stops and says so. In `interfaces` and `auto` mode
|
||||
the internal gateway is taken from the routing table, falling back to the first host
|
||||
address of the connected subnet with a warning when the routing table has nothing to say.
|
||||
|
||||
## Docker
|
||||
|
||||
Docker publishes container ports in its own firewall rules, which bypass UFW and can expose
|
||||
ports on the external interface. Docker evaluates the `DOCKER-USER` chain before its own
|
||||
rules, so the package filters there. With `docker.mode` set to `auto`, the package secures
|
||||
Docker when `docker.service` is installed, and does nothing otherwise.
|
||||
|
||||
### On a host that already runs Docker
|
||||
|
||||
Installing Docker first is the recommended order. After installing the package:
|
||||
|
||||
1. Set `general.platform` in `/etc/vpn-router/vpn-router.json`. While it is `none`, nothing is
|
||||
configured, the Docker rules included.
|
||||
2. List the TCP ports the running containers must expose to the internet, for example
|
||||
`"docker": { "exposed_ports": ["443"] }`. The default is none: once the rules are applied,
|
||||
every published port that is not listed is unreachable from the external interface, and
|
||||
remains reachable through the IPsec tunnel.
|
||||
3. Apply the configuration with `systemctl restart vpn-router-setup`. Docker is running, so
|
||||
this also restarts `vpn-router-docker`, which installs the rules.
|
||||
4. Check the result:
|
||||
|
||||
```sh
|
||||
iptables -S DOCKER-USER # "-j VPN-ROUTER-DOCKER" is the first rule
|
||||
iptables -S VPN-ROUTER-DOCKER # the rules of the external interface
|
||||
```
|
||||
|
||||
### When Docker is installed after the package
|
||||
|
||||
The Docker rules are not applied when Docker starts for the first time, so run
|
||||
`systemctl restart vpn-router-setup` after installing Docker, then check as above. A reboot or a
|
||||
restart of Docker applies them as well.
|
||||
|
||||
The rules sit in their own chain, `VPN-ROUTER-DOCKER`, entered by one jump at the top of
|
||||
`DOCKER-USER`; other rules in `DOCKER-USER` are left alone. For traffic that arrives on the
|
||||
external interface, in this order:
|
||||
|
||||
1. IPsec protected traffic is accepted.
|
||||
2. TCP to the ports in `docker.exposed_ports` is accepted; none by default.
|
||||
3. Packets of established and related connections are accepted.
|
||||
4. Everything else is dropped.
|
||||
|
||||
Traffic from every other interface, including the internal one and the Docker bridges, is
|
||||
not affected. The rules cover IPv4 only.
|
||||
|
||||
The `vpn-router-docker` service applies the rules after `docker.service` starts and removes
|
||||
them when Docker stops, so restarting Docker restores them. `systemctl restart
|
||||
vpn-router-setup` applies a changed configuration while Docker runs. With `docker.mode` set to
|
||||
`on`, the service fails when Docker is not installed or its daemon is not running; with
|
||||
`off` it removes the rules.
|
||||
|
||||
## Applying changes
|
||||
|
||||
```sh
|
||||
@@ -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
|
||||
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
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
+10
-2
@@ -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/"
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/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/
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+114
@@ -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.
|
||||
"""
|
||||
|
||||
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'),
|
||||
@@ -149,6 +156,14 @@ def filter_block(ctx):
|
||||
]
|
||||
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']:
|
||||
@@ -299,6 +314,58 @@ def run_platform_setup(cfg, ctx):
|
||||
fail(f'platform module {module.__name__} failed: {exc}')
|
||||
|
||||
|
||||
def refresh_docker_rules():
|
||||
"""Apply the new settings to the Docker rules while Docker is running.
|
||||
|
||||
When Docker is not running, vpn-router-docker applies them after
|
||||
docker.service starts.
|
||||
"""
|
||||
if unit_is_active('docker.service'):
|
||||
run(['systemctl', 'restart', 'vpn-router-docker.service'], check=False)
|
||||
|
||||
|
||||
def 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():
|
||||
cfg = vpnrouter.load_config()
|
||||
ctx, changed = vpnrouter.configure(cfg)
|
||||
@@ -337,6 +404,8 @@ def main():
|
||||
run(['systemctl', 'restart', 'systemd-resolved'], check=False)
|
||||
|
||||
run_platform_setup(cfg, ctx)
|
||||
refresh_docker_rules()
|
||||
refresh_web(cfg, ctx)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ipaddress
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -46,8 +47,21 @@ SCHEMA = {
|
||||
'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'}
|
||||
|
||||
@@ -180,6 +194,12 @@ 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(
|
||||
@@ -212,6 +232,32 @@ def validate(cfg):
|
||||
if not 1 <= port <= 65535:
|
||||
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):
|
||||
"""Return the pre-shared key, or '' when none is configured."""
|
||||
@@ -281,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
|
||||
|
||||
@@ -386,7 +442,7 @@ def build_context(cfg, local_id_override=None):
|
||||
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:
|
||||
@@ -421,6 +477,8 @@ def build_context(cfg, local_id_override=None):
|
||||
'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'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
"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 }}"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user