fix: replace webservice swanctl call human readble output with structured one.
Release / release (push) Successful in 18s

This commit is contained in:
slawek committed 2026-10-23 20:44:17 +02:00
1 parent a6b25b8bb4
commit 90e7c79840
3 files changed
+68 -12

No files matched your search

+2 -2
View File
@@ -358,8 +358,8 @@ curl http://10.0.0.4:8080/status
| Key | Content | | 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) | | `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 | | `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` | The output of `swanctl --list-conns` and `swanctl --list-sas`, as lists of lines | | `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 | | `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 | | `docker` | `docker.exposed_ports` and the rules of the `VPN-ROUTER-DOCKER` chain |
+9
View File
@@ -1,3 +1,12 @@
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 vpn-router (0.4.1-1) unstable; urgency=medium
* Fix the start of vpn-router-setup hanging on a host that runs Docker. * Fix the start of vpn-router-setup hanging on a host that runs Docker.
+57 -10
View File
@@ -29,7 +29,6 @@ import hashlib
import hmac import hmac
import json import json
import os import os
import re
import signal import signal
import subprocess import subprocess
import sys import sys
@@ -49,7 +48,6 @@ LEEWAY = 60
MAX_LIFETIME = 600 MAX_LIFETIME = 600
TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4' TAILWIND = 'https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'
SA_LINE = re.compile(r'^(\S+): #\d+, (\w+),')
_cache = {'at': 0.0, 'data': None} _cache = {'at': 0.0, 'data': None}
_lock = threading.Lock() _lock = threading.Lock()
@@ -85,13 +83,51 @@ def docker_status():
} }
def established(connection): def swanctl(command):
"""The number of IKE SAs of the connection, or None when swanctl cannot be asked.""" """The entries of a swanctl list command, or None when swanctl cannot be asked.
result = run('swanctl', '--list-sas')
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: if result is None or result.returncode != 0:
return None return None
return sum(1 for line in result.stdout.splitlines() events = []
if (match := SA_LINE.match(line)) and match.group(1) == connection and match.group(2) == 'ESTABLISHED') 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(): def wireguard_peers():
@@ -176,6 +212,17 @@ def lines_of(*args):
return result.stdout.splitlines() if result and result.returncode == 0 else None 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(): def wireguard_details():
"""The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key.""" """The interface and the peers of wg0, with endpoints and allowed IPs; no private or preshared key."""
result = run('wg', 'show', 'wg0', 'dump') result = run('wg', 'show', 'wg0', 'dump')
@@ -202,10 +249,10 @@ def details(state):
configuration = state.get('configuration', {}) configuration = state.get('configuration', {})
return { return {
'configuration': configuration, 'configuration': configuration,
'network': dict(state.get('network', {}), routes=lines_of('ip', '-4', 'route', 'show')), 'network': dict(state.get('network', {}), routes=routes()),
'ipsec': { 'ipsec': {
'connections': lines_of('swanctl', '--list-conns'), 'connections': swanctl('--list-conns'),
'sas': lines_of('swanctl', '--list-sas'), 'sas': swanctl('--list-sas'),
}, },
'wireguard': wireguard_details(), 'wireguard': wireguard_details(),
'docker': { 'docker': {