Skip to content

Commit 53c74f2

Browse files
committed
Add prometheus integration
1 parent eb711c9 commit 53c74f2

4 files changed

Lines changed: 264 additions & 37 deletions

File tree

README.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# ffshmon
2+
3+
`ffshmon` monitors a WireGuard connection and attempts to recover it when the VPN check fails. It checks the configured FastD service, tests the WireGuard interface through Mullvad, regenerates the WireGuard configuration once after a failure, and alerts the NOC if recovery fails.
4+
5+
It also provides a Prometheus endpoint with the latest up/down status.
6+
7+
## Requirements
8+
9+
- Linux with `systemd` and `systemctl`
10+
- Python 3.10 or newer
11+
- `curl`
12+
- A WireGuard interface named `exit` by default
13+
- A FastD service named `fastd@ffsh.service` by default
14+
- `/opt/wg-conf-gen/wg-conf-gen.py` for automatic configuration recovery
15+
- SMTP access to the configured mail host for failure alerts
16+
17+
## Installation
18+
19+
Create a virtual environment and install the Python dependencies:
20+
21+
```bash
22+
python3 -m venv .venv
23+
.venv/bin/python -m pip install -r requirements.txt
24+
```
25+
26+
## One-shot check
27+
28+
Run the existing scheduled check with mail credentials and a log file:
29+
30+
```bash
31+
.venv/bin/python wireguard.py check \
32+
--user noc@example.org \
33+
--password 'mail-password' \
34+
--log /var/log/ffshmon.log
35+
```
36+
37+
The command exits after one health cycle. If the FastD service is down, the connection probe is skipped and the status is considered down. If the probe fails, `ffshmon` regenerates the WireGuard configuration and retries once. A second failure stops FastD and WireGuard and sends an email alert.
38+
39+
## Prometheus endpoint
40+
41+
Start the long-running monitor with:
42+
43+
```bash
44+
.venv/bin/python wireguard.py serve \
45+
--user noc@example.org \
46+
--password 'mail-password' \
47+
--log /var/log/ffshmon.log
48+
```
49+
50+
By default, the process:
51+
52+
- Runs an immediate health check, then repeats every 60 seconds.
53+
- Listens on `127.0.0.1:8000`.
54+
- Exposes the latest completed result at `/metrics`.
55+
- Does not run a new health check when Prometheus scrapes the endpoint.
56+
57+
Example request:
58+
59+
```bash
60+
curl http://127.0.0.1:8000/metrics
61+
```
62+
63+
The relevant metric is:
64+
65+
```text
66+
wireguard_up{interface="exit"} 1.0
67+
```
68+
69+
A value of `1` means the latest check succeeded. A value of `0` means the FastD service or WireGuard connectivity check is down.
70+
71+
The listener and polling interval can be changed with `--host`, `--port`, and `--interval`:
72+
73+
```bash
74+
.venv/bin/python wireguard.py serve \
75+
--user noc@example.org \
76+
--password 'mail-password' \
77+
--log /var/log/ffshmon.log \
78+
--host 127.0.0.1 \
79+
--port 8000 \
80+
--interval 60
81+
```
82+
83+
## Prometheus configuration
84+
85+
Add a scrape job for the host running `ffshmon`:
86+
87+
```yaml
88+
scrape_configs:
89+
- job_name: ffshmon
90+
static_configs:
91+
- targets: ["127.0.0.1:8000"]
92+
```
93+
94+
If Prometheus runs on another host, bind `serve` to an appropriate reachable address and protect the endpoint with firewall rules or a reverse proxy. The endpoint has no built-in authentication.
95+
96+
## Running as a service
97+
98+
Run `serve` as a supervised systemd service so the endpoint remains available. A minimal unit could look like this:
99+
100+
```ini
101+
[Unit]
102+
Description=WireGuard connectivity monitor
103+
After=network-online.target
104+
105+
[Service]
106+
Type=simple
107+
WorkingDirectory=/opt/ffshmon
108+
ExecStart=/opt/ffshmon/.venv/bin/python /opt/ffshmon/wireguard.py serve --user noc@example.org --password mail-password --log /var/log/ffshmon.log
109+
Restart=on-failure
110+
111+
[Install]
112+
WantedBy=multi-user.target
113+
```
114+
115+
Avoid storing real credentials directly in a world-readable unit file. Use a protected environment file or another systemd credential mechanism in production.
116+
117+
## Development
118+
119+
Run the focused tests with:
120+
121+
```bash
122+
.venv/bin/python -m unittest -v test_wireguard.py
123+
```
124+
125+
Run Pylint with:
126+
127+
```bash
128+
.venv/bin/python -m pylint wireguard.py test_wireguard.py
129+
```

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
requests
22
click
3+
prometheus-client

test_wireguard.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Tests for WireGuard status checks and Prometheus metrics."""
2+
3+
import socket
4+
import unittest
5+
from unittest.mock import patch
6+
from urllib.request import urlopen
7+
8+
from prometheus_client import start_http_server
9+
10+
import wireguard
11+
12+
13+
class WireguardTests(unittest.TestCase):
14+
"""Test the public monitoring behavior without system services."""
15+
16+
def test_interface_probe_uses_requested_interface(self):
17+
"""The connectivity probe should use the requested interface."""
18+
curl_result = type("Result", (), {"stdout": '{"mullvad_exit_ip": true}'})()
19+
with patch("wireguard.subprocess.run", return_value=curl_result) as run:
20+
self.assertTrue(wireguard.test_interface("wg-test"))
21+
22+
command = run.call_args.args[0]
23+
self.assertIn("wg-test", command)
24+
25+
def test_metrics_endpoint_exposes_cached_status(self):
26+
"""The HTTP endpoint should expose the latest cached gauge value."""
27+
with socket.socket() as sock:
28+
sock.bind(("127.0.0.1", 0))
29+
port = sock.getsockname()[1]
30+
31+
wireguard.wireguard_up.labels(interface="exit").set(1)
32+
start_http_server(port, addr="127.0.0.1")
33+
34+
with urlopen(f"http://127.0.0.1:{port}/metrics") as response:
35+
metrics = response.read().decode("utf-8")
36+
37+
self.assertIn('wireguard_up{interface="exit"} 1.0', metrics)
38+
39+
40+
if __name__ == "__main__":
41+
unittest.main()

wireguard.py

Lines changed: 93 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
1-
import subprocess
1+
"""Monitor WireGuard connectivity and expose its status to Prometheus."""
2+
23
import json
34
import logging
5+
import subprocess
6+
import time
47
import click
8+
from prometheus_client import Gauge, start_http_server
59
from config_manager import new_config
610
from hard_stop import stop_fastd, stop_wg
711
from inform_admin import send_mail
812

913

14+
WIREGUARD_INTERFACE = "exit"
15+
FASTD_SERVICE = "ffsh"
16+
wireguard_up = Gauge(
17+
"wireguard_up", "Whether the WireGuard connection is up", ["interface"]
18+
)
19+
20+
1021
def is_service_running(service_name):
22+
"""Return whether the configured FastD service is running."""
1123
result = subprocess.run(
1224
["systemctl", "show", "-p", "SubState", f"fastd@{service_name}.service"],
1325
capture_output=True,
1426
text=True,
27+
check=False,
1528
)
1629
return result.stdout.strip() == "SubState=running"
1730

@@ -23,7 +36,7 @@ def test_interface(interface_name):
2336
"--connect-timeout",
2437
"10",
2538
"--interface",
26-
"exit",
39+
interface_name,
2740
"https://am.i.mullvad.net/json",
2841
]
2942
try:
@@ -34,20 +47,22 @@ def test_interface(interface_name):
3447
logging.error(e)
3548
return False
3649
try:
37-
if data["mullvad_exit_ip"] is True:
38-
logging.info("Everything ok.")
39-
return True
40-
else:
41-
# something went wrong, Mullvad says we are not connected to Mullvad
42-
logging.error("Mullvad says we are not connected to Mullvad")
43-
return False
50+
connected = data["mullvad_exit_ip"] is True
4451
except KeyError:
4552
# something went wrong the json did not contain mullvad_exit_ip
4653
logging.error("mullvad_exit_ip was not in the json")
4754
return False
4855

56+
if connected:
57+
logging.info("Everything ok.")
58+
return True
59+
60+
logging.error("Mullvad says we are not connected to Mullvad")
61+
return False
62+
4963

5064
def verify(interface_name, fastd_name, mail_config):
65+
"""Check the connection and attempt recovery once if it is down."""
5166
result = test_interface(interface_name)
5267

5368
if result is False:
@@ -61,56 +76,97 @@ def verify(interface_name, fastd_name, mail_config):
6176
stop_wg(interface_name)
6277
send_mail(
6378
mail_config,
64-
"VPN connection did not work, new VPN config did not help.\nFastd and wireguard stopped.",
79+
"VPN connection did not work, new VPN config did not help.\n"
80+
"Fastd and wireguard stopped.",
6581
)
82+
return result
6683

6784

68-
# Cli group, could add more commands in the future
69-
@click.group()
70-
def cli():
71-
pass
85+
def run_check(
86+
mail_config, interface_name=WIREGUARD_INTERFACE, fastd_name=FASTD_SERVICE
87+
):
88+
"""Run one health cycle and return the resulting up/down state."""
89+
if is_service_running(service_name=fastd_name):
90+
return verify(
91+
interface_name=interface_name,
92+
fastd_name=fastd_name,
93+
mail_config=mail_config,
94+
)
7295

96+
logging.info("Fastd service is down, not checking connection")
97+
return False
7398

74-
@cli.command()
75-
@click.option("--user", help="Mail address", required=True)
76-
@click.option("--password", help="Password for Mail Address", required=True)
77-
@click.option("--log", help="Path to log file", required=True)
78-
def check(user, password, log):
79-
"""Check Status of wireguard interface"""
8099

81-
# Create log file if it does not exist
100+
def configure_logging(log):
101+
"""Create the log file if needed and configure application logging."""
82102
try:
83-
with open(log, "x"):
84-
# This part will only execute if the file is created successfully
103+
with open(log, "x", encoding="utf-8"):
85104
pass
86105
except FileExistsError:
87106
pass
88107

89-
# Logging Config
90-
# LogLevel DEBUG, INFO, WARNING, ERROR
91-
log_level = logging.INFO
92-
log_format = "%(asctime)s %(levelname)-8s %(message)s"
93-
date_format = "%Y-%m-%d %H:%M:%S"
94108
logging.basicConfig(
95-
format=log_format,
96-
datefmt=date_format,
109+
format="%(asctime)s %(levelname)-8s %(message)s",
110+
datefmt="%Y-%m-%d %H:%M:%S",
97111
filename=log,
98112
encoding="utf-8",
99-
level=log_level,
113+
level=logging.INFO,
100114
)
101115

102-
# Mail Config
103-
config = {
116+
117+
def build_mail_config(user, password):
118+
"""Build the mail settings used by the recovery alert."""
119+
return {
104120
"target": "noc@freifunk-suedholstein.de",
105121
"host": "mail.freifunk-suedholstein.de",
106122
"port": "465",
107123
"user": user,
108124
"password": password,
109125
}
110-
if is_service_running(service_name="ffsh"):
111-
verify(interface_name="exit", fastd_name="ffsh", mail_config=config)
112-
else:
113-
logging.info("Fastd service is down, not checking connection")
126+
127+
128+
@click.group()
129+
def cli():
130+
"""WireGuard monitoring commands."""
131+
132+
133+
@cli.command()
134+
@click.option("--user", help="Mail address", required=True)
135+
@click.option("--password", help="Password for Mail Address", required=True)
136+
@click.option("--log", help="Path to log file", required=True)
137+
def check(user, password, log):
138+
"""Check the status of the WireGuard interface once."""
139+
configure_logging(log)
140+
run_check(build_mail_config(user, password))
141+
142+
143+
@cli.command()
144+
@click.option("--user", help="Mail address", required=True)
145+
@click.option("--password", help="Password for Mail Address", required=True)
146+
@click.option("--log", help="Path to log file", required=True)
147+
@click.option("--interval", type=float, default=60.0, show_default=True)
148+
@click.option("--host", default="127.0.0.1", show_default=True)
149+
@click.option("--port", type=int, default=8000, show_default=True)
150+
@click.pass_context
151+
def serve(context):
152+
"""Run checks and expose the latest WireGuard status as Prometheus metrics."""
153+
user = context.params["user"]
154+
password = context.params["password"]
155+
log = context.params["log"]
156+
interval = context.params["interval"]
157+
host = context.params["host"]
158+
port = context.params["port"]
159+
configure_logging(log)
160+
start_http_server(port, addr=host)
161+
config = build_mail_config(user, password)
162+
try:
163+
while True:
164+
wireguard_up.labels(interface=WIREGUARD_INTERFACE).set(
165+
1 if run_check(config) else 0
166+
)
167+
time.sleep(interval)
168+
except KeyboardInterrupt:
169+
logging.info("Stopping WireGuard metrics server")
114170

115171

116172
if __name__ == "__main__":

0 commit comments

Comments
 (0)