diff --git a/README.rst b/README.rst index 73f233f..c09c226 100644 --- a/README.rst +++ b/README.rst @@ -65,33 +65,31 @@ The following features are not supported yet, help is welcome. Usage ----- -:: +Pass one or multiple Unix Domain Socket path or HTTP Control-Agent URLs +to the `kea-exporter` executable. All other options are optional. - Usage: kea-exporter [OPTIONS] [SOCKETS]... +:: - Options: - -m, --mode [socket|http] Select mode. - -a, --address TEXT Specify the address to bind against. - -p, --port INTEGER Specify the port on which to listen. - -i, --interval INTEGER Minimal interval between two queries to Kea in seconds. - -t, --target TEXT Target address and port of Kea server, e.g. - http://kea.example.com:8080. - --client-cert TEXT Client certificate file path used in HTTP mode - with mTLS - --client-key TEXT Client key file path used in HTTP mode with mTLS - --version Show the version and exit. - --help Show this message and exit. + Usage: python -m kea_exporter [OPTIONS] TARGETS... + Options: + -a, --address TEXT Address that the exporter binds to. + -p, --port INTEGER Port that the exporter binds to. + -i, --interval INTEGER Minimal interval between two queries to Kea in + seconds. + --client-cert PATH Path to client certificate used to in HTTP requests + --client-key PATH Path to client key used in HTTP requests + --version Show the version and exit. + --help Show this message and exit. You can also configure the exporter using environment variables: :: - export MODE="http" export ADDRESS="0.0.0.0" export PORT="9547" export INTERVAL="7.5" - export TARGET="http://kea" + export TARGETS="http://router.example.com:8000" export CLIENT_CERT="/etc/kea-exporter/client.crt" export CLIENT_KEY="/etc/kea-exporter/client.key" diff --git a/flake.nix b/flake.nix index 1d599ad..2fcb117 100644 --- a/flake.nix +++ b/flake.nix @@ -16,7 +16,7 @@ src = ./.; hooks = { isort.enable = true; - ruff.enable = false; + ruff.enable = true; ruff-format = { enable = true; entry = "${pkgs.ruff}/bin/ruff format"; diff --git a/kea_exporter/__init__.py b/kea_exporter/__init__.py index 0b487dc..9970955 100644 --- a/kea_exporter/__init__.py +++ b/kea_exporter/__init__.py @@ -1,2 +1,9 @@ +from enum import Enum + __project__ = "kea-exporter" __version__ = "0.6.2" + + +class DHCPVersion(Enum): + DHCP4 = 1 + DHCP6 = 2 diff --git a/kea_exporter/cli.py b/kea_exporter/cli.py index 168322c..05b70a5 100644 --- a/kea_exporter/cli.py +++ b/kea_exporter/cli.py @@ -1,9 +1,11 @@ +import sys import time import click from prometheus_client import REGISTRY, make_wsgi_app, start_http_server -from . import __project__, __version__ +from kea_exporter import __project__, __version__ +from kea_exporter.exporter import Exporter class Timer: @@ -19,20 +21,13 @@ def time_elapsed(self): @click.command() -@click.option( - "-m", - "--mode", - envvar="MODE", - default="socket", - help="Select mode.", - type=click.Choice(["socket", "http"], case_sensitive=True), -) @click.option( "-a", "--address", envvar="ADDRESS", + type=str, default="0.0.0.0", - help="Specify the address to bind against.", + help="Address that the exporter binds to.", ) @click.option( "-p", @@ -40,7 +35,7 @@ def time_elapsed(self): envvar="PORT", type=int, default=9547, - help="Specify the port on which to listen.", + help="Port that the exporter binds to.", ) @click.option( "-i", @@ -50,37 +45,27 @@ def time_elapsed(self): default=0, help="Minimal interval between two queries to Kea in seconds.", ) -@click.option( - "-t", - "--target", - envvar="TARGET", - type=str, - help="Target address and port of Kea server, e.g. http://kea.example.com:8080.", -) @click.option( "--client-cert", envvar="CLIENT_CERT", - type=str, - help="Client certificate file path used in HTTP mode with mTLS", + type=click.Path(exists=True), + help="Path to client certificate used to in HTTP requests", required=False, ) @click.option( "--client-key", envvar="CLIENT_KEY", - type=str, - help="Client key file path used in HTTP mode with mTLS", + type=click.Path(exists=True), + help="Path to client key used in HTTP requests", required=False, ) -@click.argument("sockets", envvar="SOCKETS", nargs=-1, required=False) +@click.argument("targets", envvar="TARGETS", nargs=-1, required=True) @click.version_option(prog_name=__project__, version=__version__) -def cli(mode, port, address, interval, **kwargs): - if mode == "socket": - from .kea_socket_exporter import KeaSocketExporter as KeaExporter - elif mode == "http": - from .kea_http_exporter import KeaHTTPExporter as KeaExporter - - exporter = KeaExporter(**kwargs) - exporter.update() +def cli(port, address, interval, **kwargs): + exporter = Exporter(**kwargs) + + if not exporter.targets: + sys.exit(1) httpd, _ = start_http_server(port, address) diff --git a/kea_exporter/base_exporter.py b/kea_exporter/exporter.py similarity index 93% rename from kea_exporter/base_exporter.py rename to kea_exporter/exporter.py index 6fc4fd6..d5bd1da 100644 --- a/kea_exporter/base_exporter.py +++ b/kea_exporter/exporter.py @@ -1,21 +1,21 @@ import re import sys -from enum import Enum +from urllib.parse import urlparse import click from prometheus_client import Gauge +from kea_exporter import DHCPVersion +from kea_exporter.http import KeaHTTPClient +from kea_exporter.uds import KeaSocketClient -class BaseExporter: - class DHCPVersion(Enum): - DHCP4 = 1 - DHCP6 = 2 +class Exporter: subnet_pattern = re.compile( r"^subnet\[(?P[\d]+)\]\.(pool\[(?P[\d]+)\]\.(?P[\w-]+)|(?P[\w-]+))$" ) - def __init__(self): + def __init__(self, targets, **kwargs): # prometheus self.prefix = "kea" self.prefix_dhcp4 = f"{self.prefix}_dhcp4" @@ -38,10 +38,33 @@ def __init__(self): # track missing info, to notify only once self.subnet_missing_info_sent = { - self.DHCPVersion.DHCP4: [], - self.DHCPVersion.DHCP6: [], + DHCPVersion.DHCP4: [], + DHCPVersion.DHCP6: [], } + self.targets = [] + for target in targets: + url = urlparse(target) + client = None + try: + if url.scheme: + client = KeaHTTPClient(target, **kwargs) + elif url.path: + client = KeaSocketClient(target, **kwargs) + else: + click.echo(f"Unable to parse target argument: {target}") + continue + except OSError as ex: + click.echo(ex) + continue + + self.targets.append(client) + + def update(self): + for target in self.targets: + for response in target.stats(): + self.parse_metrics(*response) + def setup_dhcp4_metrics(self): self.metrics_dhcp4 = { # Packets @@ -451,10 +474,10 @@ def setup_dhcp6_metrics(self): def parse_metrics(self, dhcp_version, arguments, subnets): for key, data in arguments.items(): - if dhcp_version is self.DHCPVersion.DHCP4: + if dhcp_version is DHCPVersion.DHCP4: if key in self.metrics_dhcp4_global_ignore: continue - elif dhcp_version is self.DHCPVersion.DHCP6: + elif dhcp_version is DHCPVersion.DHCP6: if key in self.metrics_dhcp6_global_ignore: continue else: @@ -470,13 +493,13 @@ def parse_metrics(self, dhcp_version, arguments, subnets): pool_metric = subnet_match.group("pool_metric") subnet_metric = subnet_match.group("subnet_metric") - if dhcp_version is self.DHCPVersion.DHCP4: + if dhcp_version is DHCPVersion.DHCP4: if ( pool_metric in self.metric_dhcp4_subnet_ignore or subnet_metric in self.metric_dhcp4_subnet_ignore ): continue - elif dhcp_version is self.DHCPVersion.DHCP6: + elif dhcp_version is DHCPVersion.DHCP6: if ( pool_metric in self.metric_dhcp6_subnet_ignore or subnet_metric in self.metric_dhcp6_subnet_ignore @@ -521,10 +544,10 @@ def parse_metrics(self, dhcp_version, arguments, subnets): key = subnet_metric labels["pool"] = "" - if dhcp_version is self.DHCPVersion.DHCP4: + if dhcp_version is DHCPVersion.DHCP4: metrics_map = self.metrics_dhcp4_map metrics = self.metrics_dhcp4 - elif dhcp_version is self.DHCPVersion.DHCP6: + elif dhcp_version is DHCPVersion.DHCP6: metrics_map = self.metrics_dhcp6_map metrics = self.metrics_dhcp6 else: diff --git a/kea_exporter/kea_http_exporter.py b/kea_exporter/http.py similarity index 89% rename from kea_exporter/kea_http_exporter.py rename to kea_exporter/http.py index 11eebb4..4978b38 100644 --- a/kea_exporter/kea_http_exporter.py +++ b/kea_exporter/http.py @@ -1,9 +1,9 @@ import requests -from .base_exporter import BaseExporter +from kea_exporter import DHCPVersion -class KeaHTTPExporter(BaseExporter): +class KeaHTTPClient: def __init__(self, target, client_cert, client_key, **kwargs): super().__init__() @@ -49,7 +49,7 @@ def load_subnets(self): for subnet in module.get("arguments", {}).get("Dhcp6", {}).get("subnet6", {}): self.subnets6.update({subnet["id"]: subnet}) - def update(self): + def stats(self): # Reload subnets on update in case of configurational update self.load_subnets() # Note for future testing: pipe curl output to jq for an easier read @@ -67,14 +67,14 @@ def update(self): for index, module in enumerate(self.modules): if module == "dhcp4": - dhcp_version = self.DHCPVersion.DHCP4 + dhcp_version = DHCPVersion.DHCP4 subnets = self.subnets elif module == "dhcp6": - dhcp_version = self.DHCPVersion.DHCP6 + dhcp_version = DHCPVersion.DHCP6 subnets = self.subnets6 else: continue arguments = response[index].get("arguments", {}) - self.parse_metrics(dhcp_version, arguments, subnets) + yield dhcp_version, arguments, subnets diff --git a/kea_exporter/kea_socket_exporter.py b/kea_exporter/uds.py similarity index 52% rename from kea_exporter/kea_socket_exporter.py rename to kea_exporter/uds.py index ec89e62..703dc47 100644 --- a/kea_exporter/kea_socket_exporter.py +++ b/kea_exporter/uds.py @@ -5,26 +5,19 @@ import click -from .base_exporter import BaseExporter - - -class KeaSocket: - def __init__(self, sock_path): - try: - if not os.access(sock_path, os.F_OK): - raise FileNotFoundError() - if not os.access(sock_path, os.R_OK | os.W_OK): - raise PermissionError() - self.sock_path = os.path.abspath(sock_path) - except FileNotFoundError: - click.echo( - f"Socket at {sock_path} does not exist. Is Kea running?", - file=sys.stderr, - ) - sys.exit(1) - except PermissionError: - click.echo(f"Socket at {sock_path} is not read-/writeable.", file=sys.stderr) - sys.exit(1) +from kea_exporter import DHCPVersion + + +class KeaSocketClient: + def __init__(self, sock_path, **kwargs): + super().__init__() + + if not os.access(sock_path, os.F_OK): + raise FileNotFoundError(f"Unix domain socket does not exist at {sock_path}") + if not os.access(sock_path, os.R_OK | os.W_OK): + raise PermissionError(f"No read/write permissions on Unix domain socket at {sock_path}") + + self.sock_path = os.path.abspath(sock_path) self.version = None self.config = None @@ -48,16 +41,18 @@ def stats(self): # unfortunately we're reloading more often now as a workaround. self.reload() - return self.query("statistic-get-all") + arguments = self.query("statistic-get-all").get("arguments", {}) + + yield self.dhcp_version, arguments, self.subnets def reload(self): self.config = self.query("config-get")["arguments"] if "Dhcp4" in self.config: - self.dhcp_version = BaseExporter.DHCPVersion.DHCP4 + self.dhcp_version = DHCPVersion.DHCP4 subnets = self.config["Dhcp4"]["subnet4"] elif "Dhcp6" in self.config: - self.dhcp_version = BaseExporter.DHCPVersion.DHCP6 + self.dhcp_version = DHCPVersion.DHCP6 subnets = self.config["Dhcp6"]["subnet6"] else: click.echo( @@ -68,15 +63,3 @@ def reload(self): # create subnet map self.subnets = {subnet["id"]: subnet for subnet in subnets} - - -class KeaSocketExporter(BaseExporter): - def __init__(self, sockets, **kwargs): - super().__init__() - - # kea instances - self.kea_instances = [KeaSocket(socket) for socket in sockets] - - def update(self): - for kea in self.kea_instances: - self.parse_metrics(kea.dhcp_version, kea.stats().get("arguments"), kea.subnets)