Files
linux-cloud-router/debian-package/src/usr/lib/vpn-router/vpnrouter_status.py
T

149 lines
5.8 KiB
Python

"""What the router's VPN systems report, for the web service and the metrics service.
Each reader asks the running system once and answers None when it cannot be asked: swanctl for
the IKE SAs and the state and traffic of the site-to-site connections, wg for the WireGuard
peers and the OpenVPN server's status file for its clients.
"""
import subprocess
import time
COMMAND_TIMEOUT = 5
OPENVPN_STATUS = '/run/openvpn-server/status-vpn-router.log'
#: Seconds between two writes of OPENVPN_STATUS by the OpenVPN server, whose configuration sets it (OpenVPN's default is 60).
OPENVPN_STATUS_SECONDS = 5
#: Seconds since its last handshake within which a WireGuard peer counts as connected.
WIREGUARD_CONNECTED_SECONDS = 180
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 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.
Output with more closing than opening lines gives None.
"""
result = run('swanctl', command, '--pretty')
if result is None or result.returncode != 0:
return None
events = []
stack = []
for line in result.stdout.splitlines():
line = line.strip()
if not line:
continue
if line in ('}', ']'):
if not stack:
return None
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_each(connections):
"""The number of established IKE SAs of each of the connections by name, None for each when swanctl cannot be asked."""
sas = swanctl('--list-sas')
return {name: None if sas is None else sum(1 for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED')
for name in connections}
def _count(text):
"""A counter swanctl prints as a string, as an integer; 0 when it is not a whole number."""
return int(text) if isinstance(text, str) and text.isdigit() else 0
def site_to_site_tunnels(connections):
"""The state of each of the connections by name, or None when swanctl cannot be asked.
Each has up, 1 while it has an established IKE SA and 0 otherwise; uptime, the seconds since
its oldest established IKE SA was established, None while it is down; and bytes_in,
bytes_out, packets_in and packets_out, the sums over the child SAs of its established IKE
SAs. The sums start again from zero when a child SA is rekeyed.
"""
sas = swanctl('--list-sas')
if sas is None:
return None
tunnels = {}
for name in connections:
up = [sa for sa in sas if sa['name'] == name and sa.get('state') == 'ESTABLISHED']
children = [child for sa in up for child in (sa.get('child-sas') or {}).values() if isinstance(child, dict)]
tunnels[name] = {
'up': 1 if up else 0,
'uptime': max((_count(sa.get('established')) for sa in up), default=None),
**{key.replace('-', '_'): sum(_count(child.get(key)) for child in children)
for key in ('bytes-in', 'bytes-out', 'packets-in', 'packets-out')},
}
return tunnels
def established(connection):
"""The number of established IKE SAs of the connection, or None when swanctl cannot be asked."""
return established_each([connection])[connection]
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 wireguard_connected():
"""The number of WireGuard peers with a handshake in the last WIREGUARD_CONNECTED_SECONDS, or None when wg0 is down."""
peers = wireguard_peers()
if peers is None:
return None
return sum(1 for peer in peers
if peer['handshake_seconds_ago'] is not None and peer['handshake_seconds_ago'] < WIREGUARD_CONNECTED_SECONDS)
def openvpn_clients():
"""The number of connected OpenVPN clients, from the server's status file, or None when it cannot be read."""
try:
with open(OPENVPN_STATUS, encoding='utf-8') as handle:
return sum(1 for line in handle if line.startswith('CLIENT_LIST,'))
except OSError:
return None