From f4d6be0364060919afa4cefa03f5450d3fa5d4a9 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 28 Aug 2026 22:14:02 -0700 Subject: [PATCH] doorbell: add ntfy token auth to listener poll + authenticated kuma canary - doorbell-listener.py: poll private ntfy topics with auth= (Bearer token) to stop 403/429; add probe-tag routing to DoorbellProbe - scripts/kuma-heartbeat.sh: publish health canary to ntfy.sh with Authorization: Bearer (was anonymous -> rate limited -> false down in Kuma); read token from .secrets/ntfy-token --- infra/doorbell-listener/doorbell-listener.py | 16 ++++++++++++++++ .../doorbell-listener/scripts/kuma-heartbeat.sh | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/infra/doorbell-listener/doorbell-listener.py b/infra/doorbell-listener/doorbell-listener.py index 1ddb366..37b5232 100644 --- a/infra/doorbell-listener/doorbell-listener.py +++ b/infra/doorbell-listener/doorbell-listener.py @@ -26,13 +26,18 @@ Env vars (all optional): POLL_INTERVAL seconds between polls, default 30 STATE_DIR default "/data/doorbell-listener" NTFY_TOPICS comma-separated override, default "ALERT_klubhaus_topic_test,ALERT_klubhaus_topic" + NTFY_TOKEN ntfy access token (tk_...). Adds the auth= query param the + ntfy.sh topics require; without it, polling the private + topics gets 403/429 rate-limited by ntfy.sh. """ +import base64 import json import math import os import sys import threading import time +import urllib.parse from pathlib import Path import paho.mqtt.client as mqtt @@ -40,6 +45,7 @@ import requests DEFAULT_TOPICS = "ALERT_klubhaus_topic_test,ALERT_klubhaus_topic" NTFY_TOPICS = [t.strip() for t in os.environ.get("NTFY_TOPICS", DEFAULT_TOPICS).split(",") if t.strip()] +NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "") MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto") MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) @@ -72,6 +78,14 @@ def log(msg: str) -> None: print(msg, flush=True) +def auth_param() -> str: + """Raw base64 of 'Bearer ' with no trailing '=' — mirrors the + doorbell frontend's buildAuthParam(). Empty string when no token set.""" + if not NTFY_TOKEN: + return "" + return base64.b64encode(f"Bearer {NTFY_TOKEN}".encode()).decode().rstrip("=") + + def last_id_path(topic: str) -> Path: safe = topic.replace("/", "_").replace(" ", "_") return STATE_DIR / f"last_id_{safe}" @@ -112,6 +126,8 @@ def start_flash(client: mqtt.Client, count: int, interval: float, topic: str) -> def poll_topic(client: mqtt.Client, topic: str, last_id: str) -> str: """Poll one topic. Returns the latest message id seen (or last_id).""" url = f"https://ntfy.sh/{topic}/json?poll=1" + if auth_param(): + url += f"&auth={urllib.parse.quote(auth_param())}" if last_id: url += f"&since={last_id}" log(f"[{topic}] polling {url}") diff --git a/infra/doorbell-listener/scripts/kuma-heartbeat.sh b/infra/doorbell-listener/scripts/kuma-heartbeat.sh index f17d318..7601b22 100755 --- a/infra/doorbell-listener/scripts/kuma-heartbeat.sh +++ b/infra/doorbell-listener/scripts/kuma-heartbeat.sh @@ -8,6 +8,7 @@ CONTAINER="${DB_CONTAINER:-doorbell-listener}" [ -s "$TOKEN_FILE" ] || exit 1 TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")" +NTFY_TOKEN="${NTFY_TOKEN:-$(tr -d '[:space:]' < /root/nr-flow-validator/infra/.secrets/ntfy-token 2>/dev/null || true)}" reason="" @@ -25,7 +26,7 @@ else # caused false "down" alerts. The subscribe/deliver leg is continuously # exercised by the production listener consuming real alerts. url="https://ntfy.sh/${CANARY_TOPIC}" - if ! curl -fsS -m 10 -o /dev/null -X POST "$url" -d "canary-$(date +%s)" 2>/dev/null; then + if ! curl -fsS -m 10 -o /dev/null -X POST "$url" -H "Authorization: Bearer ${NTFY_TOKEN}" -d "canary-$(date +%s)" 2>/dev/null; then reason="${reason}ntfy_publish_failed" fi fi