-
-
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 <[email protected]> | |
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() |
Better like this. Thanks.
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
Added an MIT license, thanks for asking!