commit 2a8a4c51152241b31a4cfaaf34048f6fc983db96 Author: w1kl4s Date: Thu Jun 30 23:06:30 2022 +0200 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ee46ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,135 @@ +# 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 +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# 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 + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Vim +*.swp + +# VSCode +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e78460 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM python:alpine3.16 + +COPY . /src +RUN pip install /src && rm -rf /src +ENTRYPOINT ["/usr/local/bin/radio_exporter"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5ffd4a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2022 S.O.N.G@git.gammaspectra.live +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..17e4b52 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# radio_exporter - Exporter for [FinalCommander](https://git.gammaspectra.live/S.O.N.G/FinalCommander "FinalCommander") and [OrbitalBeat](https://git.gammaspectra.live/S.O.N.G/OrbitalBeat "OrbitalBeat") diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..aa76bae --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5287067 --- /dev/null +++ b/setup.py @@ -0,0 +1,45 @@ +from setuptools import setup, find_packages +# To use a consistent encoding +from codecs import open +from os import path + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, "README.md"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name="radio_exporter", + + version='0.1.0', + + description="", + long_description=long_description, + + url="https://git.gammaspectra.live/S.O.N.G/radio_exporter", + + author="w1kl4s", + author_email="w1kl4s@protonmail.com", + license="BSD 2-Clause", + + classifiers=[ + "Development Status :: 4 - Beta", + + "Intended Audience :: System Administrators", + + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + + ], + python_requires=">=3.6, <4", + keywords="monitoring metrics exporter radio", + packages=["src"], + install_requires=[ + "requests" + ], + entry_points={ + "console_scripts": ["radio_exporter=src.main:start"] + } +) diff --git a/src/Collector.py b/src/Collector.py new file mode 100644 index 0000000..e282f5f --- /dev/null +++ b/src/Collector.py @@ -0,0 +1,96 @@ +import time +import json +import random +import requests +from collections.abc import MutableMapping + +from dataclasses import dataclass +@dataclass +class Metric: + prefix: str + value_name: str + labels: dict + value: int + def format_prom(self) -> str: + labels = ','.join(x + '=' + '"' + str(self.labels[x]) + '"' for x in self.labels.keys()) + return f"{self.prefix}_{self.value_name}{{{labels}}} {self.value}" + +class Collector(object): + def __init__(self): + #self.finalcommander_url = "radio.animebits.moe/api/fcmm_status" + self.fcmm_urls = ["https://radio.animebits.moe/api/fcmm_status"] + + def _fetch_json(self, url): + try: + r = requests.get(url) + data = json.loads(r.text) + return data + except json.JSONDecodeError: + raise CollectorException(f"Failed to parse JSON at {url}") + except requests.exceptions.ConnectionError: + raise CollectorException(f"Failed to reach {url}") + except requests.exceptions.ReadTimeout: + raise CollectorException(f"Connection timeout to {url}") + except: + raise CollectorException("Request failed.") + + def get_fcmm(self, url): + data = self._fetch_json(url) + + if "public_key" not in data.keys() or "servers" not in data.keys(): + raise CollectorException("FinalCommander JSON seems to be wrong.") + + return data + + def get_instances(self, fcmm_data): + for server in fcmm_data["servers"]: + try: + data = self._fetch_json(f"https://{server['Address']}/stats") + except CollectorException: + data = {} + + data["fcmm_data"] = server + yield data + + def collect(self): + metrics = [] + fcmm_data = [] + for url in self.fcmm_urls: + try: + fcmm_data.append(self.get_fcmm(url)) + up = 1 + except CollectorException: + # TODO: log here, unreachable FCMM + up = 0 + metrics.append(Metric("fcmm", "up", {"url": url}, up)) + + servers_data = [x for y in [self.get_instances(fcmm) for fcmm in fcmm_data] for x in y] + for server_data in servers_data: + baselabel = {"server": server_data["fcmm_data"]["Address"]} + + if server_data["fcmm_data"]["LastCheckResult"]: + metrics.append(Metric("orbt", "up", {**baselabel}, 1)) + else: + metrics.append(Metric("orbt", "up", {**baselabel}, 0)) + continue + + for name, data in server_data["statistics"].items(): + if name == "http" or name == "tls": + for k,v in data.items(): + for subtype,value in v.items(): + metrics.append(Metric("orbt_stats", f"{name}_{k}", {**baselabel, **{k: subtype}}, value)) + else: + for valuetype, value in data.items(): + metrics.append(Metric("orbt_stats", name, {**baselabel, **{"type": valuetype}}, value)) + + for name, value in server_data["database"].items(): + metrics.append(Metric("orbt_db", name, {**baselabel}, value)) + + return metrics + + + +class CollectorException(Exception): + """ + Base exception class raised by Collector + """ \ No newline at end of file diff --git a/src/PrometheusServer.py b/src/PrometheusServer.py new file mode 100644 index 0000000..c5bc9b4 --- /dev/null +++ b/src/PrometheusServer.py @@ -0,0 +1,47 @@ +import socket +import signal +from ipaddress import ip_address +from http.server import HTTPServer,BaseHTTPRequestHandler +from selectors import DefaultSelector, EVENT_READ + +from src import Collector + +class PromServer(HTTPServer): + def __init__(self, address): + if ip_address(address[0]).version == 6: + self.address_family = socket.AF_INET6 + super().__init__(address, self.RequestHandler) + + class RequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path != "/metrics": + return self.send_error(404) + + metrics = [x.format_prom() for x in self.server.collector.collect()] + + output = '\n'.join(metrics) + '\n' + self.send_response(200) + self.end_headers() + self.wfile.write(output.encode()) + + def signal_handler(self, signum, frame): + print('Signal handler called with signal', signum) + self.interrupt_write.send(b'\0') + + def serve_forever(self): + sel = DefaultSelector() + sel.register(self.interrupt_read, EVENT_READ) + sel.register(self, EVENT_READ) + while True: + for key, _ in sel.select(): + if key.fileobj == self.interrupt_read: + self.interrupt_read.recv(1) + return + if key.fileobj == self: + self.handle_request() + + def start(self): + self.collector = Collector.Collector() + self.interrupt_read, self.interrupt_write = socket.socketpair() + signal.signal(signal.SIGINT, self.signal_handler) + self.serve_forever() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..70d5ce6 --- /dev/null +++ b/src/main.py @@ -0,0 +1,16 @@ +import json +import argparse + +from src import PrometheusServer + +def start(): + parser = argparse.ArgumentParser(description="Run radio exporter") + parser.add_argument("-p", "--port", metavar="port", action='store', type=int, default=8888, help='Port for http server to listen on') + parser.add_argument("-a", "--address", metavar="address", action='store', default='0.0.0.0', help='Address for http server to listen on') + args = parser.parse_args() + + server = PrometheusServer.PromServer((args.address, args.port)) + server.start() + +if __name__ == '__main__': + start()