feat: initial logwatch – Watchtower ntfy noise filter
Subscribes to an ntfy channel via JSON stream, filters Watchtower notifications by importance (updates, failures, errors) and forwards only the relevant ones to a second ntfy channel. - filter.py: SSE consumer + regex-based importance filter + forwarder - Dockerfile + docker-compose.yml for containerised deployment - Configurable via env vars (SOURCE_URL, TARGET_URL, LOG_LEVEL, …) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY filter.py .
|
||||||
|
|
||||||
|
ENV SOURCE_URL=http://192.168.123.77/albert
|
||||||
|
ENV TARGET_URL=
|
||||||
|
ENV TARGET_TOKEN=
|
||||||
|
ENV LOG_LEVEL=INFO
|
||||||
|
ENV RECONNECT_DELAY=10
|
||||||
|
|
||||||
|
CMD ["python", "-u", "filter.py"]
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
logwatch:
|
||||||
|
build: .
|
||||||
|
image: logwatch:latest
|
||||||
|
container_name: logwatch
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# ntfy source channel (your Watchtower notifications)
|
||||||
|
- SOURCE_URL=http://192.168.123.77/albert
|
||||||
|
# ntfy target channel (where important stuff goes)
|
||||||
|
- TARGET_URL=http://192.168.123.77/wichtig
|
||||||
|
# optional: Bearer token if target channel needs auth
|
||||||
|
# - TARGET_TOKEN=your-token-here
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
- RECONNECT_DELAY=10
|
||||||
168
filter.py
Normal file
168
filter.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
logwatch - Watchtower ntfy filter
|
||||||
|
Subscribes to an ntfy channel, filters Watchtower notifications,
|
||||||
|
and forwards only important ones (updates, failures, errors) to another channel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# --- Configuration (from environment) ---
|
||||||
|
SOURCE_URL = os.environ.get("SOURCE_URL", "http://192.168.123.77/albert")
|
||||||
|
TARGET_URL = os.environ.get("TARGET_URL", "") # e.g. http://192.168.123.77/wichtig
|
||||||
|
TARGET_TOKEN = os.environ.get("TARGET_TOKEN", "") # optional ntfy auth token
|
||||||
|
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||||
|
RECONNECT_DELAY = int(os.environ.get("RECONNECT_DELAY", "10"))
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)-7s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("logwatch")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filtering logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Keywords that make a message IMPORTANT (case-insensitive)
|
||||||
|
IMPORTANT_PATTERNS = [
|
||||||
|
re.compile(r, re.IGNORECASE)
|
||||||
|
for r in [
|
||||||
|
r"updated?\s+\d+", # "Updated 3 containers"
|
||||||
|
r"\d+\s+updated", # "3 updated"
|
||||||
|
r"fail(ed|ure)?", # failures
|
||||||
|
r"error", # errors
|
||||||
|
r"could not",
|
||||||
|
r"unable to",
|
||||||
|
r"pull(ed)?\s+\w+", # pulled image
|
||||||
|
r"restart(ed|ing)?", # restarts
|
||||||
|
r"stopped", # stopped containers
|
||||||
|
r"warn(ing)?", # warnings
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Keywords that mark a message as NOT important (skip even if above matches)
|
||||||
|
NOISE_PATTERNS = [
|
||||||
|
re.compile(r, re.IGNORECASE)
|
||||||
|
for r in [
|
||||||
|
r"no\s+(new\s+)?updates?\s+(found|available)", # "No updates found"
|
||||||
|
r"checked\s+\d+\s+container", # "Checked 42 containers"
|
||||||
|
r"session\s+done",
|
||||||
|
r"^watchtower\s+started",
|
||||||
|
r"scanning\s+for\s+updates",
|
||||||
|
r"0\s+updated.*0\s+failed", # nothing happened
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_important(title: str, message: str) -> bool:
|
||||||
|
text = f"{title} {message}"
|
||||||
|
for p in NOISE_PATTERNS:
|
||||||
|
if p.search(text):
|
||||||
|
log.debug("NOISE | %s", text[:120])
|
||||||
|
return False
|
||||||
|
for p in IMPORTANT_PATTERNS:
|
||||||
|
if p.search(text):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Forwarding
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def forward(title: str, message: str, priority: str = "default", tags: list[str] | None = None):
|
||||||
|
if not TARGET_URL:
|
||||||
|
log.warning("TARGET_URL not set – would forward: [%s] %s", title, message)
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Title": title,
|
||||||
|
"Priority": priority,
|
||||||
|
}
|
||||||
|
if tags:
|
||||||
|
headers["Tags"] = ",".join(tags)
|
||||||
|
if TARGET_TOKEN:
|
||||||
|
headers["Authorization"] = f"Bearer {TARGET_TOKEN}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(TARGET_URL, data=message.encode(), headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
log.info("FORWARDED | [%s] %s", title, message[:100])
|
||||||
|
except requests.RequestException as e:
|
||||||
|
log.error("Forward failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(title: str, message: str) -> tuple[str, list[str]]:
|
||||||
|
"""Return (priority, tags) for the ntfy message."""
|
||||||
|
text = f"{title} {message}".lower()
|
||||||
|
if re.search(r"fail|error|could not|unable", text):
|
||||||
|
return "high", ["rotating_light", "watchtower"]
|
||||||
|
if re.search(r"updated|pulled|restart", text):
|
||||||
|
return "default", ["arrow_up", "watchtower"]
|
||||||
|
return "low", ["watchtower"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSE consumer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def listen_once():
|
||||||
|
sse_url = SOURCE_URL.rstrip("/") + "/json"
|
||||||
|
log.info("Connecting to %s", sse_url)
|
||||||
|
|
||||||
|
with requests.get(sse_url, stream=True, timeout=90) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
for raw_line in resp.iter_lines():
|
||||||
|
if not raw_line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
event = json.loads(raw_line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
log.debug("Non-JSON line: %s", raw_line[:80])
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_type = event.get("event", "message")
|
||||||
|
if event_type != "message":
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = event.get("title", "")
|
||||||
|
message = event.get("message", "")
|
||||||
|
|
||||||
|
# Only process Watchtower messages (by title tag)
|
||||||
|
if title and "watchtower" not in title.lower():
|
||||||
|
log.debug("SKIP (not watchtower) | [%s]", title)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.debug("RECEIVED | [%s] %s", title, message[:120])
|
||||||
|
|
||||||
|
if is_important(title, message):
|
||||||
|
priority, tags = classify(title, message)
|
||||||
|
forward(title, message, priority, tags)
|
||||||
|
else:
|
||||||
|
log.debug("FILTERED | [%s] %s", title, message[:80])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("logwatch started – source: %s target: %s", SOURCE_URL, TARGET_URL or "(dry-run)")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
listen_once()
|
||||||
|
log.warning("Stream ended, reconnecting in %ds…", RECONNECT_DELAY)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
log.error("Connection error: %s – retry in %ds", e, RECONNECT_DELAY)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log.info("Stopped.")
|
||||||
|
break
|
||||||
|
time.sleep(RECONNECT_DELAY)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
requests>=2.31.0
|
||||||
Reference in New Issue
Block a user