Compare commits

..
2 Commits
Author SHA1 Message Date
slawek 90e7c79840 fix: replace webservice swanctl call human readble output with structured one.
Release / release (push) Successful in 18s
2026-10-23 20:44:17 +02:00
slawek a6b25b8bb4 fix: router setup hangs on race condition with Docker.
Release / release (push) Successful in 18s
2026-10-23 20:03:17 +02:00
6 changed files with 95 additions and 18 deletions

No files matched your search

+5 -3
View File
@@ -217,7 +217,9 @@ external interface, in this order:
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
`vpn-router-setup` starts after `docker.service`, so Docker is up when it looks for it; the
ordering has no effect on a host without Docker. The `vpn-router-docker` service applies the
rules after `docker.service` starts and removes
them when Docker stops, so restarting Docker restores them. `systemctl restart
vpn-router-setup` applies a changed configuration while Docker runs. With `docker.mode` set to
`on`, the service fails when Docker is not installed or its daemon is not running; with
@@ -356,8 +358,8 @@ curl http://10.0.0.4:8080/status
| 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 |
| `network` | The external and internal interface, their addresses, the internal gateway, the local and remote IKE identities and subnets, and the IPv4 routes as `ip -j -4 route` prints them |
| `ipsec` | `connections` and `sas`: one object per connection and per IKE SA, read from `swanctl --list-conns --pretty` and `swanctl --list-sas --pretty`, with the connection's name under `name` and swanctl's keys and string values |
| `wireguard` | The public key and port of `wg0`, and each peer's public key, endpoint, allowed IPs, handshake age and traffic; no private or preshared key |
| `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
+25
View File
@@ -1,3 +1,28 @@
vpn-router (0.4.2-1) unstable; urgency=medium
* /config gives the IPsec connections and SAs as objects, read from
swanctl --pretty, and the routes as objects from ip -j, instead of
lines of text. The site-to-site and road-warrior state of /status is
counted from the same swanctl objects.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 21:30:00 +0200
vpn-router (0.4.1-1) unstable; urgency=medium
* Fix the start of vpn-router-setup hanging on a host that runs Docker.
vpn-router-setup restarted vpn-router-docker and waited for it, but that
unit was ordered after vpn-router-setup, so each waited for the other,
the installation of the package never finished and vpn-router-web was
never enabled or started. vpn-router-docker is no longer ordered after
vpn-router-setup, so vpn-router-setup still waits for it and the Docker
rules are in place when it ends. The restart of vpn-router-web is queued
without waiting.
* Start vpn-router-setup after docker.service, so that Docker is up when
the setup looks for it and does not start in parallel with it. The
ordering applies only when Docker is installed.
-- Slawomir Koszewski <slawek@koszewscy.waw.pl> Fri, 23 Oct 2026 20:10:00 +0200
vpn-router (0.4.0-1) unstable; urgency=medium
* Add the vpn-router-web service, a small status web service that uses only
@@ -1,7 +1,7 @@
[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
After=docker.service
PartOf=docker.service
[Service]
@@ -2,7 +2,7 @@
Description=VPN Router Setup
Documentation=file:/usr/share/doc/vpn-router/README.md
Wants=network-online.target
After=network-online.target
After=network-online.target docker.service
[Service]
Type=oneshot
+6 -3
View File
@@ -318,7 +318,9 @@ 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.
docker.service starts. The restart is waited for, so the rules are in place
when this setup ends; vpn-router-docker is therefore not ordered after
vpn-router-setup.
"""
if unit_is_active('docker.service'):
run(['systemctl', 'restart', 'vpn-router-docker.service'], check=False)
@@ -332,7 +334,8 @@ def refresh_web(cfg, ctx):
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.
web.enabled is false. The restart is queued, not waited for, because the
unit is ordered after vpn-router-setup.
"""
masked = {
section: {option: '********' if SECRET_OPTION.search(option) and value else value
@@ -363,7 +366,7 @@ def refresh_web(cfg, ctx):
'remote_cidrs': ctx['remote_cidr_list'],
},
}) + '\n', encoding='utf-8')
run(['systemctl', 'restart', 'vpn-router-web.service'], check=False)
run(['systemctl', 'restart', '--no-block', 'vpn-router-web.service'], check=False)
def main():
+57 -10
View File
@@ -29,7 +29,6 @@ import hashlib
import hmac
import json
import os
import re
import signal
import subprocess
import sys
@@ -49,7 +48,6 @@ LEEWAY = 60
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()
@@ -85,13 +83,51 @@ def docker_status():
}
def established(connection):
"""The number of IKE SAs of the connection, or None when swanctl cannot be asked."""
result = run('swanctl', '--list-sas')
def swanctl(command):
"""The entries of a swanctl list command, or None when swanctl cannot be asked.
The --pretty output has one item per line: "name {" opens a section, "key = [" a list
whose items follow one per line, "}" and "]" close them, and "key = value" is a pair
whose value may contain spaces. Every "... event {" section at the top holds one
connection or SA; each becomes an entry with its name under "name". The closing
"... reply {" section is left out. Values stay strings, as swanctl prints them.
"""
result = run('swanctl', command, '--pretty')
if result is None or result.returncode != 0:
return None
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')
events = []
stack = []
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
if line in ('}', ']'):
stack.pop()
elif not stack:
section = {}
if line.endswith(' event {'):
events.append(section)
stack.append(section)
elif isinstance(stack[-1], list):
stack[-1].append(line)
elif line.endswith(' {'):
stack[-1][line[:-2]] = section = {}
stack.append(section)
elif line.endswith(' = ['):
stack[-1][line[:-4]] = items = []
stack.append(items)
else:
key, _, value = line.partition(' = ')
stack[-1][key] = value
return [dict(body, name=name) for event in events for name, body in event.items()]
def established(connection):
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
sas = swanctl('--list-sas')
if sas is None:
return None
return sum(1 for sa in sas if sa['name'] == connection and sa.get('state') == 'ESTABLISHED')
def wireguard_peers():
@@ -176,6 +212,17 @@ def lines_of(*args):
return result.stdout.splitlines() if result and result.returncode == 0 else None
def routes():
"""The IPv4 routes as ip -j prints them, or None when ip fails."""
result = run('ip', '-j', '-4', 'route', 'show')
if result is None or result.returncode != 0:
return None
try:
return json.loads(result.stdout)
except ValueError:
return None
def wireguard_details():
"""The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
result = run('wg', 'show', 'wg0', 'dump')
@@ -202,10 +249,10 @@ def details(state):
configuration = state.get('configuration', {})
return {
'configuration': configuration,
'network': dict(state.get('network', {}), routes=lines_of('ip', '-4', 'route', 'show')),
'network': dict(state.get('network', {}), routes=routes()),
'ipsec': {
'connections': lines_of('swanctl', '--list-conns'),
'sas': lines_of('swanctl', '--list-sas'),
'connections': swanctl('--list-conns'),
'sas': swanctl('--list-sas'),
},
'wireguard': wireguard_details(),
'docker': {