"""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