[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