#!/usr/bin/python3
#
# Copyright (C) 2026 PLD Linux Team <feedback@pld-linux.org>
#
# This program is free software: you can redistribute it and/or modify it under the
# terms of the GNU Affero General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
# PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>.
"""Send a nagios notification, as rendered by nagios-notify, through Pushover.

Exits 0 only when Pushover accepted the alert. Every other outcome exits 1 so that a
shell fallback in the nagios command can take over, for example:

    nagios-notify notify-service-by-pushover | nagios-notify-pushover \
        || nagios-notify notify-service-by-sms | sms-gateway
"""

import base64
import gzip
import hashlib
import hmac
import json
import logging
import os
import re
import signal
import sys
import tomllib
import urllib.error
import urllib.parse
import urllib.request
from email.parser import Parser

CONFIG = "/etc/nagios/pushover.toml"
API = "https://api.pushover.net/1/messages.json"
RECEIPTS = "https://api.pushover.net/1/receipts"
LOGFILE = "/var/log/nagios/nagios-pushover.log"
LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s"

# nagios SIGKILLs the whole notification pipeline after notification_timeout (30s by
# default). This script runs first, so its budget is what it leaves the fallback: one
# attempt only, because retrying a dead Pushover spends time a working channel needs.
# urlopen's timeout is per socket operation, so the alarm covers a slow drip. Calling
# off an emergency loop is worth a second or two on top, never the whole budget.
ATTEMPT_TIMEOUT = 5
CANCEL_TIMEOUT = 2
TOTAL_BUDGET = 8

# Pushover measures these against what it receives, and it cannot decrypt, so an
# encrypted field counts as its ciphertext - roughly three times the plaintext.
LIMITS = {"message": 1024, "title": 250, "url": 512, "url_title": 100}
MAX_RESPONSE = 65536

# 10000 messages a month, counter resets on the 1st; warn while there is time to react
LOW_QUOTA = 100

# Pushover retries a priority 2 alert no closer than 30 seconds apart and gives up
# after three hours at most; anything else comes back as a 400 that costs the alert
RETRY_RANGE = (30, 10800)

# an emergency alert repeats twice, a quarter of an hour apart, and is done inside 35
# minutes: enough to wake somebody, without ten broken services meaning a phone that
# never stops
RETRY, EXPIRE = 900, 2100

# How each event is announced when pushover.toml says nothing about it. Shipping this
# here rather than in the configuration means an installation that edited the file
# still receives later corrections, which %config(noreplace) would otherwise withhold.
DEFAULTS = {
    "DOWN": {"icon": "🔴", "priority": 2, "sound": "siren"},
    "UNREACHABLE": {"icon": "⚪", "priority": 0, "sound": "none"},
    "CRITICAL": {"icon": "🔴", "priority": 2, "sound": "gamelan"},
    "WARNING": {"icon": "🟡", "priority": 0, "sound": "falling"},
    "UNKNOWN": {"icon": "⚪", "priority": 0, "sound": "none"},
    "OK": {"icon": "🟢", "priority": -1, "sound": "none"},
    "UP": {"icon": "🟢", "priority": -1, "sound": "none"},
    "ACKNOWLEDGEMENT": {"icon": "👍", "priority": -1, "sound": "none"},
    "FLAPPING": {"icon": "🔀", "priority": 0, "sound": "none"},
    "DOWNTIME": {"icon": "💤", "priority": -1, "sound": "none"},
}

PHONE = re.compile(r"\+\d{6,15}\Z")
HEX256 = re.compile(r"[0-9a-fA-F]{64}\Z")
# tokens, user keys and group keys all share this shape, so a swap or a pasted space
# only shows up as a 400 from the API unless it is caught here
KEY30 = re.compile(r"[A-Za-z0-9]{30}\Z")
DEVICE = re.compile(r"[A-Za-z0-9_-]{1,25}(,[A-Za-z0-9_-]{1,25})*\Z")

log = logging.getLogger("nagios-pushover")


class NoRedirect(urllib.request.HTTPRedirectHandler):
    """Refuse redirects: urllib turns a redirected POST into a bodyless GET and replays
    the token to the new host, which could log a success and send nothing."""

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


OPENER = urllib.request.build_opener(NoRedirect)


def read_notification():
    """Parse the rendered notification on stdin.

    The template supplies the text and names the event; how loudly to announce it is
    decided by the configuration, so that it can differ per recipient.
    """
    # nagios spawns notification commands without a locale, so stdin has to be decoded
    # explicitly or a Polish acknowledgement comment kills the script
    notification = Parser().parsestr(sys.stdin.buffer.read().decode("utf-8", "replace"))

    # CONTACTPAGER is free text, and a folded header arrives with its continuation
    phone = "".join(notification.get("To", "").split())
    if not PHONE.match(phone):
        raise ValueError(f"{phone!r} is not a phone number, check the contact's pager")

    payload = notification.get_payload()
    fields = {"message": payload.strip() if isinstance(payload, str) else ""}
    if not fields["message"]:
        raise ValueError(f"{phone}: empty message body, refusing to send")

    for name in ("title", "url", "url_title"):
        value = notification.get("X-Pushover-" + name.replace("_", "-"), "").strip()
        if value:
            fields[name] = value

    repeat = notification.get("X-Pushover-Repeat", "").strip()
    return (phone, notification.get("X-Pushover-Event", "").strip(),
            int(repeat) if repeat.isdigit() else 1,
            notification.get("X-Pushover-Tag", "").strip(), fields)


def bounded(name, value, default):
    """Hold an emergency loop setting inside the range Pushover accepts."""
    low, high = RETRY_RANGE
    try:
        seconds = int(value)
    except (TypeError, ValueError):
        log.warning("%s is not a number, using %d", name, default)
        return default
    capped = min(max(seconds, low), high)
    if capped != seconds:
        log.warning("%s of %d is outside %d..%d, using %d", name, seconds, low, high, capped)
    return capped


def policy(config, recipient, event):
    """Look up how to announce this event, the more specific setting winning.

    Three layers, each overriding the one before key by key: what this script ships,
    what pushover.toml says for everybody, and what it says for this recipient.
    Returns the icon and the Pushover fields that carry the alerting behaviour.
    """
    rule = dict(DEFAULTS.get(event, {}))
    rule.update(config.get("event", {}).get(event, {}))
    rule.update(recipient.get("event", {}).get(event, {}))
    if not rule:
        log.warning("nothing known about event %s, announcing with Pushover defaults",
                    event or "?")

    delivery = {"priority": str(int(rule.get("priority", 0)))}
    if rule.get("sound"):
        delivery["sound"] = rule["sound"]
    if delivery["priority"] == "2":
        delivery["retry"] = str(bounded("retry", rule.get("retry", RETRY), RETRY))
        delivery["expire"] = str(bounded("expire", rule.get("expire", EXPIRE), EXPIRE))
    return rule.get("icon", ""), delivery


def fingerprint(recipient, tag):
    """Name the object in a way only this installation can read back.

    Tags are stored with the receipt and Pushover never encrypts them, so sending the
    host and service verbatim would hand over exactly what the message hides.
    """
    key = recipient.get("encryption_key", "")
    # the user key is in the material as well, so two recipients who share no
    # encryption key still do not share a tag, and one recovery cannot silence both
    material = f"{recipient['user_key']}\0{tag}".encode("utf-8")
    return hmac.new(bytes.fromhex(key) if key else b"",
                    material, hashlib.sha256).hexdigest()[:32]


def cancel(config, tag):
    """Call off whatever emergency loop is filed under this tag.

    Failure only costs a phone that keeps ringing until the loop expires by itself,
    which is never worth losing an alert over, so nothing here changes the exit code
    and nothing here may run long enough to spend somebody else's share of the budget.
    That deadline has to be enforced here: the socket timeout starts again on every
    read, so an answer arriving one byte at a time never trips it, and name resolution
    is not covered by it at all.
    """
    # setitimer rather than alarm, because the arithmetic is in fractions of seconds
    # that alarm() would round away
    budget = signal.getitimer(signal.ITIMER_REAL)[0]
    # before the alert this borrows from the budget and must leave a full attempt in
    # it; after the alert the timer is off and there is no fallback left to protect
    deadline = min(CANCEL_TIMEOUT, budget - ATTEMPT_TIMEOUT) if budget else CANCEL_TIMEOUT
    if deadline < 1:
        log.warning("too little of the budget left to call off the emergency loop")
        return

    data = urllib.parse.urlencode({"token": config["token"]}).encode()
    request = urllib.request.Request(f"{RECEIPTS}/cancel_by_tag/{tag}.json", data=data)
    spent, reason = deadline, None
    signal.setitimer(signal.ITIMER_REAL, deadline)
    try:
        try:
            with OPENER.open(request, timeout=CANCEL_TIMEOUT) as response:
                result = json.loads(response.read(MAX_RESPONSE))
            # a success says nothing about whether a loop was actually running, and most
            # of the time none was, so a line about it would be noise on every recovery
            if result.get("status") != 1:
                reason = "; ".join(result.get("errors") or ["unexpected response"])
        finally:
            # read and disarmed in one call, and nested so it runs before the except
            # below: flattened into try/except/finally, the deadline could fire while
            # the failure is being described and escape the function entirely
            spent = deadline - signal.setitimer(signal.ITIMER_REAL, 0)[0]
    except Exception as error:
        reason = f"{type(error).__name__}: {error}"

    if budget:
        signal.setitimer(signal.ITIMER_REAL, budget - spent)
    if reason:
        log.warning("could not call off the emergency loop (%s)", reason)


def encrypt(key, text):
    """Pushover's scheme: gzip, AES-256-CBC/PKCS7, HMAC-SHA256 over IV+ciphertext with
    the same key, base64 of IV+ciphertext+HMAC."""
    # imported here so a recipient without a key still works when the library is absent,
    # and a recipient with one fails into the fallback instead of killing the script
    from cryptography.hazmat.primitives import padding
    from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

    blob = gzip.compress(text.encode("utf-8"))
    iv = os.urandom(16)
    padder = padding.PKCS7(128).padder()
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
    body = cipher.update(padder.update(blob) + padder.finalize()) + cipher.finalize()
    mac = hmac.new(key, iv + body, hashlib.sha256).digest()
    return base64.b64encode(iv + body + mac).decode("ascii")


def fit(name, text, limit, cipher):
    """Shrink the plaintext until the value actually sent fits the limit.

    Returns None when no plaintext fits, which encryption can cause: its output has
    a floor of 108 characters even for an empty string, so shortening never helps.
    """
    value = cipher(text)
    if len(value) <= limit:
        return value
    # measuring the plaintext would be wrong: encryption inflates it about threefold and
    # gzip makes the factor depend on the text, so the only honest test is the result
    while len(value) > limit:
        shorter = text[:max(0, len(text) * limit // len(value) - 3)].rstrip()
        if not shorter:
            return None
        text, value = shorter + "...", cipher(shorter + "...")
    log.warning("%s truncated to fit %d characters", name, limit)
    return value


def send(config, recipient, phone, fields):
    """POST the alert to Pushover. Returns the API result, or None if it did not land."""
    key = recipient.get("encryption_key")
    if key:
        fields["encrypted"] = "1"
        # ciphertext has a floor of 108 characters whatever it wraps and Pushover
        # allows 100 here, so an encrypted link is labelled with its own address
        fields.pop("url_title", None)

    def cipher(text):
        return encrypt(bytes.fromhex(key), text) if key else text

    try:
        for name, limit in LIMITS.items():
            if name in fields:
                value = fit(name, fields[name], limit, cipher)
                if value is None:
                    # over-limit fields make Pushover reject the whole alert, and every
                    # field that can hit this floor is decoration around the message
                    log.info("%s cannot be shortened to %d characters, dropped",
                             name, limit)
                    del fields[name]
                else:
                    fields[name] = value

        fields.update(token=config["token"], user=recipient["user_key"])
        if "device" in recipient:
            fields["device"] = recipient["device"]

        request = urllib.request.Request(API,
                                         data=urllib.parse.urlencode(fields).encode())
        with OPENER.open(request, timeout=ATTEMPT_TIMEOUT) as response:
            remaining = response.headers.get("X-Limit-App-Remaining")
            result = json.loads(response.read(MAX_RESPONSE))
        if result.get("status") == 1:
            # an unparsable quota header must not void an accepted alert
            if remaining and remaining.isdigit() and int(remaining) < LOW_QUOTA:
                log.warning("only %s pushover messages left this month", remaining)
            return result
        reason = "; ".join(result.get("errors") or ["unexpected response"])
    except urllib.error.HTTPError as error:
        # 4xx is permanent per Pushover and 429 means the quota is gone: same action
        reason = "monthly message limit exhausted" if error.code == 429 else \
            f"HTTP {error.code}: {error.read(MAX_RESPONSE)[:200].decode('utf-8', 'replace')}"
    except Exception as error:
        # a dead socket, a TLS failure, a garbage response and a missing cryptography
        # all mean the same thing here: use the other channel
        reason = f"{type(error).__name__}: {error}"

    log.error("%s: pushover refused the alert (%s)", phone, reason)
    return None


def out_of_time(signum, frame):
    # a plain Exception, so whichever network step is underway fails like any other
    # and the nearest handler logs it; main() catches what nothing else claimed
    raise TimeoutError("out of time")


def check_events(owner, rules):
    """Refuse a priority Pushover would answer with a 400 that costs the alert."""
    for event, rule in rules.items():
        name = f"priority for {event}" + (f" of {owner}" if owner else "")
        priority = rule.get("priority", 0)
        # bool is an int to Python and int() floors floats, so true or 1.9 would
        # quietly become priority 1 instead of being refused
        try:
            if isinstance(priority, bool) or not isinstance(priority, (int, str)):
                raise ValueError
            priority = int(priority)
        except ValueError:
            raise ValueError(f"{name} is not a whole number")
        if not -2 <= priority <= 2:
            raise ValueError(f"{name} is outside -2..2")


def read_config():
    """Parse pushover.toml, refusing up front what Pushover would only refuse at
    sending time - with a 400 whose price is the alert."""
    with open(CONFIG, "rb") as handle:
        config = tomllib.load(handle)

    recipients = {}
    try:
        for entry in config.get("recipient", []):
            phone = entry.get("phone", "")
            if not PHONE.match(phone):
                raise ValueError(f"recipient {phone!r} needs a phone in +48... form")
            if not KEY30.match(entry.get("user_key", "")):
                raise ValueError(f"user_key of {phone} is not 30 characters of [A-Za-z0-9]")
            # an empty key would quietly send in the clear while looking encrypted in
            # the config, so present-but-wrong is refused whatever the wrongness
            if "encryption_key" in entry and not HEX256.match(entry["encryption_key"]):
                raise ValueError(f"encryption_key of {phone} is not 64 hex characters")
            if "device" in entry and not DEVICE.match(entry["device"]):
                # pushover answers an unknown device by delivering to every device the
                # person owns, so a typo here quietly widens what it was meant to narrow
                raise ValueError(f"device of {phone} is not up to 25 characters of "
                                 "[A-Za-z0-9_-], comma separated")
            if phone in recipients:
                log.warning("%s: %s appears more than once, the last block wins",
                            CONFIG, phone)
            recipients[phone] = entry

        # only worth complaining about once somebody is actually configured to receive
        if recipients and not KEY30.match(config.get("token", "")):
            raise ValueError("token is not 30 characters of [A-Za-z0-9]")

        check_events("", config.get("event", {}))
        for entry in recipients.values():
            check_events(entry["phone"], entry.get("event", {}))
    except (AttributeError, TypeError) as error:
        # [recipient] where [[recipient]] was meant, or an event that is not a table,
        # is valid TOML and only falls apart here
        raise ValueError(f"not shaped the way this script expects ({error})")
    return config, recipients


def main():
    try:
        logging.basicConfig(filename=LOGFILE, level=logging.INFO, format=LOG_FORMAT)
    except OSError as error:
        # losing the log must not cost the alert, which is what an unwritable logfile
        # after a bad rotation would otherwise do
        logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
        log.warning("cannot write %s (%s), logging to stderr", LOGFILE, error)

    signal.signal(signal.SIGALRM, out_of_time)
    signal.alarm(TOTAL_BUDGET)
    try:
        try:
            config, recipients = read_config()
        except TimeoutError:
            # a TimeoutError is an OSError: the budget running out mid-read is not
            # a broken config, let the handler below call it by its name
            raise
        except (OSError, tomllib.TOMLDecodeError) as error:
            log.error("cannot read %s: %s", CONFIG, error)
            return 1
        except ValueError as error:
            log.error("%s: %s", CONFIG, error)
            return 1

        try:
            phone, event, repeat, tag, fields = read_notification()
        except ValueError as error:
            log.error("cannot read the notification: %s", error)
            return 1

        if phone not in recipients:
            log.info("%s: no pushover recipient, leaving it to the fallback", phone)
            return 1

        recipient = recipients[phone]
        icon, delivery = policy(config, recipient, event)
        if icon and "title" in fields:
            fields["title"] = f"{icon} {fields['title']}"
        fields.update(delivery)

        emergency = delivery["priority"] == "2"
        if tag and emergency:
            # only an emergency files a receipt, and the tag is what lets whatever comes
            # next for the object call the loop off without this run remembering anything
            fields["tags"] = fingerprint(recipient, tag)
            if repeat > 1:
                # nagios saying it again means the object never left the state, so this
                # loop replaces the previous one instead of joining it. Every receipt is
                # acknowledged separately, so overlapping loops would keep ringing after
                # the phone was answered, and how many pile up would depend on the
                # interval between notifications - which is per host and per service.
                cancel(config, fields["tags"])

        # keep a readable copy for the log, since send() encrypts the fields in place
        summary = fields.get("title") or fields["message"].splitlines()[0]
        result = send(config, recipient, phone, fields)
    except TimeoutError:
        log.error("no answer within %ds, handing over to the fallback", TOTAL_BUDGET)
        return 1
    finally:
        # win or lose, the verdict is in once send() returns; a slow cancel below must
        # not be able to turn a delivered notification into a duplicate over the fallback
        signal.alarm(0)

    if result is None:
        return 1

    # spell out what reached the phone: "why did it not ring" is answerable from here
    detail = [event or "?", "prio=" + delivery["priority"]]
    detail += [f"{k}={delivery[k]}" for k in ("sound", "retry", "expire") if k in delivery]
    if repeat > 1:
        detail.append(f"repeat={repeat}")
    detail.append("encrypted" if "encryption_key" in recipient else "cleartext")
    detail += [f"{k}={v}" for k, v in (("device", recipient.get("device")),
                                       ("request", result.get("request")),
                                       ("receipt", result.get("receipt"))) if v]
    log.info("%s: sent [%s]: %s", phone, " ".join(detail), summary)

    if tag and not emergency:
        # anything this object is worth less than an emergency means an emergency loop
        # left over from an earlier state is now ringing about a state it is not in.
        # After the message and only once it landed: the loop lives on the same API,
        # so an unreachable Pushover could not be told to stop anyway.
        cancel(config, fingerprint(recipient, tag))
    return 0


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