commit 15bb93d8a4a609ade191fff53db1122dc423f7d5 Author: Pierre Verkest Date: Fri Sep 1 13:27:55 2023 +0200 First implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1626ced --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +coverage.xml +junit.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e82ce49 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 # Use the ref you want to point at + hooks: + - id: check-yaml + - id: trailing-whitespace + - id: end-of-file-fixer + + - repo: https://github.com/pycqa/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + + - repo: https://github.com/ambv/black + rev: 23.7.0 + hooks: + - id: black + + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb2d4e7 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# OPNSense Prometheus exporter + +I've configures OPNSense with High Availability settings using 2 servers. + +* https://docs.opnsense.org/manual/hacarp.html +* https://docs.opnsense.org/manual/how-tos/carp.html + +So I've 2 servers: *MAIN* and *BACKUP*, in normal situation *MAIN* server +is expected to be `active` and the *BACKUP* server to be in `hot_standby` state. + + +The initial needs was to be able to make sure that *BACKUP* server is ready (hot standby) +to get the main server role with the `active` state at any time. + +> Unfortunately I've not found a proper configuration to call OPNSense HTTP API over +> opnvpn on backup server using blackbox configuratoin. That why I've started to develop +> this exporter install on a server on the LAN to be able to resquest both OPNSense servers. + +## Metrics + +This exporter gives following metrics: + +* ``: + + +## Usage + + +> *Note*: Most updated documentation from command line ! + +``` +opnsense-exporter --help +``` diff --git a/opnsense_exporter/__init__.py b/opnsense_exporter/__init__.py new file mode 100644 index 0000000..6d83202 --- /dev/null +++ b/opnsense_exporter/__init__.py @@ -0,0 +1,9 @@ +# this is a namespace package +try: + import pkg_resources + + pkg_resources.declare_namespace(__name__) +except ImportError: + import pkgutil + + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/opnsense_exporter/opnsense_api.py b/opnsense_exporter/opnsense_api.py new file mode 100644 index 0000000..0841dcc --- /dev/null +++ b/opnsense_exporter/opnsense_api.py @@ -0,0 +1,90 @@ +import logging + +import requests +from requests import RequestException + +logger = logging.getLogger(__name__) + + +class OPNSenseAPI: + host: str = None + login: str = None + password: str = None + + def __init__(self, host, login, password): + self.host = host + self.login = login + self.password = password + + def prepare_url(self, path): + return f"https://{self.host}{path}" + + def get(self, path): + response = requests.get( + self.prepare_url(path), + auth=(self.login, self.password), + timeout=0.5, + # # as today I'm using the opnsense selfsigned certificat + # # but we should avoid this instead trust any certificat + verify=False, + ) + response.raise_for_status() + return response.json() + + def get_interface_vip_status(self): + try: + data = self.get("/api/diagnostics/interface/get_vip_status/") + except RequestException as ex: + logger.error( + "Get VIP STATUS on %s failed with the following error %r", self.host, ex + ) + return "unavailable" + if data["carp"]["maintenancemode"]: + return "maintenancemode" + is_active = all([row["status"] == "MASTER" for row in data["rows"]]) + if is_active: + return "active" + is_backup = all([row["status"] == "BACKUP" for row in data["rows"]]) + if is_backup: + return "hot_standby" + logger.warning( + "this host %s is no active nor backup received payload %s", self.host, data + ) + return "unavailable" + + def get_wan_trafic(self): + try: + data = self.get("/api/diagnostics/traffic/interface") + except RequestException as ex: + logger.error( + "Get diagnostics traffic on WAN interface for %s host failed with the following error %r", + self.host, + ex, + ) + return None, None + return ( + int(data["interfaces"]["wan"]["bytes received"]), + int(data["interfaces"]["wan"]["bytes transmitted"]), + ) + + +# def get_server_system_status(self): +# # https://192.168.200.1/api/core/system/status +# return { +# "CrashReporter": +# { +# "statusCode":2, +# "message":"No problems were detected.", +# "logLocation":"/crash_reporter.php", +# "timestamp":"0", +# "status":"OK" +# }, +# "Firewall":{ +# "statusCode":2, +# "message":"No problems were detected.", +# "logLocation":"/ui/diagnostics/log/core/firewall", +# "timestamp":"0", +# "status":"OK" +# }, +# "System":{"status":"OK"} +# } diff --git a/opnsense_exporter/server.py b/opnsense_exporter/server.py new file mode 100644 index 0000000..83ca978 --- /dev/null +++ b/opnsense_exporter/server.py @@ -0,0 +1,118 @@ +import argparse +import os +import socket +import time + +from dotenv import load_dotenv +from prometheus_client import Enum, Gauge, start_http_server + +from opnsense_exporter.opnsense_api import OPNSenseAPI + +load_dotenv() + +HA_STATES = ["active", "hot_standby", "unavailable", "maintenancemode"] +main_ha_state = Enum( + "opnsense_main_ha_state", "OPNSense HA state of the MAIN server", states=HA_STATES +) +backup_ha_state = Enum( + "opnsense_backup_ha_state", + "OPNSense HA state of the BACKUP server", + states=HA_STATES, +) +active_server_bytes_received = Gauge( + "opnsense_active_server_bytes_received", + "Active OPNSense server bytes received on WAN interface", +) +active_server_bytes_transmitted = Gauge( + "opnsense_active_server_bytes_transmitted", + "Active OPNSense server bytes transmitted on WAN interface", +) + + +def process_requests(main, backup): + """A dummy function that takes some time.""" + main_state = main.get_interface_vip_status() + backup_sate = backup.get_interface_vip_status() + main_ha_state.state(main_state) + backup_ha_state.state(backup_sate) + bytes_received = None + bytes_transmitted = None + if main_state == "active": + bytes_received, bytes_transmitted = main.get_wan_trafic() + if backup_sate == "active": + bytes_received, bytes_transmitted = backup.get_wan_trafic() + active_server_bytes_received.set(bytes_received or -1) + active_server_bytes_transmitted.set(bytes_transmitted or -1) + + +def start_server(main: OPNSenseAPI, backup: OPNSenseAPI, check_frequency: int = 1): + # Start up the server to expose the metrics. + start_http_server(8000) + # Generate some requests. + while True: + process_requests(main, backup) + time.sleep(check_frequency) + + +def run(): + parser = argparse.ArgumentParser( + description="OPNSense prometheus exporter", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--check-frequency-seconds", + "-c", + type=int, + dest="frequency", + default=int(os.environ.get("CHECK_FREQUENCY_SECONDS", 2)), + help="How often (in seconds) this server requests OPNSense servers", + ) + parser.add_argument( + "--main-host", + "-m", + type=str, + dest="main", + default=os.environ.get("OPNSENSE_MAIN_HOST", None), + help="MAIN OPNsense server that should be in `active` state in normal configuration.", + ) + parser.add_argument( + "--backup-host", + "-b", + type=str, + dest="backup", + default=os.environ.get("OPNSENSE_BACKUP_HOST", None), + help="BACKUP OPNsense server that should be `hot_standby` state in normal configuration.", + ) + parser.add_argument( + "--opnsense-user", + "-u", + type=str, + dest="user", + default=os.environ.get("OPNSENSE_USERNAME", None), + help="OPNsense user. Expect to be the same on MAIN and BACKUP servers", + ) + parser.add_argument( + "--opnsense-password", + "-p", + type=str, + dest="password", + default=os.environ.get("OPNSENSE_PASSWORD", None), + help="OPNsense password. Expect to be the same on MAIN and BACKUP servers", + ) + parser.add_argument( + "--prometheus-instance", + dest="prom_instance", + type=str, + default=socket.gethostname(), + help=( + "Instance name, default value computed with hostname " + "where the server is running. Use to set the instance label." + ), + ) + + arguments = parser.parse_args() + start_server( + OPNSenseAPI(arguments.main, arguments.user, arguments.password), + OPNSenseAPI(arguments.backup, arguments.user, arguments.password), + check_frequency=arguments.frequency, + ) diff --git a/requirements.tests.txt b/requirements.tests.txt new file mode 100644 index 0000000..847062e --- /dev/null +++ b/requirements.tests.txt @@ -0,0 +1,3 @@ +pytest +pytest-cov +responses diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..716a944 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +prometheus-client +python-dotenv +requests diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..778e6e8 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,25 @@ +[isort] +profile = black + +[flake8] +max-line-length = 92 +exclude = log/*,doc/*,*.egg-info +max-complexity = 12 +ignore = + # line length is handled by black + E501 + # line break before binary operator (black move the line breaker before) + W503 +per-file-ignores = + # tests doesn't require doctrings + test_*: D103, W605 + # empty init doesn't need a docstring + # ignore unused imported in init files + __init__.py: + D104 + F401 + +[tool:pytest] +addopts = -v -s --junit-xml junit.xml --cov ./opnsense_exporter/ --cov-report term --cov-report xml --cov-report html +testpaths = + tests diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7899ad6 --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +from urllib.parse import urlparse + +from setuptools import find_packages, setup + +version = "0.1" + + +def parse_requirements(file): + required = [] + with open(file) as f: + for req in f.read().splitlines(): + if req.strip().startswith("git"): + req = urlparse(req.strip()).fragment.split("=")[1] + if req.strip().startswith("-e"): + req = urlparse(req.strip().split()[1]).fragment.split("=")[1] + if not req.strip().startswith("#") and not req.strip().startswith("--"): + required.append(req) + return required + + +requires = parse_requirements("requirements.txt") +tests_requires = parse_requirements("requirements.tests.txt") + +setup( + name="opnsense-prom-exporter", + version=version, + description="OPNSense Prometheus exporter", + author="Pierre Verkest", + author_email="pierreverkest84@gmail.com", + license="GNU GPL v3", + namespace_packages=["opnsense_exporter"], + packages=find_packages(exclude=["ez_setup", "examples", "tests"]), + install_requires=requires, + tests_require=requires + tests_requires, + entry_points=""" + [console_scripts] + opnsense-exporter=opnsense_exporter.server:run + """, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 0000000..ea6365a --- /dev/null +++ b/tests/common.py @@ -0,0 +1,149 @@ +import json + +MAIN_HOST = "192.168.1.1" +BACKUP_HOST = "192.168.1.2" +LOGIN = "user" +PASSWORD = "pwd" + + +def generate_get_vip_status_paylaod(state_wan, state_lan, maintenance_mode): + return json.dumps( + { + "total": 2, + "rowCount": 2, + "current": 1, + "rows": [ + { + "interface": "wan", + "vhid": "1", + "advbase": "1", + "advskew": "0", + "subnet": "176.149.171.241", + "status": state_wan, + "mode": "carp", + "status_txt": state_wan, + "vhid_txt": "1 (freq. 1/0)", + }, + { + "interface": "lan", + "vhid": "3", + "advbase": "1", + "advskew": "0", + "subnet": "192.168.200.1", + "status": state_lan, + "mode": "carp", + "status_txt": state_lan, + "vhid_txt": "3 (freq. 1/0)", + }, + ], + "carp": { + "demotion": "0", + "allow": "1", + "maintenancemode": maintenance_mode, + "status_msg": "", + }, + } + ) + + +def generate_diagnostics_traffic_interface_paylaod(): + return json.dumps( + { + "interfaces": { + "lan": { + "index": "2", + "flags": "8963", + "promiscuous listeners": "1", + "send queue length": "0", + "send queue max length": "50", + "send queue drops": "0", + "type": "Ethernet", + "address length": "6", + "header length": "18", + "link state": "2", + "vhid": "0", + "datalen": "152", + "mtu": "1500", + "metric": "0", + "line rate": "10000000000 bit/s", + "packets received": "3699327747", + "input errors": "0", + "packets transmitted": "8972963403", + "output errors": "0", + "collisions": "0", + "bytes received": "2474843996609", + "bytes transmitted": "11711737078752", + "multicasts received": "29274204", + "multicasts transmitted": "0", + "input queue drops": "0", + "packets for unknown protocol": "0", + "HW offload capabilities": "0x0", + "uptime at attach or stat reset": "1", + "name": "LAN", + }, + "opt1": { + "index": "3", + "flags": "8863", + "promiscuous listeners": "0", + "send queue length": "0", + "send queue max length": "50", + "send queue drops": "0", + "type": "Ethernet", + "address length": "6", + "header length": "18", + "link state": "2", + "vhid": "0", + "datalen": "152", + "mtu": "1500", + "metric": "0", + "line rate": "10000000000 bit/s", + "packets received": "1120457", + "input errors": "0", + "packets transmitted": "178007891", + "output errors": "0", + "collisions": "0", + "bytes received": "221674964", + "bytes transmitted": "55808795987", + "multicasts received": "393767", + "multicasts transmitted": "0", + "input queue drops": "0", + "packets for unknown protocol": "0", + "HW offload capabilities": "0x0", + "uptime at attach or stat reset": "1", + "name": "LANOPNSYNC", + }, + "wan": { + "index": "1", + "flags": "8963", + "promiscuous listeners": "1", + "send queue length": "0", + "send queue max length": "50", + "send queue drops": "0", + "type": "Ethernet", + "address length": "6", + "header length": "18", + "link state": "2", + "vhid": "0", + "datalen": "152", + "mtu": "1500", + "metric": "0", + "line rate": "10000000000 bit/s", + "packets received": "9008014155", + "input errors": "0", + "packets transmitted": "3724630846", + "output errors": "0", + "collisions": "0", + "bytes received": "11725192686820", + "bytes transmitted": "2489262014203", + "multicasts received": "2288794", + "multicasts transmitted": "0", + "input queue drops": "0", + "packets for unknown protocol": "0", + "HW offload capabilities": "0x0", + "uptime at attach or stat reset": "1", + "name": "WAN", + }, + }, + "time": 1693404742.796736, + } + ) diff --git a/tests/test_opnsense_api.py b/tests/test_opnsense_api.py new file mode 100644 index 0000000..4fa1b37 --- /dev/null +++ b/tests/test_opnsense_api.py @@ -0,0 +1,106 @@ +import responses + +from opnsense_exporter.opnsense_api import OPNSenseAPI + +from .common import ( + LOGIN, + MAIN_HOST, + PASSWORD, + generate_diagnostics_traffic_interface_paylaod, + generate_get_vip_status_paylaod, +) + + +@responses.activate +def test_get_interface_vip_status_active(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("MASTER", "MASTER", False), + ) + + assert ( + OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD).get_interface_vip_status() == "active" + ) + + +@responses.activate +def test_get_interface_vip_status_backup(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("BACKUP", "BACKUP", False), + ) + + assert ( + OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD).get_interface_vip_status() + == "hot_standby" + ) + + +@responses.activate +def test_get_interface_vip_status_mainteance_mode(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("MASTER", "MASTER", True), + ) + + assert ( + OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD).get_interface_vip_status() + == "maintenancemode" + ) + + +@responses.activate +def test_get_interface_vip_status_unavailable_weird_case(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("MASTER", "BACKUP", False), + ) + assert ( + OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD).get_interface_vip_status() + == "unavailable" + ) + + +@responses.activate +def test_get_interface_vip_status_unavailable_rest_api_error(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/interface/get_vip_status/", + json={"error": "not found"}, + status=404, + ) + assert ( + OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD).get_interface_vip_status() + == "unavailable" + ) + + +@responses.activate +def test_get_wan_traffic(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/traffic/interface", + body=generate_diagnostics_traffic_interface_paylaod(), + ) + assert OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD).get_wan_trafic() == ( + 11725192686820, + 2489262014203, + ) + + +@responses.activate +def test_get_wan_traffic_none(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/traffic/interface", + json={"error": "not found"}, + status=404, + ) + assert OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD).get_wan_trafic() == ( + None, + None, + ) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..54859c1 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,165 @@ +from unittest import mock + +import responses + +from opnsense_exporter.opnsense_api import OPNSenseAPI +from opnsense_exporter.server import process_requests, run + +from .common import ( + BACKUP_HOST, + LOGIN, + MAIN_HOST, + PASSWORD, + generate_diagnostics_traffic_interface_paylaod, + generate_get_vip_status_paylaod, +) + + +@mock.patch("opnsense_exporter.server.start_server") +def test_parser(server_mock): + with mock.patch( + "sys.argv", + [ + "opnsense-exporter", + "-c", + "15", + "-m", + "main.host", + "-b", + "backup.host", + "-u", + "user-test", + "-p", + "pwd-test", + "--prometheus-instance", + "server-hostname-instance", + ], + ): + run() + server_mock.assert_called_once() + main, bck = server_mock.call_args.args + assert main.login == "user-test" + assert bck.login == "user-test" + assert main.password == "pwd-test" + assert bck.password == "pwd-test" + assert main.host == "main.host" + assert bck.host == "backup.host" + assert server_mock.call_args.kwargs["check_frequency"] == 15 + + +@responses.activate +def test_process_requests(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("MASTER", "MASTER", False), + ) + responses.add( + responses.GET, + f"https://{BACKUP_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("BACKUP", "BACKUP", False), + ) + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/traffic/interface", + body=generate_diagnostics_traffic_interface_paylaod(), + ) + with mock.patch( + "opnsense_exporter.server.main_ha_state.state" + ) as main_ha_state_mock: + with mock.patch( + "opnsense_exporter.server.backup_ha_state.state" + ) as backup_ha_state_mock: + with mock.patch( + "opnsense_exporter.server.active_server_bytes_received.set" + ) as active_server_bytes_received_mock: + with mock.patch( + "opnsense_exporter.server.active_server_bytes_transmitted.set" + ) as active_server_bytes_transmitted_mock: + process_requests( + OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD), + OPNSenseAPI(BACKUP_HOST, LOGIN, PASSWORD), + ) + main_ha_state_mock.assert_called_once_with("active") + backup_ha_state_mock.assert_called_once_with("hot_standby") + active_server_bytes_received_mock.assert_called_once_with(11725192686820) + active_server_bytes_transmitted_mock.assert_called_once_with(2489262014203) + + +@responses.activate +def test_process_requests_backend_active(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("MASTER", "MASTER", True), + ) + responses.add( + responses.GET, + f"https://{BACKUP_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("MASTER", "MASTER", False), + ) + responses.add( + responses.GET, + f"https://{BACKUP_HOST}/api/diagnostics/traffic/interface", + body=generate_diagnostics_traffic_interface_paylaod(), + ) + with mock.patch( + "opnsense_exporter.server.main_ha_state.state" + ) as main_ha_state_mock: + with mock.patch( + "opnsense_exporter.server.backup_ha_state.state" + ) as backup_ha_state_mock: + with mock.patch( + "opnsense_exporter.server.active_server_bytes_received.set" + ) as active_server_bytes_received_mock: + with mock.patch( + "opnsense_exporter.server.active_server_bytes_transmitted.set" + ) as active_server_bytes_transmitted_mock: + process_requests( + OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD), + OPNSenseAPI(BACKUP_HOST, LOGIN, PASSWORD), + ) + main_ha_state_mock.assert_called_once_with("maintenancemode") + backup_ha_state_mock.assert_called_once_with("active") + active_server_bytes_received_mock.assert_called_once_with(11725192686820) + active_server_bytes_transmitted_mock.assert_called_once_with(2489262014203) + + +@responses.activate +def test_process_no_active(): + responses.add( + responses.GET, + f"https://{MAIN_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("MASTER", "MASTER", True), + ) + responses.add( + responses.GET, + f"https://{BACKUP_HOST}/api/diagnostics/interface/get_vip_status/", + body=generate_get_vip_status_paylaod("MASTER", "MASTER", True), + status=404, + ) + responses.add( + responses.GET, + f"https://{BACKUP_HOST}/api/diagnostics/traffic/interface", + body=generate_diagnostics_traffic_interface_paylaod(), + ) + with mock.patch( + "opnsense_exporter.server.main_ha_state.state" + ) as main_ha_state_mock: + with mock.patch( + "opnsense_exporter.server.backup_ha_state.state" + ) as backup_ha_state_mock: + with mock.patch( + "opnsense_exporter.server.active_server_bytes_received.set" + ) as active_server_bytes_received_mock: + with mock.patch( + "opnsense_exporter.server.active_server_bytes_transmitted.set" + ) as active_server_bytes_transmitted_mock: + process_requests( + OPNSenseAPI(MAIN_HOST, LOGIN, PASSWORD), + OPNSenseAPI(BACKUP_HOST, LOGIN, PASSWORD), + ) + main_ha_state_mock.assert_called_once_with("maintenancemode") + backup_ha_state_mock.assert_called_once_with("unavailable") + active_server_bytes_received_mock.assert_called_once_with(-1) + active_server_bytes_transmitted_mock.assert_called_once_with(-1)