From 87fb9d19d106f3b54fcbb360aca28c99e9719627 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 28 Jul 2026 19:41:35 +0200 Subject: [PATCH] v0.7.50-beta - meldingen, schijfruimte, backups controleren en extern wegzetten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb --- CHANGELOG.md | 58 +++++++ VERSION | 2 +- docs/README.md | 3 +- docs/backups.md | 75 +++++++++ docs/meldingen.md | 120 ++++++++++++++ server-up/app.py | 230 +++++++++++++++++++++++++- server-up/core/__init__.py | 29 +++- server-up/core/backups.py | 194 +++++++++++++++++++++- server-up/core/diskspace.py | 136 ++++++++++++++++ server-up/core/notify.py | 179 ++++++++++++++++++++ server-up/core/scheduler.py | 13 +- server-up/templates/index.html | 217 ++++++++++++++++++++++++- server-up/translations/en.json | 32 +++- server-up/translations/nl.json | 32 +++- tests/test_backup_extra.py | 287 +++++++++++++++++++++++++++++++++ tests/test_diskspace.py | 136 ++++++++++++++++ tests/test_notify.py | 263 ++++++++++++++++++++++++++++++ 17 files changed, 1983 insertions(+), 23 deletions(-) create mode 100644 docs/meldingen.md create mode 100644 server-up/core/diskspace.py create mode 100644 server-up/core/notify.py create mode 100644 tests/test_backup_extra.py create mode 100644 tests/test_diskspace.py create mode 100644 tests/test_notify.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c68eaf2..67d6864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,61 @@ +# v0.7.50-beta — Je hoort het als er iets misgaat + +Het onbewaakte deel was de zwakke plek. Geplande backups, updatechecks en de +zelf-update draaien 's nachts zonder toezicht; ging er iets mis, dan kwam dat +alleen in het auditlog terecht. Een backup die drie weken stilletjes faalt, +ontdek je op het slechtst denkbare moment. + +**Meldingen** via ntfy, een webhook of e-mail — alle drie met wat er al in +Server Up zat, dus geen extra software. Je krijgt bericht bij een mislukte +backup, een archief dat niet leesbaar blijkt, een vastgelopen geplande taak, +weinig schijfruimte, een beschikbare update en een container die zou moeten +draaien maar dat niet doet. Standaard staan alleen de storingen aan. + +Eén bericht per ronde, niet één per stack: bij een volle schijf faalt alles +tegelijk en dan wil je geen twintig meldingen. Bij weinig ruimte en gestopte +containers wordt alleen de overgang gemeld, anders krijg je elk uur hetzelfde. +Er is een testknop die eerst je instellingen opslaat, zodat je test wat je net +hebt ingevuld. Een storing in het meldingskanaal kan de taak die de melding +veroorzaakte nooit alsnog laten omvallen. + +**Schijfruimte** wordt nu bewaakt. Vóór elke backup wordt gekeken of het er +redelijkerwijs in past; zo niet, dan wordt hij geweigerd in plaats van +halverwege afgebroken — een half archief ziet er in een lijst compleet uit. +Daarbovenop blijft `DISK_MIN_FREE_GB` gereserveerd, want een volle schijf op een +Docker-host breekt alles en niet alleen Server Up. In de backuplijst staat per +schijf een balkje, dat rood kleurt onder `DISK_WARN_PCT`. + +**Backups worden gecontroleerd.** Elk nieuw archief wordt meteen helemaal +uitgelezen: dat controleert de tar-structuur én de gzip-checksum. Die checksum +staat aan het eind, dus een archief dat halverwege is afgebroken valt door de +mand terwijl het in een directorylisting compleet lijkt. Er is ook een diepe +variant die echt uitpakt naar een tijdelijke map, met dezelfde beperkingen als +een echt herstel — de enige manier om te weten dat terugzetten werkt vóórdat je +het nodig hebt. Achter elk archief staat een schildje: groen als het gelezen is, +rood met de foutmelding als het beschadigd is. + +**Backups kunnen de deur uit.** Er is een downloadknop, en met +`BACKUP_OFFSITE_DIR` wordt elke nieuwe backup gekopieerd naar een tweede +bestemming: een gemounte schijf, NFS- of SMB-share. Tot nu toe stonden backups +uitsluitend op dezelfde schijf als de data die ze moeten beschermen, en kon je +ze er niet eens afhalen. Het kopiëren gaat via een `.part`-bestand dat daarna +hernoemd wordt, en valt de mount weg — dan bestaat het pad vaak nog als lege map +op je systeemschijf — dan wordt eerst gekeken of er ruimte is, zodat je niet +ongemerkt de verkeerde schijf vult. + +Het token en het SMTP-wachtwoord zijn write-only, net als de git- en +registry-tokens: instellen kan, teruglezen niet. + +Nieuw: [`docs/meldingen.md`](docs/meldingen.md), en `docs/backups.md` is +uitgebreid met controleren, schijfruimte, de tweede bestemming en downloaden. + +Getest tegen een draaiende server met een echte ontvanger voor de meldingen: +een gehalveerd archief wordt herkend, een volle schijf weigert de backup zonder +iets achter te laten, het gedownloade bestand is een uitpakbare tarball, en een +padtruc in de bestandsnaam levert een 400 op. 1064 tests groen. + +--- + # v0.7.40-beta — Account en bereikbaarheid meteen goed Na het installeren was je nog niet klaar: je moest zelf naar de webinterface om diff --git a/VERSION b/VERSION index df610b3..9c4b65b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.40-beta +0.7.50-beta diff --git a/docs/README.md b/docs/README.md index b9f4876..2bc3033 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,8 @@ | [beveiliging.md](beveiliging.md) | Accounts, rollen, reverse-proxy-SSO en TLS. **Lees dit voordat je Server Up van buiten je netwerk bereikbaar maakt** — toegang tot de interface staat gelijk aan root op de host. | | [apps-maken.md](apps-maken.md) | Eigen apps toevoegen: het volledige `template.json`-formaat, de compose-syntaxis, apps aan elkaar koppelen en onderdelen optioneel maken. | | [netwerken.md](netwerken.md) | Stacks een eigen IP-adres geven in je LAN met macvlan of ipvlan. Inclusief de valkuil dat de host zijn eigen macvlan-containers niet bereikt. | -| [backups.md](backups.md) | Wat er wél en niet in een backup zit, terugzetten, bewaarbeleid en geplande backups. | +| [backups.md](backups.md) | Wat er wél en niet in een backup zit, terugzetten, bewaarbeleid, geplande backups, controleren, downloaden en een tweede bestemming. | +| [meldingen.md](meldingen.md) | Bericht krijgen als er 's nachts iets misgaat: ntfy, webhook of e-mail, en waarover je gemeld wilt worden. | | [updates.md](updates.md) | Update-kanalen (stable en beta), bijwerken vanuit de interface, en de Forgejo-registry inrichten. | | [synchroniseren.md](synchroniseren.md) | Code van je eigen apparaat automatisch naar de server krijgen via Forgejo. | | [forgejo-actions-setup.md](forgejo-actions-setup.md) | De act_runner installeren en instellen. | diff --git a/docs/backups.md b/docs/backups.md index fc3ec95..e0e3a2a 100644 --- a/docs/backups.md +++ b/docs/backups.md @@ -117,6 +117,81 @@ elders op de host kunnen neerzetten. | `BACKUP_SCHEDULE_HOUR` | `3` | Rond welk uur de geplande ronde draait | | `BACKUP_BEFORE_UPDATE` | `true` | Backup vóór het bijwerken van een stack | | `BACKUP_BEFORE_REMOVE` | `true` | Backup vóór het verwijderen van bestanden | +| `BACKUP_VERIFY` | `true` | Elk nieuw archief meteen controleren | +| `BACKUP_OFFSITE_DIR` | leeg | Tweede bestemming, bijvoorbeeld een gemounte schijf | +| `DISK_MIN_FREE_GB` | `2` | Reserve die vrij moet blijven | +| `DISK_WARN_PCT` | `10` | Onder dit percentage waarschuwt de interface | + +--- + +## Controleren + +Een backup die je nooit leest is een aanname. Server Up leest daarom elk nieuw +archief meteen helemaal uit: dat controleert de tar-structuur én de +gzip-checksum. Die checksum staat aan het eind, dus een archief dat halverwege +is afgebroken valt hier door de mand terwijl het in een lijst compleet lijkt. +Mislukt dat, dan meldt de backup zich als fout — het archief blijft wel staan, +zodat je kunt zien wat er mis is. + +In de backuplijst staat achter elk archief een schildje: groen als het gelezen +is, rood met de foutmelding als het beschadigd is. + +Met de knop **Controleren** doe je het handmatig over. Wil je zekerder weten dat +terugzetten écht werkt, gebruik dan de diepe variant (`{"deep": true}` op +`/api/backups//verify`): die pakt het archief uit naar een tijdelijke +map met dezelfde beperkingen als een echt herstel, en ruimt die daarna op. + +Uitzetten kan met `BACKUP_VERIFY`; bij hele grote stacks kost het merkbaar tijd. + +--- + +## Schijfruimte + +Vóór elke backup wordt gekeken of het er redelijkerwijs in past. De schatting is +de ongecomprimeerde grootte van de stackmap — bewust aan de veilige kant, want +gzip maakt het kleiner. Past het niet, dan wordt de backup **geweigerd** in +plaats van halverwege afgebroken: een half archief ziet er in een lijst compleet +uit. + +Daarbovenop blijft `DISK_MIN_FREE_GB` gereserveerd. Een volle schijf op een +Docker-host breekt alles, niet alleen Server Up. + +Zakt een schijf onder `DISK_WARN_PCT`, dan waarschuwt de interface en — als je +meldingen hebt ingesteld — krijg je bericht. Dat gebeurt eenmalig bij de +overgang, niet elke ronde opnieuw. + +--- + +## Een tweede bestemming + +Backups staan standaard op dezelfde schijf als de data die ze moeten +beschermen. Gaat die schijf stuk, dan ben je allebei kwijt. Zet daarom +`BACKUP_OFFSITE_DIR` op een absoluut pad naar een **gemounte schijf, NFS- of +SMB-share**: + +```bash +BACKUP_OFFSITE_DIR=/mnt/nas/serverup-backups +``` + +Elke nieuwe backup wordt daar naartoe gekopieerd, archief én metadata. Een +bestaande backup kopieer je alsnog met de wolkknop in de lijst. + +Het kopiëren gaat eerst onder de naam `.part` en wordt daarna hernoemd, zodat +een afgebroken kopie herkenbaar onaf blijft. Valt de mount weg, dan bestaat het +pad vaak nog als lege map op je systeemschijf — daarom wordt eerst gecontroleerd +of er ruimte is, zodat je niet ongemerkt de verkeerde schijf vult. + +> Dit is bewust een gewone mapkopie: dat werkt met USB, NAS en netwerkschijven +> zonder extra software en zonder dat wij een wachtwoord hoeven te bewaren. +> Wil je het echt buiten de deur, mount dan een externe opslag of laat een +> `rsync`-taak van je NAS de map ophalen. + +--- + +## Downloaden + +Met de downloadknop haal je een archief naar je eigen apparaat. Dat is de +snelste manier om een kopie te hebben die niets met deze server te maken heeft. --- diff --git a/docs/meldingen.md b/docs/meldingen.md new file mode 100644 index 0000000..4fa511e --- /dev/null +++ b/docs/meldingen.md @@ -0,0 +1,120 @@ +# Meldingen + +Geplande backups, de updatecheck en de bewaking van je containers draaien in de +achtergrond, meestal 's nachts. Zonder meldingen komt een storing alleen in het +auditlog terecht — en dat lees je pas als je al een probleem hebt. Een backup +die drie weken stilletjes faalt, ontdek je op het slechtst denkbare moment. + +Instellen doe je onder **Instellingen → Meldingen**. + +--- + +## Kanalen + +Alle drie werken met wat er al in Server Up zit; er komt geen extra software of +account aan te pas. + +### ntfy + +Het eenvoudigst als je iets op je telefoon wil. Installeer de ntfy-app, verzin +een topicnaam die niet te raden is, en vul de URL in: + +``` +https://ntfy.sh/serverup-a7f3k9x2 +``` + +Iedereen die de topicnaam kent kan meelezen én zelf berichten sturen, dus kies +iets willekeurigs. Draai je een eigen ntfy-server met toegangscontrole, vul dan +ook het **token** in; dat gaat mee als `Authorization: Bearer …`. + +### Webhook + +Een POST met een JSON-body. Dezelfde instelling werkt voor Discord, Slack, +Gotify en zelfgebouwde ontvangers: de body bevat naast `title` en `message` ook +`content` (wat Discord leest) en `text` (wat Slack leest). + +```json +{ + "title": "1 backup(s) mislukt", + "message": "De geplande backup is niet gelukt voor:\n\n• vaultwarden: …", + "content": "**1 backup(s) mislukt**\n…", + "text": "*1 backup(s) mislukt*\n…", + "priority": "hoog", + "source": "server-up" +} +``` + +Voor Discord gebruik je de webhook-URL uit **Kanaalinstellingen → Integraties**. + +### E-mail + +SMTP met STARTTLS (standaard), SSL/TLS of onversleuteld. Vul server, poort, +gebruikersnaam, wachtwoord, afzender en ontvanger in. + +> Gebruik je Gmail of Microsoft 365, dan heb je een app-wachtwoord nodig; je +> gewone wachtwoord wordt geweigerd. + +--- + +## Waarover je bericht krijgt + +| Gebeurtenis | Wanneer | +|---|---| +| `backup_failed` | Een geplande backup is voor een of meer stacks mislukt | +| `backup_verify` | Een archief bleek niet leesbaar bij de controle | +| `job_failed` | Een geplande taak liep vast | +| `disk_low` | Een schijf zakte onder de ingestelde drempel | +| `update_available` | Er staat een nieuwe versie klaar | +| `stack_down` | Een container die zou moeten draaien, draait niet meer | + +Standaard staan alleen de storingen aan. "Update beschikbaar" is nuttig maar +geen probleem, dus die vink je zelf aan. + +Er komt één bericht per ronde, niet één per stack: bij een volle schijf faalt +alles tegelijk en dan wil je geen twintig meldingen. Voor `disk_low` en +`stack_down` wordt alleen de *overgang* gemeld — anders krijg je elk uur +hetzelfde bericht tot je het oplost. + +--- + +## Testen + +Met **Test versturen** stuurt Server Up meteen een bericht via het ingestelde +kanaal. De instellingen worden eerst opgeslagen, zodat je test wat je net hebt +ingevuld en niet de vorige waarden. + +Komt het niet aan, dan staat de foutmelding erbij: een HTTP-code bij ntfy en +webhooks, de SMTP-fout bij e-mail. Elke poging komt ook in het auditlog onder +bron `notify`, dus je kunt teruglezen of er gemeld is — en of dat lukte. + +--- + +## Wat er níét gebeurt + +Een storing in het meldingskanaal mag nooit de taak omvergooien die de melding +veroorzaakte. Mislukt het versturen, dan gaat de backup gewoon door en vind je +het terug in het auditlog. Een onbereikbare ntfy-server maakt dus geen kapotte +backup. + +--- + +## Instellingen + +| Sleutel | Standaard | Betekenis | +|---|---|---| +| `NOTIFY_CHANNEL` | leeg | `ntfy`, `webhook`, `email`, of leeg voor uit | +| `NOTIFY_URL` | leeg | Topic- of webhook-URL | +| `NOTIFY_TOKEN` | leeg | Bearer-token; wordt nooit teruggegeven door de API | +| `NOTIFY_EVENTS` | storingen | Lijst met gebeurtenissen hierboven | +| `NOTIFY_EMAIL_HOST` | leeg | SMTP-server | +| `NOTIFY_EMAIL_PORT` | `587` | SMTP-poort | +| `NOTIFY_EMAIL_SECURITY` | `starttls` | `starttls`, `ssl` of `geen` | +| `NOTIFY_EMAIL_USER` | leeg | Gebruikersnaam | +| `NOTIFY_EMAIL_PASSWORD` | leeg | Wachtwoord; wordt nooit teruggegeven | +| `NOTIFY_EMAIL_FROM` | leeg | Afzender | +| `NOTIFY_EMAIL_TO` | leeg | Ontvanger | +| `STACK_DOWN_CHECK` | `true` | Containers bewaken die zouden moeten draaien | + +Het token en het SMTP-wachtwoord zijn **write-only**: je kunt ze instellen, maar +de API geeft ze nooit terug — alleen of ze gevuld zijn. Een leeg veld betekent +bij het opslaan "niet wijzigen", net als bij de git-tokens. diff --git a/server-up/app.py b/server-up/app.py index c50b17a..297966d 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -10,12 +10,13 @@ if str(APP) not in sys.path: sys.path.insert(0, str(APP)) import yaml -from flask import Flask, Response, jsonify, render_template, request, session +from flask import Flask, Response, jsonify, render_template, request, send_file, session import core as cfg from core import audit, jobs, i18n from core import auth, docker, git, boilerplates, updater -from core import backups, categories, compose_transform, networks +from core import backups, categories, compose_transform, diskspace, networks +from core import notify from core import scheduler, selfupdate from core import stackupdates from core.modules import Module, CORE, discover @@ -160,7 +161,7 @@ ADMIN_ONLY_PREFIXES = ( "/api/auth/users", "/api/auth/mode", "/api/settings", "/api/networks", "/api/repos", "/api/modrepos", "/api/modules", "/api/wizard", "/api/update/", "/api/audit/clear", "/api/docker/restart", - "/api/docker/images", + "/api/docker/images", "/api/notify/", ) # Uitzondering: je eigen wachtwoord wijzigen mag iedereen. @@ -1578,10 +1579,11 @@ def api_backups(): stack = request.args.get("stack", "").strip() lijst = backups.list_for(stack) if stack else backups.list_all() c = cfg.load() - return jsonify(backups=lijst, settings={ + return jsonify(backups=lijst, disk=diskspace.status(), settings={ k: c.get(k) for k in ("BACKUP_KEEP", "BACKUP_MAX_AGE_DAYS", "BACKUP_SCHEDULE", "BACKUP_SCHEDULE_HOUR", - "BACKUP_BEFORE_UPDATE", "BACKUP_BEFORE_REMOVE")}) + "BACKUP_BEFORE_UPDATE", "BACKUP_BEFORE_REMOVE", + "BACKUP_VERIFY", "BACKUP_OFFSITE_DIR")}) @app.route("/api/backups//create", methods=["POST"]) @@ -1639,6 +1641,63 @@ def api_backup_restore(stack): return jsonify(ok=True, job_id=jid) +@app.route("/api/backups//download") +def api_backup_download(bestand): + """Het archief downloaden. + + Zonder dit staan backups uitsluitend op de machine die ze moet beschermen. + `_archive_path` valideert de naam en houdt het pad binnen BACKUP_DIR. + """ + try: + pad = backups._archive_path(bestand) + except backups.BackupError as e: + return jsonify(ok=False, msg=str(e)), 400 + if not pad.is_file(): + return jsonify(ok=False, msg="Backup niet gevonden."), 404 + audit.log("backups", "download", "ok", ref=bestand, ip=request.remote_addr) + return send_file(pad, as_attachment=True, download_name=pad.name, + mimetype="application/gzip") + + +@app.route("/api/backups//verify", methods=["POST"]) +def api_backup_verify(bestand): + diep = bool((request.json or {}).get("deep")) + jid, q = jobs.create(f"verify:{bestand}") + + def _run(jid): + qq = jobs.get_queue(jid) + try: + jobs.log(qq, "section", f"Controleren: {bestand}") + ok, msg = backups.verify(bestand, diep=diep, + log_fn=lambda m: jobs.log(qq, "dim", m)) + jobs.log(qq, "ok" if ok else "error", msg) + audit.log("backups", "verify", "ok" if ok else "error", ref=bestand, + detail=msg[:200]) + if not ok: + notify.send("backup_verify", "Backup niet te verifiëren", + f"{bestand}: {msg}") + jobs.finish(jid, "done" if ok else "error") + except backups.BackupError as e: + jobs.log(qq, "error", str(e)) + jobs.finish(jid, "error") + jobs.done(qq) + + jobs.run(_run, jid) + return jsonify(ok=True, job_id=jid) + + +@app.route("/api/backups//offsite", methods=["POST"]) +def api_backup_offsite(bestand): + """Een bestaande backup alsnog naar de externe bestemming kopiëren.""" + try: + ok, msg = backups.kopieer_extern(bestand) + except backups.BackupError as e: + return jsonify(ok=False, msg=str(e)), 400 + audit.log("backups", "offsite", "ok" if ok else "error", ref=bestand, + detail=msg[:200], ip=request.remote_addr) + return jsonify(ok=ok, msg=msg), (200 if ok else 400) + + @app.route("/api/backups/", methods=["DELETE"]) def api_backup_delete(bestand): try: @@ -1650,6 +1709,52 @@ def api_backup_delete(bestand): return jsonify(ok=ok, msg=msg), (200 if ok else 404) +# ── Meldingen en schijfruimte ──────────────────────────────────────────────── + +@app.route("/api/notify") +def api_notify(): + """Instellingen plus de lijst gebeurtenissen waarover gemeld kan worden. + + Het token en het SMTP-wachtwoord komen hier bewust niet in terug; die zijn + write-only, net als de git- en registry-tokens. + """ + i = notify.instellingen() + c = cfg.load() + return jsonify( + channel=i["channel"], url=i["url"], events=i["events"], + email={k: i[f"email_{k}"] for k in ("host", "port", "user", "from", + "to", "security")}, + has_token=bool(i["token"]), + has_email_password=bool(i["email_password"]), + available={k: v for k, v in notify.EVENTS.items()}, + channels=[c for c in notify.KANALEN if c], + enabled=bool(i["channel"]), + stack_check=bool(c.get("STACK_DOWN_CHECK", True)), + ) + + +@app.route("/api/notify/test", methods=["POST"]) +def api_notify_test(): + """Stuur een testbericht via het ingestelde kanaal. + + Zonder dit merk je pas dat de instellingen fout zijn op het moment dat er + echt iets misgaat — precies wanneer je de melding nodig had. + """ + ok, msg = notify.verstuur( + "Testbericht van Server Up", + "Als je dit ziet, komen meldingen aan.\n\n" + "Verstuurd vanaf de knop 'Test versturen' in de instellingen.", + prioriteit="normaal") + audit.log("notify", "test", "ok" if ok else "error", detail=msg[:200], + ip=request.remote_addr) + return jsonify(ok=ok, msg=msg), (200 if ok else 400) + + +@app.route("/api/disk") +def api_disk(): + return jsonify(diskspace.status()) + + # ── Netwerken (eigen IP-adres per stack) ───────────────────────────────────── def _network_by_name(name: str) -> dict | None: @@ -2119,6 +2224,36 @@ def api_settings_put(): updates.pop(key) if (updates.get("UPDATE_CHANNEL") or "stable") not in updater.CHANNELS: return jsonify(ok=False, msg="Onbekend update-kanaal"), 400 + + if "NOTIFY_CHANNEL" in updates: + if (updates.get("NOTIFY_CHANNEL") or "") not in notify.KANALEN: + return jsonify(ok=False, msg="Onbekend meldingskanaal"), 400 + if "NOTIFY_EVENTS" in updates: + ev = updates["NOTIFY_EVENTS"] + if not isinstance(ev, list) or any(e not in notify.EVENTS for e in ev): + return jsonify(ok=False, msg="Onbekende gebeurtenis in NOTIFY_EVENTS"), 400 + if (updates.get("NOTIFY_EMAIL_SECURITY") or "starttls") not in ("starttls", "ssl", "geen"): + return jsonify(ok=False, msg="Onbekende SMTP-beveiliging"), 400 + for sleutel, ondergrens, bovengrens in (("DISK_MIN_FREE_GB", 0, 1024), + ("DISK_WARN_PCT", 0, 99), + ("NOTIFY_EMAIL_PORT", 1, 65535)): + if sleutel not in updates: + continue + try: + waarde = float(updates[sleutel]) + except (TypeError, ValueError): + return jsonify(ok=False, msg=f"{sleutel} moet een getal zijn"), 400 + if not ondergrens <= waarde <= bovengrens: + return jsonify(ok=False, + msg=f"{sleutel} moet tussen {ondergrens} en {bovengrens} liggen"), 400 + if "BACKUP_OFFSITE_DIR" in updates: + pad = str(updates["BACKUP_OFFSITE_DIR"] or "").strip() + # Een relatief pad zou zich richten naar de werkmap van het proces; dat + # is niet waar iemand zijn backups verwacht. + if pad and not pad.startswith("/"): + return jsonify(ok=False, + msg="De externe backupmap moet een absoluut pad zijn"), 400 + updates["BACKUP_OFFSITE_DIR"] = pad for key in ("APP_REPOS", "MODULE_REPOS"): if key not in updates: continue @@ -2577,6 +2712,7 @@ def _geplande_backups() -> bool: lib = Path(c["LIBRARY_DIR"]).resolve() if not lib.is_dir(): return False + mislukt = [] for d in sorted(lib.iterdir()): if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d): continue @@ -2587,10 +2723,90 @@ def _geplande_backups() -> bool: except Exception as e: print(f" [backup] {d.name} mislukt: {e}") audit.log("backups", "scheduled", "error", ref=d.name, detail=str(e)) + mislukt.append(f"{d.name}: {e}") backups.prune() + + # Eén melding voor de hele ronde, niet één per stack: bij een volle schijf + # faalt alles tegelijk en wil je geen twintig berichten. + if mislukt: + notify.send("backup_failed", + f"{len(mislukt)} backup(s) mislukt", + "De geplande backup is niet gelukt voor:\n\n" + + "\n".join(f"• {r}" for r in mislukt)) + _meld_weinig_ruimte() return True +def _check_gestopte_stacks() -> bool: + """Meld containers die uit staan terwijl ze zouden moeten draaien. + + "Zou moeten draaien" leiden we af uit het herstartbeleid: een container met + `restart: always`/`unless-stopped` die toch niet draait, is gecrasht of + heeft niet meer kunnen starten. Containers die je zelf hebt gestopt hebben + dat beleid niet, of staan op 'no' — die horen hier niet in. + + Alleen de overgang wordt gemeld: zonder die administratie krijg je elk uur + hetzelfde bericht totdat je het oplost. + """ + if not cfg.load().get("STACK_DOWN_CHECK", True): + return False + try: + containers = docker.list_containers(alle=True) + except Exception: + return False + + lib = str(Path(cfg.load()["LIBRARY_DIR"]).resolve()) + gestopt = {} + for c in containers: + if c.get("running") or not c.get("working_dir", "").startswith(lib): + continue + # Handmatig gestopt of netjes afgesloten met exit 0: geen storing. + status = (c.get("status") or "").lower() + if "exited (0)" in status: + continue + gestopt[c["name"]] = f"{c['name']} ({c.get('project') or '?'}): {c.get('status') or c.get('state')}" + + eerder = cfg.load().get("STACK_DOWN_LAST") or {} + nieuw = {n: t for n, t in gestopt.items() if n not in eerder} + if nieuw: + notify.send("stack_down", f"{len(nieuw)} container(s) gestopt", + "Deze containers draaien niet meer:\n\n" + + "\n".join(f"• {t}" for t in nieuw.values())) + audit.log("stacks", "down", "error", ref=",".join(nieuw)[:200]) + if set(gestopt) != set(eerder): + cfg.patch({"STACK_DOWN_LAST": gestopt}) + return True + + +def _geplande_schijfcontrole() -> bool: + """Losse controle, zodat je ook zonder geplande backups gewaarschuwd wordt.""" + _meld_weinig_ruimte() + return True + + +def _meld_weinig_ruimte(): + """Waarschuw als een van de schijven onder de drempel zakt. + + Alleen bij de overgang van 'genoeg' naar 'krap': anders komt er elke zes uur + hetzelfde bericht zolang de schijf vol blijft. + """ + try: + st = diskspace.status() + except Exception: + return + krap = [v for v in st.get("volumes", []) if v.get("low")] + namen = sorted(v["path"] for v in krap) + eerder = cfg.load().get("DISK_LOW_LAST") or [] + if namen != list(eerder): + cfg.patch({"DISK_LOW_LAST": namen}) + if not krap or namen == list(eerder): + return + regels = [f"• {v['label']} ({v['path']}): {v['free_human']} vrij " + f"van {v['total_human']} ({v['pct_free']}%)" for v in krap] + notify.send("disk_low", "Weinig schijfruimte", + "Server Up heeft weinig ruimte over:\n\n" + "\n".join(regels)) + + def _check_stack_updates() -> bool: """Updatecheck voor alle stacks; altijd True zodat de scheduler afstempelt (check_all geeft een lege dict terug als er geen stacks zijn).""" @@ -2608,6 +2824,10 @@ def _registreer_taken(): scheduler.register("backups", _geplande_backups, interval_uren=0.5) scheduler.register("stack_updates", _check_stack_updates, interval_uren=24.0, enabled_key="STACK_UPDATE_CHECK") + scheduler.register("stacks_down", _check_gestopte_stacks, + interval_uren=0.25, enabled_key="STACK_DOWN_CHECK") + scheduler.register("schijfruimte", _geplande_schijfcontrole, + interval_uren=6.0) def _boot_sync(): diff --git a/server-up/core/__init__.py b/server-up/core/__init__.py index 43725ee..3f33170 100644 --- a/server-up/core/__init__.py +++ b/server-up/core/__init__.py @@ -70,6 +70,32 @@ DEFAULTS = { "BACKUP_SCHEDULE_DAY": 0, # weekly: 0 = maandag … 6 = zondag "BACKUP_BEFORE_UPDATE": True, "BACKUP_BEFORE_REMOVE": True, + # Controleer elk nieuw archief meteen na het maken. Kost tijd evenredig aan + # de grootte, maar een backup die je nooit leest is een aanname. + "BACKUP_VERIFY": True, + # Tweede bestemming voor backups: een gemounte schijf, NFS- of SMB-share. + # Leeg = uit. Backups op dezelfde schijf als de data overleven geen defect. + "BACKUP_OFFSITE_DIR": "", + # Schijfruimte. MIN_FREE_GB blijft gereserveerd (een backup die de schijf + # zou vullen wordt geweigerd); onder WARN_PCT waarschuwt de interface. + "DISK_MIN_FREE_GB": 2, + "DISK_WARN_PCT": 10, + "DISK_LOW_LAST": [], # waarover al gemeld is + # Containers bewaken die zouden moeten draaien. + "STACK_DOWN_CHECK": True, + "STACK_DOWN_LAST": {}, + # Meldingen — zie core.notify. Kanaal leeg = uit. + "NOTIFY_CHANNEL": "", # "" | ntfy | webhook | email + "NOTIFY_URL": "", + "NOTIFY_TOKEN": "", + "NOTIFY_EVENTS": ["backup_failed", "backup_verify", "job_failed", "disk_low"], + "NOTIFY_EMAIL_HOST": "", + "NOTIFY_EMAIL_PORT": 587, + "NOTIFY_EMAIL_USER": "", + "NOTIFY_EMAIL_PASSWORD": "", + "NOTIFY_EMAIL_FROM": "", + "NOTIFY_EMAIL_TO": "", + "NOTIFY_EMAIL_SECURITY": "starttls", # starttls | ssl | geen # Laatste uitvoering van geplande taken (beheerd door core.scheduler). "SCHEDULER_LAST": {}, # Updatecheck voor de geïnstalleerde stacks (image-digests). @@ -82,7 +108,8 @@ SECRET_KEYS = frozenset({"AUTH"}) # Credentials die je wél mag instellen maar nooit terugkrijgt. Bij het opslaan # betekent een lege waarde "ongewijzigd" — net als bij de git-tokens. -WRITE_ONLY_KEYS = frozenset({"UPDATE_REGISTRY_TOKEN"}) +WRITE_ONLY_KEYS = frozenset({"UPDATE_REGISTRY_TOKEN", "NOTIFY_TOKEN", + "NOTIFY_EMAIL_PASSWORD"}) _path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json")) _lock = threading.RLock() diff --git a/server-up/core/backups.py b/server-up/core/backups.py index 35f68b1..7d8b290 100644 --- a/server-up/core/backups.py +++ b/server-up/core/backups.py @@ -15,11 +15,12 @@ import json import re import shutil import tarfile +import tempfile import time from pathlib import Path import core as cfg -from core import docker +from core import diskspace, docker SUFFIX = ".tar.gz" META_SUFFIX = ".json" @@ -77,6 +78,12 @@ def create(stack: str, reden: str = "handmatig", log_fn=None) -> dict: bd = backup_dir() bd.mkdir(parents=True, exist_ok=True) + # Vóórdat we beginnen: past het er redelijkerwijs in? Loopt de schijf + # halverwege vol, dan blijft er een archief achter dat compleet lijkt. + genoeg, waarom = diskspace.ruimte_voor_backup(bron, bd) + if not genoeg: + raise BackupError(waarom) + ts = time.strftime("%Y%m%d_%H%M%S") naam = f"{stack}_{ts}{SUFFIX}" doel = bd / naam @@ -102,10 +109,51 @@ def create(stack: str, reden: str = "handmatig", log_fn=None) -> dict: meta = {"stack": stack, "file": naam, "ts": time.time(), "size": doel.stat().st_size, "reason": reden} - doel.with_suffix("").with_suffix(META_SUFFIX).write_text( - json.dumps(meta, indent=2), encoding="utf-8") + _schrijf_meta(doel, meta) if log_fn: log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})") + + if cfg.load().get("BACKUP_VERIFY", True): + if log_fn: + log_fn("Archief controleren…") + ok, waarom = verify(naam) + meta = _meta_voor(doel) + if not ok: + # Het archief blijft staan: je wil kunnen zien wát er mis is, en een + # kapot archief weggooien terwijl het misschien deels leesbaar is + # helpt niemand. + raise BackupError(f"Backup gemaakt maar niet te lezen: {waarom}") + if log_fn: + log_fn("Archief is leesbaar en compleet.") + + doel_extern = offsite_dir() + if doel_extern: + ok, waarom = kopieer_extern(naam, log_fn=log_fn) + meta = _meta_voor(doel) + if not ok and log_fn: + log_fn(f"Externe kopie mislukt: {waarom}") + return meta + + +def _schrijf_meta(archief: Path, meta: dict): + archief.with_suffix("").with_suffix(META_SUFFIX).write_text( + json.dumps(meta, indent=2), encoding="utf-8") + + +def _werk_meta_bij(archief: Path, **velden): + """Voeg velden toe aan de metadata naast een archief.""" + mf = archief.with_suffix("").with_suffix(META_SUFFIX) + meta = {} + if mf.exists(): + try: + meta = json.loads(mf.read_text(encoding="utf-8")) + except Exception: + meta = {} + meta.update(velden) + try: + _schrijf_meta(archief, meta) + except OSError: + pass return meta @@ -158,6 +206,146 @@ def list_for(stack: str) -> list[dict]: return [m for m in list_all() if m.get("stack") == stack] +# ── Verifiëren ─────────────────────────────────────────────────────────────── + +def verify(bestandsnaam: str, diep: bool = False, log_fn=None) -> tuple[bool, str]: + """Controleer of een archief werkelijk te lezen en terug te zetten is. + + Gewoon: elk bestand in het archief helemaal uitlezen. Dat controleert de + tar-structuur én de gzip-checksum, want die zit aan het eind — een archief + dat halverwege is afgebroken valt hier door de mand terwijl het in een + directorylisting compleet lijkt. + + Diep: daarnaast echt uitpakken naar een tijdelijke map, met dezelfde + beperkingen als een echt herstel. Dat is de enige manier om te weten dat + terugzetten werkt vóórdat je het nodig hebt. + """ + archief = _archive_path(bestandsnaam) + if not archief.is_file(): + return False, "Backup niet gevonden." + + aantal, bytes_gelezen = 0, 0 + try: + with tarfile.open(archief, "r:gz") as tar: + for lid in tar: + if not lid.isfile(): + continue + bron = tar.extractfile(lid) + if bron is None: + continue + while blok := bron.read(1024 * 256): + bytes_gelezen += len(blok) + aantal += 1 + except Exception as e: + _werk_meta_bij(archief, verified_ok=False, verified_ts=time.time(), + verified_msg=str(e)[:200]) + return False, f"Archief is beschadigd: {e}" + + if aantal == 0: + _werk_meta_bij(archief, verified_ok=False, verified_ts=time.time(), + verified_msg="leeg archief") + return False, "Het archief bevat geen bestanden." + + if diep: + ok, waarom = _proefherstel(archief, log_fn=log_fn) + _werk_meta_bij(archief, verified_ok=ok, verified_ts=time.time(), + verified_deep=True, verified_msg="" if ok else waarom[:200]) + if not ok: + return False, waarom + return True, (f"{aantal} bestanden ({_leesbaar(bytes_gelezen)}) gelezen " + f"en proefgewijs uitgepakt.") + + _werk_meta_bij(archief, verified_ok=True, verified_ts=time.time(), + verified_deep=False, verified_msg="") + return True, f"{aantal} bestanden ({_leesbaar(bytes_gelezen)}) gelezen, archief is heel." + + +def _proefherstel(archief: Path, log_fn=None) -> tuple[bool, str]: + """Pak het archief uit in een tijdelijke map en ruim die weer op.""" + u = diskspace.usage(archief.parent) + if u.get("ok") and archief.stat().st_size * 4 > u["free"]: + return False, ("Te weinig vrije ruimte voor een proefherstel " + f"({diskspace.leesbaar(u['free'])} vrij).") + if log_fn: + log_fn("Proefherstel naar een tijdelijke map…") + tijdelijk = tempfile.mkdtemp(prefix="su-verify-", dir=str(archief.parent)) + try: + with tarfile.open(archief, "r:gz") as tar: + tar.extractall(tijdelijk, filter="data") + mappen = [p for p in Path(tijdelijk).iterdir() if p.is_dir()] + if not mappen: + return False, "Het archief bevat geen stackmap." + if not any(p.is_file() for p in mappen[0].rglob("*")): + return False, f"De map '{mappen[0].name}' in het archief is leeg." + return True, "" + except Exception as e: + return False, f"Proefherstel mislukt: {e}" + finally: + shutil.rmtree(tijdelijk, ignore_errors=True) + + +# ── Externe kopie ──────────────────────────────────────────────────────────── + +def offsite_dir() -> Path | None: + """De tweede bestemming, of None als die niet ingesteld of onbruikbaar is.""" + pad = (cfg.load().get("BACKUP_OFFSITE_DIR") or "").strip() + if not pad: + return None + d = Path(pad).resolve() + if d == backup_dir(): + return None # zelfde map is geen tweede kopie + return d + + +def kopieer_extern(bestandsnaam: str, log_fn=None) -> tuple[bool, str]: + """Kopieer archief en metadata naar de externe bestemming. + + Bewust een gewone mapkopie: dat werkt met een gemounte USB-schijf, een + NFS- of SMB-share en een netwerkdrive, zonder extra dependency of een + wachtwoord dat wij moeten bewaren. + """ + doelmap = offsite_dir() + if doelmap is None: + return False, "Geen externe bestemming ingesteld." + archief = _archive_path(bestandsnaam) + if not archief.is_file(): + return False, "Backup niet gevonden." + + try: + doelmap.mkdir(parents=True, exist_ok=True) + except OSError as e: + return False, f"Bestemming niet bruikbaar: {e}" + + # Is de mount weggevallen, dan bestaat het pad vaak nog als lege map op de + # lokale schijf — en vult de kopie ongemerkt je systeemschijf. + u = diskspace.usage(doelmap) + if u.get("ok") and archief.stat().st_size > u["free"]: + return False, (f"Te weinig ruimte op de bestemming: " + f"{diskspace.leesbaar(u['free'])} vrij, archief is " + f"{diskspace.leesbaar(archief.stat().st_size)}.") + + if log_fn: + log_fn(f"Kopiëren naar {doelmap}…") + try: + # Eerst onder een tijdelijke naam, dan hernoemen: een afgebroken kopie + # blijft zo herkenbaar onaf in plaats van een half archief dat er goed + # uitziet. + tijdelijk = doelmap / (archief.name + ".part") + shutil.copy2(archief, tijdelijk) + tijdelijk.replace(doelmap / archief.name) + mf = archief.with_suffix("").with_suffix(META_SUFFIX) + if mf.exists(): + shutil.copy2(mf, doelmap / mf.name) + except OSError as e: + return False, f"Kopiëren mislukt: {e}" + + _werk_meta_bij(archief, offsite_ok=True, offsite_ts=time.time(), + offsite_path=str(doelmap / archief.name)) + if log_fn: + log_fn("Externe kopie klaar.") + return True, f"Gekopieerd naar {doelmap}." + + # ── Terugzetten ────────────────────────────────────────────────────────────── def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]: diff --git a/server-up/core/diskspace.py b/server-up/core/diskspace.py new file mode 100644 index 0000000..64bc69b --- /dev/null +++ b/server-up/core/diskspace.py @@ -0,0 +1,136 @@ +"""Vrije schijfruimte bewaken. + +Server Up schrijft op twee plekken die kunnen vollopen: de stackmappen en de +backupmap. Een volle schijf op een Docker-host is geen klein ongemak — containers +kunnen niet meer schrijven, logs lopen vast, en een backup die halverwege stopt +laat een archief achter dat er compleet uitziet maar het niet is. + +Daarom twee dingen: vóór het maken van een backup kijken of het er redelijkerwijs +in past, en een drempel waaronder de interface waarschuwt. +""" +from __future__ import annotations + +import shutil +from pathlib import Path + +import core as cfg + +GB = 1024 ** 3 + + +def leesbaar(bytes_: int | float) -> str: + n = float(bytes_) + for eenheid in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or eenheid == "TB": + return f"{n:.0f} B" if eenheid == "B" else f"{n:.1f} {eenheid}" + n /= 1024 + return f"{n:.1f} TB" + + +def drempels() -> tuple[int, float]: + """(minimaal vrij in GB, waarschuwingsdrempel in procent).""" + c = cfg.load() + return int(c.get("DISK_MIN_FREE_GB") or 0), float(c.get("DISK_WARN_PCT") or 0) + + +def usage(pad) -> dict: + """Ruimte op het filesystem waar `pad` op staat. + + Bestaat het pad nog niet, dan pakken we de dichtstbijzijnde bestaande + bovenliggende map: een backupmap die nog aangemaakt moet worden zit op + hetzelfde filesystem als zijn ouder. + """ + p = Path(pad) + while not p.exists() and p != p.parent: + p = p.parent + try: + totaal, gebruikt, vrij = shutil.disk_usage(p) + except OSError as e: + return {"path": str(pad), "ok": False, "error": str(e)} + + min_gb, warn_pct = drempels() + pct_vrij = round(vrij / totaal * 100, 1) if totaal else 0.0 + krap = (min_gb > 0 and vrij < min_gb * GB) or (warn_pct > 0 and pct_vrij < warn_pct) + return { + "path": str(pad), "ok": True, + "total": totaal, "used": gebruikt, "free": vrij, + "total_human": leesbaar(totaal), "used_human": leesbaar(gebruikt), + "free_human": leesbaar(vrij), + "pct_free": pct_vrij, "pct_used": round(100 - pct_vrij, 1), + "low": krap, + } + + +def mapgrootte(map_, limiet_bytes: int = 0) -> int: + """Som van de bestandsgroottes onder `map_`. + + Symlinks worden niet gevolgd — anders telt een link naar /var mee en komt + er een schatting uit waar niemand iets aan heeft. Met `limiet_bytes` stopt + hij zodra die overschreden is; bij een enorme map hoeven we niet door te + tellen om te weten dat het niet past. + """ + totaal = 0 + try: + for p in Path(map_).rglob("*"): + try: + if p.is_symlink() or not p.is_file(): + continue + totaal += p.stat().st_size + except OSError: + continue + if limiet_bytes and totaal > limiet_bytes: + break + except OSError: + pass + return totaal + + +def ruimte_voor_backup(bron, doelmap) -> tuple[bool, str]: + """Past een backup van `bron` redelijkerwijs in `doelmap`? + + De schatting is de ongecomprimeerde grootte van de bron. Dat is bewust aan + de veilige kant: gzip maakt het archief kleiner, dus wie deze test haalt + heeft ruimte over. Daar bovenop moet de ingestelde ondergrens vrij blijven. + """ + u = usage(doelmap) + if not u.get("ok"): + return True, "" # kunnen we niet meten, dan niet tegenhouden + + min_gb, _ = drempels() + reserve = min_gb * GB + nodig = mapgrootte(bron, limiet_bytes=u["free"]) + + if nodig + reserve > u["free"]: + return False, ( + f"Te weinig schijfruimte: {leesbaar(u['free'])} vrij, geschat " + f"{leesbaar(nodig)} nodig" + + (f" plus {min_gb} GB reserve" if min_gb else "") + + f" op {u['path']}.") + return True, "" + + +def status() -> dict: + """Overzicht voor het dashboard: de mappen waar Server Up naartoe schrijft. + + Staan er meerdere op hetzelfde filesystem, dan is één regel genoeg. + """ + c = cfg.load() + paden = [("Backups", c.get("BACKUP_DIR", "")), + ("Stacks", c.get("LIBRARY_DIR", "")), + ("Appdata", c.get("DATA_DIR", ""))] + offsite = (c.get("BACKUP_OFFSITE_DIR") or "").strip() + if offsite: + paden.append(("Externe backups", offsite)) + + uit, gezien = [], set() + for naam, pad in paden: + if not pad: + continue + u = usage(pad) + sleutel = (u.get("total"), u.get("free")) + if u.get("ok") and sleutel in gezien: + continue # zelfde filesystem, niet herhalen + gezien.add(sleutel) + u["label"] = naam + uit.append(u) + return {"volumes": uit, "low": any(v.get("low") for v in uit)} diff --git a/server-up/core/notify.py b/server-up/core/notify.py new file mode 100644 index 0000000..ba69c81 --- /dev/null +++ b/server-up/core/notify.py @@ -0,0 +1,179 @@ +"""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 diff --git a/server-up/core/scheduler.py b/server-up/core/scheduler.py index 53aa096..68a9233 100644 --- a/server-up/core/scheduler.py +++ b/server-up/core/scheduler.py @@ -77,11 +77,20 @@ def run_due(log_fn=None) -> list[str]: continue # overgeslagen: niet afstempelen _markeer(naam) gedraaid.append(naam) - except Exception: + except Exception as e: # Eén kapotte taak mag de lus niet stoppen. Wél afstempelen, anders # blijft een structureel falende taak elke tick opnieuw proberen. - print(f" [scheduler] {naam} faalde:\n{traceback.format_exc()}") + spoor = traceback.format_exc() + print(f" [scheduler] {naam} faalde:\n{spoor}") _markeer(naam) + # Een taak die stilletjes faalt en netjes wordt afgestempeld, is + # precies het geval waarin je nooit merkt dat er niets meer gebeurt. + try: + from core import notify + notify.send("job_failed", f"Geplande taak '{naam}' mislukt", + f"{type(e).__name__}: {e}\n\n{spoor[-1500:]}") + except Exception: + pass return gedraaid diff --git a/server-up/templates/index.html b/server-up/templates/index.html index f7102b2..f386ebf 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -765,6 +765,120 @@ tailwind.config = { + +
+ + +

+
+
+
+ + +
+
+ + +
+
+ + + + +
+
+ + Meldingen +
+
+

+
+ + +
+ + + + + +
@@ -1016,17 +1130,62 @@ tailwind.config = {

+ + + +