First implementation

This commit is contained in:
Pierre Verkest 2023-09-01 13:27:55 +02:00
commit 15bb93d8a4
14 changed files with 765 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.env
coverage.xml
junit.xml

22
.pre-commit-config.yaml Normal file
View file

@ -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

33
README.md Normal file
View file

@ -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
```

View file

@ -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__)

View file

@ -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"}
# }

118
opnsense_exporter/server.py Normal file
View file

@ -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,
)

3
requirements.tests.txt Normal file
View file

@ -0,0 +1,3 @@
pytest
pytest-cov
responses

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
prometheus-client
python-dotenv
requests

25
setup.cfg Normal file
View file

@ -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

39
setup.py Normal file
View file

@ -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
""",
)

0
tests/__init__.py Normal file
View file

149
tests/common.py Normal file
View file

@ -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,
}
)

106
tests/test_opnsense_api.py Normal file
View file

@ -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,
)

165
tests/test_server.py Normal file
View file

@ -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)