[packages/nagios-alert-pushover] new: nagios alerts through Pushover, with a fallback left to the command

arekm arekm at pld-linux.org
Tue Aug 11 09:20:53 CEST 2026


commit 2798bc7ee2e2ed5b1746c31d8e057876e8a59d55
Author: Arkadiusz Miśkiewicz <arekm at maven.pl>
Date:   Mon Aug 10 20:51:56 2026 +0200

    new: nagios alerts through Pushover, with a fallback left to the command

 nagios-alert-pushover.cfg       |  26 ++
 nagios-alert-pushover.logrotate |  10 +
 nagios-alert-pushover.spec      |  89 +++++
 nagios-pushover.alert           | 481 +++++++++++++++++++++++++
 notify-host-by-pushover.tmpl    |  31 ++
 notify-service-by-pushover.tmpl |  31 ++
 pushover.toml                   | 113 ++++++
 test_nagios_pushover.py         | 751 ++++++++++++++++++++++++++++++++++++++++
 8 files changed, 1532 insertions(+)
---
diff --git a/nagios-alert-pushover.spec b/nagios-alert-pushover.spec
new file mode 100644
index 0000000..13790af
--- /dev/null
+++ b/nagios-alert-pushover.spec
@@ -0,0 +1,89 @@
+#
+# Conditional build:
+%bcond_without	tests		# tests against a stub Pushover API
+#
+Summary:	Program to send (Nagios) alerts via Pushover
+Summary(pl.UTF-8):	Program do wysyłania alarmów (Nagiosa) przez Pushover
+Name:		nagios-alert-pushover
+Version:	1.0
+Release:	1
+License:	AGPL v3+
+Group:		Networking
+Source0:	nagios-pushover.alert
+Source1:	notify-service-by-pushover.tmpl
+Source2:	notify-host-by-pushover.tmpl
+Source3:	%{name}.cfg
+Source4:	pushover.toml
+Source5:	%{name}.logrotate
+Source6:	test_nagios_pushover.py
+URL:		https://pushover.net/api
+%if %{with tests}
+BuildRequires:	python3
+BuildRequires:	python3-cryptography
+BuildRequires:	python3-modules
+%endif
+Requires:	nagios-common
+# templates use the duration and timestamp_date filters
+Requires:	nagios-notify >= 0.16.1-2
+Requires:	python3-cryptography
+Requires:	python3-modules
+Conflicts:	logrotate < 3.8.0
+BuildArch:	noarch
+BuildRoot:	%{tmpdir}/%{name}-%{version}-root-%(id -u -n)
+
+%define		_sysconfdir	/etc/nagios
+
+%description
+nagios-notify-pushover sends Nagios notifications through the Pushover
+service, with a title, a priority, a notification sound and a link back
+to the web interface.
+
+Messages can be encrypted end to end with a key that only the recipient's
+phone knows, so that Pushover itself cannot read the alerts.
+
+It exits non-zero whenever the alert did not reach Pushover, so that a
+shell fallback in the nagios command can deliver it another way.
+
+%description -l pl.UTF-8
+nagios-notify-pushover wysyła powiadomienia Nagiosa przez serwis
+Pushover, wraz z tytułem, priorytetem, dźwiękiem powiadomienia i
+odnośnikiem do interfejsu WWW.
+
+Wiadomości można szyfrować end-to-end kluczem znanym wyłącznie
+telefonowi odbiorcy, dzięki czemu sam Pushover nie może odczytać
+alarmów.
+
+Program kończy się kodem niezerowym zawsze, gdy alarm nie dotarł do
+Pushovera, dzięki czemu awaryjna gałąź w poleceniu Nagiosa może
+dostarczyć go inną drogą.
+
+%prep
+%setup -q -c -T
+install %{SOURCE0} %{SOURCE1} %{SOURCE2} %{SOURCE3} %{SOURCE4} %{SOURCE5} %{SOURCE6} .
+
+%build
+%if %{with tests}
+%{__python3} test_nagios_pushover.py
+%endif
+
+%install
+rm -rf $RPM_BUILD_ROOT
+install -d $RPM_BUILD_ROOT{%{_sbindir},%{_sysconfdir}/{templates,plugins}}
+install nagios-pushover.alert $RPM_BUILD_ROOT%{_sbindir}/nagios-notify-pushover
+cp -a notify-service-by-pushover.tmpl notify-host-by-pushover.tmpl \
+	$RPM_BUILD_ROOT%{_sysconfdir}/templates
+cp -a %{name}.cfg $RPM_BUILD_ROOT%{_sysconfdir}/plugins
+cp -a pushover.toml $RPM_BUILD_ROOT%{_sysconfdir}
+install -Dp %{name}.logrotate $RPM_BUILD_ROOT/etc/logrotate.d/%{name}
+
+%clean
+rm -rf $RPM_BUILD_ROOT
+
+%files
+%defattr(644,root,root,755)
+%attr(640,root,nagios) %config(noreplace) %verify(not md5 mtime size) %{_sysconfdir}/pushover.toml
+%config(noreplace) %verify(not md5 mtime size) /etc/logrotate.d/%{name}
+%config(noreplace) %verify(not md5 mtime size) %{_sysconfdir}/plugins/%{name}.cfg
+%config(noreplace) %verify(not md5 mtime size) %{_sysconfdir}/templates/notify-host-by-pushover.tmpl
+%config(noreplace) %verify(not md5 mtime size) %{_sysconfdir}/templates/notify-service-by-pushover.tmpl
+%attr(755,root,root) %{_sbindir}/nagios-notify-pushover
diff --git a/nagios-alert-pushover.cfg b/nagios-alert-pushover.cfg
new file mode 100644
index 0000000..8880d90
--- /dev/null
+++ b/nagios-alert-pushover.cfg
@@ -0,0 +1,26 @@
+# vim:ft=nagios
+
+################################################################################
+#
+# PUSHOVER NOTIFICATION COMMANDS
+#
+# Recipients are matched by their CONTACTPAGER against /etc/nagios/pushover.toml.
+# nagios-notify-pushover exits non-zero whenever the alert did not reach Pushover -
+# because of an API error, an exhausted monthly limit, or simply because the number
+# is not listed - which is what lets a shell fallback take over:
+#
+#	command_line	/usr/lib/nagios/nagios-notify notify-service-by-pushover | /usr/sbin/nagios-notify-pushover || /usr/lib/nagios/nagios-notify notify-service-by-sms | /usr/lib/sendmail -oi -t
+#
+# Keep both branches inside notification_timeout (30s by default) or nagios kills
+# the pipeline before the fallback finishes.
+#
+################################################################################
+
+define command {
+	command_name	notify-service-by-pushover
+	command_line	/usr/lib/nagios/nagios-notify notify-service-by-pushover | /usr/sbin/nagios-notify-pushover
+}
+define command {
+	command_name	notify-host-by-pushover
+	command_line	/usr/lib/nagios/nagios-notify notify-host-by-pushover | /usr/sbin/nagios-notify-pushover
+}
diff --git a/nagios-alert-pushover.logrotate b/nagios-alert-pushover.logrotate
new file mode 100644
index 0000000..2bca7a2
--- /dev/null
+++ b/nagios-alert-pushover.logrotate
@@ -0,0 +1,10 @@
+# /var/log/nagios is 770 root:nagcmd and logrotate refuses a group-writable
+# directory without su; the global "create 640 root logs" would hand the fresh
+# log to root and leave the nagios user unable to write it
+/var/log/nagios/nagios-pushover.log {
+	su root nagcmd
+	# the script creates the log on the first notification it handles, and until
+	# then logrotate would report a missing file on every run
+	missingok
+	create 640 nagios nagios
+}
diff --git a/nagios-pushover.alert b/nagios-pushover.alert
new file mode 100644
index 0000000..66fc351
--- /dev/null
+++ b/nagios-pushover.alert
@@ -0,0 +1,481 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2026 PLD Linux Team <feedback at 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())
diff --git a/notify-host-by-pushover.tmpl b/notify-host-by-pushover.tmpl
new file mode 100644
index 0000000..6fa0a4d
--- /dev/null
+++ b/notify-host-by-pushover.tmpl
@@ -0,0 +1,31 @@
+{%- set ntype = NOTIFICATIONTYPE|string -%}
+{%- set state = HOSTSTATE|string -%}
+{%- set event, label = state, state -%}
+{%- if ntype == "ACKNOWLEDGEMENT" -%}{%- set event, label = "ACKNOWLEDGEMENT", "ACK " ~ state -%}
+{%- elif ntype.startswith("FLAPPING") -%}{%- set event, label = "FLAPPING", ntype -%}
+{%- elif ntype.startswith("DOWNTIME") -%}{%- set event, label = "DOWNTIME", ntype -%}
+{%- endif -%}
+{#- the problem id scopes the tag to one problem episode: a recovery that is slow to
+    cancel must not be able to silence the loop a brand-new problem just filed -#}
+To: {{ CONTACTPAGER }}
+X-Pushover-Event: {{ event }}
+X-Pushover-Repeat: {% if ntype == "PROBLEM" %}{{ NOTIFICATIONNUMBER|int }}{% else %}1{% endif %}
+X-Pushover-Tag: {{ HOSTNAME }}/{{ HOSTPROBLEMID|int or LASTHOSTPROBLEMID|int }}
+X-Pushover-Title: {{ label }} — {{ HOSTNAME }}{% if HOSTINFOURL %}
+X-Pushover-Url: {{ HOSTINFOURL }}
+X-Pushover-Url-Title: Open in nagios{% endif %}
+
+{% if ntype == "RECOVERY" and LASTHOSTUP|int -%}
+Since: {{ LASTHOSTSTATECHANGE|timestamp_date }}
+Not UP for {{ (LASTHOSTSTATECHANGE|int - LASTHOSTUP|int)|duration }}
+{%- else -%}
+Since: {{ LASTHOSTSTATECHANGE|timestamp_date }} ({{ HOSTDURATION }})
+Last UP: {% if LASTHOSTUP|int %}{{ LASTHOSTUP|timestamp_date }}{% else %}never{% endif %}
+{%- endif %}
+Sent: {{ TIMET|timestamp_date }}
+
+{% if ntype == "ACKNOWLEDGEMENT" -%}
+Acknowledged by {{ HOSTACKAUTHOR }}: {{ HOSTACKCOMMENT }}
+{%- else -%}
+{{ HOSTOUTPUT }}
+{%- endif %}
diff --git a/notify-service-by-pushover.tmpl b/notify-service-by-pushover.tmpl
new file mode 100644
index 0000000..ddd5289
--- /dev/null
+++ b/notify-service-by-pushover.tmpl
@@ -0,0 +1,31 @@
+{%- set ntype = NOTIFICATIONTYPE|string -%}
+{%- set state = SERVICESTATE|string -%}
+{%- set event, label = state, state -%}
+{%- if ntype == "ACKNOWLEDGEMENT" -%}{%- set event, label = "ACKNOWLEDGEMENT", "ACK " ~ state -%}
+{%- elif ntype.startswith("FLAPPING") -%}{%- set event, label = "FLAPPING", ntype -%}
+{%- elif ntype.startswith("DOWNTIME") -%}{%- set event, label = "DOWNTIME", ntype -%}
+{%- endif -%}
+{#- the problem id scopes the tag to one problem episode: a recovery that is slow to
+    cancel must not be able to silence the loop a brand-new problem just filed -#}
+To: {{ CONTACTPAGER }}
+X-Pushover-Event: {{ event }}
+X-Pushover-Repeat: {% if ntype == "PROBLEM" %}{{ NOTIFICATIONNUMBER|int }}{% else %}1{% endif %}
+X-Pushover-Tag: {{ HOSTNAME }}/{{ SERVICEDESC }}/{{ SERVICEPROBLEMID|int or LASTSERVICEPROBLEMID|int }}
+X-Pushover-Title: {{ label }} — {{ HOSTNAME }} / {{ SERVICEDESC }}{% if SERVICEINFOURL %}
+X-Pushover-Url: {{ SERVICEINFOURL }}
+X-Pushover-Url-Title: Open in nagios{% endif %}
+
+{% if ntype == "RECOVERY" and LASTSERVICEOK|int -%}
+Since: {{ LASTSERVICESTATECHANGE|timestamp_date }}
+Not OK for {{ (LASTSERVICESTATECHANGE|int - LASTSERVICEOK|int)|duration }}
+{%- else -%}
+Since: {{ LASTSERVICESTATECHANGE|timestamp_date }} ({{ SERVICEDURATION }})
+Last OK: {% if LASTSERVICEOK|int %}{{ LASTSERVICEOK|timestamp_date }}{% else %}never{% endif %}
+{%- endif %}
+Sent: {{ TIMET|timestamp_date }}
+
+{% if ntype == "ACKNOWLEDGEMENT" -%}
+Acknowledged by {{ SERVICEACKAUTHOR }}: {{ SERVICEACKCOMMENT }}
+{%- else -%}
+{{ SERVICEOUTPUT }}
+{%- endif %}
diff --git a/pushover.toml b/pushover.toml
new file mode 100644
index 0000000..f65c9d7
--- /dev/null
+++ b/pushover.toml
@@ -0,0 +1,113 @@
+# Read by nagios-notify-pushover. Keep it 0640 root:nagios - it holds the
+# application token and the end-to-end encryption keys.
+
+# application token, from https://pushover.net/apps
+token = "APPLICATIONTOKEN00000000000000"
+
+
+# One block per contact. phone has to match that contact's CONTACTPAGER exactly, in
+# international form; somebody who is not listed here gets nothing from this script,
+# so the nagios command should fall back to another channel. user_key is the
+# 30-character key from that person's dashboard - exactly 30 of [A-Za-z0-9],
+# the same shape as the token above, so take care not to swap the two.
+#
+# encryption_key turns on end-to-end encryption. You generate the key yourself and
+# enter the same 64 hex characters into the phone app; Pushover never learns it.
+# Generate a separate key for each person with:
+#
+#     openssl rand -hex 32
+#
+# Only the iOS and Android apps from version 5.0 can read encrypted messages - the
+# desktop and web clients cannot. Leave it out and the alert reaches Pushover in the
+# clear. Encrypted alerts also lose the "Open in nagios" label on the link, which is
+# then shown as its own address: ciphertext never comes out under 108 characters and
+# Pushover allows 100 for that label.
+#
+# device restricts delivery to one registered device instead of all of them. It is
+# the name the device was registered under in the app - "iphone", "pixel8",
+# "galaxy-s24", "work-ipad" - not a model name, and it allows at most 25 letters,
+# digits, dashes and underscores. Several names can be separated by commas. Leave it
+# out to reach every device that person owns, which is what an alert usually wants.
+# Copy the name exactly: a device Pushover does not recognise is not an error there,
+# it delivers to every device instead, so a typo widens what it was meant to narrow.
+# It matters most together with encryption: the key is stored per device, so either
+# give every device the same key, or point this at the one that has it.
+
+# The examples below are commented out on purpose: as shipped, this script delivers
+# nothing and every notification falls straight through to the command's fallback.
+
+# on call, encrypted, every device they own
+#[[recipient]]
+#phone = "+48600123456"
+#user_key = "USERKEYOFFIRSTADMIN00000000000"
+#encryption_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+
+# own key, only the work phone rings
+#[[recipient]]
+#phone = "+48600654321"
+#user_key = "USERKEYOFSECONDADMIN0000000000"
+#encryption_key = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
+#device = "iphone"
+
+# no key set up yet, so these alerts travel in the clear
+#[[recipient]]
+#phone = "+48600987654"
+#user_key = "USERKEYOFTHIRDADMIN00000000000"
+
+# a delivery group key works in user_key too and Pushover fans it out to every
+# member. Encryption cannot go with one: each device holds its own key, so there is
+# no single key the whole group could decrypt with.
+#[[recipient]]
+#phone = "+48600111222"
+#user_key = "GROUPKEYFROMDELIVERYGROUPSPAGE"
+
+
+# How loudly each event is announced is decided by nagios-notify-pushover itself, so
+# that later corrections reach installations that edited this file. Its table sits at
+# the top of /usr/sbin/nagios-notify-pushover under DEFAULTS, and as shipped a dead
+# host or a CRITICAL service rings until acknowledged, a WARNING chimes once, and a
+# recovery arrives silently.
+#
+# Anything below overrides that, key by key - what you leave out keeps its default.
+# Event names are the nagios states - DOWN, UNREACHABLE, CRITICAL, WARNING, UNKNOWN,
+# OK, UP - plus ACKNOWLEDGEMENT, FLAPPING and DOWNTIME.
+#
+# priority: -2 no notification, -1 no sound, 0 normal, 1 bypasses quiet hours,
+#           2 repeats until acknowledged and needs retry and expire.
+# sound:    any built-in name (pushover, bike, bugle, cashregister, classical, cosmic,
+#           falling, gamelan, incoming, intermission, magic, mechanical, pianobar,
+#           siren, spacealarm, tugboat, alien, climb, persistent, echo, updown,
+#           vibrate, none) or a sound uploaded to your account. A name Pushover does
+#           not know is rejected with a 400 and the alert falls through to the backup
+#           channel, so mind the spelling.
+# icon:     any character, shown at the front of the title.
+# retry:    seconds between repeats of a priority 2 alert, and expire is how long to
+#           keep repeating for. Pushover allows 30 to 10800 seconds; anything else is
+#           capped to fit, with a note in the log. Repeating happens on Pushover's
+#           side, so it costs nothing here.
+#
+# A repeated notification about the same unresolved problem is announced exactly like
+# the first one - whoever slept through that one has to be told again. How often it
+# comes back is notification_interval in nagios. A repeat replaces a running priority 2
+# loop rather than adding to it, so a host or service never has more than one: each
+# receipt has to be acknowledged on its own, and several would go on ringing after the
+# phone was answered.
+# The next notification about that host or service that is worth less than priority 2
+# calls off a running loop, since it would otherwise keep ringing about a state the
+# object has left: recovery and acknowledgement, but a drop to WARNING as well.
+
+# a CRITICAL service should chime once instead of ringing until acknowledged
+#[event.CRITICAL]
+#priority = 1
+
+# a dead host may keep ringing, but every half hour and for two hours
+#[event.DOWN]
+#retry = 1800
+#expire = 7200
+
+# The same blocks written under a recipient apply to that person only, so one admin
+# can keep the siren while another sleeps next to the phone. They have to follow that
+# recipient's own block.
+#[recipient.event.DOWN]
+#priority = 1
+#sound = "gamelan"
diff --git a/test_nagios_pushover.py b/test_nagios_pushover.py
new file mode 100644
index 0000000..285d653
--- /dev/null
+++ b/test_nagios_pushover.py
@@ -0,0 +1,751 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2026 PLD Linux Team <feedback at 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/>.
+"""Exercise nagios-pushover.alert against a stub Pushover API."""
+import base64
+import gzip
+import hashlib
+import hmac
+import http.server
+import importlib.machinery
+import importlib.util
+import io
+import json
+import logging
+import os
+import signal
+import sys
+import tempfile
+import threading
+import tomllib
+import types
+import urllib.parse
+from cryptography.hazmat.primitives import padding
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+
+PKG = os.path.dirname(os.path.abspath(__file__))
+# never beside the script: the suite writes its own pushover.toml and would
+# overwrite the packaged one
+SCRATCH = tempfile.mkdtemp(prefix="nagios-pushover-test-")
+# the package directory is a git repo, so leave no __pycache__ behind for the
+# alert module loaded below
+sys.dont_write_bytecode = True
+
+spec = importlib.util.spec_from_loader(
+    "pushover", importlib.machinery.SourceFileLoader("pushover", f"{PKG}/nagios-pushover.alert"))
+assert spec and spec.loader
+g = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(g)
+g.LOGFILE = f"{SCRATCH}/pushover.log"
+# start from an empty log, or assertions about it read the previous run's lines
+open(g.LOGFILE, "w").close()
+
+
+def unhook_logging():
+    """Leave the root logger bare, so the script's basicConfig() is the one that counts.
+
+    It gives up silently when a handler is already there, which under pytest means
+    every line the script logs would land in pytest's capture instead of LOGFILE.
+    """
+    for handler in list(logging.root.handlers):
+        logging.root.removeHandler(handler)
+
+
+unhook_logging()
+
+KEY = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
+TOKEN = "azGDORePK8gMaC0QOYAMyEEuzJnyUi"
+USER = "uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
+PHONE = "+48600123456"
+RED, GREEN = "\U0001F534", "\U0001F7E2"
+fails = []
+
+
+def check(ok, msg):
+    print(("OK    " if ok else "FAIL  ") + msg)
+    if not ok:
+        fails.append(msg)
+
+
+def decrypt(token, key=KEY):
+    """Independent inverse of the script's encrypt(), so the round-trip is a real check."""
+    raw = base64.b64decode(token)
+    iv, body, mac = raw[:16], raw[16:-32], raw[-32:]
+    secret = bytes.fromhex(key)
+    assert hmac.compare_digest(mac, hmac.new(secret, iv + body, hashlib.sha256).digest()), "HMAC"
+    plain = Cipher(algorithms.AES(secret), modes.CBC(iv)).decryptor()
+    unpad = padding.PKCS7(128).unpadder()
+    blob = unpad.update(plain.update(body) + plain.finalize()) + unpad.finalize()
+    return gzip.decompress(blob).decode("utf-8")
+
+
+TITLE = "X-Pushover-Title: CRITICAL — mbox10.example.com / Check SSL certificate"
+NOTIF = (f"To: {PHONE}\n"
+         "X-Pushover-Event: CRITICAL\n"
+         "X-Pushover-Repeat: 1\n"
+         "X-Pushover-Tag: mbox10.example.com/Check SSL certificate/173\n"
+         f"{TITLE}\n"
+         "X-Pushover-Url: https://nagios.example.org/cgi-bin/extinfo.cgi?type=2&host=mbox10\n"
+         "X-Pushover-Url-Title: Open in nagios\n"
+         "\n"
+         "Since: 2026-08-10 13:19:13 CEST (0d 0h 52m 0s)\n"
+         "Last OK: 2026-08-10 12:57:41 CEST\nSent: 2026-08-10 13:20:05 CEST\n"
+         "\nSSL_CERT CRITICAL: does not match the CN\n")
+ACCEPTED = json.dumps({"status": 1, "request": "req-1"}).encode()
+RECEIPTED = json.dumps({"status": 1, "request": "req-2", "receipt": "rcpt-9"}).encode()
+REFUSED = json.dumps({"status": 0, "request": "req-3",
+                      "errors": ["user identifier is invalid"]}).encode()
+
+
+def feed(text):
+    sys.stdin = types.SimpleNamespace(buffer=io.BytesIO(text.encode("utf-8")))
+
+
+class Api(http.server.BaseHTTPRequestHandler):
+    """Replies with the next entry of `script`, or accepts once the script runs out."""
+    script = []
+    cancel_script = []
+    requests = []
+    quota = "5000"
+
+    def do_POST(self):
+        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
+        Api.requests.append((self.path, body))
+        if "cancel_by_tag" in self.path:
+            step = Api.cancel_script.pop(0) if Api.cancel_script else (200, ACCEPTED, {})
+        else:
+            step = Api.script.pop(0) if Api.script else (200, ACCEPTED, {})
+        if step == "garbage":
+            self.wfile.write(b"nonsense\r\n\r\n")
+            self.close_connection = True
+            return
+        if step == "slow":
+            import time
+            time.sleep(g.ATTEMPT_TIMEOUT + 2)
+            step = (200, ACCEPTED, {})
+        if step == "stall":
+            import time
+            time.sleep(g.CANCEL_TIMEOUT + 1)
+            step = (200, ACCEPTED, {})
+        if step == "dribble":
+            # a byte at a time: every read succeeds, so the socket timeout never fires
+            # and only a deadline of its own can stop the caller waiting
+            import time
+            self.send_response(200)
+            self.send_header("Content-Length", str(len(ACCEPTED)))
+            self.end_headers()
+            try:
+                for byte in ACCEPTED:
+                    self.wfile.write(bytes([byte]))
+                    self.wfile.flush()
+                    time.sleep(1)
+            except OSError:
+                pass
+            self.close_connection = True
+            return
+        code, body, headers = step
+        self.send_response(code)
+        for name, value in headers.items():
+            self.send_header(name, value)
+        self.send_header("X-Limit-App-Remaining", Api.quota)
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+    def log_message(self, format, *args):
+        pass
+
+
+class Server(http.server.HTTPServer):
+    def handle_error(self, request, client_address):
+        # the timeout tests hang up on purpose, so the reply landing on a closed
+        # socket is the expected outcome, not a traceback worth printing
+        if not isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)):
+            super().handle_error(request, client_address)
+
+
+server = Server(("127.0.0.1", 0), Api)
+threading.Thread(target=server.serve_forever, daemon=True).start()
+g.API = f"http://127.0.0.1:{server.server_port}/1/messages.json"
+g.RECEIPTS = f"http://127.0.0.1:{server.server_port}/1/receipts"
+
+EVENTS = (f'[event.CRITICAL]\nicon = "{RED}"\npriority = 1\nsound = "gamelan"\n\n'
+          f'[event.DOWN]\nicon = "{RED}"\npriority = 2\nsound = "siren"\n'
+          'retry = 60\nexpire = 1800\n\n'
+          f'[event.OK]\nicon = "{GREEN}"\npriority = -1\nsound = "none"\n\n')
+g.CONFIG = f"{SCRATCH}/pushover.toml"
+CONFIG = (f'token = "{TOKEN}"\n\n' + EVENTS +
+          f'[[recipient]]\nphone = "{PHONE}"\nuser_key = "{USER}"\n'
+          f'encryption_key = "{KEY}"\n')
+with open(g.CONFIG, "w") as handle:
+    handle.write(CONFIG)
+
+
+def run(script=(), notification=NOTIF, config=None, cancel_script=()):
+    if config is not None:
+        with open(g.CONFIG, "w") as handle:
+            handle.write(config)
+    Api.script = list(script)
+    Api.cancel_script = list(cancel_script)
+    Api.requests = []
+    feed(notification)
+    try:
+        return g.main()
+    finally:
+        signal.alarm(0)
+
+
+def sent():
+    """The last message request as a plain dict."""
+    body = [b for path, b in Api.requests if "cancel_by_tag" not in path][-1]
+    return {k: v[0] for k, v in urllib.parse.parse_qs(body.decode()).items()}
+
+
+def cancelled():
+    """Tags the run asked Pushover to call off."""
+    return [path.split("cancel_by_tag/")[1].removesuffix(".json")
+            for path, _ in Api.requests if "cancel_by_tag" in path]
+
+
+def logged():
+    with open(g.LOGFILE) as handle:
+        return handle.readlines()
+
+
+def last_log():
+    return logged()[-1].strip()
+
+
+def last_warning():
+    return [line for line in logged() if "WARNING" in line][-1]
+
+
+print("== notification parsing")
+feed(NOTIF)
+phone, event, repeat, tag, fields = g.read_notification()
+check(phone == PHONE, "phone number from To:")
+check(event == "CRITICAL" and repeat == 1, "event and repeat number from the headers")
+check(fields["url_title"] == "Open in nagios", "X-Pushover-Url-Title lands in url_title")
+check("priority" not in fields and "sound" not in fields,
+      "the template no longer supplies priority or sound")
+check(fields["message"].startswith("Since:") and "\n\n\n" not in fields["message"],
+      "body free of headers and doubled blank lines")
+
+feed(NOTIF.replace("does not match the CN", "zażółć gęślą jaźń"))
+check("zażółć gęślą jaźń" in g.read_notification()[4]["message"], "UTF-8 without a locale")
+
+feed(NOTIF.replace("X-Pushover-Repeat: 1", "X-Pushover-Repeat: not-a-number"))
+check(g.read_notification()[2] == 1, "non-numeric repeat treated as the first one")
+
+for header, label in [("To: 600123456", "number without + rejected"),
+                      (f"To: {PHONE},+48600123457", "list of numbers rejected"),
+                      ("To: nagios at example.org", "e-mail address rejected"),
+                      ("X-Pushover-Event: CRITICAL", "missing To: rejected")]:
+    feed(f"{header}\n\ntext\n")
+    try:
+        g.read_notification()
+        check(False, label)
+    except ValueError:
+        check(True, label)
+
+feed(f"To: {PHONE}\n\n\n")
+try:
+    g.read_notification()
+    check(False, "empty body rejected")
+except ValueError:
+    check(True, "empty body rejected")
+
+print("\n== policy comes from the configuration")
+def messages():
+    return [p for p, _ in Api.requests if "cancel_by_tag" not in p]
+
+check(run() == 0 and len(messages()) == 1, "success on the first try, a single message")
+payload = sent()
+check(payload["priority"] == "1" and payload["sound"] == "gamelan",
+      "priority and sound for CRITICAL straight from [event.CRITICAL]")
+check(decrypt(payload["title"]).startswith(RED), "icon from the configuration prepended to the title")
+check("retry" not in payload and "expire" not in payload,
+      "priority 1 carries no emergency loop")
+
+check(run([(200, RECEIPTED, {})],
+          notification=NOTIF.replace("X-Pushover-Event: CRITICAL",
+                                     "X-Pushover-Event: DOWN")) == 0, "DOWN accepted")
+payload = sent()
+check(payload["priority"] == "2" and payload["sound"] == "siren",
+      "DOWN gets priority 2 and the siren from the configuration")
+check(payload["retry"] == "60" and payload["expire"] == "1800",
+      "priority 2 carries retry and expire, without which the API refuses")
+
+check(run(notification=NOTIF.replace("X-Pushover-Event: CRITICAL",
+                                     "X-Pushover-Event: UNLISTED")) == 0,
+      "an unknown event does not lose the alert")
+payload = sent()
+check(payload["priority"] == "0" and "sound" not in payload,
+      "an unknown event goes out with Pushover's defaults")
+
+print("\n== per-recipient override")
+OVERRIDE = CONFIG + '\n[recipient.event.CRITICAL]\nsound = "none"\n'
+check(run(config=OVERRIDE) == 0, "the override does not break the send")
+payload = sent()
+check(payload["sound"] == "none", "sound taken from the recipient's override")
+check(payload["priority"] == "1", "priority still inherited from the global [event.CRITICAL]")
+check(decrypt(payload["title"]).startswith(RED), "icon still inherited")
+
+QUIET = CONFIG + '\n[recipient.event.DOWN]\npriority = 1\nsound = "gamelan"\n'
+check(run(config=QUIET, notification=NOTIF.replace("X-Pushover-Event: CRITICAL",
+                                                   "X-Pushover-Event: DOWN")) == 0,
+      "a recipient may step down from priority 2 to 1")
+payload = sent()
+check(payload["priority"] == "1" and payload["sound"] == "gamelan",
+      "a sleeping recipient gets no siren")
+check("retry" not in payload, "after stepping down from 2 there is no emergency loop anymore")
+run(config=CONFIG)
+
+print("\n== a repeat sounds like the first notification")
+REPEAT = NOTIF.replace("X-Pushover-Repeat: 1", "X-Pushover-Repeat: 4")
+check(run(notification=REPEAT) == 0, "the repeat is still sent")
+payload = sent()
+check(payload["priority"] == "1" and payload["sound"] == "gamelan",
+      "a repeated CRITICAL goes out exactly like the first: nagios decides when to repeat")
+check(run(notification=REPEAT.replace("X-Pushover-Event: CRITICAL",
+                                      "X-Pushover-Event: DOWN")) == 0, "a repeated DOWN is sent")
+payload = sent()
+check(payload["priority"] == "2" and payload["sound"] == "siren",
+      "a repeated DOWN howls to the end again: whoever slept through one ring will sleep through another")
+check(payload["retry"] == "60" and payload["expire"] == "1800",
+      "the repeat gets the full loop parameters, not half of them")
+check(run(notification=REPEAT.replace("X-Pushover-Event: CRITICAL",
+                                      "X-Pushover-Event: OK")) == 0, "a repeated OK is sent")
+check(sent()["priority"] == "-1", "a repeated recovery is as quiet as the first one")
+
+print("\n== encryption")
+check(run() == 0, "send with a key")
+payload = sent()
+check(payload["token"] == TOKEN and payload["user"] == USER, "token and user_key from the configuration")
+check(payload.get("encrypted") == "1", "encrypted=1 when a key is present")
+check(decrypt(payload["message"]).startswith("Since:"), "the message decrypts back")
+check(decrypt(payload["url"]).startswith("https://nagios.example.org"), "url encrypted")
+check("url_title" not in payload,
+      "url_title dropped: the ciphertext floor is 108 characters and Pushover allows 100")
+check(len(g.encrypt(bytes.fromhex(KEY), "")) == 108,
+      "the ciphertext floor is 108 characters even for empty text")
+check(not any("url_title" in line for line in logged()),
+      "dropping url_title does not litter the log on every alert")
+try:
+    decrypt(payload["message"], key="ff" * 32)
+    check(False, "a foreign key does not decrypt")
+except AssertionError:
+    check(True, "a foreign key does not decrypt (HMAC refuses)")
+
+check(run(config=CONFIG.replace(f'encryption_key = "{KEY}"\n', "")) == 0,
+      "no key: still sends")
+payload = sent()
+check("encrypted" not in payload and payload["message"].startswith("Since:"),
+      "without a key the message goes in the clear and without encrypted=1")
+check(payload.get("url_title") == "Open in nagios",
+      "without encryption url_title fits and stays")
+run(config=CONFIG)
+
+print("\n== device")
+check(run(config=CONFIG + 'device = "iphone"\n') == 0 and sent()["device"] == "iphone",
+      "device from the configuration lands in the request")
+check(run(config=CONFIG) == 0 and "device" not in sent(),
+      "no device in the configuration, none in the request")
+
+print("\n== trimming to the limits after encryption")
+# gzip sits before encryption, so entropy decides the trimming, not length
+noise = "".join(base64.b64encode(hashlib.sha256(str(i).encode()).digest()).decode()
+                for i in range(10))
+check(run(notification=NOTIF.replace(TITLE, f"X-Pushover-Title: {noise}")) == 0,
+      "an incompressibly long title is still sent")
+payload = sent()
+check(len(payload["title"]) <= g.LIMITS["title"],
+      f"the encrypted title fits the limit ({len(payload['title'])} <= {g.LIMITS['title']})")
+check(decrypt(payload["title"]).endswith("..."), "the trimmed title ends with an ellipsis")
+check(decrypt(payload["title"]).startswith(RED), "trimming does not lose the icon at the front")
+check(run(notification=NOTIF.replace(TITLE, "X-Pushover-Title: " + "repetitive title " * 40)) == 0
+      and len(sent()["title"]) <= g.LIMITS["title"] and not decrypt(sent()["title"]).endswith("..."),
+      "a long but compressible title fits without trimming")
+
+huge = NOTIF.replace("SSL_CERT CRITICAL: does not match the CN", "\n".join(
+    f"line {i}: assorted irregular data {i * 7919}" for i in range(400)))
+check(run(notification=huge) == 0, "an overlong message is still sent")
+payload = sent()
+check(len(payload["message"]) <= g.LIMITS["message"],
+      f"the encrypted message fits the limit ({len(payload['message'])} <= {g.LIMITS['message']})")
+check(decrypt(payload["message"]).startswith("Since:"), "the trimmed message still decrypts")
+
+print("\n== failures hand the case to the fallback")
+check(run([(429, b"", {})]) == 1 and len(Api.requests) == 1,
+      "429 (monthly limit): exit 1, no retrying")
+check(run([(400, REFUSED, {})]) == 1 and len(Api.requests) == 1, "400: exit 1, no retrying")
+check(run([(500, b"", {})]) == 1 and len(Api.requests) == 1, "500: exit 1, no retrying")
+check(run([(200, REFUSED, {})]) == 1, "status 0 in the response is a failure, not a success")
+check(run(["garbage"]) == 1, "a mangled response is a failure, not a traceback")
+check(run([(302, b"", {"Location": "http://127.0.0.1:1/x"})]) == 1,
+      "a redirect does not fake a success")
+saved = g.ATTEMPT_TIMEOUT
+g.ATTEMPT_TIMEOUT = 1
+check(run(["slow"]) == 1, "timeout: exit 1")
+g.ATTEMPT_TIMEOUT = saved
+check(run(notification="To: +48999888777\n\ntext\n") == 1 and not Api.requests,
+      "a number not in the configuration: exit 1 and no request")
+check(run(notification="X-Pushover-Event: CRITICAL\n\ntext\n") == 1 and not Api.requests,
+      "broken input: exit 1 and no request")
+
+# a nagios-notify that never finishes writing must not hang the script past the
+# budget either - the socket timeout does not cover reading stdin, only the alarm can
+reader, writer = os.pipe()
+sys.stdin = types.SimpleNamespace(buffer=os.fdopen(reader, "rb"))
+budget, g.TOTAL_BUDGET = g.TOTAL_BUDGET, 1
+Api.requests = []
+try:
+    code = g.main()
+finally:
+    g.TOTAL_BUDGET = budget
+    signal.alarm(0)
+    sys.stdin.buffer.close()
+    os.close(writer)
+check(code == 1 and not Api.requests,
+      "stalled input: exit 1 once the budget runs out, no request")
+check("no answer within 1s" in last_log(), "the log says the budget ran out")
+
+print("\n== configuration")
+good = g.CONFIG
+g.CONFIG = f"{SCRATCH}/no-such-file.toml"
+check(run() == 1 and not Api.requests, "missing file: exit 1, no request")
+g.CONFIG = good
+
+# an alarm during the configuration read is not a broken file: the log must talk
+# about the budget, not send the on-call to /etc (TimeoutError is a subclass of OSError)
+g.CONFIG = f"{SCRATCH}/hangs.toml"
+os.mkfifo(g.CONFIG)
+budget, g.TOTAL_BUDGET = g.TOTAL_BUDGET, 1
+feed(NOTIF)
+Api.requests = []
+try:
+    code = g.main()
+finally:
+    g.CONFIG = good
+    g.TOTAL_BUDGET = budget
+    signal.alarm(0)
+check(code == 1 and not Api.requests, "a stalled configuration read: exit 1 after the budget")
+check("no answer within 1s" in last_log(),
+      "a stalled configuration read reported as the budget, not as a broken file")
+check(run(config="this is not = = toml\n") == 1 and not Api.requests,
+      "mangled toml: exit 1, no request")
+check(run(config=f'token = "{TOKEN}"\n') == 1 and not Api.requests,
+      "no recipients: exit 1, no request")
+check(run(config='token = "APPLICATIONTOKEN00000000000000"\n') == 1 and not Api.requests,
+      "a freshly installed package: exit 1 without complaining about the placeholder token")
+check(run(config=CONFIG.replace(TOKEN, "too-short")) == 1 and not Api.requests,
+      "a bad token with a configured recipient: exit 1 before sending")
+check(run(config=CONFIG.replace(PHONE, "600123456")) == 1 and not Api.requests,
+      "a number without + in the configuration: exit 1, no request")
+check(run(config=CONFIG.replace(f'user_key = "{USER}"\n', "")) == 1 and not Api.requests,
+      "missing user_key: exit 1, no request")
+check(run(config=CONFIG.replace(USER, "USERKEY")) == 1 and not Api.requests,
+      "too short a user_key: exit 1 before sending, not a 400 from the API")
+check(run(config=CONFIG.replace(USER, USER[:-1] + " ")) == 1 and not Api.requests,
+      "a space pasted along with user_key: exit 1 before sending, not a 400 from the API")
+check(run(config=CONFIG.replace(f'user_key = "{USER}"', f'user_key = "{TOKEN}"')) == 0,
+      "a token in place of user_key has the right shape - only the API can catch that")
+check(run(config=CONFIG.replace(KEY, "ZZZZ")) == 1 and not Api.requests,
+      "a non-hex key: exit 1 before sending, not a traceback midway")
+check(run(config=CONFIG.replace(KEY, "")) == 1 and not Api.requests,
+      "an empty encryption_key: exit 1 instead of a cleartext send the log calls encrypted")
+check(run(config=CONFIG.replace("priority = 1", "priority = 5")) == 1 and not Api.requests,
+      "a priority outside -2..2 globally: exit 1 before sending")
+check(run(config=CONFIG + '\n[recipient.event.CRITICAL]\npriority = -7\n') == 1
+      and not Api.requests, "a priority outside -2..2 at the recipient: exit 1 before sending")
+check(run(config=CONFIG.replace("priority = 1", 'priority = "loud"')) == 1
+      and not Api.requests,
+      "a non-numeric priority: exit 1 with a log entry, not a traceback into an empty log")
+check("not a whole number" in last_log(), "the log names the broken priority")
+check(run(config=CONFIG.replace("priority = 1", "priority = 1.9")) == 1 and not Api.requests,
+      "a fractional priority (1.9): exit 1 instead of a silent truncation to 1")
+check(run(config=CONFIG.replace("priority = 1", "priority = true")) == 1 and not Api.requests,
+      "priority true: exit 1 instead of a silent int(true), that is 1")
+check("not a whole number" in last_log(), "the log names the non-integer priority")
+check(run(config=CONFIG.replace("priority = 1", 'priority = "1"')) == 0,
+      "a quoted priority still works")
+check(sent()["priority"] == "1", "the quoted priority reaches the API as 1")
+
+DOWN = NOTIF.replace("X-Pushover-Event: CRITICAL", "X-Pushover-Event: DOWN")
+for setting, written, expected in (("retry", "10", "30"), ("retry", "99999", "10800"),
+                                   ("expire", "5", "30"), ("expire", "99999", "10800")):
+    other = "expire = 1800" if setting == "retry" else "retry = 60"
+    rule = f'[event.DOWN]\npriority = 2\n{other}\n{setting} = {written}\n'
+    check(run(config=CONFIG.replace('[event.DOWN]\nicon', '[event.UNUSED]\nicon') + rule,
+              notification=DOWN) == 0, f"{setting} = {written}: the alert still goes out")
+    check(sent()[setting] == expected,
+          f"{setting} = {written} clamped to {expected}, not handed to the API for a 400")
+    check("outside 30..10800" in last_warning(),
+          f"{setting} = {written}: the log says the value was clamped")
+
+BAD = CONFIG.replace('[event.DOWN]\nicon', '[event.UNUSED]\nicon') + \
+    '[event.DOWN]\npriority = 2\nretry = "fast"\nexpire = 1800\n'
+check(run(config=BAD, notification=DOWN) == 0, "a non-numeric retry does not lose the alert")
+check(sent()["retry"] == str(g.RETRY), f"a non-numeric retry falls back to the built-in {g.RETRY}")
+check("retry is not a number" in last_warning(),
+      "a non-numeric retry leaves a trace in the log")
+
+check(run(config=CONFIG, notification=DOWN) == 0, "a valid retry passes through unchanged")
+check(sent()["retry"] == "60" and sent()["expire"] == "1800",
+      "in-range values are left alone")
+check("outside" not in last_log(), "valid values produce no warning")
+
+DOUBLE = CONFIG + f'\n[[recipient]]\nphone = "{PHONE}"\nuser_key = "{USER}"\ndevice = "second"\n'
+check(run(config=DOUBLE) == 0, "a duplicated number does not abort the send")
+check(sent().get("device") == "second", "of two blocks with the same number the last one wins")
+check("more than once" in last_warning(),
+      "a duplicated number leaves a warning in the log")
+
+print("\n== the emergency loop dies together with the problem")
+# tag as today's template writes it: the object plus the problem episode id
+OBJECT = "mbox10.example.com/Check SSL certificate/173"
+DOWN_NOTIF = NOTIF.replace("X-Pushover-Event: CRITICAL", "X-Pushover-Event: DOWN")
+RESOLVED = NOTIF.replace("X-Pushover-Event: CRITICAL", "X-Pushover-Event: OK")
+
+check(run(config=CONFIG, notification=DOWN_NOTIF) == 0, "DOWN sent")
+payload = sent()
+check("tags" in payload and payload["priority"] == "2",
+      "priority 2 carries the tag under which Pushover files the receipt")
+check(OBJECT not in payload["tags"] and "mbox10" not in payload["tags"],
+      "the tag betrays neither host nor service name, though Pushover does not encrypt tags")
+check(len(payload["tags"]) == 32 and all(c in "0123456789abcdef" for c in payload["tags"]),
+      "the tag is an HMAC fingerprint, so it needs no URL encoding")
+emergency_tag = payload["tags"]
+check(not cancelled(), "reporting a problem cancels nothing")
+
+check(run() == 0 and "tags" not in sent(),
+      "CRITICAL at priority 1 needs no tag, as there is no receipt")
+
+check(run(notification=RESOLVED) == 0, "the recovery to OK is sent")
+check(cancelled() == [emergency_tag],
+      "the recovery cancels exactly the tag the siren went out under")
+check(len([p for p, _ in Api.requests if "cancel" not in p]) == 1,
+      "cancelling does not eat the budget with a second message request")
+
+# a drop to a lighter state used to leave the siren howling about a state that is gone
+DEGRADED = NOTIF.replace("X-Pushover-Event: CRITICAL", "X-Pushover-Event: WARNING")
+check(run(notification=DEGRADED) == 0, "the drop CRITICAL -> WARNING is sent")
+check(cancelled() == [emergency_tag],
+      "a drop to WARNING silences the siren left from CRITICAL, as that state is gone")
+UNREACH_NOTIF = DOWN_NOTIF.replace("X-Pushover-Event: DOWN",
+                                   "X-Pushover-Event: UNREACHABLE")
+check(run(notification=UNREACH_NOTIF) == 0 and cancelled() == [emergency_tag],
+      "the drop DOWN -> UNREACHABLE silences the siren left from DOWN")
+
+# nagios repeating a notification says the object is still in the same state: the new
+# loop must replace the previous one, as every receipt is acknowledged separately
+REPEATED_DOWN = DOWN_NOTIF.replace("X-Pushover-Repeat: 1", "X-Pushover-Repeat: 4")
+check(run(notification=REPEATED_DOWN) == 0, "the repeated DOWN is sent")
+payload = sent()
+check(payload["priority"] == "2" and payload["tags"] == emergency_tag,
+      "the repeat howls to the end again, under the same tag")
+check(cancelled() == [emergency_tag], "the repeat cancels the previous loop...")
+check("cancel_by_tag" in Api.requests[0][0] and "messages.json" in Api.requests[1][0],
+      "...and does it BEFORE sending, else it would cancel its own freshly filed receipt")
+check(len(Api.requests) == 2, "replacing the loop is one cancel and one message")
+
+# a stalled cancel before the repeat has no right to eat the budget owed to the alert
+try:
+    code = run(notification=REPEATED_DOWN, cancel_script=["dribble"])
+except SystemExit as error:
+    code = error.code
+check(code == 0 and "messages.json" in Api.requests[-1][0],
+      "a cancel dribbling its response byte by byte does not block the repeated alarm")
+
+# and when less budget remains than one send attempt needs, the cancel is not
+# worth even starting
+budget, g.TOTAL_BUDGET = g.TOTAL_BUDGET, g.ATTEMPT_TIMEOUT
+try:
+    code = run(notification=REPEATED_DOWN)
+finally:
+    g.TOTAL_BUDGET = budget
+check(code == 0 and not cancelled() and "messages.json" in Api.requests[-1][0],
+      "with a scrap of budget left the cancel lets go, and the alarm still goes out")
+check("too little of the budget" in last_warning(), "the skipped cancel shows up in the log")
+
+check(run(notification=NOTIF.replace("X-Pushover-Event: CRITICAL",
+                                     "X-Pushover-Event: FLAPPING")) == 0
+      and cancelled() == [emergency_tag],
+      "FLAPPING silences the siren: the object is bouncing, so a steady alarm about it is stale")
+
+HOST_RESOLVED = RESOLVED.replace(f"X-Pushover-Tag: {OBJECT}",
+                                 "X-Pushover-Tag: mbox10.example.com/88")
+check(run(notification=HOST_RESOLVED) == 0 and cancelled() != [emergency_tag],
+      "a host recovery does not cancel the siren running for a service on that host")
+
+# the episode id in the tag closes a race: a late recovery of an old problem cancels
+# its own episode's tag, not the siren the next problem has just set up
+STALE_RESOLVED = RESOLVED.replace(f"X-Pushover-Tag: {OBJECT}",
+                                  "X-Pushover-Tag: " + OBJECT.replace("/173", "/174"))
+check(run(notification=STALE_RESOLVED) == 0
+      and cancelled() and cancelled() != [emergency_tag],
+      "a recovery of a different episode does not silence the current episode's siren")
+
+check(run(notification=RESOLVED, cancel_script=[(500, b"nope", {})]) == 0,
+      "a failed cancel does not turn a delivered recovery into a failure")
+check("could not call off" in last_log(), "a failed cancel leaves a warning")
+
+check(run([(500, b"nope", {})], notification=RESOLVED) == 1 and not cancelled(),
+      "when the message did not get through, no point asking the same API to cancel")
+
+# the budget guards room for the fallback, which will not happen anymore - a delivered
+# message must not turn into a failure just because the cancel drags on
+budget, g.TOTAL_BUDGET = g.TOTAL_BUDGET, 1
+try:
+    code = run(notification=RESOLVED, cancel_script=["stall"])
+except SystemExit as error:
+    code = error.code
+finally:
+    g.TOTAL_BUDGET = budget
+check(code == 0, "a dragging cancel does not send an SMS for a delivered recovery")
+
+NOKEY = CONFIG.replace(f'encryption_key = "{KEY}"\n', "")
+check(run(config=NOKEY, notification=DOWN_NOTIF) == 0 and sent()["tags"] != emergency_tag,
+      "a keyless recipient gets a different fingerprint than one with a key")
+run(config=CONFIG)
+
+print("\n== device name")
+for name in ("iphone", "pixel8", "work-ipad", "iphone,work_ipad", "a" * 25):
+    check(run(config=CONFIG + f'device = "{name}"\n') == 0, f"device {name!r} accepted")
+    check(sent()["device"] == name, f"device {name!r} reaches the API unchanged")
+for name, label in (("my phone", "space"), ("a" * 26, "26 characters"), ("iphone at home", "at sign"),
+                    ("", "empty name"), ("iphone,", "trailing comma")):
+    check(run(config=CONFIG + f'device = "{name}"\n') == 1 and not Api.requests,
+          f"a broken device ({label}): exit 1 before sending, not a silently widened send")
+
+print("\n== configuration of the wrong shape")
+for text, label in (
+        (f'token = "{TOKEN}"\n[recipient]\nphone = "{PHONE}"\nuser_key = "{USER}"\n',
+         "[recipient] instead of [[recipient]]"),
+        (f'token = "{TOKEN}"\nevent = 5\n[[recipient]]\nphone = "{PHONE}"\n'
+         f'user_key = "{USER}"\n', "event is not a table"),
+        (f'token = "{TOKEN}"\nrecipient = "+48600123456"\n', "recipient is a string")):
+    check(run(config=text) == 1 and not Api.requests, f"{label}: exit 1, no request")
+    check("not shaped the way this script expects" in last_log(),
+          f"{label}: the log names the problem instead of going quiet after a traceback")
+
+print("\n== an encryption failure hands the case to the fallback")
+real_encrypt = g.encrypt
+g.encrypt = lambda key, text: (_ for _ in ()).throw(ImportError("no cryptography"))
+check(run(config=CONFIG) == 1, "missing library: exit 1, the alert goes by SMS")
+check("refused the alert" in last_log() and "ImportError" in last_log(),
+      "the missing library is logged, not thrown as a traceback")
+g.encrypt = real_encrypt
+
+print("\n== built-in defaults")
+BUILTIN = {"pushover", "bike", "bugle", "cashregister", "classical", "cosmic", "falling",
+           "gamelan", "incoming", "intermission", "magic", "mechanical", "pianobar",
+           "siren", "spacealarm", "tugboat", "alien", "climb", "persistent", "echo",
+           "updown", "vibrate", "none"}
+used = {r.get("sound") for r in g.DEFAULTS.values()} - {None}
+check(used <= BUILTIN, f"sounds outside the built-in set: {sorted(used - BUILTIN) or 'none'}")
+check(all(-2 <= r.get("priority", 0) <= 2 for r in g.DEFAULTS.values()),
+      "all built-in priorities fit within -2..2")
+check(30 <= g.RETRY <= 10800 and 30 <= g.EXPIRE <= 10800,
+      "the built-in retry and expire fit the range Pushover accepts")
+check({"DOWN", "CRITICAL", "WARNING", "UNKNOWN", "OK", "UP", "ACKNOWLEDGEMENT",
+       "FLAPPING", "DOWNTIME", "UNREACHABLE"} <= set(g.DEFAULTS),
+      "the script knows every event the templates emit")
+
+shipped = tomllib.load(open(f"{PKG}/pushover.toml", "rb"))
+check("event" not in shipped and "recipient" not in shipped,
+      "the shipped config does not duplicate the defaults - examples in comments only")
+
+print("\n== a bare config is enough")
+BARE = f'token = "{TOKEN}"\n[[recipient]]\nphone = "{PHONE}"\nuser_key = "{USER}"\n'
+check(run(config=BARE) == 0, "a config without any [event] section sends the alert")
+payload = sent()
+check(payload["priority"] == "2" and payload["sound"] == "gamelan",
+      "CRITICAL takes priority and sound from the built-in defaults")
+check(payload["retry"] == "900" and payload["expire"] == "2100",
+      "priority 2 with no config entry gets the built-in retry and expire")
+check(payload["title"].startswith(RED), "the icon comes from the built-in defaults too")
+
+check(run(config=BARE, notification=NOTIF.replace("X-Pushover-Event: CRITICAL",
+                                                  "X-Pushover-Event: OK")) == 0
+      and sent()["priority"] == "-1", "OK drops to -1 without the config's help")
+
+print("\n== three layers of overriding")
+GLOBAL = BARE.replace("[[recipient]]",
+                      '[event.CRITICAL]\nsound = "bugle"\n\n[[recipient]]')
+check(run(config=GLOBAL) == 0, "a global override does not break the send")
+payload = sent()
+check(payload["sound"] == "bugle", "the global [event.CRITICAL] wins over the built-in")
+check(payload["priority"] == "2" and payload["retry"] == "900",
+      "the rest stays built-in, key by key")
+
+PERSON = GLOBAL + '[recipient.event.CRITICAL]\nsound = "none"\npriority = 0\n'
+check(run(config=PERSON) == 0, "a recipient override does not break the send")
+payload = sent()
+check(payload["sound"] == "none" and payload["priority"] == "0",
+      "the recipient's entry wins over the global and the built-in")
+check("retry" not in payload, "after stepping down from priority 2 there is no emergency loop anymore")
+run(config=CONFIG)
+
+print("\n== the log says how the alert went")
+
+
+run(config=CONFIG)
+line = last_log()
+check(all(s in line for s in (PHONE, "CRITICAL", "prio=1", "sound=gamelan")),
+      f"the log gives the event, priority and sound -> {line[-90:]!r}")
+check("encrypted" in line and "request=req-1" in line,
+      "the log says it went encrypted, and gives the identifier from the API")
+check("retry" not in line and "repeat" not in line,
+      "the log invents neither an emergency loop nor a repeat when there was none")
+
+run(notification=REPEAT)
+line = last_log()
+check("repeat=4" in line and "sound=gamelan" in line and "prio=1" in line,
+      f"the log says it is a repeat and that it went exactly like the first one -> {line[-70:]!r}")
+
+run([(200, RECEIPTED, {})], notification=NOTIF.replace("X-Pushover-Event: CRITICAL",
+                                                       "X-Pushover-Event: DOWN"))
+line = last_log()
+check("DOWN" in line and "prio=2" in line and "retry=60" in line and "expire=1800" in line,
+      "the log gives the emergency loop parameters")
+check("receipt=rcpt-9" in line, "the log gives the receipt to check the acknowledgement by")
+
+run(config=CONFIG.replace(f'encryption_key = "{KEY}"\n', "") + 'device = "iphone"\n')
+line = last_log()
+check("cleartext" in line and "device=iphone" in line,
+      f"the log says outright the alert went in the clear and to one device -> {line[-60:]!r}")
+run(config=CONFIG)
+
+print("\n== logging must not cost the alert")
+Api.quota = "12"
+check(run(config=CONFIG) == 0, "a low monthly quota still sends (just a warning)")
+Api.quota = "unlimited"
+check(run() == 0, "a non-numeric quota header does not turn a successful send into a failure")
+Api.quota = "5000"
+g.LOGFILE = "/nonexistent/directory/pushover.log"
+unhook_logging()
+check(run() == 0, "an unwritable log does not cost the alert")
+
+server.shutdown()
+print("\n" + ("ALL OK" if not fails else "\n".join("FAIL " + f for f in fails)))
+
+
+def test_nagios_pushover():
+    """Everything above runs while pytest imports this file; here is only the verdict."""
+    assert not fails, "\n".join(fails)
+
+
+if __name__ == "__main__":
+    raise SystemExit(1 if fails else 0)
================================================================

---- gitweb:

http://git.pld-linux.org/gitweb.cgi/packages/nagios-alert-pushover.git/commitdiff/2798bc7ee2e2ed5b1746c31d8e057876e8a59d55



More information about the pld-cvs-commit mailing list