From 893b93379fffd91c18a21315dc0b5c5d7285494c Mon Sep 17 00:00:00 2001 From: nico Date: Thu, 27 Aug 2020 18:10:23 +0200 Subject: Initial working release TeamSpeak InfluxDB exporter base of ejabberd-tools framework + implement TeamSpeak REST api call to gather statistics + implement metrics logic to omit unnecessary data points + add systemd service file + implement pre-commit framework --- .gitignore | 116 +++++++++++++++++++ .pre-commit-config.yaml | 25 ++++ api.py | 37 ++++++ config.py | 76 ++++++++++++ .../init/linux-systemd/teamspeak-influx.service | 23 ++++ influx.py | 128 +++++++++++++++++++++ metrics.py | 24 ++++ pyproject.toml | 20 ++++ requirements.txt | 3 + setup.cfg | 5 + teamspeak-influx.yaml.template | 13 +++ 11 files changed, 470 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 api.py create mode 100644 config.py create mode 100644 contrib/init/linux-systemd/teamspeak-influx.service create mode 100644 influx.py create mode 100644 metrics.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 teamspeak-influx.yaml.template diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a87969a --- /dev/null +++ b/.gitignore @@ -0,0 +1,116 @@ +# Created by https://www.gitignore.io/api/python +# Edit at https://www.gitignore.io/?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Pycharm +.idea/ +.venv/ +venv + +# config +teamspeak-influx.yml +config.yml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..44cb32f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: fix-encoding-pragma + - id: trailing-whitespace + - id: end-of-file-fixer + - id: mixed-line-ending + args: + - "--fix=lf" + - id: check-yaml + +- repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.3 + hooks: + - id: flake8 diff --git a/api.py b/api.py new file mode 100644 index 0000000..9cfb820 --- /dev/null +++ b/api.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import requests + + +class TeamSpeakApi: + """ + class to interact with the teamspeak rest api + """ + + def __init__(self, apikey: str, url: str): + # variables + self._apikey = apikey + self._url = url + + # define api handler + self.cmd = self._rest + + self.session = requests.Session() + + @property + def _auth(self): + if self._apikey is not None: + return {"x-api-key": self._apikey} + + def _rest(self, command: str) -> dict: + # add authentication header to the session obj + auth_header = self._auth + + # build get request + r = self.session.get("/".join([self._url, command]), headers=auth_header, verify=False) + + # proceed if response is ok + if r.ok: + return r.json() + + return {} diff --git a/config.py b/config.py new file mode 100644 index 0000000..c3db1b4 --- /dev/null +++ b/config.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import sys +from pathlib import Path + +from ruamel.yaml import YAML +from ruamel.yaml.parser import ParserError +from ruamel.yaml.scanner import ScannerError + + +class Config: + def __init__(self): + # class variables + self.content = None + + # select config file + if Path.exists(Path("teamspeak-influx.yml")): + self.conf_file = Path("teamspeak-influx.yml") + else: + self.conf_file = Path("/etc/teamspeak-influx.yml") + + # read config file + self._read() + + def _read(self): + """init the config object with this method""" + self._check() + + # open file as an iostream + with open(self.conf_file, "r", encoding="utf-8") as f: + try: + self.content = YAML(typ="safe").load(f) + + # catch json decoding errors + except (ParserError, ScannerError) as err: + print(err, file=sys.stderr) + exit(1) + + def _check(self): + """internal method to check if the config file exists""" + try: + # if file is present continue + if self.conf_file.exists(): + return + + # if not create a blank file + else: + self.conf_file.touch(mode=0o640) + + # catch permission exceptions as this tries to write to /etc/ + except PermissionError as err: + print(err, file=sys.stderr) + sys.exit(err.errno) + + def get(self, key: str = None, default: (str, int) = None) -> (dict, str, int, None): + """ + method to retrieve all config values, a single value + or the optional default value + """ + # if a special key is request, return only that value + if key is not None: + + # safety measure + if key in self.content: + return self.content[key] + + # if a default value is given return that + if default is not None: + return default + + # if the key isn't part if self.content return None + else: + return None + + # else return everything + return self.content diff --git a/contrib/init/linux-systemd/teamspeak-influx.service b/contrib/init/linux-systemd/teamspeak-influx.service new file mode 100644 index 0000000..6ff9978 --- /dev/null +++ b/contrib/init/linux-systemd/teamspeak-influx.service @@ -0,0 +1,23 @@ +[Unit] +Description=teamspeak influxdb exporter +Requires=teamspeak.service + +[Service] +Type=simple +User=nobody +Group=nogroup +Environment="PATH=/opt/teamspeak-influx/venv/bin:/usr/local/bin:/usr/bin:/bin" +ExecStart=/opt/teamspeak-influx/influx.py +WorkingDirectory=/opt/teamspeak-influx/ +PrivateDevices=true +ProtectControlGroups=true +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=yes +ProtectSystem=full +NoNewPrivileges=yes +Restart=always +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/influx.py b/influx.py new file mode 100644 index 0000000..e2488ed --- /dev/null +++ b/influx.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import time +import string + +from influxdb import InfluxDBClient + +from config import Config +from metrics import TeamSpeakMetrics + +keys = [ + "connection_bytes_received_control", + "connection_bytes_received_keepalive", + "connection_bytes_received_speech", + "connection_bytes_received_total", + "connection_bytes_sent_control", + "connection_bytes_sent_keepalive", + "connection_bytes_sent_speech", + "connection_bytes_sent_total", + "connection_filetransfer_bytes_received_total", + "connection_filetransfer_bytes_sent_total", + "connection_packets_received_control", + "connection_packets_received_keepalive", + "connection_packets_received_speech", + "connection_packets_received_total", + "connection_packets_sent_control", + "connection_packets_sent_keepalive", + "connection_packets_sent_speech", + "connection_packets_sent_total", + "virtualserver_autostart", + "virtualserver_channelsonline", + "virtualserver_clientsonline", + "virtualserver_maxclients", + "virtualserver_queryclientsonline", + "virtualserver_reserved_slots", + "virtualserver_status", + "virtualserver_total_packetloss_control", + "virtualserver_total_packetloss_keepalive", + "virtualserver_total_packetloss_speech", + "virtualserver_total_packetloss_total", + "virtualserver_total_ping", + "virtualserver_uptime", +] + + +class Influx: + def __init__(self, data, cld): + self._metrics = data + self.client = cld + + @staticmethod + def _timestamp(): + return int(time.time() * 1000) + + @staticmethod + def _rmspace(key: str = None, value: (str, int) = None): + try: + key = key.replace(" ", "\ ") # noqa: W605 + value = value.replace(" ", "\ ") # noqa: W605 + except (TypeError, AttributeError): + pass + + return key, value + + def _parse(self, name, key, value, ts, tags=None): + output = name + + # check if tags is a dict + if isinstance(tags, dict): + + # create tag_key=tag_value pairs for all elements and append them to name + for k, v in tags.items(): + output += ",{}={}".format(*self._rmspace(k, v)) + + # append key=value to name + if value[0] in string.ascii_letters: + output += ' {}="{}" {}'.format(*self._rmspace(key, value), ts) + elif value.isdigit(): + output += " {}={}i {}".format(*self._rmspace(key, value), ts) + else: + output += " {}={} {}".format(*self._rmspace(key, value), ts) + + return output + + def write_metrics(self): + data = list() + cur_ts = self._timestamp() + + # serverinfo + for id in ids: + query = self._metrics.serverinfo(id) + + name, sid = query["virtualserver_name"], query["virtualserver_id"] + for key, value in query.items(): + if key in keys: + data.append(self._parse("teamspeak", key, value, cur_ts, {"name": name, "virtual_server": sid})) + + self.client.write_points(data, time_precision="ms", batch_size=10000, protocol="line") + + +if __name__ == "__main__": + # load config + config = Config() + + # credentials and parameters + url = config.get("url", default="https://localhost:10443") + apikey = config.get("apikey", default="") + ids = config.get("ids", default=1) + + # config influxdb + influx_host = config.get("influxdb_host", default="localhost") + influx_port = config.get("influxdb_port", default=8086) + influx_dbname = config.get("influxdb_db", default="teamspeak") + + # init handler + metrics = TeamSpeakMetrics(url=url, apikey=apikey) + client = InfluxDBClient(host=influx_host, port=influx_port, database=influx_dbname, retries=5) + + # create database only once + client.create_database(influx_dbname) + + # init influx class + influx = Influx(metrics, client) + + while True: + influx.write_metrics() + + time.sleep(10) diff --git a/metrics.py b/metrics.py new file mode 100644 index 0000000..af07f1d --- /dev/null +++ b/metrics.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from api import TeamSpeakApi + + +class TeamSpeakMetrics(TeamSpeakApi): + """ + TeamSpeak metrics harvester + """ + + def __init__(self, url: str, apikey: str = None): + # init teamspeak api + super().__init__(apikey=apikey, url=url) + + # variables + self.url = url + + def serverinfo(self, id: int = 1) -> dict: + tmp = self.cmd(f"{id}/serverinfo") + + if tmp["status"]["code"] != 0: + return {} + else: + return tmp["body"][0] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e834e5c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[tool.black] +line-length = 120 +target-version = ['py37', 'py38'] +include = '\.pyi?$' +exclude = ''' +( + /( + \.eggs # exclude a few common directories in the + | \.git # root of the project + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + )/ # the root of the project +) +''' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6402d0a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +influxdb>=5.2.0 +requests>=2.21.0 +ruamel.yaml diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..76eda04 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[flake8] +ignore = E501,E203 +exclude = .git,__pycache__,.gitlab +max-complexity = 15 +max-line-length = 120 diff --git a/teamspeak-influx.yaml.template b/teamspeak-influx.yaml.template new file mode 100644 index 0000000..a446bd2 --- /dev/null +++ b/teamspeak-influx.yaml.template @@ -0,0 +1,13 @@ +# endpoint url +url: "https://127.0.0.1:10443" + +# virtual server ids +ids: [1,2,3] + +# authentication +apikey: "loreipsumloreipsumloreipsum" + +# influx db configuration +influxdb_host: "localhost" +influxdb_port: 8086 +influxdb_db: "example" -- cgit v1.2.3-18-g5258