diff --git a/hosts/12kingdoms-youko/homelab-services/grafana/dashboards/homelab/transmission.json b/hosts/12kingdoms-youko/homelab-services/grafana/dashboards/homelab/transmission.json new file mode 100644 index 00000000..867a0f0b --- /dev/null +++ b/hosts/12kingdoms-youko/homelab-services/grafana/dashboards/homelab/transmission.json @@ -0,0 +1,257 @@ +{ + "annotations": { + "list": [] + }, + "editable": false, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": "prometheus-homelab", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 10240 + }, + { + "color": "green", + "value": 1048576 + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": "prometheus-homelab", + "expr": "transmission_upload_rate_bytes_per_second", + "legendFormat": "upload", + "refId": "A" + } + ], + "title": "Upload rate", + "type": "stat" + }, + { + "datasource": "prometheus-homelab", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 4, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": "prometheus-homelab", + "expr": "transmission_download_rate_bytes_per_second", + "legendFormat": "download", + "refId": "A" + } + ], + "title": "Download rate", + "type": "stat" + }, + { + "datasource": "prometheus-homelab", + "gridPos": { + "h": 8, + "w": 16, + "x": 8, + "y": 0 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "pieType": "donut", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": "prometheus-homelab", + "expr": "transmission_torrents", + "legendFormat": "{{state}}", + "refId": "A" + } + ], + "title": "Torrents by state", + "type": "piechart" + }, + { + "datasource": "prometheus-homelab", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2 + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": "prometheus-homelab", + "expr": "transmission_upload_rate_bytes_per_second", + "legendFormat": "upload", + "refId": "A" + }, + { + "datasource": "prometheus-homelab", + "expr": "transmission_download_rate_bytes_per_second", + "legendFormat": "download", + "refId": "B" + } + ], + "title": "Session rates", + "type": "timeseries" + }, + { + "datasource": "prometheus-homelab", + "fieldConfig": { + "defaults": { + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "displayMode": "basic", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showUnfilled": true + }, + "targets": [ + { + "datasource": "prometheus-homelab", + "expr": "transmission_torrent_upload_rate_bytes_per_second", + "legendFormat": "{{name}}", + "refId": "A" + } + ], + "title": "Per-torrent upload rate", + "type": "bargauge" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["homelab", "transmission"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timezone": "browser", + "title": "Transmission", + "uid": "transmission", + "version": 1 +} diff --git a/hosts/12kingdoms-youko/homelab-services/monitoring/victoriametrics.nix b/hosts/12kingdoms-youko/homelab-services/monitoring/victoriametrics.nix index 72d41826..20e7595f 100644 --- a/hosts/12kingdoms-youko/homelab-services/monitoring/victoriametrics.nix +++ b/hosts/12kingdoms-youko/homelab-services/monitoring/victoriametrics.nix @@ -204,6 +204,22 @@ in } ]; } + { + job_name = "transmission-exporter"; + scrape_interval = "30s"; + metrics_path = "/metrics"; + static_configs = [ + { + # same-host exporter that proxies the transmission netns RPC + targets = [ "127.0.0.1:9555" ]; + labels.type = "app"; + labels.app = "transmission"; + labels.host = "youko"; + labels.env = "homelab"; + labels.cluster = "homelab"; + } + ]; + } ] # --- Hosts --- # ++ (lib.attrsets.foldlAttrs diff --git a/hosts/12kingdoms-youko/homelab-services/transmission-exporter.nix b/hosts/12kingdoms-youko/homelab-services/transmission-exporter.nix new file mode 100644 index 00000000..32f2351b --- /dev/null +++ b/hosts/12kingdoms-youko/homelab-services/transmission-exporter.nix @@ -0,0 +1,46 @@ +{ + config, + pkgs, + myvars, + ... +}: +let + script = pkgs.writeText "transmission-exporter.py" (builtins.readFile ./transmission-exporter.py); +in +{ + # There is no transmission exporter in nixpkgs, so expose a small set of + # gauges from the authenticated RPC on loopback for VictoriaMetrics. + systemd.services.transmission-exporter = { + description = "Prometheus exporter for the transmission RPC"; + wantedBy = [ "multi-user.target" ]; + after = [ "transmission.service" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${script}"; + Restart = "on-failure"; + RestartSec = "10s"; + DynamicUser = true; + # The RPC password lives only in the agenix credentials file; hand it to + # the dynamic user through a systemd credential instead of widening the + # file's permissions. + LoadCredential = "transmission-rpc:${config.age.secrets."transmission-credentials.json".path}"; + Environment = [ + "TRANSMISSION_RPC_URL=http://192.168.5.118:9091/transmission/rpc" + "TRANSMISSION_RPC_USER=${myvars.username}" + "TRANSMISSION_EXPORTER_LISTEN=127.0.0.1:9555" + ]; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + ]; + IPAddressDeny = [ "any" ]; + IPAddressAllow = [ + "localhost" + "192.168.5.118" + ]; + }; + }; +} diff --git a/hosts/12kingdoms-youko/homelab-services/transmission-exporter.py b/hosts/12kingdoms-youko/homelab-services/transmission-exporter.py new file mode 100644 index 00000000..7b2825ea --- /dev/null +++ b/hosts/12kingdoms-youko/homelab-services/transmission-exporter.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Minimal Prometheus exporter for Transmission's RPC (standard library only). + +Transmission runs inside its own network namespace and its RPC needs +authentication, so we query it here and re-expose a small set of gauges on +loopback for VictoriaMetrics to scrape. +""" + +import base64 +import json +import os +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, HTTPServer + +RPC_URL = os.environ.get( + "TRANSMISSION_RPC_URL", "http://127.0.0.1:9091/transmission/rpc" +) +RPC_USER = os.environ.get("TRANSMISSION_RPC_USER", "admin") +LISTEN = os.environ.get("TRANSMISSION_EXPORTER_LISTEN", "127.0.0.1:9555") + +STATUS = { + 0: "stopped", + 1: "check-wait", + 2: "checking", + 3: "download-wait", + 4: "downloading", + 5: "seed-wait", + 6: "seeding", +} + + +def _password(): + directory = os.environ.get("CREDENTIALS_DIRECTORY") + if directory: + path = os.path.join(directory, "transmission-rpc") + if os.path.exists(path): + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + for key in ("rpc-password", "password"): + if data.get(key): + return data[key] + path = os.environ.get("TRANSMISSION_RPC_PASSWORD_FILE") + if path: + with open(path, encoding="utf-8") as handle: + return handle.read().strip() + return os.environ.get("TRANSMISSION_RPC_PASSWORD", "") + + +_AUTH = "Basic " + base64.b64encode( + f"{RPC_USER}:{_password()}".encode() +).decode() +_SESSION = [""] + + +def rpc(method, arguments=None): + payload = json.dumps({"method": method, "arguments": arguments or {}}).encode() + request = urllib.request.Request( + RPC_URL, + data=payload, + headers={ + "Authorization": _AUTH, + "Content-Type": "application/json", + "X-Transmission-Session-Id": _SESSION[0], + }, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return json.load(response)["arguments"] + except urllib.error.HTTPError as error: + if error.code == 409: + _SESSION[0] = error.headers.get("X-Transmission-Session-Id", "") + return rpc(method, arguments) + raise + + +def _escape(value): + return ( + str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ") + ) + + +def _collect(): + try: + torrents = rpc( + "torrent-get", + { + "fields": [ + "name", + "hashString", + "status", + "rateUpload", + "rateDownload", + "peersConnected", + "uploadRatio", + "percentDone", + "trackerStats", + ] + }, + )["torrents"] + session = rpc("session-stats") + except Exception as error: # noqa: BLE001 - report any failure as down + return f"# HELP transmission_up 1 if the RPC was reachable.\n# TYPE transmission_up gauge\ntransmission_up 0\n# scrape_error {_escape(error)}\n" + + out = [ + "# HELP transmission_up 1 if the RPC was reachable.", + "# TYPE transmission_up gauge", + "transmission_up 1", + "# HELP transmission_upload_rate_bytes_per_second Session upload rate.", + "# TYPE transmission_upload_rate_bytes_per_second gauge", + f"transmission_upload_rate_bytes_per_second {session.get('uploadRate', 0)}", + "# HELP transmission_download_rate_bytes_per_second Session download rate.", + "# TYPE transmission_download_rate_bytes_per_second gauge", + f"transmission_download_rate_bytes_per_second {session.get('downloadRate', 0)}", + ] + + states = {} + for tor in torrents: + labels = ( + f'{{name="{_escape(tor["name"])}",' + f'turbohash="{tor["hashString"]}"}}' + ) + out.append( + f"transmission_torrent_upload_rate_bytes_per_second{labels} " + f"{tor['rateUpload']}" + ) + out.append( + f"transmission_torrent_download_rate_bytes_per_second{labels} " + f"{tor['rateDownload']}" + ) + out.append( + f"transmission_torrent_peers_connected{labels} {tor['peersConnected']}" + ) + out.append( + f"transmission_torrent_upload_ratio{labels} {tor['uploadRatio']}" + ) + out.append( + f"transmission_torrent_percent_done{labels} {tor['percentDone']}" + ) + state = STATUS.get(tor["status"], str(tor["status"])) + states[state] = states.get(state, 0) + 1 + trackers = tor.get("trackerStats") or [] + if trackers: + ok = 1 if trackers[0].get("lastAnnounceSucceeded") else 0 + out.append( + f"transmission_torrent_tracker_announce_ok{labels} {ok}" + ) + + out.append("# HELP transmission_torrents Number of torrents by state.") + out.append("# TYPE transmission_torrents gauge") + for state, count in sorted(states.items()): + out.append(f'transmission_torrents{{state="{state}"}} {count}') + + return "\n".join(out) + "\n" + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - http.server API + if self.path != "/metrics": + self.send_response(404) + self.end_headers() + return + body = _collect().encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + +def main(): + host, port = LISTEN.rsplit(":", 1) + HTTPServer((host, int(port)), Handler).serve_forever() + + +if __name__ == "__main__": + main()