From 7206e0c4dbf4c8a20dc5ce8013023c1e472fa092 Mon Sep 17 00:00:00 2001 From: Milan Pandurov Date: Thu, 17 Sep 2026 15:42:09 +0200 Subject: [PATCH] Add network monitoring, dashboard, and Home Assistant MQTT publishing Keep /alive unchanged and serve a dashboard at / with status cards, latency graphs, an event timeline, and a Wi-Fi section. Background monitors on configurable intervals: TCP reachability, DNS resolution, public IP change detection, host reboot detection, Wi-Fi link quality, and Wi-Fi scan of networks in range via iw. The Wi-Fi interface is selected with WIFI_INTERFACE; the page lists visible interfaces and reports when none is available. Scan results stay in memory, other metrics go to SQLite on a mounted volume. Optional MQTT publishing with Home Assistant discovery groups all sensors under one device per host. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TdvJYoY8kc8bmBck89U2i6 --- .gitignore | 3 + Dockerfile | 7 +- README.md | 113 +++++++++++++++ app.py | 77 +++++++++- config.py | 88 ++++++++++++ docker-compose.yml | 23 +++ hamqtt.py | 135 ++++++++++++++++++ monitors.py | 328 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + storage.py | 106 ++++++++++++++ templates/index.html | 279 ++++++++++++++++++++++++++++++++++++ tests/test_wifi.py | 155 ++++++++++++++++++++ wifi.py | 200 ++++++++++++++++++++++++++ 13 files changed, 1509 insertions(+), 6 deletions(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 hamqtt.py create mode 100644 monitors.py create mode 100644 storage.py create mode 100644 templates/index.html create mode 100644 tests/test_wifi.py create mode 100644 wifi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93d68de --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +data/ diff --git a/Dockerfile b/Dockerfile index d83521e..dd81f83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ -FROM python:alpine3.17 +FROM python:3.12-alpine +RUN apk add --no-cache iw WORKDIR /app +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt COPY . /app -RUN pip install -r requirements.txt EXPOSE 9999 +VOLUME /data ENTRYPOINT ["python", "app.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cfc00c --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# healthcheck-container + +Small Flask service for remote devices (Raspberry Pi in Docker) that answers +`/alive` for container monitoring and, on top of that, watches the network the +device sits on: internet reachability, DNS, public IP changes, Wi-Fi link +quality, Wi-Fi networks in range, and host reboots. Metrics go to SQLite and +are shown on a dashboard at `/`. Optionally the same values are published to an +MQTT broker with Home Assistant discovery, so each device shows up in Home +Assistant as a device with sensors and no manual configuration. + +## Endpoints + +| Path | Purpose | +| --- | --- | +| `/alive` | Unchanged health check, returns `{"alive": true}` | +| `/` | Dashboard: status, Wi-Fi, graphs, timeline | +| `/api/status` | Latest state of every monitor as JSON | +| `/api/wifi` | Interface list, link details, last scan results | +| `/api/events?limit=200` | Timeline events, newest first | +| `/api/samples?prefix=reach.&range=3600` | Bucketed samples for graphs | + +## Running + +```sh +docker compose up -d --build +``` + +Then open `http://:9999/`. + +Wi-Fi needs two things from Docker: + +- `network_mode: host`, otherwise the container has no wireless interface at all. +- `cap_add: NET_ADMIN`, otherwise link and interface info work but scanning fails + with a permission error that is shown on the dashboard. + +Without `WIFI_INTERFACE` the dashboard lists the interfaces it can see and +nothing is scanned. Set the variable to one of the listed wireless interfaces +and restart the container. + +## Storage + +Samples and events are stored in SQLite at `DB_PATH` (default +`/data/healthcheck.db`). The compose file mounts a named volume at `/data`, so +the database survives `docker compose down`, image rebuilds and `up -d`. Only +`docker compose down -v` or `docker volume rm` deletes it. A bind mount works +as well, for example `- /opt/healthcheck:/data`. + +Samples older than `SAMPLE_RETENTION_DAYS` (default 7) are deleted hourly. +Events are kept. Wi-Fi scan results are held in memory only and never written. + +## Configuration + +All settings are environment variables. Intervals are in seconds. + +| Variable | Default | Meaning | +| --- | --- | --- | +| `PORT` | `9999` | HTTP port | +| `DB_PATH` | `/data/healthcheck.db` | SQLite file | +| `SAMPLE_RETENTION_DAYS` | `7` | How long graph samples are kept | +| `DEVICE_NAME` | hostname | Shown on the dashboard and in Home Assistant | +| `DEVICE_ID` | slug of `DEVICE_NAME` | Used in MQTT topics and unique ids | +| `REACH_TARGETS` | `1.1.1.1:443,8.8.8.8:443,9.9.9.9:443` | TCP connect targets, `host:port` | +| `REACH_INTERVAL` | `30` | | +| `REACH_TIMEOUT` | `3` | Connect timeout per target | +| `DNS_NAME` | `cloudflare.com` | Name resolved through the system resolver | +| `DNS_INTERVAL` | `60` | | +| `PUBLIC_IP_URLS` | ipify, ifconfig.me, icanhazip | Tried in order, first answer wins | +| `PUBLIC_IP_INTERVAL` | `300` | | +| `PUBLIC_IP_TIMEOUT` | `5` | | +| `UPTIME_INTERVAL` | `60` | Reboot detection via `/proc/uptime` | +| `WIFI_INTERFACE` | empty | Wireless interface to use, empty disables Wi-Fi | +| `WIFI_LINK_INTERVAL` | `30` | Signal, bitrate, retries of the current link | +| `WIFI_SCAN_INTERVAL` | `60` | Scan for networks in range | +| `MQTT_HOST` | empty | Broker host, empty disables MQTT | +| `MQTT_PORT` | `1883` | | +| `MQTT_USERNAME` | empty | | +| `MQTT_PASSWORD` | empty | | +| `MQTT_DISCOVERY_PREFIX` | `homeassistant` | Must match the MQTT integration setting | +| `LOG_LEVEL` | `INFO` | | + +## Events on the timeline + +- Internet unreachable / reachable again (all targets failed, then any succeeded) +- DNS resolution failed / working again +- Public IP is X, Public IP changed from X to Y +- Wi-Fi connected, disconnected, roamed to another BSSID +- Host rebooted +- Health check service started + +## Home Assistant + +Set `MQTT_HOST` (and credentials if the broker needs them). On connect the +service publishes retained discovery messages under +`homeassistant//healthcheck_//config`, all pointing +to one device, then publishes state to `healthcheck//state` and +availability to `healthcheck//availability`. When Home Assistant +restarts it announces itself on `homeassistant/status` and the service +re-publishes discovery. + +Entities per device: + +- Internet, DNS, Wi-Fi (binary sensors, `connectivity` class) +- Internet latency, DNS latency (ms) +- Public IP +- Host uptime +- Wi-Fi SSID, BSSID, channel, signal (dBm), TX bitrate (Mbit/s), networks in range + +## Development + +```sh +python3 -m unittest discover -s tests +DB_PATH=./data/hc.db python3 app.py +``` diff --git a/app.py b/app.py index d0a5f80..d2e887e 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,25 @@ -from flask import Flask, request, jsonify +import logging +import time +from flask import Flask, jsonify, render_template, request + +from config import Config +from monitors import State, build_monitors +from storage import Storage + +cfg = Config() +logging.basicConfig(level=cfg.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +log = logging.getLogger("app") + +storage = Storage(cfg.db_path) +state = State() app = Flask(__name__) +WIFI_KEYS = ("wifi_interface", "wifi_interfaces", "wifi_status", "wifi_error", + "wifi_link", "wifi_link_checked_at", "wifi_link_error", + "wifi_scan", "wifi_scan_error") + + @app.route('/alive', methods=['GET']) def get_healthcheck(): response = { @@ -9,9 +27,60 @@ def get_healthcheck(): } return jsonify(response) + @app.route('/') -def hello_world(): - return 'Health check application.' +def index(): + return render_template("index.html") + + +@app.route('/api/status') +def api_status(): + snapshot = state.snapshot() + snapshot.pop("wifi_scan", None) + snapshot["now"] = time.time() + snapshot["config"] = cfg.public() + return jsonify(snapshot) + + +@app.route('/api/wifi') +def api_wifi(): + snapshot = state.snapshot() + result = {key: snapshot.get(key) for key in WIFI_KEYS} + result["now"] = time.time() + result["scan_interval"] = cfg.wifi_scan_interval + return jsonify(result) + + +@app.route('/api/events') +def api_events(): + limit = min(max(request.args.get("limit", 200, type=int), 1), 1000) + return jsonify(storage.events(limit)) + + +@app.route('/api/samples') +def api_samples(): + prefix = request.args.get("prefix", "") + span = min(max(request.args.get("range", 3600, type=int), 60), cfg.sample_retention_days * 86400) + bucket = max(10, span // 400) + since = time.time() - span + return jsonify({"since": since, "bucket": bucket, "series": storage.samples_by_prefix(prefix, since, bucket)}) + + +def start_background(): + storage.add_event("service_started", "Health check service started") + storage.prune_samples(cfg.sample_retention_days) + + if cfg.mqtt_host: + from hamqtt import HomeAssistantPublisher + publisher = HomeAssistantPublisher(cfg) + state.on_change(publisher.publish_state) + publisher.start() + + for monitor in build_monitors(cfg, state, storage): + monitor.start() + log.info("started monitor %s every %ss", monitor.name, monitor.interval) + if __name__ == "__main__": - app.run(host="0.0.0.0", port=9999) + start_background() + app.run(host="0.0.0.0", port=cfg.port) diff --git a/config.py b/config.py new file mode 100644 index 0000000..3533e50 --- /dev/null +++ b/config.py @@ -0,0 +1,88 @@ +import os +import re +import socket + + +def _int(name, default): + raw = os.environ.get(name) + if raw is None or raw.strip() == "": + return default + try: + return int(raw) + except ValueError: + return default + + +def _float(name, default): + raw = os.environ.get(name) + if raw is None or raw.strip() == "": + return default + try: + return float(raw) + except ValueError: + return default + + +def _list(name, default): + raw = os.environ.get(name, default) + return [item.strip() for item in raw.split(",") if item.strip()] + + +def _slug(value): + return re.sub(r"[^a-z0-9_-]+", "_", value.lower()).strip("_") or "device" + + +class Config: + def __init__(self): + env = os.environ + hostname = socket.gethostname() + + self.port = _int("PORT", 9999) + self.log_level = env.get("LOG_LEVEL", "INFO") + self.db_path = env.get("DB_PATH", "/data/healthcheck.db") + self.sample_retention_days = _int("SAMPLE_RETENTION_DAYS", 7) + + self.device_name = env.get("DEVICE_NAME", hostname) + self.device_id = _slug(env.get("DEVICE_ID", self.device_name)) + + self.reach_targets = _list("REACH_TARGETS", "1.1.1.1:443,8.8.8.8:443,9.9.9.9:443") + self.reach_interval = _int("REACH_INTERVAL", 30) + self.reach_timeout = _float("REACH_TIMEOUT", 3.0) + + self.dns_name = env.get("DNS_NAME", "cloudflare.com") + self.dns_interval = _int("DNS_INTERVAL", 60) + + self.public_ip_urls = _list( + "PUBLIC_IP_URLS", + "https://api.ipify.org,https://ifconfig.me/ip,https://icanhazip.com", + ) + self.public_ip_interval = _int("PUBLIC_IP_INTERVAL", 300) + self.public_ip_timeout = _float("PUBLIC_IP_TIMEOUT", 5.0) + + self.uptime_interval = _int("UPTIME_INTERVAL", 60) + + self.wifi_interface = env.get("WIFI_INTERFACE", "").strip() + self.wifi_link_interval = _int("WIFI_LINK_INTERVAL", 30) + self.wifi_scan_interval = _int("WIFI_SCAN_INTERVAL", 60) + + self.mqtt_host = env.get("MQTT_HOST", "").strip() + self.mqtt_port = _int("MQTT_PORT", 1883) + self.mqtt_username = env.get("MQTT_USERNAME", "") + self.mqtt_password = env.get("MQTT_PASSWORD", "") + self.mqtt_discovery_prefix = env.get("MQTT_DISCOVERY_PREFIX", "homeassistant") + + def public(self): + return { + "device_name": self.device_name, + "device_id": self.device_id, + "reach_targets": self.reach_targets, + "reach_interval": self.reach_interval, + "dns_name": self.dns_name, + "dns_interval": self.dns_interval, + "public_ip_interval": self.public_ip_interval, + "wifi_interface": self.wifi_interface, + "wifi_link_interval": self.wifi_link_interval, + "wifi_scan_interval": self.wifi_scan_interval, + "sample_retention_days": self.sample_retention_days, + "mqtt_enabled": bool(self.mqtt_host), + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ea9f53 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + healthcheck: + build: . + image: healthcheck-container + container_name: healthcheck + restart: unless-stopped + # Host networking exposes the Wi-Fi interface to the container. The app + # listens on port 9999 on the host directly, so no `ports:` mapping is used. + network_mode: host + cap_add: + - NET_ADMIN + volumes: + - healthcheck-data:/data + environment: + DEVICE_NAME: pi-livingroom + WIFI_INTERFACE: wlan0 + WIFI_SCAN_INTERVAL: "60" + # MQTT_HOST: homeassistant.local + # MQTT_USERNAME: mqtt + # MQTT_PASSWORD: secret + +volumes: + healthcheck-data: diff --git a/hamqtt.py b/hamqtt.py new file mode 100644 index 0000000..846b5f4 --- /dev/null +++ b/hamqtt.py @@ -0,0 +1,135 @@ +import json +import logging + +import paho.mqtt.client as mqtt + +log = logging.getLogger(__name__) + +VERSION = "2.0.0" + +CONNECTIVITY = {"device_class": "connectivity"} + +SENSORS = [ + ("internet_up", "binary_sensor", "Internet", CONNECTIVITY), + ("internet_latency_ms", "sensor", "Internet latency", + {"unit_of_measurement": "ms", "state_class": "measurement", "icon": "mdi:timer-outline"}), + ("dns_up", "binary_sensor", "DNS", CONNECTIVITY), + ("dns_latency_ms", "sensor", "DNS latency", + {"unit_of_measurement": "ms", "state_class": "measurement", "icon": "mdi:dns"}), + ("public_ip", "sensor", "Public IP", {"icon": "mdi:ip-network-outline"}), + ("uptime_s", "sensor", "Host uptime", + {"device_class": "duration", "unit_of_measurement": "s", "entity_category": "diagnostic"}), +] + +WIFI_SENSORS = [ + ("wifi_connected", "binary_sensor", "Wi-Fi", CONNECTIVITY), + ("wifi_ssid", "sensor", "Wi-Fi SSID", {"icon": "mdi:wifi"}), + ("wifi_signal_dbm", "sensor", "Wi-Fi signal", + {"device_class": "signal_strength", "unit_of_measurement": "dBm", "state_class": "measurement"}), + ("wifi_tx_bitrate_mbps", "sensor", "Wi-Fi TX bitrate", + {"device_class": "data_rate", "unit_of_measurement": "Mbit/s", "state_class": "measurement"}), + ("wifi_bssid", "sensor", "Wi-Fi BSSID", {"icon": "mdi:access-point", "entity_category": "diagnostic"}), + ("wifi_channel", "sensor", "Wi-Fi channel", {"icon": "mdi:radio-tower", "entity_category": "diagnostic"}), + ("wifi_ap_count", "sensor", "Wi-Fi networks in range", + {"icon": "mdi:access-point-network", "state_class": "measurement"}), +] + + +def flatten(snapshot): + link = snapshot.get("wifi_link") or {} + scan = snapshot.get("wifi_scan") or {} + return { + "internet_up": snapshot.get("internet_up"), + "internet_latency_ms": snapshot.get("internet_latency_ms"), + "dns_up": snapshot.get("dns_up"), + "dns_latency_ms": snapshot.get("dns_latency_ms"), + "public_ip": snapshot.get("public_ip"), + "uptime_s": snapshot.get("uptime_s"), + "wifi_connected": link.get("connected"), + "wifi_ssid": link.get("ssid"), + "wifi_bssid": link.get("bssid"), + "wifi_signal_dbm": link.get("signal_dbm"), + "wifi_tx_bitrate_mbps": link.get("tx_bitrate_mbps"), + "wifi_channel": link.get("channel"), + "wifi_ap_count": scan.get("count"), + } + + +class HomeAssistantPublisher: + def __init__(self, cfg): + self.cfg = cfg + self.node = f"healthcheck_{cfg.device_id}" + base = f"healthcheck/{cfg.device_id}" + self.state_topic = f"{base}/state" + self.availability_topic = f"{base}/availability" + self.status_topic = f"{cfg.mqtt_discovery_prefix}/status" + self._last_payload = None + + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.node) + if cfg.mqtt_username: + self.client.username_pw_set(cfg.mqtt_username, cfg.mqtt_password) + self.client.will_set(self.availability_topic, "offline", retain=True) + self.client.on_connect = self._on_connect + self.client.on_message = self._on_message + self.client.on_disconnect = self._on_disconnect + + def start(self): + self.client.reconnect_delay_set(min_delay=2, max_delay=60) + self.client.connect_async(self.cfg.mqtt_host, self.cfg.mqtt_port, keepalive=60) + self.client.loop_start() + log.info("MQTT: connecting to %s:%s as %s", self.cfg.mqtt_host, self.cfg.mqtt_port, self.node) + + def publish_state(self, snapshot): + payload = flatten(snapshot) + if payload == self._last_payload: + return + self._last_payload = payload + self.client.publish(self.state_topic, json.dumps(payload), retain=True) + + def _on_connect(self, client, userdata, flags, reason_code, properties): + if reason_code.is_failure: + log.warning("MQTT: connection refused: %s", reason_code) + return + log.info("MQTT: connected") + client.subscribe(self.status_topic) + self._announce() + if self._last_payload is not None: + client.publish(self.state_topic, json.dumps(self._last_payload), retain=True) + + def _on_disconnect(self, client, userdata, flags, reason_code, properties): + log.warning("MQTT: disconnected: %s", reason_code) + + def _on_message(self, client, userdata, message): + if message.topic == self.status_topic and message.payload.decode(errors="ignore") == "online": + log.info("MQTT: Home Assistant came online, re-announcing") + self._announce() + + def _announce(self): + device = { + "identifiers": [self.node], + "name": self.cfg.device_name, + "model": "healthcheck-container", + "manufacturer": "healthcheck", + "sw_version": VERSION, + } + sensors = SENSORS + (WIFI_SENSORS if self.cfg.wifi_interface else []) + for key, component, name, extra in sensors: + if component == "binary_sensor": + template = ( + f"{{{{ 'None' if value_json.{key} is none " + f"else ('ON' if value_json.{key} else 'OFF') }}}}" + ) + else: + template = f"{{{{ value_json.{key} }}}}" + payload = { + "name": name, + "unique_id": f"{self.node}_{key}", + "state_topic": self.state_topic, + "value_template": template, + "availability_topic": self.availability_topic, + "device": device, + **extra, + } + topic = f"{self.cfg.mqtt_discovery_prefix}/{component}/{self.node}/{key}/config" + self.client.publish(topic, json.dumps(payload), retain=True) + self.client.publish(self.availability_topic, "online", retain=True) diff --git a/monitors.py b/monitors.py new file mode 100644 index 0000000..60eb870 --- /dev/null +++ b/monitors.py @@ -0,0 +1,328 @@ +import ipaddress +import logging +import socket +import threading +import time +import urllib.request + +import wifi + +log = logging.getLogger(__name__) + + +class State: + def __init__(self): + self._lock = threading.Lock() + self._data = {} + self._listeners = [] + + def on_change(self, listener): + self._listeners.append(listener) + + def update(self, **fields): + with self._lock: + self._data.update(fields) + snapshot = dict(self._data) + for listener in self._listeners: + try: + listener(snapshot) + except Exception: + log.exception("state listener failed") + + def snapshot(self): + with self._lock: + return dict(self._data) + + +class Monitor(threading.Thread): + def __init__(self, name, interval, state, storage): + super().__init__(name=name, daemon=True) + self.interval = interval + self.state = state + self.storage = storage + + def run(self): + while True: + started = time.monotonic() + try: + self.tick() + except Exception as exc: + log.warning("%s: %s", self.name, exc) + self.state.update(**{f"{self.name}_error": str(exc)}) + elapsed = time.monotonic() - started + time.sleep(max(1.0, self.interval - elapsed)) + + def tick(self): + raise NotImplementedError + + +def tcp_latency_ms(target, timeout): + if ":" in target: + host, port = target.rsplit(":", 1) + else: + host, port = target, "443" + started = time.monotonic() + try: + with socket.create_connection((host, int(port)), timeout=timeout): + pass + except (OSError, ValueError): + return None + return round((time.monotonic() - started) * 1000, 1) + + +class Reachability(Monitor): + def __init__(self, cfg, state, storage): + super().__init__("internet", cfg.reach_interval, state, storage) + self.targets = cfg.reach_targets + self.timeout = cfg.reach_timeout + self.up = None + + def tick(self): + ts = time.time() + results = {target: tcp_latency_ms(target, self.timeout) for target in self.targets} + reachable = [ms for ms in results.values() if ms is not None] + up = bool(reachable) + + samples = [(f"reach.{target}", ms) for target, ms in results.items()] + samples.append(("internet.up", 1 if up else 0)) + self.storage.add_samples(samples, ts) + + if up != self.up: + if not up: + self.storage.add_event("internet_down", "Internet unreachable: all targets failed") + elif self.up is False: + self.storage.add_event("internet_up", "Internet reachable again") + self.up = up + + self.state.update( + internet_up=up, + internet_latency_ms=min(reachable) if reachable else None, + reach=results, + internet_checked_at=ts, + internet_error=None, + ) + + +class Dns(Monitor): + def __init__(self, cfg, state, storage): + super().__init__("dns", cfg.dns_interval, state, storage) + self.name_to_resolve = cfg.dns_name + self.up = None + + def tick(self): + ts = time.time() + started = time.monotonic() + try: + socket.getaddrinfo(self.name_to_resolve, 443, proto=socket.IPPROTO_TCP) + ms = round((time.monotonic() - started) * 1000, 1) + except socket.gaierror: + ms = None + up = ms is not None + + self.storage.add_sample("dns.latency_ms", ms, ts) + if up != self.up: + if not up: + self.storage.add_event("dns_down", f"DNS resolution of {self.name_to_resolve} failed") + elif self.up is False: + self.storage.add_event("dns_up", "DNS resolution working again") + self.up = up + + self.state.update(dns_up=up, dns_latency_ms=ms, dns_checked_at=ts, dns_error=None) + + +class PublicIp(Monitor): + def __init__(self, cfg, state, storage): + super().__init__("public_ip", cfg.public_ip_interval, state, storage) + self.urls = cfg.public_ip_urls + self.timeout = cfg.public_ip_timeout + previous = storage.get("public_ip") + since = storage.get("public_ip_since") + if previous: + state.update(public_ip=previous, public_ip_since=float(since) if since else None) + + def tick(self): + ts = time.time() + ip, source, error = None, None, "no lookup services configured" + for url in self.urls: + try: + request = urllib.request.Request(url, headers={"User-Agent": "healthcheck-container"}) + with urllib.request.urlopen(request, timeout=self.timeout) as response: + body = response.read(200).decode("utf-8", errors="replace").strip() + ip = str(ipaddress.ip_address(body)) + source = url + break + except (OSError, ValueError) as exc: + error = f"{url}: {exc}" + + if ip is None: + self.state.update(public_ip_checked_at=ts, public_ip_error=error) + return + + previous = self.storage.get("public_ip") + if previous != ip: + if previous: + self.storage.add_event("public_ip_changed", f"Public IP changed from {previous} to {ip}") + else: + self.storage.add_event("public_ip", f"Public IP is {ip}") + self.storage.set("public_ip", ip) + self.storage.set("public_ip_since", str(ts)) + + since = self.storage.get("public_ip_since") + self.state.update( + public_ip=ip, + public_ip_source=source, + public_ip_since=float(since) if since else ts, + public_ip_checked_at=ts, + public_ip_error=None, + ) + + +class Uptime(Monitor): + def __init__(self, cfg, state, storage): + super().__init__("host", cfg.uptime_interval, state, storage) + + def tick(self): + with open("/proc/uptime") as handle: + uptime = float(handle.read().split()[0]) + boot_time = time.time() - uptime + previous = self.storage.get("boot_time") + if previous is None or abs(float(previous) - boot_time) > 120: + if previous is not None: + self.storage.add_event("reboot", "Host rebooted") + self.storage.set("boot_time", str(boot_time)) + self.state.update(uptime_s=int(uptime), boot_time=boot_time, host_error=None) + + +class Housekeeping(Monitor): + def __init__(self, cfg, state, storage): + super().__init__("housekeeping", 3600, state, storage) + self.retention_days = cfg.sample_retention_days + + def tick(self): + self.storage.prune_samples(self.retention_days) + + +def resolve_wifi_interface(cfg, state): + found = wifi.interfaces() + wireless = [item["name"] for item in found if item["wireless"]] + fields = {"wifi_interfaces": found, "wifi_interface": cfg.wifi_interface} + + if not cfg.wifi_interface: + if wireless: + fields["wifi_status"] = "not_configured" + fields["wifi_error"] = ( + "No interface selected. Set WIFI_INTERFACE to one of: " + ", ".join(wireless) + ) + else: + fields["wifi_status"] = "unavailable" + fields["wifi_error"] = ( + "No wireless interfaces are visible from this container. " + "Run it with network_mode: host on a device that has Wi-Fi." + ) + state.update(**fields) + return None + + if cfg.wifi_interface not in wireless: + available = ", ".join(wireless) if wireless else "none" + fields["wifi_status"] = "missing" + fields["wifi_error"] = ( + f"Interface '{cfg.wifi_interface}' is not a visible wireless interface. " + f"Wireless interfaces visible: {available}." + ) + state.update(**fields) + return None + + fields["wifi_status"] = "ok" + fields["wifi_error"] = None + state.update(**fields) + return cfg.wifi_interface + + +class WifiLink(Monitor): + def __init__(self, cfg, state, storage): + super().__init__("wifi_link", cfg.wifi_link_interval, state, storage) + self.cfg = cfg + self.connected = None + self.bssid = None + self.counters = None + + def tick(self): + iface = resolve_wifi_interface(self.cfg, self.state) + if iface is None: + return + + ts = time.time() + info = wifi.link(iface) + if info["connected"]: + info.update(wifi.station(iface)) + + self._record_events(info) + self._record_samples(info, ts) + self.state.update(wifi_link=info, wifi_link_checked_at=ts, wifi_link_error=None) + + def _record_events(self, info): + connected = info["connected"] + bssid = info.get("bssid") + where = f"{info.get('ssid', '?')} ({bssid}, ch {info.get('channel', '?')}, {info.get('band', '?')})" + if connected != self.connected: + if connected: + self.storage.add_event("wifi_connected", f"Wi-Fi connected to {where}") + else: + self.storage.add_event("wifi_disconnected", "Wi-Fi disconnected") + self.connected = connected + self.counters = None + elif connected and bssid != self.bssid: + self.storage.add_event("wifi_roamed", f"Wi-Fi roamed to {where}") + self.counters = None + self.bssid = bssid + + def _record_samples(self, info, ts): + if not info["connected"]: + self.storage.add_sample("wifi.signal_dbm", None, ts) + self.counters = None + return + samples = [ + ("wifi.signal_dbm", info.get("signal_dbm")), + ("wifi.tx_bitrate_mbps", info.get("tx_bitrate_mbps")), + ("wifi.rx_bitrate_mbps", info.get("rx_bitrate_mbps")), + ] + counters = (info.get("tx_retries"), info.get("tx_failed"), info.get("beacon_loss")) + if None not in counters: + if self.counters and all(now >= before for now, before in zip(counters, self.counters)): + deltas = [now - before for now, before in zip(counters, self.counters)] + samples += [ + ("wifi.tx_retries", deltas[0]), + ("wifi.tx_failed", deltas[1]), + ("wifi.beacon_loss", deltas[2]), + ] + self.counters = counters + self.storage.add_samples(samples, ts) + + +class WifiScan(Monitor): + def __init__(self, cfg, state, storage): + super().__init__("wifi_scan", cfg.wifi_scan_interval, state, storage) + self.cfg = cfg + + def tick(self): + iface = resolve_wifi_interface(self.cfg, self.state) + if iface is None: + self.state.update(wifi_scan=None, wifi_scan_error=None) + return + aps = wifi.scan(iface) + self.state.update( + wifi_scan={"aps": aps, "count": len(aps), "ts": time.time()}, + wifi_scan_error=None, + ) + + +def build_monitors(cfg, state, storage): + return [ + Reachability(cfg, state, storage), + Dns(cfg, state, storage), + PublicIp(cfg, state, storage), + Uptime(cfg, state, storage), + WifiLink(cfg, state, storage), + WifiScan(cfg, state, storage), + Housekeeping(cfg, state, storage), + ] diff --git a/requirements.txt b/requirements.txt index 7e10602..32ae12f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask +paho-mqtt>=2,<3 diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..aaf9950 --- /dev/null +++ b/storage.py @@ -0,0 +1,106 @@ +import os +import sqlite3 +import time +from contextlib import closing + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS samples ( + ts REAL NOT NULL, + metric TEXT NOT NULL, + value REAL +); +CREATE INDEX IF NOT EXISTS samples_metric_ts ON samples(metric, ts); +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + kind TEXT NOT NULL, + message TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS events_ts ON events(ts); +CREATE TABLE IF NOT EXISTS kv ( + key TEXT PRIMARY KEY, + value TEXT +); +""" + + +def _like_prefix(prefix): + escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return escaped + "%" + + +class Storage: + def __init__(self, path): + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + self.path = path + with closing(self._connect()) as conn: + conn.executescript(SCHEMA) + + def _connect(self): + conn = sqlite3.connect(self.path, timeout=10) + conn.execute("PRAGMA journal_mode=WAL") + return conn + + def add_samples(self, pairs, ts=None): + ts = ts or time.time() + with closing(self._connect()) as conn, conn: + conn.executemany( + "INSERT INTO samples (ts, metric, value) VALUES (?, ?, ?)", + [(ts, metric, value) for metric, value in pairs], + ) + + def add_sample(self, metric, value, ts=None): + self.add_samples([(metric, value)], ts) + + def add_event(self, kind, message, ts=None): + with closing(self._connect()) as conn, conn: + conn.execute( + "INSERT INTO events (ts, kind, message) VALUES (?, ?, ?)", + (ts or time.time(), kind, message), + ) + + def events(self, limit=200): + with closing(self._connect()) as conn: + rows = conn.execute( + "SELECT ts, kind, message FROM events ORDER BY ts DESC, id DESC LIMIT ?", + (limit,), + ).fetchall() + return [{"ts": ts, "kind": kind, "message": message} for ts, kind, message in rows] + + def samples_by_prefix(self, prefix, since, bucket): + bucket = max(1, int(bucket)) + with closing(self._connect()) as conn: + rows = conn.execute( + """ + SELECT metric, CAST(ts / ? AS INTEGER) * ? AS bucket, AVG(value) + FROM samples + WHERE metric LIKE ? ESCAPE '\\' AND ts >= ? + GROUP BY metric, bucket + ORDER BY metric, bucket + """, + (bucket, bucket, _like_prefix(prefix), since), + ).fetchall() + series = {} + for metric, ts, value in rows: + series.setdefault(metric, []).append([ts, value]) + return series + + def get(self, key): + with closing(self._connect()) as conn: + row = conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone() + return row[0] if row else None + + def set(self, key, value): + with closing(self._connect()) as conn, conn: + conn.execute( + "INSERT INTO kv (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + + def prune_samples(self, days): + cutoff = time.time() - days * 86400 + with closing(self._connect()) as conn, conn: + conn.execute("DELETE FROM samples WHERE ts < ?", (cutoff,)) diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6791f39 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,279 @@ + + + + + +Health check + + + +
+
+

Health check

+ + +
+ +
+
Internet
…
+
DNS
…
+
Public IP
…
+
Wi-Fi
…
+
+ +

History

+
+ + + + +
+
+

Reachability latency (ms)

+

DNS latency (ms)

+
+ +

Timeline

+
    +

    Wi-Fi

    +
    +
    +
    + +
    +
    +
    + Networks in range + +
    +
    +
    +
    + +
    +

    Wi-Fi signal (dBm)

    +

    Wi-Fi bitrate (Mbit/s)

    +

    Wi-Fi retries / failed / beacon loss per interval

    +
    + +
    + + + + diff --git a/tests/test_wifi.py b/tests/test_wifi.py new file mode 100644 index 0000000..0d112ce --- /dev/null +++ b/tests/test_wifi.py @@ -0,0 +1,155 @@ +import unittest + +import wifi + +LINK = """Connected to 9c:3d:cf:12:34:56 (on wlan0) +\tSSID: Home:Net +\tfreq: 5180 +\tRX: 123456 bytes (789 packets) +\tTX: 6543 bytes (98 packets) +\tsignal: -52 dBm +\trx bitrate: 6.0 MBit/s +\ttx bitrate: 866.7 MBit/s VHT-MCS 9 80MHz short GI VHT-NSS 2 + +\tbss flags:\tshort-slot-time +\tdtim period:\t1 +\tbeacon int:\t100 +""" + +STATION = """Station 9c:3d:cf:12:34:56 (on wlan0) +\tinactive time:\t20 ms +\trx bytes:\t123456 +\trx packets:\t789 +\ttx bytes:\t6543 +\ttx packets:\t98 +\ttx retries:\t42 +\ttx failed:\t3 +\tbeacon loss:\t1 +\tbeacon rx:\t1000 +\trx drop misc:\t5 +\tsignal: \t-52 [-54, -58] dBm +\tsignal avg:\t-53 [-55, -57] dBm +\ttx bitrate:\t866.7 MBit/s VHT-MCS 9 80MHz short GI VHT-NSS 2 +\trx bitrate:\t6.0 MBit/s +\tauthorized:\tyes +\tconnected time:\t12345 seconds +""" + +SCAN = """BSS 9c:3d:cf:12:34:56(on wlan0) -- associated +\tlast seen: 1234.567s [boottime] +\tTSF: 1 usec (0d, 00:00:00) +\tfreq: 5180.0 +\tbeacon interval: 100 TUs +\tcapability: ESS Privacy ShortSlotTime (0x0411) +\tsignal: -52.00 dBm +\tlast seen: 0 ms ago +\tInformation elements from Probe Response frame: +\tSSID: Home:Net +\tSupported rates: 6.0* 9.0 12.0* 18.0 24.0* 36.0 48.0 54.0 +\tRSN:\t * Version: 1 +\t\t * Group cipher: CCMP +\t\t * Pairwise ciphers: CCMP +\t\t * Authentication suites: PSK SAE +\t\t * Capabilities: 1-PTKSA-RC 1-GTKSA-RC MFP-capable (0x0080) +BSS 00:11:22:33:44:55(on wlan0) +\tfreq: 2437 +\tcapability: ESS (0x0401) +\tsignal: -71.00 dBm +\tSSID: Cafe +\tDS Parameter set: channel 6 +BSS 66:77:88:99:aa:bb(on wlan0) +\tfreq: 2412 +\tcapability: ESS Privacy (0x0411) +\tsignal: -80.00 dBm +\tSSID: +\tWPA:\t * Version: 1 +\t\t * Group cipher: TKIP +\t\t * Pairwise ciphers: TKIP +\t\t * Authentication suites: PSK +BSS cc:dd:ee:ff:00:11(on wlan0) +\tfreq: 2462 +\tcapability: ESS Privacy (0x0411) +\tsignal: -85.00 dBm +\tSSID: Legacy +""" + + +class ParseLinkTest(unittest.TestCase): + def test_connected(self): + info = wifi.parse_link(LINK) + self.assertTrue(info["connected"]) + self.assertEqual(info["bssid"], "9c:3d:cf:12:34:56") + self.assertEqual(info["ssid"], "Home:Net") + self.assertEqual(info["freq_mhz"], 5180) + self.assertEqual(info["channel"], 36) + self.assertEqual(info["band"], "5 GHz") + self.assertEqual(info["signal_dbm"], -52) + self.assertEqual(info["tx_bitrate_mbps"], 866.7) + self.assertEqual(info["rx_bitrate_mbps"], 6.0) + + def test_not_connected(self): + self.assertEqual(wifi.parse_link("Not connected.\n"), {"connected": False}) + self.assertEqual(wifi.parse_link(""), {"connected": False}) + + +class ParseStationTest(unittest.TestCase): + def test_counters(self): + info = wifi.parse_station_dump(STATION) + self.assertEqual(info["tx_retries"], 42) + self.assertEqual(info["tx_failed"], 3) + self.assertEqual(info["beacon_loss"], 1) + self.assertEqual(info["connected_s"], 12345) + self.assertEqual(info["signal_avg_dbm"], -53) + + def test_empty(self): + self.assertEqual(wifi.parse_station_dump(""), {}) + + +class ParseScanTest(unittest.TestCase): + def test_networks_sorted_by_signal(self): + aps = wifi.parse_scan(SCAN) + self.assertEqual([ap["bssid"] for ap in aps], [ + "9c:3d:cf:12:34:56", "00:11:22:33:44:55", "66:77:88:99:aa:bb", "cc:dd:ee:ff:00:11", + ]) + + def test_fields(self): + home, cafe, hidden, legacy = wifi.parse_scan(SCAN) + self.assertTrue(home["associated"]) + self.assertEqual(home["ssid"], "Home:Net") + self.assertEqual(home["freq_mhz"], 5180) + self.assertEqual(home["channel"], 36) + self.assertEqual(home["signal_dbm"], -52.0) + self.assertEqual(home["security"], "WPA3/WPA2") + + self.assertFalse(cafe["associated"]) + self.assertEqual(cafe["channel"], 6) + self.assertEqual(cafe["band"], "2.4 GHz") + self.assertEqual(cafe["security"], "Open") + + self.assertEqual(hidden["ssid"], "") + self.assertEqual(hidden["channel"], 1) + self.assertEqual(hidden["security"], "WPA") + + self.assertEqual(legacy["channel"], 11) + self.assertEqual(legacy["security"], "WEP") + + def test_no_internal_keys_leak(self): + for ap in wifi.parse_scan(SCAN): + self.assertFalse([key for key in ap if key.startswith("_")]) + + def test_empty(self): + self.assertEqual(wifi.parse_scan(""), []) + + +class ChannelTest(unittest.TestCase): + def test_mapping(self): + self.assertEqual(wifi.freq_to_channel(2412), 1) + self.assertEqual(wifi.freq_to_channel(2484), 14) + self.assertEqual(wifi.freq_to_channel(5745), 149) + self.assertEqual(wifi.freq_to_channel(5955), 1) + self.assertIsNone(wifi.freq_to_channel(None)) + self.assertIsNone(wifi.freq_to_channel(1000)) + + +if __name__ == "__main__": + unittest.main() diff --git a/wifi.py b/wifi.py new file mode 100644 index 0000000..5561f56 --- /dev/null +++ b/wifi.py @@ -0,0 +1,200 @@ +import os +import re +import subprocess + +SYS_NET = "/sys/class/net" + +_KV = re.compile(r"^\s*([^:]+?):\s*(.*)$") +_BSS = re.compile(r"^BSS ([0-9a-f]{2}(?::[0-9a-f]{2}){5})", re.IGNORECASE) + + +class WifiError(Exception): + pass + + +def interfaces(): + try: + names = sorted(os.listdir(SYS_NET)) + except OSError: + return [] + result = [] + for name in names: + path = os.path.join(SYS_NET, name) + wireless = os.path.isdir(os.path.join(path, "wireless")) + physical = os.path.exists(os.path.join(path, "device")) + if wireless or physical: + result.append({"name": name, "wireless": wireless}) + return result + + +def freq_to_channel(freq): + if freq is None: + return None + if freq == 2484: + return 14 + if 2412 <= freq <= 2472: + return (freq - 2407) // 5 + if 5160 <= freq <= 5885: + return (freq - 5000) // 5 + if 5955 <= freq <= 7115: + return (freq - 5950) // 5 + return None + + +def freq_to_band(freq): + if freq is None: + return None + if freq < 3000: + return "2.4 GHz" + if freq < 5925: + return "5 GHz" + return "6 GHz" + + +def _run(args, timeout): + try: + proc = subprocess.run(["iw", *args], capture_output=True, text=True, timeout=timeout) + except FileNotFoundError: + raise WifiError("the 'iw' tool is not installed in the container image") + except subprocess.TimeoutExpired: + raise WifiError(f"'iw {' '.join(args)}' timed out after {timeout}s") + if proc.returncode != 0: + raise WifiError(_explain(proc.stderr.strip() or proc.stdout.strip(), args)) + return proc.stdout + + +def _explain(stderr, args): + if "Operation not permitted" in stderr: + return "permission denied: scanning needs the NET_ADMIN capability (cap_add: NET_ADMIN)" + if "Device or resource busy" in stderr: + return "device busy: another scan is in progress, will retry on the next interval" + if "No such device" in stderr: + return "interface disappeared" + if "Network is down" in stderr: + return "interface is down" + return f"iw {' '.join(args)} failed: {stderr}" + + +def link(iface): + return parse_link(_run(["dev", iface, "link"], timeout=10)) + + +def station(iface): + return parse_station_dump(_run(["dev", iface, "station", "dump"], timeout=10)) + + +def scan(iface): + return parse_scan(_run(["dev", iface, "scan"], timeout=40)) + + +def _first_number(value): + return float(value.split()[0]) + + +def parse_link(text): + lines = text.strip().splitlines() + if not lines or not lines[0].startswith("Connected to"): + return {"connected": False} + info = {"connected": True, "bssid": lines[0].split()[2].lower()} + for line in lines[1:]: + match = _KV.match(line) + if not match: + continue + key, value = match.group(1).strip(), match.group(2).strip() + if key == "SSID": + info["ssid"] = value + elif key == "freq": + info["freq_mhz"] = int(_first_number(value)) + elif key == "signal": + info["signal_dbm"] = int(_first_number(value)) + elif key == "tx bitrate": + info["tx_bitrate_mbps"] = _first_number(value) + elif key == "rx bitrate": + info["rx_bitrate_mbps"] = _first_number(value) + freq = info.get("freq_mhz") + info["channel"] = freq_to_channel(freq) + info["band"] = freq_to_band(freq) + return info + + +def parse_station_dump(text): + info = {} + for line in text.splitlines(): + if line.startswith("Station") and info: + break + match = _KV.match(line) + if not match: + continue + key, value = match.group(1).strip(), match.group(2).strip() + if key == "tx retries": + info["tx_retries"] = int(value) + elif key == "tx failed": + info["tx_failed"] = int(value) + elif key == "beacon loss": + info["beacon_loss"] = int(value) + elif key == "connected time": + info["connected_s"] = int(_first_number(value)) + elif key == "signal avg": + info["signal_avg_dbm"] = int(_first_number(value)) + return info + + +def parse_scan(text): + aps = [] + current = None + for line in text.splitlines(): + match = _BSS.match(line) + if match: + current = { + "bssid": match.group(1).lower(), + "ssid": "", + "associated": "associated" in line, + "_rsn": False, + "_wpa": False, + "_sae": False, + "_privacy": False, + } + aps.append(current) + continue + if current is None: + continue + stripped = line.strip() + if stripped.startswith("freq:"): + current["freq_mhz"] = int(_first_number(stripped[5:])) + elif stripped.startswith("signal:"): + current["signal_dbm"] = round(_first_number(stripped[7:]), 1) + elif stripped.startswith("SSID:"): + current["ssid"] = stripped[5:].strip() + elif stripped.startswith("RSN:"): + current["_rsn"] = True + elif stripped.startswith("WPA:"): + current["_wpa"] = True + elif stripped.startswith("capability:") and "Privacy" in stripped: + current["_privacy"] = True + elif "Authentication suites:" in stripped and "SAE" in stripped: + current["_sae"] = True + + for ap in aps: + freq = ap.get("freq_mhz") + ap["channel"] = freq_to_channel(freq) + ap["band"] = freq_to_band(freq) + ap["security"] = _security_label(ap) + for key in ("_rsn", "_wpa", "_sae", "_privacy"): + del ap[key] + aps.sort(key=lambda ap: ap.get("signal_dbm", -1000), reverse=True) + return aps + + +def _security_label(ap): + labels = [] + if ap["_sae"]: + labels.append("WPA3") + if ap["_rsn"]: + labels.append("WPA2") + if ap["_wpa"]: + labels.append("WPA") + if labels: + return "/".join(labels) + if ap["_privacy"]: + return "WEP" + return "Open"