commit eeeec364c0bc7b69ed7de7d06bd4e8a28d61d22a Author: Bxio Date: Sun Mar 29 23:39:18 2026 +0100 feat: initial release - TeamSpeak 6 Prometheus Exporter v1.0.0 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..74cb22a --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# TeamSpeak 6 WebQuery connection +TS6_HOST=ts6-server +TS6_QUERY_PORT=10080 +TS6_API_KEY=your-api-key-here +TS6_SERVER_ID=1 + +# Exporter settings +EXPORTER_PORT=9189 +POLL_INTERVAL=15 +LOG_LEVEL=INFO + +# Set to "false" to include Python process/GC metrics +DISABLE_DEFAULT_COLLECTORS=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8de471a --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg + +# Environment +.env +.venv/ +env/ +venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..86fa47d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-03-29 + +### Added +- Initial release of TeamSpeak 6 Prometheus Exporter +- **TS6 WebQuery HTTP API client** (`ts6_client.py`) + - HTTP communication with TeamSpeak 6 WebQuery on port 10080 + - API Key authentication via `x-api-key` header + - Methods: `server_info()`, `client_list()`, `channel_list()`, `ban_list()`, `server_group_list()`, `version()`, `whoami()` + - Support for both JSON and legacy key=value response formats +- **Prometheus exporter** (`exporter.py`) + - Server metrics: `ts6_server_up`, `ts6_server_uptime_seconds` + - Client metrics: `ts6_clients_online`, `ts6_clients_max`, `ts6_query_clients_online` + - Channel metrics: `ts6_channels_total` + - Bandwidth metrics: `ts6_bytes_sent_total`, `ts6_bytes_received_total`, `ts6_packets_sent_total`, `ts6_packets_received_total` + - File transfer metrics: `ts6_file_transfer_bytes_sent_total`, `ts6_file_transfer_bytes_received_total` + - Quality metrics: `ts6_average_ping_seconds`, `ts6_average_packet_loss` + - Moderation metrics: `ts6_bans_total`, `ts6_server_groups_total` + - Per-client info with labels: `ts6_client_connected{nickname, platform, version, country, channel_id}` + - Exporter health: `ts6_scrape_duration_seconds`, `ts6_scrape_errors_total` + - Server version info: `ts6_server_version_info{version, build, platform}` +- **Docker support** + - Dockerfile based on Python 3.12-slim + - docker-compose.yml with external network support for existing TS6 stacks +- **Grafana dashboard** (`grafana/dashboard.json`) + - Server Overview row: Status, Uptime, Clients Online, Max Clients, Channels, Active Bans + - Clients row: Clients Online Over Time (time series), Connected Clients table + - Bandwidth & Network row: Bandwidth bytes/s, Packets/s + - Quality row: Average Ping gauge, Average Packet Loss gauge, File Transfer bandwidth + - Exporter Health row: Scrape Duration, Scrape Errors, Server Groups, Query Clients +- **Configuration** via environment variables +- MIT License diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..254097f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +LABEL maintainer="Bxio" +LABEL description="Prometheus exporter for TeamSpeak 6 servers" +LABEL org.opencontainers.image.source="https://gitea.zol.oixb.run/oixb.run/ts6-grafana" + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY exporter.py . +COPY ts6_client.py . + +EXPOSE 9189 + +USER nobody + +ENTRYPOINT ["python", "-u", "exporter.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5b8dc04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bxio + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc58842 --- /dev/null +++ b/README.md @@ -0,0 +1,244 @@ +# TeamSpeak 6 Prometheus Exporter + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](https://gitea.zol.oixb.run/oixb.run/ts6-grafana) +[![Prometheus](https://img.shields.io/badge/Prometheus-exporter-E6522C?logo=prometheus&logoColor=white)](#metrics) + +> Prometheus exporter for TeamSpeak 6 servers. Collects metrics via the WebQuery HTTP API and exposes them for Prometheus scraping, with a ready-to-import Grafana dashboard. + +--- + +## Features + +- 🔍 **15+ metrics** — Server status, clients, bandwidth, channels, bans, quality, and more +- 📊 **Grafana dashboard included** — Ready to import with time series, gauges, and tables +- 🐳 **Docker ready** — Run alongside your existing TS6 stack +- ⚡ **Lightweight** — Python-based, minimal resource usage +- 🏷️ **Per-client labels** — Track individual clients with nickname, platform, country +- 🔧 **Fully configurable** — All settings via environment variables + +--- + +## Quick Start + +### 1. Generate a TS6 API Key + +Connect to your TeamSpeak 6 server via ServerQuery (telnet/SSH) and generate an API key: + +```bash +# Connect via SSH Query +ssh serveradmin@your-ts6-server -p 10022 + +# Or via telnet +telnet your-ts6-server 10011 + +# Login and generate API key +login serveradmin YOUR_PASSWORD +apikeyadd scope=manage lifetime=0 +``` + +Save the returned `apikey` value. + +### 2. Run with Docker Compose + +Create a `.env` file: + +```env +TS6_API_KEY=your-api-key-here +``` + +If your TS6 server is in a Docker network (e.g., `ts6-panel_ts6-net`), update the network name in `docker-compose.yml` and run: + +```bash +docker compose up -d +``` + +### 3. Run Standalone (Python) + +```bash +pip install -r requirements.txt + +export TS6_HOST=your-ts6-server +export TS6_QUERY_PORT=10080 +export TS6_API_KEY=your-api-key-here + +python exporter.py +``` + +Metrics will be available at `http://localhost:9189/metrics`. + +--- + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `TS6_HOST` | `localhost` | TeamSpeak 6 server hostname | +| `TS6_QUERY_PORT` | `10080` | WebQuery HTTP port | +| `TS6_API_KEY` | **(required)** | API key for authentication | +| `TS6_SERVER_ID` | `1` | Virtual server ID | +| `EXPORTER_PORT` | `9189` | Port for the `/metrics` endpoint | +| `POLL_INTERVAL` | `15` | Polling interval in seconds | +| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) | +| `DISABLE_DEFAULT_COLLECTORS` | `true` | Remove default Python/GC metrics | +| `METRIC_PREFIX` | `ts6` | Prefix for all metric names | + +--- + +## Metrics + +### Server + +| Metric | Type | Description | +|---|---|---| +| `ts6_server_up` | Gauge | 1 if server is reachable, 0 otherwise | +| `ts6_server_uptime_seconds` | Gauge | Server uptime in seconds | +| `ts6_server_version_info` | Info | Version, build number, platform | + +### Clients + +| Metric | Type | Description | +|---|---|---| +| `ts6_clients_online` | Gauge | Online clients (excluding query clients) | +| `ts6_clients_max` | Gauge | Maximum allowed clients | +| `ts6_query_clients_online` | Gauge | Connected query clients | +| `ts6_client_connected` | Gauge | Per-client info (labels: nickname, platform, version, country, channel_id) | + +### Channels & Groups + +| Metric | Type | Description | +|---|---|---| +| `ts6_channels_total` | Gauge | Total channels | +| `ts6_server_groups_total` | Gauge | Total server groups | + +### Bandwidth + +| Metric | Type | Description | +|---|---|---| +| `ts6_bytes_sent_total` | Gauge | Total bytes sent | +| `ts6_bytes_received_total` | Gauge | Total bytes received | +| `ts6_packets_sent_total` | Gauge | Total packets sent | +| `ts6_packets_received_total` | Gauge | Total packets received | +| `ts6_file_transfer_bytes_sent_total` | Gauge | File transfer bytes sent | +| `ts6_file_transfer_bytes_received_total` | Gauge | File transfer bytes received | + +### Quality + +| Metric | Type | Description | +|---|---|---| +| `ts6_average_ping_seconds` | Gauge | Average client ping | +| `ts6_average_packet_loss` | Gauge | Average packet loss ratio | + +### Bans + +| Metric | Type | Description | +|---|---|---| +| `ts6_bans_total` | Gauge | Active bans count | + +### Exporter Health + +| Metric | Type | Description | +|---|---|---| +| `ts6_scrape_duration_seconds` | Gauge | Last scrape duration | +| `ts6_scrape_errors_total` | Counter | Total scrape errors | + +--- + +## Grafana Dashboard + +Import the included dashboard from `grafana/dashboard.json`: + +1. Open Grafana → **Dashboards** → **Import** +2. Upload the `grafana/dashboard.json` file or paste its contents +3. Select your Prometheus data source +4. Click **Import** + +### Dashboard Sections + +| Section | Panels | +|---|---| +| **Server Overview** | Status indicator, Uptime, Clients Online, Max Clients, Channels, Active Bans | +| **Clients** | Clients Online Over Time (graph), Connected Clients (table) | +| **Bandwidth & Network** | Bandwidth bytes/s, Packets/s | +| **Quality** | Average Ping (gauge), Packet Loss (gauge), File Transfer bandwidth | +| **Exporter Health** | Scrape Duration, Scrape Errors, Server Groups, Query Clients | + +--- + +## Prometheus Configuration + +Add the exporter as a scrape target in your `prometheus.yml`: + +```yaml +scrape_configs: + - job_name: 'teamspeak6' + scrape_interval: 15s + static_configs: + - targets: ['ts6-exporter:9189'] +``` + +--- + +## Docker Integration + +### With existing TS6 Docker stack + +If your TeamSpeak 6 runs in Docker, the exporter can join the same network. Update the `networks` section in `docker-compose.yml` to match your TS6 network name: + +```yaml +networks: + ts6-net: + external: true + name: your-ts6-network-name # e.g., ts6_ts6-net +``` + +### Build from source + +```bash +docker build -t ts6-prometheus-exporter . +docker run -d \ + --name ts6-exporter \ + --network your-ts6-network \ + -p 9189:9189 \ + -e TS6_HOST=ts6-server \ + -e TS6_API_KEY=your-api-key \ + ts6-prometheus-exporter +``` + +--- + +## How It Works + +``` +┌─────────────┐ HTTP/API Key ┌─────────────┐ Scrape ┌────────────┐ +│ TeamSpeak 6 │ ◄──────────────── │ TS6 │ ◄──────────── │ Prometheus │ +│ Server │ Port 10080 │ Exporter │ Port 9189 │ │ +│ (WebQuery) │ ──────────────► │ (Python) │ ────────────► │ │ +└─────────────┘ JSON response └─────────────┘ /metrics └────────────┘ + │ + ▼ + ┌────────────┐ + │ Grafana │ + │ Dashboard │ + └────────────┘ +``` + +1. The **exporter** polls the TS6 WebQuery HTTP API every N seconds +2. Responses are parsed and converted to **Prometheus metrics** +3. **Prometheus** scrapes the `/metrics` endpoint +4. **Grafana** queries Prometheus and renders the dashboard + +--- + +## Requirements + +- TeamSpeak 6 server with **WebQuery HTTP enabled** (port 10080) +- A valid **API key** with `manage` scope +- Python 3.10+ (for standalone) or Docker +- Prometheus + Grafana for visualization + +--- + +## License + +[MIT](LICENSE) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c3dcc81 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3.8" + +services: + ts6-exporter: + build: . + container_name: ts6-exporter + restart: unless-stopped + ports: + - "9189:9189" + environment: + TS6_HOST: ts6-server + TS6_QUERY_PORT: 10080 + TS6_API_KEY: ${TS6_API_KEY} + TS6_SERVER_ID: 1 + EXPORTER_PORT: 9189 + POLL_INTERVAL: 15 + LOG_LEVEL: INFO + networks: + - ts6-net + +networks: + ts6-net: + external: true + name: ts6-panel_ts6-net diff --git a/exporter.py b/exporter.py new file mode 100644 index 0000000..4211c4c --- /dev/null +++ b/exporter.py @@ -0,0 +1,281 @@ +""" +TeamSpeak 6 Prometheus Exporter. + +Polls the TS6 WebQuery HTTP API and exposes metrics +in Prometheus format on /metrics endpoint. +""" + +import os +import sys +import time +import signal +import logging +from prometheus_client import ( + start_http_server, + Gauge, + Counter, + Info, + REGISTRY, + GC_COLLECTOR, + PLATFORM_COLLECTOR, + PROCESS_COLLECTOR, +) +from ts6_client import TS6Client + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() +logging.basicConfig( + level=getattr(logging, LOG_LEVEL, logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger("ts6_exporter") + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +TS6_HOST = os.getenv("TS6_HOST", "localhost") +TS6_QUERY_PORT = int(os.getenv("TS6_QUERY_PORT", "10080")) +TS6_API_KEY = os.getenv("TS6_API_KEY", "") +TS6_SERVER_ID = int(os.getenv("TS6_SERVER_ID", "1")) +EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9189")) +POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "15")) +METRIC_PREFIX = os.getenv("METRIC_PREFIX", "ts6") + +if not TS6_API_KEY: + logger.error("TS6_API_KEY environment variable is required!") + sys.exit(1) + +# --------------------------------------------------------------------------- +# Optionally remove default Python collectors for cleaner output +# --------------------------------------------------------------------------- +DISABLE_DEFAULT_COLLECTORS = os.getenv("DISABLE_DEFAULT_COLLECTORS", "true").lower() == "true" +if DISABLE_DEFAULT_COLLECTORS: + for collector in [GC_COLLECTOR, PLATFORM_COLLECTOR, PROCESS_COLLECTOR]: + try: + REGISTRY.unregister(collector) + except Exception: + pass + +# --------------------------------------------------------------------------- +# Prometheus Metrics +# --------------------------------------------------------------------------- + +# Server +server_up = Gauge(f"{METRIC_PREFIX}_server_up", "Whether the TS6 server is reachable (1=up, 0=down)") +server_uptime = Gauge(f"{METRIC_PREFIX}_server_uptime_seconds", "Server uptime in seconds") +server_version_info = Info(f"{METRIC_PREFIX}_server_version", "TeamSpeak server version info") + +# Clients +clients_online = Gauge(f"{METRIC_PREFIX}_clients_online", "Number of clients currently online (excluding query clients)") +clients_max = Gauge(f"{METRIC_PREFIX}_clients_max", "Maximum allowed clients") +query_clients_online = Gauge(f"{METRIC_PREFIX}_query_clients_online", "Number of query clients online") + +# Channels +channels_total = Gauge(f"{METRIC_PREFIX}_channels_total", "Total number of channels") + +# Bandwidth +bytes_sent = Gauge(f"{METRIC_PREFIX}_bytes_sent_total", "Total bytes sent by the server") +bytes_received = Gauge(f"{METRIC_PREFIX}_bytes_received_total", "Total bytes received by the server") +packets_sent = Gauge(f"{METRIC_PREFIX}_packets_sent_total", "Total packets sent") +packets_received = Gauge(f"{METRIC_PREFIX}_packets_received_total", "Total packets received") + +# File Transfer +ft_bytes_sent = Gauge(f"{METRIC_PREFIX}_file_transfer_bytes_sent_total", "Total file transfer bytes sent") +ft_bytes_received = Gauge(f"{METRIC_PREFIX}_file_transfer_bytes_received_total", "Total file transfer bytes received") + +# Quality +avg_ping = Gauge(f"{METRIC_PREFIX}_average_ping_seconds", "Average client ping in seconds") +avg_packet_loss = Gauge(f"{METRIC_PREFIX}_average_packet_loss", "Average client packet loss ratio") + +# Bans +bans_total = Gauge(f"{METRIC_PREFIX}_bans_total", "Total number of active bans") + +# Server Groups +server_groups_total = Gauge(f"{METRIC_PREFIX}_server_groups_total", "Total number of server groups") + +# Per-client info (using labels) +client_info_gauge = Gauge( + f"{METRIC_PREFIX}_client_connected", + "Connected client info (1 = connected)", + ["client_id", "nickname", "platform", "version", "country", "channel_id"], +) + +# Scrape metrics +scrape_duration = Gauge(f"{METRIC_PREFIX}_scrape_duration_seconds", "Duration of the last scrape in seconds") +scrape_errors = Counter(f"{METRIC_PREFIX}_scrape_errors_total", "Total number of scrape errors") + +# --------------------------------------------------------------------------- +# Collector +# --------------------------------------------------------------------------- + +def safe_int(value, default=0): + """Safely convert a value to int.""" + try: + return int(value) + except (ValueError, TypeError): + return default + + +def safe_float(value, default=0.0): + """Safely convert a value to float.""" + try: + return float(value) + except (ValueError, TypeError): + return default + + +def collect_metrics(client: TS6Client): + """Collect all metrics from the TS6 server.""" + start_time = time.time() + + try: + # Check if server is alive + alive = client.is_alive() + server_up.set(1 if alive else 0) + + if not alive: + logger.warning("TS6 server is not reachable") + scrape_errors.inc() + return + + # Server version (only needs to be set once, but low cost) + try: + ver = client.version() + if ver: + server_version_info.info({ + "version": ver.get("version", "unknown"), + "build": str(ver.get("build", "unknown")), + "platform": ver.get("platform", "unknown"), + }) + except Exception as e: + logger.debug("Could not get version: %s", e) + + # Server info + try: + info = client.server_info() + if info: + server_uptime.set(safe_int(info.get("virtualserver_uptime", 0))) + clients_online.set(safe_int(info.get("virtualserver_clientsonline", 0)) - safe_int(info.get("virtualserver_queryclientsonline", 0))) + clients_max.set(safe_int(info.get("virtualserver_maxclients", 0))) + query_clients_online.set(safe_int(info.get("virtualserver_queryclientsonline", 0))) + channels_total.set(safe_int(info.get("virtualserver_channelsonline", 0))) + + # Bandwidth + bytes_sent.set(safe_int(info.get("connection_bytes_sent_total", 0))) + bytes_received.set(safe_int(info.get("connection_bytes_received_total", 0))) + packets_sent.set(safe_int(info.get("connection_packets_sent_total", 0))) + packets_received.set(safe_int(info.get("connection_packets_received_total", 0))) + + # File transfer + ft_bytes_sent.set(safe_int(info.get("connection_filetransfer_bytes_sent_total", 0))) + ft_bytes_received.set(safe_int(info.get("connection_filetransfer_bytes_received_total", 0))) + + # Quality + avg_ping.set(safe_float(info.get("virtualserver_total_ping", 0.0)) / 1000.0) + avg_packet_loss.set(safe_float(info.get("virtualserver_total_packetloss_total", 0.0))) + except Exception as e: + logger.error("Error collecting server info: %s", e) + scrape_errors.inc() + + # Bans + try: + bans = client.ban_list() + bans_total.set(len(bans)) + except Exception as e: + logger.debug("Could not get ban list: %s", e) + bans_total.set(0) + + # Server groups + try: + groups = client.server_group_list() + server_groups_total.set(len(groups)) + except Exception as e: + logger.debug("Could not get server groups: %s", e) + + # Per-client metrics + try: + # Clear previous client labels + client_info_gauge._metrics.clear() + + clients = client.client_list() + for c in clients: + # Skip query clients (client_type=1) + if safe_int(c.get("client_type", 0)) == 1: + continue + + client_info_gauge.labels( + client_id=c.get("clid", ""), + nickname=c.get("client_nickname", "unknown"), + platform=c.get("client_platform", "unknown"), + version=c.get("client_version", "unknown"), + country=c.get("client_country", ""), + channel_id=c.get("cid", ""), + ).set(1) + except Exception as e: + logger.debug("Could not get client list: %s", e) + + except Exception as e: + logger.error("Unexpected error during collection: %s", e) + server_up.set(0) + scrape_errors.inc() + + finally: + duration = time.time() - start_time + scrape_duration.set(duration) + logger.info( + "Scrape completed in %.3fs | clients=%s channels=%s", + duration, + clients_online._value.get() if hasattr(clients_online._value, 'get') else '?', + channels_total._value.get() if hasattr(channels_total._value, 'get') else '?', + ) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + logger.info("=" * 60) + logger.info("TeamSpeak 6 Prometheus Exporter") + logger.info("=" * 60) + logger.info("TS6 Host: %s:%s", TS6_HOST, TS6_QUERY_PORT) + logger.info("Server ID: %s", TS6_SERVER_ID) + logger.info("Exporter Port: %s", EXPORTER_PORT) + logger.info("Poll Interval: %ss", POLL_INTERVAL) + logger.info("=" * 60) + + client = TS6Client( + host=TS6_HOST, + port=TS6_QUERY_PORT, + api_key=TS6_API_KEY, + server_id=TS6_SERVER_ID, + ) + + # Start Prometheus HTTP server + start_http_server(EXPORTER_PORT) + logger.info("Metrics server started on http://0.0.0.0:%s/metrics", EXPORTER_PORT) + + # Graceful shutdown + running = True + + def shutdown(signum, frame): + nonlocal running + logger.info("Received signal %s, shutting down...", signum) + running = False + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + # Main polling loop + while running: + collect_metrics(client) + time.sleep(POLL_INTERVAL) + + logger.info("Exporter stopped.") + + +if __name__ == "__main__": + main() diff --git a/grafana/dashboard.json b/grafana/dashboard.json new file mode 100644 index 0000000..3395a15 --- /dev/null +++ b/grafana/dashboard.json @@ -0,0 +1,621 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "10.0.0" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Server Overview", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "options": { "0": { "color": "red", "text": "OFFLINE" }, "1": { "color": "green", "text": "ONLINE" } }, "type": "value" } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "green", "value": 1 } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, + "id": 1, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Server Status", + "type": "stat", + "targets": [ + { "expr": "ts6_server_up", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "unit": "dtdurations", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "blue", "value": null } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Uptime", + "type": "stat", + "targets": [ + { "expr": "ts6_server_uptime_seconds", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 20 }, + { "color": "red", "value": 50 } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, + "id": 3, + "options": { + "colorMode": "background_solid", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Clients Online", + "type": "stat", + "targets": [ + { "expr": "ts6_clients_online", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "purple", "value": null } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Max Clients", + "type": "stat", + "targets": [ + { "expr": "ts6_clients_max", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "cyan", "value": null } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Channels", + "type": "stat", + "targets": [ + { "expr": "ts6_channels_total", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "orange", "value": null } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Active Bans", + "type": "stat", + "targets": [ + { "expr": "ts6_bans_total", "refId": "A" } + ] + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, + "id": 101, + "title": "Clients", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 20, + "gradientMode": "scheme", + "spanNulls": true, + "drawStyle": "line", + "pointSize": 5, + "showPoints": "auto" + }, + "color": { "mode": "palette-classic" }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 16, "x": 0, "y": 6 }, + "id": 10, + "options": { + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Clients Online Over Time", + "type": "timeseries", + "targets": [ + { "expr": "ts6_clients_online", "legendFormat": "Clients Online", "refId": "A" }, + { "expr": "ts6_query_clients_online", "legendFormat": "Query Clients", "refId": "B" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "custom": { + "filterable": true + }, + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + } + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 6 }, + "id": 11, + "options": { + "showHeader": true, + "sortBy": [{ "desc": false, "displayName": "nickname" }] + }, + "title": "Connected Clients", + "type": "table", + "targets": [ + { + "expr": "ts6_client_connected", + "format": "table", + "instant": true, + "refId": "A" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { "Time": true, "Value": true, "__name__": true, "instance": true, "job": true, "client_id": false }, + "renameByName": { + "client_id": "ID", + "nickname": "Nickname", + "platform": "Platform", + "version": "Version", + "country": "Country", + "channel_id": "Channel" + } + } + } + ] + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, + "id": 102, + "title": "Bandwidth & Network", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 15, + "gradientMode": "scheme", + "spanNulls": true, + "drawStyle": "line", + "pointSize": 5, + "showPoints": "never" + }, + "color": { "mode": "palette-classic" }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "id": 20, + "options": { + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Bandwidth (Bytes/s)", + "type": "timeseries", + "targets": [ + { "expr": "rate(ts6_bytes_sent_total[5m])", "legendFormat": "Sent", "refId": "A" }, + { "expr": "rate(ts6_bytes_received_total[5m])", "legendFormat": "Received", "refId": "B" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 15, + "gradientMode": "scheme", + "spanNulls": true, + "drawStyle": "line", + "pointSize": 5, + "showPoints": "never" + }, + "color": { "mode": "palette-classic" }, + "unit": "pps" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "id": 21, + "options": { + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "Packets/s", + "type": "timeseries", + "targets": [ + { "expr": "rate(ts6_packets_sent_total[5m])", "legendFormat": "Sent", "refId": "A" }, + { "expr": "rate(ts6_packets_received_total[5m])", "legendFormat": "Received", "refId": "B" } + ] + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }, + "id": 103, + "title": "Quality", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "unit": "s", + "min": 0, + "max": 0.5, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.05 }, + { "color": "orange", "value": 0.1 }, + { "color": "red", "value": 0.2 } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 8, "x": 0, "y": 24 }, + "id": 30, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "title": "Average Ping", + "type": "gauge", + "targets": [ + { "expr": "ts6_average_ping_seconds", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.01 }, + { "color": "orange", "value": 0.05 }, + { "color": "red", "value": 0.1 } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 8, "x": 8, "y": 24 }, + "id": 31, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "title": "Average Packet Loss", + "type": "gauge", + "targets": [ + { "expr": "ts6_average_packet_loss", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "gradientMode": "scheme", + "spanNulls": true, + "drawStyle": "line", + "pointSize": 5, + "showPoints": "never" + }, + "color": { "mode": "palette-classic" }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": { "h": 6, "w": 8, "x": 16, "y": 24 }, + "id": 32, + "options": { + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "title": "File Transfer Bandwidth", + "type": "timeseries", + "targets": [ + { "expr": "rate(ts6_file_transfer_bytes_sent_total[5m])", "legendFormat": "Sent", "refId": "A" }, + { "expr": "rate(ts6_file_transfer_bytes_received_total[5m])", "legendFormat": "Received", "refId": "B" } + ] + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 30 }, + "id": 104, + "title": "Exporter Health", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 31 }, + "id": 40, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Scrape Duration", + "type": "stat", + "targets": [ + { "expr": "ts6_scrape_duration_seconds", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 31 }, + "id": 41, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Scrape Errors", + "type": "stat", + "targets": [ + { "expr": "ts6_scrape_errors_total", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "blue", "value": null } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 31 }, + "id": 42, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Server Groups", + "type": "stat", + "targets": [ + { "expr": "ts6_server_groups_total", "refId": "A" } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "blue", "value": null } + ] + }, + "color": { "mode": "thresholds" } + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 31 }, + "id": 43, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "textMode": "value" + }, + "title": "Query Clients", + "type": "stat", + "targets": [ + { "expr": "ts6_query_clients_online", "refId": "A" } + ] + } + ], + "schemaVersion": 39, + "tags": ["teamspeak", "ts6", "gaming"], + "templating": { "list": [] }, + "time": { "from": "now-6h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "TeamSpeak 6 Server", + "uid": "ts6-server-dashboard", + "version": 1 +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a7b3301 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +prometheus-client>=0.20.0,<1.0.0 +requests>=2.31.0,<3.0.0 diff --git a/ts6_client.py b/ts6_client.py new file mode 100644 index 0000000..c525c55 --- /dev/null +++ b/ts6_client.py @@ -0,0 +1,139 @@ +""" +TeamSpeak 6 WebQuery HTTP API Client. + +Communicates with the TS6 server via the WebQuery HTTP interface +to retrieve server information, client lists, channels, bans, etc. +""" + +import urllib.parse +import logging +import requests + +logger = logging.getLogger(__name__) + + +class TS6Client: + """Client for the TeamSpeak 6 WebQuery HTTP API.""" + + def __init__(self, host: str, port: int, api_key: str, server_id: int = 1, timeout: int = 10): + self.base_url = f"http://{host}:{port}" + self.server_id = server_id + self.api_key = api_key + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update({ + "x-api-key": self.api_key, + }) + + def _request(self, command: str, params: dict = None, use_sid: bool = True) -> list[dict]: + """Execute a WebQuery command and return parsed response.""" + if use_sid: + url = f"{self.base_url}/{self.server_id}/{command}" + else: + url = f"{self.base_url}/{command}" + + if params: + query_string = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items()) + url = f"{url}?{query_string}" + + try: + response = self.session.get(url, timeout=self.timeout) + response.raise_for_status() + data = response.json() + + if "body" in data: + body = data["body"] + if isinstance(body, list): + return body + return [body] if body else [] + + if "status" in data and data["status"].get("code", 0) != 0: + error_msg = data["status"].get("message", "Unknown error") + logger.error("TS6 API error for '%s': %s", command, error_msg) + return [] + + return [] + + except requests.exceptions.ConnectionError: + logger.error("Cannot connect to TS6 WebQuery at %s", self.base_url) + raise + except requests.exceptions.Timeout: + logger.error("Timeout connecting to TS6 WebQuery at %s", self.base_url) + raise + except requests.exceptions.RequestException as e: + logger.error("Request error for '%s': %s", command, e) + raise + except ValueError: + # JSON decode error — try raw text parsing + return self._parse_raw_response(response.text) + + @staticmethod + def _parse_raw_response(text: str) -> list[dict]: + """Parse legacy-style key=value response format.""" + results = [] + for entry in text.split("|"): + item = {} + for pair in entry.strip().split(" "): + if "=" in pair: + key, value = pair.split("=", 1) + # Unescape TS6 special chars + value = value.replace("\\s", " ").replace("\\p", "|").replace("\\/", "/") + item[key] = value + else: + item[pair] = "" + if item: + results.append(item) + return results + + def is_alive(self) -> bool: + """Check if the TS6 server is reachable.""" + try: + self._request("version", use_sid=False) + return True + except Exception: + return False + + def version(self) -> dict: + """Get server version info.""" + result = self._request("version", use_sid=False) + return result[0] if result else {} + + def server_info(self) -> dict: + """Get detailed server information.""" + result = self._request("serverinfo") + return result[0] if result else {} + + def client_list(self) -> list[dict]: + """Get list of connected clients with extended info.""" + return self._request("clientlist", params={ + "-uid": "", + "-away": "", + "-voice": "", + "-groups": "", + "-info": "", + "-country": "", + }) + + def channel_list(self) -> list[dict]: + """Get list of channels.""" + return self._request("channellist", params={ + "-topic": "", + "-flags": "", + "-voice": "", + }) + + def ban_list(self) -> list[dict]: + """Get list of active bans.""" + try: + return self._request("banlist") + except Exception: + return [] + + def server_group_list(self) -> list[dict]: + """Get list of server groups.""" + return self._request("servergrouplist") + + def whoami(self) -> dict: + """Get info about the current query connection.""" + result = self._request("whoami") + return result[0] if result else {}