diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8542ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# 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/ +*.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/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +.static_storage/ +.media/ +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# 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/ + +# pycharm +.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..cc0d116 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include LICENSE \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbe8a36 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Connectbox Prometheus +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + +A [Prometheus](https://prometheus.io/) exporter for the modem connection status of UPC Connect Boxes (used by Unitymedia in Germany, Irish Virgin Media, Ziggo in the Netherlands, and probably several others). + +Makes thorough use of [python-connect-box](https://github.com/fabaff/python-connect-box) by [@fabaff](https://github.com/fabaff) (thanks!). + +## Installation +You need python3.6 or higher. Run: + +`$ pip3 install connectbox-prometheus` + +## Usage +`./connectbox_exporter --pw YOUR_CONNECTBOX_PASSWORD` + +To see all options, run `./connectbox_exporter --help` + +## Prometheus Configuration +```yaml +scrape_configs: + - job_name: 'connectbox' + static_configs: + - targets: + - 192.168.0.1 # IP address of your Connect Box + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: 127.0.0.1:9705 # The exporter's real hostname:port. +``` + +## Exported Metrics +| Metric name | Description | +|:---------------------------------------------|:---------------------------------------------------------| +| `connectbox_up` | Connect Box reachable yes/no | +| `connectbox_num_devices` | Number of connected devices | +| `connectbox_downstream_frequency_hz` | Downstream channel frequency | +| `connectbox_downstream_power_level_dbmv` | Downstream channel power level | +| `connectbox_downstream_modulation_qam` | Downstream channel modulation | +| `connectbox_downstream_signal_to_noise_db` | Downstream channel signal-to-noise | +| `connectbox_downstream_errors_pre_rs_total` | Downstream channel errors before Reed-Solomon correction | +| `connectbox_downstream_errors_post_rs_total` | Downstream channel errors after Reed-Solomon correction | +| `connectbox_downstream_qam_locked` | Downstream channel QAM lock status | +| `connectbox_downstream_freq_locked` | Downstream channel frequency lock status | +| `connectbox_downstream_mpeg_locked` | Downstream channel MPEG lock status | +| `connectbox_upstream_frequency_hz` | Upstream channel frequency | +| `connectbox_upstream_power_level_dbmv` | Upstream channel power level | +| `connectbox_upstream_symbol_rate_ksps` | Upstream channel symbol rate | +| `connectbox_upstream_modulation_qam` | Upstream channel modulation | +| `connectbox_upstream_timeouts_total` | Upstream channel timeouts | \ No newline at end of file diff --git a/connectbox_exporter/__init__.py b/connectbox_exporter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/connectbox_exporter/__main__.py b/connectbox_exporter/__main__.py new file mode 100644 index 0000000..5f15be1 --- /dev/null +++ b/connectbox_exporter/__main__.py @@ -0,0 +1,3 @@ +from .connectbox_exporter import main + +main() diff --git a/connectbox_exporter/connectbox_exporter.py b/connectbox_exporter/connectbox_exporter.py new file mode 100644 index 0000000..bb2be0b --- /dev/null +++ b/connectbox_exporter/connectbox_exporter.py @@ -0,0 +1,235 @@ +import asyncio +import logging +import sys +from typing import Tuple, Optional + +import aiohttp +import click +from connect_box import ConnectBox +from connect_box.exceptions import ( + ConnectBoxConnectionError, + ConnectBoxLoginError, + ConnectBoxNoDataAvailable, +) +from prometheus_client import start_http_server, CollectorRegistry +from prometheus_client.core import GaugeMetricFamily, CounterMetricFamily + + +class ConnectBoxCollector(object): + def __init__(self, logger: logging.Logger, host: str, password: str): + self.logger = logger + self.host = host + self.password = password + + def collect(self): + PREFIX = "connectbox" + CHANNEL_ID = "channel_id" + TIMEOUT_TYPE = "timeout_type" + + self.logger.debug("Retrieving measurements from Connect Box...") + retrieved = self._retrieve_values() + + connectbox_is_up = retrieved is not None + yield GaugeMetricFamily( + PREFIX + "_up", + documentation="Connect Box reachable yes/no", + value=int(connectbox_is_up), + ) + + if connectbox_is_up: + ds_channels, us_channels, devices = retrieved + self.logger.debug("Success.") + + yield GaugeMetricFamily( + PREFIX + "_num_devices", + "Number of connected devices", + value=len(devices), + ) + + # collect downstream metrics + ds_frequency = GaugeMetricFamily( + PREFIX + "_downstream_frequency", + "Downstream channel frequency", + unit="hz", + labels=[CHANNEL_ID], + ) + ds_power_level = GaugeMetricFamily( + PREFIX + "_downstream_power_level", + "Downstream channel power level", + unit="dbmv", + labels=[CHANNEL_ID], + ) + ds_modulation = GaugeMetricFamily( + PREFIX + "_downstream_modulation", + "Downstream channel modulation", + unit="qam", + labels=[CHANNEL_ID], + ) + ds_snr = GaugeMetricFamily( + PREFIX + "_downstream_signal_to_noise", + "Downstream channel signal-to-noise", + unit="db", + labels=[CHANNEL_ID], + ) + ds_pre_rs = CounterMetricFamily( + PREFIX + "_downstream_errors_pre_rs", + "Downstream channel errors before Reed-Solomon correction", + labels=[CHANNEL_ID], + ) + ds_post_rs = CounterMetricFamily( + PREFIX + "_downstream_errors_post_rs", + "Downstream channel errors after Reed-Solomon correction", + labels=[CHANNEL_ID], + ) + ds_qam_locked = GaugeMetricFamily( + PREFIX + "_downstream_qam_locked", + "Downstream channel QAM lock status", + labels=[CHANNEL_ID], + ) + ds_freq_locked = GaugeMetricFamily( + PREFIX + "_downstream_freq_locked", + "Downstream channel frequency lock status", + labels=[CHANNEL_ID], + ) + ds_mpeg_locked = GaugeMetricFamily( + PREFIX + "_downstream_mpeg_locked", + "Downstream channel MPEG lock status", + labels=[CHANNEL_ID], + ) + for ds_ch in ds_channels: + modulation = int(ds_ch.modulation[:-3]) # trim the trailing "qam" + labels = [ds_ch.id] + + ds_frequency.add_metric(labels, ds_ch.frequency) + ds_power_level.add_metric(labels, ds_ch.powerLevel) + ds_modulation.add_metric(labels, modulation) + ds_snr.add_metric(labels, ds_ch.snr) + ds_pre_rs.add_metric(labels, ds_ch.preRs) + ds_post_rs.add_metric(labels, ds_ch.postRs) + ds_qam_locked.add_metric(labels, int(ds_ch.qamLocked)) + ds_freq_locked.add_metric(labels, int(ds_ch.fecLocked)) + ds_mpeg_locked.add_metric(labels, int(ds_ch.mpegLocked)) + for metric in [ + ds_frequency, + ds_power_level, + ds_modulation, + ds_snr, + ds_pre_rs, + ds_post_rs, + ds_qam_locked, + ds_freq_locked, + ds_mpeg_locked, + ]: + yield metric + + # collect upstream metrics + us_frequency = GaugeMetricFamily( + PREFIX + "_upstream_frequency", + "Upstream channel frequency", + unit="hz", + labels=[CHANNEL_ID], + ) + us_power_level = GaugeMetricFamily( + PREFIX + "_upstream_power_level", + "Upstream channel power level", + unit="dbmv", + labels=[CHANNEL_ID], + ) + us_symbol_rate = GaugeMetricFamily( + PREFIX + "_upstream_symbol_rate", + "Upstream channel symbol rate", + unit="ksps", + labels=[CHANNEL_ID], + ) + us_modulation = GaugeMetricFamily( + PREFIX + "_upstream_modulation", + "Upstream channel modulation", + unit="qam", + labels=[CHANNEL_ID], + ) + us_timeouts = CounterMetricFamily( + PREFIX + "_upstream_timeouts", + "Upstream channel timeout occurrences", + labels=[CHANNEL_ID, TIMEOUT_TYPE], + ) + for us_ch in us_channels: + modulation = int(us_ch.modulation[:-3]) # trim the trailing "qam" + labels = [us_ch.id] + + us_frequency.add_metric(labels, us_ch.frequency) + us_power_level.add_metric(labels, us_ch.powerLevel) + us_symbol_rate.add_metric(labels, us_ch.symbolRate) + us_modulation.add_metric(labels, modulation) + us_timeouts.add_metric(labels + ["T1"], us_ch.t1Timeouts) + us_timeouts.add_metric(labels + ["T2"], us_ch.t2Timeouts) + us_timeouts.add_metric(labels + ["T3"], us_ch.t3Timeouts) + us_timeouts.add_metric(labels + ["T4"], us_ch.t4Timeouts) + for metric in [ + us_frequency, + us_power_level, + us_symbol_rate, + us_modulation, + us_timeouts, + ]: + yield metric + + def _retrieve_values(self) -> Optional[Tuple]: + async def retrieve(): + async with aiohttp.ClientSession() as session: + client = ConnectBox(session, self.password, host=self.host) + try: + await client.async_get_downstream() + await client.async_get_upstream() + await client.async_get_devices() + await client.async_close_session() + return client.ds_channels, client.us_channels, client.devices + except ConnectBoxLoginError: + self.logger.warning( + "Login error: Incorrect password or concurrent login session." + ) + return None + except (ConnectBoxConnectionError, ConnectBoxNoDataAvailable): + self.logger.warning("Connection error or not data available.") + return None + + return asyncio.run(retrieve()) + + +@click.command() +@click.option( + "--port", + default=9705, + help="Port where this exporter serves metrics", + type=int, + show_default=True, +) +@click.option("--host", default="192.168.0.1", help="Connect Box IP address", type=str) +@click.option( + "--pw", help="Connect Box web interface password", required=True, type=str +) +@click.option("-v", "--verbose", help="Print more log messages", is_flag=True) +def main(port, host, pw, verbose): + log_level = logging.DEBUG if verbose else logging.INFO + + logger = logging.getLogger(__name__) + logger.setLevel(log_level) + + # log to stdout + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(log_level) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + + # fire up collector + reg = CollectorRegistry() + reg.register(ConnectBoxCollector(logger, host=host, password=pw)) + start_http_server(port, registry=reg) + + logger.info("Exporter started.") + + # stall the main thread indefinitely + while True: + input() diff --git a/connectbox_exporter_wrapper.py b/connectbox_exporter_wrapper.py new file mode 100755 index 0000000..4d489ad --- /dev/null +++ b/connectbox_exporter_wrapper.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from connectbox_exporter.connectbox_exporter import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..40f9125 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +black==19.10b0 +click==7.1.1 +connect-box==0.2.5 +prometheus-client==0.7.1 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..404fa74 --- /dev/null +++ b/setup.py @@ -0,0 +1,31 @@ +from setuptools import find_packages, setup + +with open("README.md", "rb") as f: + long_descr = f.read().decode("utf-8") + +with open("requirements.txt") as f: + install_requires = [s.strip() for s in f.readlines()] + +setup( + name="Connectbox Prometheus", + version="0.1.0", + author="Michael Bugert", + description="Prometheus exporter for the modem connection status of UPC Connect Boxes", + long_description=long_descr, + long_description_content_type="text/markdown", + url="https://github.com/mbugert/connectbox-prometheus", + entry_points={ + "console_scripts": ['connectbox_exporter = connectbox_exporter.connectbox_exporter:main'] + }, + packages=find_packages(), + install_requires=install_requires, + include_package_data=True, + python_requires=">=3.6", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.6", + "Topic :: System :: Networking :: Monitoring", + ] +)