-
-
Save jbaiter/b9e1c5bce9567531e14a4be474c0e203 to your computer and use it in GitHub Desktop.
| #!/usr/bin/env python3 | |
| """Wireguard logging and monitoring tool. | |
| Logs the following events along with the peer state in JSON format to stdout: | |
| - Peer connected (= successfull handshake after period of no handshakes) | |
| - Peer disconnected (= no handshake for longer than 5 minutes) | |
| - Peer roamed (= source IP of the peer changed) | |
| Additionally, the tool exposes a Prometheus-compatible monitoring endpoint | |
| on port 9000 that exports the following metrics: | |
| - `wireguard_sent_bytes_total`: Bytes sent to the peer (counter) | |
| - `wireguard_received_bytes_total`: Bytes received from the peer (counter) | |
| - `wireguard_latest_handshake_seconds`: Seconds from the last handshake (gauge) | |
| Requires Python >=3.7, but no additional libraries. | |
| -------------------------------------------------------------------------------- | |
| Copyright 2020 Johannes Baiter <johannes.baiter@gmail.com> | |
| Permission is hereby granted, free of charge, to any person obtaining a copy of | |
| this software and associated documentation files (the "Software"), to deal in | |
| the Software without restriction, including without limitation the rights to use, | |
| copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the | |
| Software, and to permit persons to whom the Software is furnished to do so, subject | |
| to the following conditions: | |
| The above copyright notice and this permission notice shall be included in all | |
| copies or substantial portions of the Software. | |
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | |
| INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | |
| PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | |
| HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | |
| OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE | |
| SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import http.server | |
| import json | |
| import re | |
| import socketserver | |
| import sys | |
| import subprocess | |
| import threading | |
| import time | |
| from datetime import datetime | |
| from http import HTTPStatus | |
| from typing import Any, List, Mapping, MutableMapping, NamedTuple, Optional, Tuple | |
| from pathlib import Path | |
| # Regular expression to parse friendly names for peers | |
| PEER_NAME_PAT = re.compile( | |
| r"\[Peer\]\n# (?P<name>.+?)\nPublicKey = (?P<pubkey>.+?)\n", re.MULTILINE | |
| ) | |
| class MetricsHandler(http.server.BaseHTTPRequestHandler): | |
| def log_request(self, *args, **kwargs): | |
| """ Dummy logging handler that just does nothing. """ | |
| pass | |
| def do_GET(self): | |
| """ Return Prometheus metrics. """ | |
| self.send_response(HTTPStatus.OK) | |
| self.send_header("Content-Type", "text/plain; version=0.0.4") | |
| self.end_headers() | |
| self.wfile.write("\n".join(export_metrics()).encode("utf8") + b"\n") | |
| class RemotePeer(NamedTuple): | |
| """ State of a remote Wireguard peer. """ | |
| device: str | |
| name: Optional[str] | |
| public_key: str | |
| remote_addr: Optional[str] | |
| allowed_ips: Tuple[str] | |
| latest_handshake: Optional[datetime] | |
| transfer_rx: int | |
| transfer_tx: int | |
| @classmethod | |
| def parse(cls, namemap: Mapping[str, str], *columns) -> RemotePeer: | |
| """ Parse a RemotePeer from a `wg show all dump` line. """ | |
| dev, pub, _, remote_addr, ip_list, handshake_ts, bytes_rx, bytes_tx, _ = columns | |
| return cls( | |
| device=dev, | |
| name=namemap.get(pub), | |
| public_key=pub, | |
| remote_addr=remote_addr if remote_addr != "(none)" else None, | |
| allowed_ips=ip_list.split(","), | |
| latest_handshake=datetime.fromtimestamp(int(handshake_ts)) | |
| if handshake_ts != "0" | |
| else None, | |
| transfer_rx=int(bytes_rx), | |
| transfer_tx=int(bytes_tx), | |
| ) | |
| def get_friendly_names() -> Mapping[str, str]: | |
| """ Parse human-readable name for peers from the Wireguard config. """ | |
| ifaces = ( | |
| subprocess.check_output(["wg", "show", "interfaces"]).decode("utf8").split() | |
| ) | |
| name_map: MutableMapping[str, str] = {} | |
| for iface in ifaces: | |
| cfg = Path(f"/etc/wireguard/{iface}.conf").read_text() | |
| name_map.update((key, name) for name, key in PEER_NAME_PAT.findall(cfg)) | |
| return name_map | |
| def log_json(msg: str, peer: RemotePeer, **kwargs): | |
| payload = peer._asdict() | |
| if peer.latest_handshake is not None: | |
| payload["latest_handshake"] = peer.latest_handshake.isoformat() | |
| print( | |
| json.dumps( | |
| { | |
| "@timestamp": datetime.now().isoformat(), | |
| "message": msg, | |
| **payload, | |
| **kwargs, | |
| } | |
| ) | |
| ) | |
| def get_peer_states() -> List[RemotePeer]: | |
| """ Get the state of all remote peers from Wireguard. """ | |
| name_map = get_friendly_names() | |
| wg_out = subprocess.check_output(["wg", "show", "all", "dump"]).decode("utf8") | |
| rows = [l.split("\t") for l in wg_out.split("\n")] | |
| return [RemotePeer.parse(name_map, *row) for row in rows if len(row) > 5] | |
| def is_peer_connected(last_handshake: Optional[datetime], timeout: int) -> bool: | |
| if last_handshake is None: | |
| last_handshake = datetime.fromtimestamp(0) | |
| since_handshake = datetime.now() - last_handshake | |
| return since_handshake.total_seconds() < timeout | |
| def log_wireguard_peers(poll_delay: int, handshake_timeout: int): | |
| """Poll wireguard peer state and log connections/disconnections in JSON to stdout.""" | |
| peer_state: MutableMapping[str, Tuple[bool, Optional[datetime], Optional[str]]] = { | |
| peer.public_key: ( | |
| is_peer_connected(peer.latest_handshake, handshake_timeout), | |
| peer.latest_handshake, | |
| peer.remote_addr, | |
| ) | |
| for peer in get_peer_states() | |
| } | |
| t = threading.currentThread() | |
| while True: | |
| peers = get_peer_states() | |
| for peer in peers: | |
| now_connected = is_peer_connected(peer.latest_handshake, handshake_timeout) | |
| if peer.public_key not in peer_state: | |
| log_json("New Peer registered", peer, connected=now_connected) | |
| peer_state[peer.public_key] = ( | |
| now_connected, | |
| peer.latest_handshake, | |
| peer.remote_addr, | |
| ) | |
| continue | |
| previously_connected, previous_handshake, remote_addr = peer_state[ | |
| peer.public_key | |
| ] | |
| if previously_connected and not now_connected: | |
| log_json("Peer disconnected", peer, connected=now_connected) | |
| elif not previously_connected and now_connected: | |
| log_json("Peer connected", peer, connected=now_connected) | |
| elif previously_connected and remote_addr != peer.remote_addr: | |
| log_json( | |
| "Peer roamed", | |
| peer, | |
| connected=now_connected, | |
| old_remote_addr=remote_addr, | |
| ) | |
| peer_state[peer.public_key] = ( | |
| now_connected, | |
| peer.latest_handshake, | |
| peer.remote_addr, | |
| ) | |
| # Check if we're stopped | |
| for i in range(poll_delay * 10): | |
| if getattr(t, "stop_logging", False): | |
| return | |
| time.sleep(0.1) | |
| def export_metrics() -> List[str]: | |
| """ Export metrics for all registered peers in the Prometheus text format. """ | |
| peers = get_peer_states() | |
| tx = [] | |
| rx = [] | |
| handshakes = [] | |
| for peer in peers: | |
| since_last_handshake = datetime.now() - ( | |
| peer.latest_handshake or datetime.fromtimestamp(0) | |
| ) | |
| info = f'interface="{peer.device}",public_key="{peer.public_key}",allowed_ips="{",".join(peer.allowed_ips)}",friendly_name="{peer.name}"' | |
| tx.append(f"wireguard_sent_bytes_total{{{info}}} {peer.transfer_tx}") | |
| rx.append(f"wireguard_received_bytes_total{{{info}}} {peer.transfer_rx}") | |
| handshakes.append( | |
| f"wireguard_latest_handshake_seconds{{{info}}} {since_last_handshake.total_seconds()}" | |
| ) | |
| return [ | |
| "# HELP wireguard_sent_bytes_total Bytes sent to the peer", | |
| "# TYPE wireguard_sent_bytes_total counter", | |
| *tx, | |
| "# HELP wireguard_received_bytes_total Bytes received from the peer", | |
| "# TYPE wireguard_received_bytes_total counter", | |
| *rx, | |
| "# HELP wireguard_latest_handshake_seconds Seconds from the last handshake", | |
| "# TYPE wireguard_latest_handshake_seconds gauge", | |
| *handshakes, | |
| ] | |
| def start_metrics_server(port: int, bind: str): | |
| """ Start a HTTP server that serves Prometheus metrics in a background thread. """ | |
| httpd = socketserver.TCPServer((bind, port), MetricsHandler) | |
| try: | |
| httpd.serve_forever() | |
| except KeyboardInterrupt: | |
| return | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description=__doc__, formatter_class=argparse.RawTextHelpFormatter | |
| ) | |
| parser.add_argument( | |
| "--poll-delay", | |
| dest="poll_delay", | |
| action="store", | |
| default=30, | |
| type=int, | |
| help="Period in seconds between polling of Wireguard state.", | |
| ) | |
| parser.add_argument( | |
| "--handshake-timeout", | |
| dest="handshake_timeout", | |
| action="store", | |
| default=300, | |
| type=int, | |
| help="Minimum period in seconds between handshakes to consider a peer disconnected.", | |
| ) | |
| parser.add_argument( | |
| "--metrics-port", | |
| dest="metrics_port", | |
| action="store", | |
| default=9000, | |
| type=int, | |
| help="Port to export the Prometheus metrics endpoint on.", | |
| ) | |
| parser.add_argument( | |
| "--metrics-bind", | |
| dest="metrics_bind", | |
| action="store", | |
| default="0.0.0.0", | |
| type=str, | |
| help="Address to bind the metrics endpoint to.", | |
| ) | |
| args = parser.parse_args() | |
| # Check if the user running the scripts is permitted to access the Wireguard state | |
| try: | |
| get_peer_states() | |
| except PermissionError: | |
| print("The tool needs to be run as the root user.") | |
| sys.exit(1) | |
| # Start logging and metrics endpoint | |
| logger_thread = threading.Thread( | |
| target=log_wireguard_peers, args=(args.poll_delay, args.handshake_timeout) | |
| ) | |
| logger_thread.start() | |
| start_metrics_server(args.metrics_port, args.metrics_bind) | |
| logger_thread.stop_logging = True | |
| logger_thread.join() | |
| if __name__ == "__main__": | |
| main() |
Firstly, thanks for your work.
We need the monitor part for IsardVDI project.
We can copy and modify it like https://gitlab.com/isard/isardvdi/-/merge_requests/491/diffs#73c263e105e1d49efcaf607f1240221a8fa5a79c
But I think would be better that we can contribute in this code to make usable for us and help to maintain it. What do you think about create a project instead of manage it via this gist?
I honestly didn't plan to do much more work on this, it works very well for us internally and I thought I'd just share it with the world without taking on the burden of maintaining yet another open source project, I'm neglecting too many as it is already 😅
But you're welcome to simply create a proper project of your own with the code and expand and maintain it there if that works better for you!
OK, thanks for your honesty.
Thanks for sharing
When I run the code and change the IP address and port number, I get the following error:
start_metrics_server(args.metrics_port, args.metrics_bind)
File "./Wireguard.py", line 226, in start_metrics_server
httpd = socketserver.TCPServer((bind, port), MetricsHandler)
File "/usr/lib/python3.8/socketserver.py", line 452, in __init__
self.server_bind()
File "/usr/lib/python3.8/socketserver.py", line 466, in server_bind
self.socket.bind(self.server_address)
OSError: [Errno 99] Cannot assign requested address
When i telnet to the IP and Port number, session opened (Prometheus is OK)
Have you tried changing the listening address (--metrics-bind, defaults to 0.0.0.0) or the port (--metrics-port, defaults to 9000)? Looks like a problem in your environment, it seems that you can't bind to 0.0.0.0:9000
get_friendly_names() uses a hard coded path to the config file. wg has a built in function for printing the current config like so:
wg showconf <interface>
I noticed that this is a more stable and portable method to do it, when setting up a system based on FreeBSD, where the default config file location is not in /etc/wireguard/
Hope this is useful to someone.
also there is a repo with this functionality now: https://github.com/MindFlavor/prometheus_wireguard_exporter
Better like this. Thanks.