#!/usr/bin/env python3
# vexor_check_zerossl - Nagios/Naemon active check for a ZeroSSL account.
#
# Queries the ZeroSSL REST API (GET /certificates) and alerts when certificates
# are expiring soon or have expired without being renewed. Runs on the Vexor
# monitoring server itself (one account-wide access key), NOT via an agent.
#
# It groups certificates by common name and evaluates the NEWEST certificate per
# domain, so a renewal (a fresh cert for the same domain) correctly supersedes an
# older/expired one and clears the alert.
#
# Read-only: only performs GET requests, never modifies the account.
#
# Usage:
#   vexor_check_zerossl [--key-file /etc/vexor/zerossl.key | --key KEY]
#                       [--warn-days 14] [--crit-days 3]
#                       [--timeout 20] [--api-base https://api.zerossl.com]
#
# Exit codes: 0 OK, 1 WARNING, 2 CRITICAL, 3 UNKNOWN.

import sys
import os
import json
import argparse
import datetime
import urllib.parse
import urllib.request
import urllib.error

OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3
DEFAULT_KEY_FILE = "/etc/vexor/zerossl.key"


def out(code, label, msg, perf=""):
    line = "%s %s - %s" % (label, {0: "OK", 1: "WARNING", 2: "CRITICAL", 3: "UNKNOWN"}[code], msg)
    if perf:
        line += " |" + perf
    print(line)
    sys.exit(code)


def load_key(args):
    if args.key:
        return args.key.strip()
    path = args.key_file or DEFAULT_KEY_FILE
    try:
        with open(path, "r") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#"):
                    return line
    except OSError as e:
        out(UNKNOWN, "ZEROSSL", "cannot read access key from %s (%s); pass --key-file or --key" % (path, e.strerror))
    out(UNKNOWN, "ZEROSSL", "no access key found in %s" % path)


def api_get(base, access_key, status, page, limit, timeout):
    q = {"access_key": access_key, "limit": str(limit), "page": str(page)}
    if status:
        q["certificate_status"] = status
    url = base.rstrip("/") + "/certificates?" + urllib.parse.urlencode(q)
    req = urllib.request.Request(url, headers={"User-Agent": "vexor_check_zerossl/1.0"})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        raw = resp.read().decode("utf-8", "replace")
    return json.loads(raw)


def fetch_all(base, access_key, status, timeout):
    limit = 1000
    page = 1
    results = []
    while True:
        data = api_get(base, access_key, status, page, limit, timeout)
        if isinstance(data, dict) and data.get("success") is False:
            err = data.get("error", {})
            raise RuntimeError("ZeroSSL API error: %s" % (err.get("type") or err))
        batch = data.get("results", []) if isinstance(data, dict) else []
        results.extend(batch)
        total = int(data.get("total_count", len(results)) or 0)
        if len(results) >= total or not batch:
            break
        page += 1
        if page > 100:  # safety valve
            break
    return results


def parse_expires(s):
    # ZeroSSL returns UTC "YYYY-MM-DD HH:MM:SS"
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
        try:
            return datetime.datetime.strptime(s, fmt).replace(tzinfo=datetime.timezone.utc)
        except (ValueError, TypeError):
            continue
    return None


def main():
    p = argparse.ArgumentParser(add_help=True)
    p.add_argument("--key-file", dest="key_file", default="")
    p.add_argument("--key", dest="key", default="")
    p.add_argument("--warn-days", dest="warn_days", default="14")
    p.add_argument("--crit-days", dest="crit_days", default="3")
    p.add_argument("--timeout", dest="timeout", default="20")
    p.add_argument("--api-base", dest="api_base", default="https://api.zerossl.com")
    # Keep empty NRPE-padded tokens harmless.
    argv = [a for a in sys.argv[1:]]
    args, _ = p.parse_known_args(argv)

    def sint(v, d):
        try:
            v = str(v).strip()
            return int(v) if v != "" else d
        except (ValueError, TypeError):
            return d

    warn_days = sint(args.warn_days, 14)
    crit_days = sint(args.crit_days, 3)
    timeout = sint(args.timeout, 20)
    base = (args.api_base or "").strip() or "https://api.zerossl.com"

    access_key = load_key(args)

    try:
        # issued = active certs; expired = lapsed certs still on the account.
        certs = fetch_all(base, access_key, "issued,expired", timeout)
    except urllib.error.HTTPError as e:
        out(UNKNOWN, "ZEROSSL", "HTTP %s from ZeroSSL API" % e.code)
    except urllib.error.URLError as e:
        out(UNKNOWN, "ZEROSSL", "cannot reach ZeroSSL API: %s" % e.reason)
    except (RuntimeError, ValueError, json.JSONDecodeError) as e:
        out(UNKNOWN, "ZEROSSL", str(e))

    if not certs:
        out(OK, "ZEROSSL", "no certificates on the account", "expiring=0 expired=0 total=0")

    # Group by common_name, keep the newest (latest expiry) cert per domain.
    newest = {}
    for c in certs:
        cn = (c.get("common_name") or "").strip().lower()
        exp = parse_expires(c.get("expires"))
        if not cn or exp is None:
            continue
        prev = newest.get(cn)
        if prev is None or exp > prev[0]:
            newest[cn] = (exp, c)

    if not newest:
        out(UNKNOWN, "ZEROSSL", "could not parse any certificate expiry dates from the account")

    now = datetime.datetime.now(datetime.timezone.utc)
    crit_list = []   # (days, cn) already expired or within crit-days
    warn_list = []   # (days, cn) within warn-days
    soonest = None   # (days, cn)
    for cn, (exp, c) in newest.items():
        days = (exp - now).days
        if soonest is None or days < soonest[0]:
            soonest = (days, cn)
        if days < crit_days:
            crit_list.append((days, cn))
        elif days < warn_days:
            warn_list.append((days, cn))

    crit_list.sort()
    warn_list.sort()
    total = len(newest)
    expired_n = sum(1 for cn, (exp, c) in newest.items() if (exp - now).days < 0)
    expiring_n = len(crit_list) + len(warn_list)
    sd = soonest[0] if soonest else 0
    perf = "expiring=%d expired=%d total=%d soonest_days=%d;%d;%d" % (
        expiring_n, expired_n, total, sd, warn_days, crit_days)

    def fmt(items, n=5):
        parts = []
        for days, cn in items[:n]:
            parts.append("%s (%s)" % (cn, "expired %dd ago" % (-days) if days < 0 else "%dd" % days))
        if len(items) > n:
            parts.append("+%d more" % (len(items) - n))
        return ", ".join(parts)

    if crit_list:
        out(CRITICAL, "ZEROSSL",
            "%d cert(s) expired or expiring within %dd: %s" % (len(crit_list), crit_days, fmt(crit_list)),
            perf)
    if warn_list:
        out(WARNING, "ZEROSSL",
            "%d cert(s) expiring within %dd: %s" % (len(warn_list), warn_days, fmt(warn_list)),
            perf)
    sd_txt = "n/a"
    if soonest:
        sd_txt = "%s expired %dd ago" % (soonest[1], -soonest[0]) if soonest[0] < 0 else "%s in %dd" % (soonest[1], soonest[0])
    out(OK, "ZEROSSL", "%d cert(s) OK, soonest expiry: %s" % (total, sd_txt), perf)


if __name__ == "__main__":
    main()
