server-up/server-up/core/notify.py
Ramon 87fb9d19d1
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m22s
v0.7.50-beta - meldingen, schijfruimte, backups controleren en extern wegzetten
Het onbewaakte deel was de zwakke plek: geplande taken draaiden 's nachts en
een storing kwam alleen in het auditlog terecht.

Meldingen (core/notify.py):
- ntfy, webhook (Discord/Slack/Gotify) en e-mail, alle drie met de
  standaardbibliotheek.
- Gebeurtenissen: mislukte backup, onleesbaar archief, vastgelopen taak,
  weinig schijfruimte, beschikbare update, gestopte container.
- Eén bericht per ronde; bij schijfruimte en gestopte containers alleen bij
  de overgang, zodat je niet elk uur hetzelfde krijgt.
- Testknop die eerst opslaat, zodat je test wat je net hebt ingevuld.
- send() gooit nooit: het kanaal mag de taak die de melding veroorzaakte niet
  alsnog laten omvallen.

Schijfruimte (core/diskspace.py):
- Controle vóór elke backup; past het niet, dan weigeren in plaats van
  halverwege afbreken.
- DISK_MIN_FREE_GB blijft gereserveerd, DISK_WARN_PCT kleurt de balk rood.
- Per filesystem één regel in de backuplijst.

Backups controleren:
- Elk nieuw archief wordt helemaal uitgelezen (tar-structuur plus
  gzip-checksum, die aan het eind staat).
- Diepe variant pakt echt uit naar een tijdelijke map, met dezelfde
  beperkingen als een echt herstel.
- Resultaat staat in de metadata en als schildje in de lijst.

Backups de deur uit:
- Downloadknop.
- BACKUP_OFFSITE_DIR kopieert elke nieuwe backup naar een gemounte schijf,
  NFS- of SMB-share, via .part zodat een afgebroken kopie herkenbaar onaf is.
- Ruimtecontrole op de bestemming, want een weggevallen mount laat vaak een
  lege map op de systeemschijf achter.

NOTIFY_TOKEN en NOTIFY_EMAIL_PASSWORD zijn write-only.

Nieuw: docs/meldingen.md; docs/backups.md uitgebreid. Getest tegen een
draaiende server met een echte ontvanger. 1064 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-28 19:41:35 +02:00

179 lines
7.1 KiB
Python

"""Meldingen bij storingen.
Geplande backups, updatechecks en de zelf-update draaien zonder dat er iemand
kijkt. Zonder meldingen komt een mislukte backup alleen in het auditlog terecht
— en dat ontdek je op het moment dat je hem nodig hebt.
Drie kanalen, alle drie met de standaardbibliotheek:
* **ntfy** — POST naar een topic-URL. Het eenvoudigst: een app op je telefoon.
* **webhook** — POST met een JSON-body, voor Discord, Slack, Gotify of eigen spul.
* **e-mail** — SMTP met STARTTLS of SSL.
`send()` gooit nooit: een storing in het meldingskanaal mag de taak die de
melding veroorzaakte niet alsnog laten omvallen.
"""
from __future__ import annotations
import json
import smtplib
import ssl
import urllib.error
import urllib.request
from email.message import EmailMessage
import core as cfg
TIMEOUT = 10
# Gebeurtenissen waarover gemeld kan worden. De sleutel staat in de config, de
# omschrijving is wat de gebruiker in de interface ziet.
EVENTS = {
"backup_failed": "Backup mislukt",
"backup_verify": "Backup niet te verifiëren",
"job_failed": "Geplande taak mislukt",
"disk_low": "Weinig schijfruimte",
"update_available": "Update beschikbaar",
"stack_down": "Stack onverwacht gestopt",
}
# Standaard alleen de dingen die misgaan. "Update beschikbaar" is nuttig maar
# geen storing, dus die zet je er zelf bij.
STANDAARD_EVENTS = ["backup_failed", "backup_verify", "job_failed", "disk_low"]
KANALEN = ("", "ntfy", "webhook", "email")
def instellingen() -> dict:
c = cfg.load()
events = c.get("NOTIFY_EVENTS")
if not isinstance(events, list):
events = list(STANDAARD_EVENTS)
return {
"channel": (c.get("NOTIFY_CHANNEL") or "").strip(),
"url": (c.get("NOTIFY_URL") or "").strip(),
"token": (c.get("NOTIFY_TOKEN") or "").strip(),
"events": [e for e in events if e in EVENTS],
"email_host": (c.get("NOTIFY_EMAIL_HOST") or "").strip(),
"email_port": int(c.get("NOTIFY_EMAIL_PORT") or 587),
"email_user": (c.get("NOTIFY_EMAIL_USER") or "").strip(),
"email_password": c.get("NOTIFY_EMAIL_PASSWORD") or "",
"email_from": (c.get("NOTIFY_EMAIL_FROM") or "").strip(),
"email_to": (c.get("NOTIFY_EMAIL_TO") or "").strip(),
"email_security": (c.get("NOTIFY_EMAIL_SECURITY") or "starttls").strip(),
}
def ingeschakeld(event: str = "") -> bool:
i = instellingen()
if i["channel"] not in KANALEN or not i["channel"]:
return False
if event and event not in i["events"]:
return False
return True
# ── Kanalen ──────────────────────────────────────────────────────────────────
def _post(url: str, data: bytes, headers: dict) -> tuple[bool, str]:
verzoek = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(verzoek, timeout=TIMEOUT) as a:
return True, f"HTTP {a.status}"
except urllib.error.HTTPError as e:
return False, f"HTTP {e.code}: {(e.read() or b'')[:200].decode(errors='replace')}"
except Exception as e: # netwerk, DNS, TLS, timeout
return False, f"{type(e).__name__}: {e}"
def _ntfy(i: dict, titel: str, tekst: str, prioriteit: str) -> tuple[bool, str]:
if not i["url"]:
return False, "Geen ntfy-URL ingesteld."
headers = {"Title": titel, "Content-Type": "text/plain; charset=utf-8"}
if prioriteit == "hoog":
headers["Priority"] = "high"
if i["token"]:
headers["Authorization"] = f"Bearer {i['token']}"
return _post(i["url"], tekst.encode("utf-8"), headers)
def _webhook(i: dict, titel: str, tekst: str, prioriteit: str) -> tuple[bool, str]:
if not i["url"]:
return False, "Geen webhook-URL ingesteld."
# `content` en `text` staan erbij omdat Discord respectievelijk Slack die
# verwachten; zo werkt dezelfde instelling voor allebei zonder keuzemenu.
body = {"title": titel, "message": tekst, "priority": prioriteit,
"content": f"**{titel}**\n{tekst}", "text": f"*{titel}*\n{tekst}",
"source": "server-up"}
headers = {"Content-Type": "application/json"}
if i["token"]:
headers["Authorization"] = f"Bearer {i['token']}"
return _post(i["url"], json.dumps(body).encode("utf-8"), headers)
def _email(i: dict, titel: str, tekst: str, prioriteit: str) -> tuple[bool, str]:
if not i["email_host"] or not i["email_to"]:
return False, "SMTP-server of ontvanger ontbreekt."
bericht = EmailMessage()
bericht["Subject"] = f"[Server Up] {titel}"
bericht["From"] = i["email_from"] or i["email_user"] or "server-up@localhost"
bericht["To"] = i["email_to"]
bericht.set_content(tekst)
try:
if i["email_security"] == "ssl":
server = smtplib.SMTP_SSL(i["email_host"], i["email_port"],
timeout=TIMEOUT,
context=ssl.create_default_context())
else:
server = smtplib.SMTP(i["email_host"], i["email_port"], timeout=TIMEOUT)
with server:
if i["email_security"] == "starttls":
server.starttls(context=ssl.create_default_context())
if i["email_user"]:
server.login(i["email_user"], i["email_password"])
server.send_message(bericht)
return True, "Verstuurd."
except Exception as e:
return False, f"{type(e).__name__}: {e}"
# ── Versturen ────────────────────────────────────────────────────────────────
def verstuur(titel: str, tekst: str, prioriteit: str = "normaal") -> tuple[bool, str]:
"""Stuur een bericht via het ingestelde kanaal, ongeacht de gebeurtenislijst.
Dit is wat de testknop gebruikt.
"""
i = instellingen()
if not i["channel"]:
return False, "Er is geen meldingskanaal ingesteld."
if i["channel"] == "ntfy":
return _ntfy(i, titel, tekst, prioriteit)
if i["channel"] == "webhook":
return _webhook(i, titel, tekst, prioriteit)
if i["channel"] == "email":
return _email(i, titel, tekst, prioriteit)
return False, f"Onbekend kanaal: {i['channel']}"
def send(event: str, titel: str, tekst: str, prioriteit: str = "hoog") -> bool:
"""Meld een gebeurtenis, als daarvoor gekozen is.
Gooit nooit: het kanaal mag de taak die de melding veroorzaakte niet alsnog
laten omvallen. Het resultaat gaat wel naar het auditlog, zodat je kunt zien
dát er gemeld is — en of dat lukte.
"""
try:
if not ingeschakeld(event):
return False
ok, melding = verstuur(titel, tekst, prioriteit)
except Exception as e: # nooit doorgeven
ok, melding = False, f"{type(e).__name__}: {e}"
try:
from core import audit
audit.log("notify", event, "ok" if ok else "fout", ref=melding[:200])
except Exception:
pass
return ok