#!/usr/bin/env python3
"""Checkmk special agent for OPNsense (REST API).

Polls an OPNsense firewall over its REST API using Basic auth (API key as
username, API secret as password) and emits Checkmk agent sections:

    <<<opnsense_firmware:sep(0)>>>   JSON from core/firmware/status
    <<<opnsense_services:sep(0)>>>   JSON list from core/service/search
    <<<opnsense_system:sep(0)>>>     JSON: system/status + diagnostics gauges
    <<<opnsense_smart:sep(0)>>>      JSON: SMART data per disk (os-smart)

All endpoints are read-only (GET, or POST with empty body). A read-only user
with "Status: Services" + "Lobby: Dashboard" + "System: Firmware" + "System:
Status" privileges is sufficient. SMART data additionally requires the
"Services: SMART" privilege and the os-smart plugin on OPNsense.
"""
import argparse
import json
import sys
import ssl
import base64
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError


def parse_args(argv):
    p = argparse.ArgumentParser(description="Checkmk OPNsense special agent")
    p.add_argument("--api-key", required=True, help="OPNsense API key")
    p.add_argument("--api-secret", required=True, help="OPNsense API secret")
    p.add_argument("--port", type=int, default=8443, help="HTTPS port (default 8443)")
    p.add_argument("--no-cert-check", action="store_true",
                   help="Disable TLS certificate verification (self-signed)")
    p.add_argument("--timeout", type=int, default=20, help="Per-request timeout (s)")
    p.add_argument("hostname", help="OPNsense host / address")
    return p.parse_args(argv)


def make_context(no_cert_check):
    ctx = ssl.create_default_context()
    if no_cert_check:
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
    return ctx


class OPNsenseClient:
    def __init__(self, host, port, key, secret, ctx, timeout):
        # Bracket bare IPv6 addresses for the URL authority component.
        if ":" in host and not host.startswith("["):
            host = "[%s]" % host
        self.base = "https://%s:%d/api" % (host, port)
        self.ctx = ctx
        self.timeout = timeout
        token = base64.b64encode(("%s:%s" % (key, secret)).encode()).decode()
        self.auth = "Basic %s" % token

    def call(self, path, method="GET", form=None):
        url = "%s/%s" % (self.base, path)
        if method == "POST" and form is not None:
            data = urlencode(form).encode("utf-8")
        elif method == "POST":
            data = b""
        else:
            data = None
        req = Request(url, data=data, method=method)
        req.add_header("Authorization", self.auth)
        if form is not None:
            req.add_header("Content-Type", "application/x-www-form-urlencoded")
        with urlopen(req, context=self.ctx, timeout=self.timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))


def emit(section, payload):
    sys.stdout.write("<<<%s:sep(0)>>>\n" % section)
    sys.stdout.write(json.dumps(payload) + "\n")


def main(argv=None):
    args = parse_args(argv if argv is not None else sys.argv[1:])
    ctx = make_context(args.no_cert_check)
    client = OPNsenseClient(args.hostname, args.port, args.api_key,
                            args.api_secret, ctx, args.timeout)

    # --- Firmware / updates ---
    firmware = {}
    try:
        firmware = client.call("core/firmware/status", method="POST")
    except (HTTPError, URLError, ValueError) as exc:
        firmware = {"_error": str(exc)}
    emit("opnsense_firmware", firmware)

    # --- Services (per-service discovery) ---
    services = {}
    try:
        services = client.call("core/service/search")
    except (HTTPError, URLError, ValueError) as exc:
        services = {"_error": str(exc)}
    emit("opnsense_services", services)

    # --- System status + diagnostics gauges ---
    system = {}
    for key, path, method in (
        ("status", "core/system/status", "GET"),
        ("time", "diagnostics/system/system_time", "GET"),
        ("resources", "diagnostics/system/system_resources", "GET"),
        ("disk", "diagnostics/system/system_disk", "GET"),
        ("swap", "diagnostics/system/system_swap", "GET"),
        ("temperature", "diagnostics/system/system_temperature", "GET"),
    ):
        try:
            system[key] = client.call(path, method=method)
        except (HTTPError, URLError, ValueError) as exc:
            system[key] = {"_error": str(exc)}
    emit("opnsense_system", system)

    # --- SMART disk health (requires os-smart plugin) ---
    smart = {"installed": False, "devices": []}
    try:
        roster = client.call("smart/service/list", method="POST")
        devices = []
        for dev in roster.get("devices", []):
            if not isinstance(dev, str) or not dev:
                continue
            try:
                info = client.call(
                    "smart/service/info",
                    method="POST",
                    form={"device": dev, "type": "a", "json": "1"},
                )
                state = info.get("output") if isinstance(info, dict) else None
                devices.append({"device": dev, "state": state})
            except (HTTPError, URLError, ValueError) as exc:
                devices.append({"device": dev, "state": None,
                                "_error": str(exc)})
        smart = {"installed": True, "devices": devices}
    except (HTTPError, URLError, ValueError) as exc:
        # If the smart module is absent we get a 404; treat as not installed.
        smart = {"installed": False, "devices": [], "_error": str(exc)}
    emit("opnsense_smart", smart)
    return 0


if __name__ == "__main__":
    sys.exit(main())
