From 90e7c79840ebdf37df171440d0ca486ccc9483db Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Fri, 23 Oct 2026 20:44:17 +0200 Subject: [PATCH] fix: replace webservice swanctl call human readble output with structured one. --- README.md | 4 +- debian-package/debian/changelog | 9 +++ debian-package/src/usr/lib/vpn-router/web | 67 +++++++++++++++++++---- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index da9489b..f430b53 100644 --- a/README.md +++ b/README.md @@ -358,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 | diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index ec26e03..4aca898 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -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 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. diff --git a/debian-package/src/usr/lib/vpn-router/web b/debian-package/src/usr/lib/vpn-router/web index 376e7d7..7b240d6 100644 --- a/debian-package/src/usr/lib/vpn-router/web +++ b/debian-package/src/usr/lib/vpn-router/web @@ -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': {