149 lines
5.8 KiB
Python
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
|