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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Waarover melden
+
+
+
+
+
+
+
+
+
@@ -1016,17 +1130,62 @@ tailwind.config = {
+
+
+
+
+
+
+
+
+
+
+ vrij van
+
+
+
+
+
+
+
+
+
+
-
+
·
- ·
+ ·
+
+
+ ·
+
+
+ · beschadigd
+
+
+ ·
+
+
+
+
+
+
@@ -1636,7 +1795,9 @@ function app() {
connectApps: [], sharedNetwork: 'serverup', fieldInfo: null,
// Backups en updatestatus per stack
- bkStack: '', bkList: [], bkSettings: {},
+ bkStack: '', bkList: [], bkSettings: {}, bkDisk: {volumes: []},
+ notify: {channel: '', url: '', events: [], email: {}, available: {}, channels: []},
+ notifyTesting: false,
stackUpd: {}, checkingUpd: false,
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
@@ -2063,6 +2224,41 @@ function app() {
const d = await this.rpc('/api/backups?stack=' + encodeURIComponent(stack));
this.bkList = d.backups || [];
this.bkSettings = d.settings || {};
+ this.bkDisk = d.disk || {volumes: []};
+ },
+ async verifyBackup(file, deep) {
+ const d = await this.rpc(`/api/backups/${encodeURIComponent(file)}/verify`, {deep});
+ if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
+ this.termOpen = true;
+ this.pollJob(d.job_id, () => this.loadBackups(this.bkStack));
+ },
+ // ── Meldingen ────────────────────────────────────────────────────────────
+ async loadNotify() {
+ this.notify = await this.rpc('/api/notify');
+ },
+ toggleNotifyEvent(key, aan) {
+ const lijst = new Set(this.settings.NOTIFY_EVENTS || []);
+ aan ? lijst.add(key) : lijst.delete(key);
+ this.settings.NOTIFY_EVENTS = [...lijst];
+ this.autoSave();
+ },
+ async testNotify() {
+ // Eerst opslaan: anders test hij de vorige instellingen en denk je dat
+ // het werkt terwijl je net iets anders hebt ingevuld.
+ this.notifyTesting = true;
+ try {
+ await this.saveSettings();
+ const d = await this.rpc('/api/notify/test', {});
+ this.toast(d.msg || this.t(d.ok ? 'done' : 'error'), d.ok ? 'ok' : 'er');
+ await this.loadNotify();
+ } finally {
+ this.notifyTesting = false;
+ }
+ },
+ async offsiteBackup(file) {
+ const d = await this.rpc(`/api/backups/${encodeURIComponent(file)}/offsite`, {});
+ this.toast(d.msg || this.t(d.ok ? 'done' : 'error'), d.ok ? 'ok' : 'er');
+ this.loadBackups(this.bkStack);
},
async createBackup(stack) {
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/create`, {});
@@ -2478,14 +2674,19 @@ function app() {
await this.loadAppStore();
await this.loadUsers();
await this.loadNetworks();
+ await this.loadNotify();
+ },
+ // Meteen opslaan. autoSave() wacht bewust even zodat typen niet elk teken
+ // een verzoek oplevert, maar de testknop moet de nieuwe waarden gebruiken.
+ async saveSettings() {
+ if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
+ const body = { ...this.settings, LANGUAGE: this.settings.LANGUAGE, THEME: this.theme };
+ await this.req('/api/settings', { method:'PUT', json: body });
+ this.saved = true; setTimeout(() => this.saved = false, 1500);
},
autoSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
- this.saveTimer = setTimeout(async () => {
- const body = { ...this.settings, LANGUAGE: this.settings.LANGUAGE, THEME: this.theme };
- await this.req('/api/settings', { method:'PUT', json: body });
- this.saved = true; setTimeout(() => this.saved = false, 1500);
- }, 400);
+ this.saveTimer = setTimeout(() => this.saveSettings(), 400);
},
async restartSelf() {
if (!confirm('Server Up container herstarten?')) return;
diff --git a/server-up/translations/en.json b/server-up/translations/en.json
index 4bde881..8f65567 100644
--- a/server-up/translations/en.json
+++ b/server-up/translations/en.json
@@ -267,5 +267,35 @@
"last_week": "7 days",
"last_month": "30 days",
"audit_count": "{shown} of {total}",
- "load_more": "Load more"
+ "load_more": "Load more",
+ "backups_disk": "Disk space",
+ "disk_free_of": "free of",
+ "download": "Download",
+ "verify": "Verify",
+ "copy_offsite": "Copy to second location",
+ "backup_verified": "Verified and readable",
+ "backup_broken": "damaged",
+ "backup_verify": "Verify every new backup immediately",
+ "backup_offsite_dir": "Second location for backups",
+ "backup_offsite_hint": "Absolute path to a mounted disk, NFS or SMB share. Backups on the same disk as your data do not survive a disk failure.",
+ "disk_min_free": "Keep free (GB)",
+ "disk_warn_pct": "Warn below (%)",
+ "none": "None",
+ "notifications": "Notifications",
+ "notify_intro": "Scheduled backups and checks run unattended. Without notifications you only discover a failed backup at the moment you need it.",
+ "notify_channel": "Channel",
+ "notify_channel_off": "Off",
+ "notify_channel_webhook": "Webhook (Discord, Slack, …)",
+ "notify_channel_email": "Email",
+ "notify_url": "URL",
+ "notify_token": "Token (optional)",
+ "notify_events": "Notify about",
+ "notify_test": "Send test",
+ "notify_smtp_host": "SMTP server",
+ "notify_smtp_port": "Port",
+ "notify_smtp_security": "Security",
+ "notify_smtp_user": "Username",
+ "notify_smtp_password": "Password",
+ "notify_email_from": "From",
+ "notify_email_to": "To"
}
diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json
index 56970d2..49e2cd8 100644
--- a/server-up/translations/nl.json
+++ b/server-up/translations/nl.json
@@ -267,5 +267,35 @@
"last_week": "7 dagen",
"last_month": "30 dagen",
"audit_count": "{shown} van {total}",
- "load_more": "Meer laden"
+ "load_more": "Meer laden",
+ "backups_disk": "Schijfruimte",
+ "disk_free_of": "vrij van",
+ "download": "Downloaden",
+ "verify": "Controleren",
+ "copy_offsite": "Naar externe bestemming kopiëren",
+ "backup_verified": "Gecontroleerd en leesbaar",
+ "backup_broken": "beschadigd",
+ "backup_verify": "Elke nieuwe backup meteen controleren",
+ "backup_offsite_dir": "Tweede bestemming voor backups",
+ "backup_offsite_hint": "Absoluut pad naar een gemounte schijf, NFS- of SMB-share. Backups op dezelfde schijf als je data overleven geen defecte schijf.",
+ "disk_min_free": "Reserve vrijhouden (GB)",
+ "disk_warn_pct": "Waarschuwen onder (%)",
+ "none": "Geen",
+ "notifications": "Meldingen",
+ "notify_intro": "Geplande backups en controles draaien zonder dat er iemand kijkt. Zonder melding merk je een mislukte backup pas op het moment dat je hem nodig hebt.",
+ "notify_channel": "Kanaal",
+ "notify_channel_off": "Uit",
+ "notify_channel_webhook": "Webhook (Discord, Slack, …)",
+ "notify_channel_email": "E-mail",
+ "notify_url": "URL",
+ "notify_token": "Token (optioneel)",
+ "notify_events": "Waarover melden",
+ "notify_test": "Test versturen",
+ "notify_smtp_host": "SMTP-server",
+ "notify_smtp_port": "Poort",
+ "notify_smtp_security": "Beveiliging",
+ "notify_smtp_user": "Gebruikersnaam",
+ "notify_smtp_password": "Wachtwoord",
+ "notify_email_from": "Afzender",
+ "notify_email_to": "Ontvanger"
}
diff --git a/tests/test_backup_extra.py b/tests/test_backup_extra.py
new file mode 100644
index 0000000..9bac78d
--- /dev/null
+++ b/tests/test_backup_extra.py
@@ -0,0 +1,287 @@
+"""Backups: verifiëren, downloaden, extern wegzetten en de ruimtecontrole.
+
+Een backup die je nooit leest is een aanname, en een backup op dezelfde schijf
+als de data overleeft geen defecte schijf. Dit bestand test wat die twee
+aannames wegneemt.
+"""
+import gzip
+import sys
+import tarfile
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
+
+from conftest import login
+
+
+@pytest.fixture
+def bk(env, monkeypatch):
+ from core import backups, docker
+ env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
+ monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
+ monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
+ return backups
+
+
+def _maak_stack(env, naam="vaultwarden"):
+ d = env["lib"] / naam
+ d.mkdir(parents=True, exist_ok=True)
+ (d / "docker-compose.yml").write_text("services:\n app:\n image: nginx\n",
+ encoding="utf-8")
+ (d / ".env").write_text("PORT=8222\n", encoding="utf-8")
+ return d
+
+
+# ── Verifiëren ───────────────────────────────────────────────────────────────
+
+def test_nieuw_archief_is_meteen_geverifieerd(bk, env):
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+ assert meta.get("verified_ok") is True
+ assert meta.get("verified_ts")
+
+
+def test_verify_leest_het_hele_archief(bk, env):
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+ ok, msg = bk.verify(meta["file"])
+ assert ok is True
+ assert "bestanden" in msg
+
+
+def test_verify_ziet_een_afgekapt_archief(bk, env):
+ """Een archief dat halverwege is afgebroken ziet er in een lijst compleet
+ uit; de gzip-checksum staat aan het eind en valt dan pas door de mand."""
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+ pad = bk.backup_dir() / meta["file"]
+ ruw = pad.read_bytes()
+ pad.write_bytes(ruw[: len(ruw) // 2])
+
+ ok, msg = bk.verify(meta["file"])
+ assert ok is False
+ assert "beschadigd" in msg
+ assert bk.list_for("vaultwarden")[0]["verified_ok"] is False
+
+
+def test_verify_ziet_omgewisselde_bytes(bk, env):
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+ pad = bk.backup_dir() / meta["file"]
+ ruw = bytearray(pad.read_bytes())
+ ruw[len(ruw) // 2] ^= 0xFF
+ pad.write_bytes(bytes(ruw))
+ assert bk.verify(meta["file"])[0] is False
+
+
+def test_verify_weigert_een_leeg_archief(bk, env):
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+ pad = bk.backup_dir() / meta["file"]
+ with tarfile.open(pad, "w:gz"):
+ pass # geldig, maar zonder inhoud
+ ok, msg = bk.verify(meta["file"])
+ assert ok is False
+ assert "geen bestanden" in msg
+
+
+def test_verify_van_onbekend_bestand(bk, env):
+ ok, msg = bk.verify("bestaatniet_20260101_000000.tar.gz")
+ assert ok is False
+ assert "niet gevonden" in msg
+
+
+@pytest.mark.parametrize("naam", ["../../etc/passwd", "/etc/passwd", "geen-backup"])
+def test_verify_weigert_padtrucs(bk, naam):
+ with pytest.raises(bk.BackupError):
+ bk.verify(naam)
+
+
+def test_proefherstel_pakt_echt_uit_en_ruimt_op(bk, env):
+ """Alleen zo weet je dat terugzetten werkt vóórdat je het nodig hebt."""
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+ voor = set(p.name for p in bk.backup_dir().iterdir())
+
+ ok, msg = bk.verify(meta["file"], diep=True)
+ assert ok is True, msg
+ assert "uitgepakt" in msg
+ assert bk.list_for("vaultwarden")[0]["verified_deep"] is True
+ # Geen rommel achtergelaten.
+ assert set(p.name for p in bk.backup_dir().iterdir()) == voor
+
+
+def test_create_faalt_zichtbaar_bij_een_kapot_archief(bk, env, monkeypatch):
+ """Je wil kunnen zien wát er mis is, dus het archief blijft staan."""
+ _maak_stack(env)
+ monkeypatch.setattr(bk, "verify", lambda *a, **k: (False, "stuk"))
+ with pytest.raises(bk.BackupError) as exc:
+ bk.create("vaultwarden")
+ assert "niet te lezen" in str(exc.value)
+ # Het archief staat er nog, zodat je kunt kijken wat er mis is.
+ assert list(bk.backup_dir().glob("*.tar.gz"))
+
+
+def test_verificatie_uitzetten_kan(bk, env):
+ env["core"].patch({"BACKUP_VERIFY": False})
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+ assert "verified_ok" not in meta
+
+
+# ── Schijfruimte ─────────────────────────────────────────────────────────────
+
+def test_backup_wordt_geweigerd_bij_te_weinig_ruimte(bk, env, monkeypatch):
+ from core import diskspace
+ _maak_stack(env)
+ monkeypatch.setattr(diskspace.shutil, "disk_usage", lambda p: (1000, 999, 1))
+ with pytest.raises(bk.BackupError) as exc:
+ bk.create("vaultwarden")
+ assert "schijfruimte" in str(exc.value).lower()
+ # En er is geen half archief blijven staan.
+ assert not list(bk.backup_dir().glob("*.tar.gz"))
+
+
+# ── Externe kopie ────────────────────────────────────────────────────────────
+
+def test_kopieert_naar_de_externe_bestemming(bk, env):
+ extern = env["tmp"] / "extern"
+ env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+
+ assert (extern / meta["file"]).is_file()
+ assert (extern / meta["file"]).stat().st_size == (bk.backup_dir() / meta["file"]).stat().st_size
+ # De metadata gaat mee, anders weet je bij de kopie niet waar hij bij hoort.
+ assert list(extern.glob("*.json"))
+ assert bk.list_for("vaultwarden")[0]["offsite_ok"] is True
+
+
+def test_geen_half_bestand_bij_een_afgebroken_kopie(bk, env, monkeypatch):
+ """Een .part blijft herkenbaar onaf staan in plaats van een archief dat er
+ compleet uitziet."""
+ import shutil as sh
+ extern = env["tmp"] / "extern"
+ env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+
+ monkeypatch.setattr(bk.shutil, "copy2",
+ lambda *a, **k: (_ for _ in ()).throw(OSError("mount weg")))
+ ok, msg = bk.kopieer_extern(meta["file"])
+ assert ok is False
+ assert "mislukt" in msg
+
+
+def test_zelfde_map_is_geen_tweede_kopie(bk, env):
+ env["core"].patch({"BACKUP_OFFSITE_DIR": str(bk.backup_dir())})
+ assert bk.offsite_dir() is None
+
+
+def test_offsite_weigert_bij_te_weinig_ruimte_op_de_bestemming(bk, env, monkeypatch):
+ """Valt de mount weg, dan bestaat het pad vaak nog als lege map op de
+ systeemschijf — en vult de kopie ongemerkt de verkeerde schijf."""
+ from core import diskspace
+ extern = env["tmp"] / "extern"
+ _maak_stack(env)
+ meta = bk.create("vaultwarden")
+ env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
+ monkeypatch.setattr(diskspace.shutil, "disk_usage", lambda p: (1000, 999, 1))
+ ok, msg = bk.kopieer_extern(meta["file"])
+ assert ok is False
+ assert "ruimte" in msg
+
+
+def test_mislukte_externe_kopie_laat_de_backup_zelf_staan(bk, env, monkeypatch):
+ """De lokale backup is het belangrijkste; de kopie is een extra."""
+ extern = env["tmp"] / "extern"
+ env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
+ _maak_stack(env)
+ monkeypatch.setattr(bk, "kopieer_extern", lambda *a, **k: (False, "mount weg"))
+ meta = bk.create("vaultwarden")
+ assert (bk.backup_dir() / meta["file"]).is_file()
+
+
+# ── API ──────────────────────────────────────────────────────────────────────
+
+def test_downloaden(client, env, monkeypatch):
+ from core import backups, docker
+ monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
+ monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
+ env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
+ login(client)
+ _maak_stack(env)
+ meta = backups.create("vaultwarden")
+
+ r = client.get(f"/api/backups/{meta['file']}/download")
+ assert r.status_code == 200
+ assert r.headers["Content-Disposition"].startswith("attachment")
+ assert meta["file"] in r.headers["Content-Disposition"]
+ # En het is een echt, uitpakbaar archief.
+ assert gzip.decompress(r.data)[:100]
+
+
+@pytest.mark.parametrize("naam", ["../config.json", "..%2Fconfig.json", "onzin"])
+def test_download_weigert_padtrucs(client, env, naam):
+ login(client)
+ r = client.get(f"/api/backups/{naam}/download")
+ assert r.status_code in (400, 404), r.data
+
+
+def test_download_van_onbekend_bestand(client, env):
+ login(client)
+ r = client.get("/api/backups/weg_20260101_000000.tar.gz/download")
+ assert r.status_code == 404
+
+
+def test_download_vereist_een_sessie(anon_client):
+ r = anon_client.get("/api/backups/x_20260101_000000.tar.gz/download")
+ assert r.status_code == 401
+
+
+def test_offsite_endpoint(client, env, monkeypatch):
+ from core import backups, docker
+ monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
+ monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
+ env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
+ csrf = login(client)
+ _maak_stack(env)
+ meta = backups.create("vaultwarden")
+
+ extern = env["tmp"] / "extern"
+ client.put("/api/settings", json={"BACKUP_OFFSITE_DIR": str(extern)},
+ headers={"X-CSRF-Token": csrf})
+ r = client.post(f"/api/backups/{meta['file']}/offsite",
+ headers={"X-CSRF-Token": csrf})
+ assert r.status_code == 200, r.get_json()
+ assert (extern / meta["file"]).is_file()
+
+
+def test_offsite_zonder_bestemming_zegt_dat(client, env, monkeypatch):
+ from core import backups, docker
+ monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
+ monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
+ env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
+ csrf = login(client)
+ _maak_stack(env)
+ meta = backups.create("vaultwarden")
+ r = client.post(f"/api/backups/{meta['file']}/offsite",
+ headers={"X-CSRF-Token": csrf})
+ assert r.status_code == 400
+ assert "bestemming" in r.get_json()["msg"]
+
+
+def test_backuplijst_toont_de_schijfruimte(client, env):
+ login(client)
+ d = client.get("/api/backups").get_json()
+ assert d["disk"]["volumes"]
+ assert "BACKUP_OFFSITE_DIR" in d["settings"]
+
+
+def test_disk_endpoint(client, env):
+ login(client)
+ d = client.get("/api/disk").get_json()
+ assert d["volumes"][0]["free"] > 0
+ assert "low" in d
diff --git a/tests/test_diskspace.py b/tests/test_diskspace.py
new file mode 100644
index 0000000..331f91e
--- /dev/null
+++ b/tests/test_diskspace.py
@@ -0,0 +1,136 @@
+"""Schijfruimte: meten, drempels en het weigeren van een backup die niet past.
+
+Een volle schijf op een Docker-host breekt alles, niet alleen Server Up. En een
+backup die halverwege stopt omdat de ruimte op is, laat een archief achter dat
+er in een lijst compleet uitziet.
+"""
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
+
+
+@pytest.fixture
+def ds(env):
+ from core import diskspace
+ return diskspace
+
+
+def test_usage_geeft_bruikbare_getallen(ds, tmp_path):
+ u = ds.usage(tmp_path)
+ assert u["ok"] is True
+ assert u["total"] > 0
+ assert 0 <= u["pct_free"] <= 100
+ assert u["free_human"].endswith(("B", "KB", "MB", "GB", "TB"))
+
+
+def test_usage_werkt_voor_een_map_die_nog_niet_bestaat(ds, tmp_path):
+ """De backupmap wordt pas bij de eerste backup aangemaakt, maar we willen
+ de ruimte er vóór die tijd al van weten."""
+ u = ds.usage(tmp_path / "bestaat" / "nog" / "niet")
+ assert u["ok"] is True
+ assert u["total"] > 0
+
+
+def test_onmeetbaar_pad_blokkeert_niets(ds, monkeypatch, tmp_path):
+ """Kunnen we het niet meten, dan is tegenhouden erger dan doorlaten."""
+ def _stuk(_):
+ raise OSError("geen idee")
+ monkeypatch.setattr(ds.shutil, "disk_usage", _stuk)
+ assert ds.usage(tmp_path)["ok"] is False
+ ok, _ = ds.ruimte_voor_backup(tmp_path, tmp_path)
+ assert ok is True
+
+
+@pytest.mark.parametrize("bytes_,verwacht", [
+ (0, "0 B"), (512, "512 B"), (1536, "1.5 KB"),
+ (1024 ** 2, "1.0 MB"), (3 * 1024 ** 3, "3.0 GB"),
+])
+def test_leesbaar(ds, bytes_, verwacht):
+ assert ds.leesbaar(bytes_) == verwacht
+
+
+def test_mapgrootte_telt_alleen_bestanden(ds, tmp_path):
+ (tmp_path / "map").mkdir()
+ (tmp_path / "map" / "a.txt").write_bytes(b"x" * 100)
+ (tmp_path / "b.txt").write_bytes(b"y" * 50)
+ assert ds.mapgrootte(tmp_path) == 150
+
+
+def test_mapgrootte_volgt_geen_symlinks(ds, tmp_path):
+ """Een link naar /var zou de schatting onbruikbaar maken."""
+ groot = tmp_path / "groot.bin"
+ groot.write_bytes(b"z" * 1000)
+ doel = tmp_path / "map"
+ doel.mkdir()
+ (doel / "link").symlink_to(groot)
+ assert ds.mapgrootte(doel) == 0
+
+
+def test_mapgrootte_stopt_bij_de_limiet(ds, tmp_path):
+ for i in range(10):
+ (tmp_path / f"{i}.bin").write_bytes(b"x" * 1000)
+ # Zodra de limiet gepasseerd is hoeft hij niet door te tellen.
+ assert ds.mapgrootte(tmp_path, limiet_bytes=2000) <= 10000
+
+
+def test_ruimte_voor_backup_weigert_wat_niet_past(ds, env, monkeypatch, tmp_path):
+ bron = tmp_path / "stack"
+ bron.mkdir()
+ (bron / "data.bin").write_bytes(b"x" * 5000)
+ monkeypatch.setattr(ds.shutil, "disk_usage",
+ lambda p: (1000, 900, 100)) # 100 bytes vrij
+ ok, waarom = ds.ruimte_voor_backup(bron, tmp_path)
+ assert ok is False
+ assert "schijfruimte" in waarom.lower()
+
+
+def test_ruimte_voor_backup_houdt_de_reserve_vrij(ds, env, monkeypatch, tmp_path):
+ """Er is genoeg voor het archief zelf, maar niet voor archief + reserve."""
+ bron = tmp_path / "stack"
+ bron.mkdir()
+ (bron / "data.bin").write_bytes(b"x" * 1000)
+ env["core"].patch({"DISK_MIN_FREE_GB": 1})
+ monkeypatch.setattr(ds.shutil, "disk_usage",
+ lambda p: (10 * ds.GB, 9 * ds.GB, 5000))
+ ok, waarom = ds.ruimte_voor_backup(bron, tmp_path)
+ assert ok is False
+ assert "reserve" in waarom
+
+
+def test_zonder_reserve_mag_het_krap(ds, env, monkeypatch, tmp_path):
+ bron = tmp_path / "stack"
+ bron.mkdir()
+ (bron / "data.bin").write_bytes(b"x" * 1000)
+ env["core"].patch({"DISK_MIN_FREE_GB": 0})
+ monkeypatch.setattr(ds.shutil, "disk_usage", lambda p: (10000, 8000, 2000))
+ assert ds.ruimte_voor_backup(bron, tmp_path)[0] is True
+
+
+def test_drempel_markeert_krappe_schijf(ds, env, monkeypatch, tmp_path):
+ env["core"].patch({"DISK_MIN_FREE_GB": 0, "DISK_WARN_PCT": 20})
+ monkeypatch.setattr(ds.shutil, "disk_usage", lambda p: (1000, 900, 100))
+ assert ds.usage(tmp_path)["low"] is True
+ monkeypatch.setattr(ds.shutil, "disk_usage", lambda p: (1000, 100, 900))
+ assert ds.usage(tmp_path)["low"] is False
+
+
+def test_status_toont_elk_filesystem_een_keer(ds, env):
+ """Backups, stacks en appdata staan meestal op dezelfde schijf; drie
+ identieke regels helpen niemand."""
+ st = ds.status()
+ assert st["volumes"], st
+ sleutels = [(v["total"], v["free"]) for v in st["volumes"]]
+ assert len(sleutels) == len(set(sleutels))
+
+
+def test_status_neemt_de_externe_bestemming_mee(ds, env, tmp_path):
+ extern = tmp_path / "extern"
+ extern.mkdir()
+ env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
+ labels = [v["label"] for v in ds.status()["volumes"]]
+ # Zelfde filesystem als de rest in de test, dus hij mag samenvallen — maar
+ # de instelling mag nooit een fout opleveren.
+ assert labels
diff --git a/tests/test_notify.py b/tests/test_notify.py
new file mode 100644
index 0000000..ce580bb
--- /dev/null
+++ b/tests/test_notify.py
@@ -0,0 +1,263 @@
+"""Meldingen: kanalen, gebeurtenisfilter en de belofte dat send() nooit gooit.
+
+Het punt van dit onderdeel is dat je hoort dat er iets misging. Een melding die
+zelf een uitzondering veroorzaakt zou de taak die hem afvuurde alsnog laten
+omvallen — dan is de kuur erger dan de kwaal.
+"""
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
+
+from conftest import login
+
+
+@pytest.fixture
+def nt(env):
+ from core import notify
+ return notify
+
+
+@pytest.fixture
+def verzonden(nt, monkeypatch):
+ """Vangt uitgaande HTTP-verzoeken op in plaats van ze te versturen."""
+ opgevangen = []
+
+ def _nep(url, data, headers):
+ opgevangen.append({"url": url, "data": data, "headers": headers})
+ return True, "HTTP 200"
+
+ monkeypatch.setattr(nt, "_post", _nep)
+ return opgevangen
+
+
+# ── Kanaalkeuze ──────────────────────────────────────────────────────────────
+
+def test_zonder_kanaal_gebeurt_er_niets(nt, env, verzonden):
+ assert nt.ingeschakeld("backup_failed") is False
+ assert nt.send("backup_failed", "x", "y") is False
+ assert verzonden == []
+
+
+def test_ntfy_stuurt_titel_als_header(nt, env, verzonden):
+ env["core"].patch({"NOTIFY_CHANNEL": "ntfy",
+ "NOTIFY_URL": "https://ntfy.sh/mijn-topic"})
+ ok, _ = nt.verstuur("Backup mislukt", "de schijf is vol")
+ assert ok is True
+ v = verzonden[0]
+ assert v["url"] == "https://ntfy.sh/mijn-topic"
+ assert v["headers"]["Title"] == "Backup mislukt"
+ assert v["data"] == "de schijf is vol".encode()
+
+
+def test_ntfy_zet_hoge_prioriteit(nt, env, verzonden):
+ env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t"})
+ nt.verstuur("x", "y", prioriteit="hoog")
+ assert verzonden[0]["headers"]["Priority"] == "high"
+
+
+def test_token_gaat_mee_als_bearer(nt, env, verzonden):
+ env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
+ "NOTIFY_TOKEN": "tk_geheim"})
+ nt.verstuur("x", "y")
+ assert verzonden[0]["headers"]["Authorization"] == "Bearer tk_geheim"
+
+
+def test_webhook_bedient_discord_en_slack_tegelijk(nt, env, verzonden):
+ """Dezelfde instelling moet voor allebei werken zonder extra keuzemenu."""
+ import json
+ env["core"].patch({"NOTIFY_CHANNEL": "webhook",
+ "NOTIFY_URL": "https://voorbeeld.test/hook"})
+ nt.verstuur("Titel", "Tekst")
+ body = json.loads(verzonden[0]["data"])
+ assert "Titel" in body["content"] # Discord
+ assert "Titel" in body["text"] # Slack
+ assert body["message"] == "Tekst"
+
+
+def test_kanaal_zonder_url_meldt_dat(nt, env, verzonden):
+ env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": ""})
+ ok, msg = nt.verstuur("x", "y")
+ assert ok is False
+ assert "URL" in msg
+ assert verzonden == []
+
+
+def test_email_zonder_server_meldt_dat(nt, env):
+ env["core"].patch({"NOTIFY_CHANNEL": "email"})
+ ok, msg = nt.verstuur("x", "y")
+ assert ok is False
+ assert "SMTP" in msg
+
+
+def test_email_gaat_via_smtp(nt, env, monkeypatch):
+ verstuurd = {}
+
+ class NepSMTP:
+ def __init__(self, host, port, timeout=None):
+ verstuurd["host"], verstuurd["port"] = host, port
+ def __enter__(self):
+ return self
+ def __exit__(self, *a):
+ return False
+ def starttls(self, context=None):
+ verstuurd["tls"] = True
+ def login(self, u, p):
+ verstuurd["login"] = (u, p)
+ def send_message(self, m):
+ verstuurd["subject"] = m["Subject"]
+ verstuurd["to"] = m["To"]
+ verstuurd["body"] = m.get_content()
+
+ monkeypatch.setattr(nt.smtplib, "SMTP", NepSMTP)
+ env["core"].patch({"NOTIFY_CHANNEL": "email", "NOTIFY_EMAIL_HOST": "smtp.test",
+ "NOTIFY_EMAIL_PORT": 587, "NOTIFY_EMAIL_USER": "ik",
+ "NOTIFY_EMAIL_PASSWORD": "geheim",
+ "NOTIFY_EMAIL_TO": "ik@voorbeeld.test"})
+ ok, _ = nt.verstuur("Backup mislukt", "uitleg")
+ assert ok is True
+ assert verstuurd["host"] == "smtp.test"
+ assert verstuurd["tls"] is True
+ assert verstuurd["login"] == ("ik", "geheim")
+ assert "Backup mislukt" in verstuurd["subject"]
+ assert "uitleg" in verstuurd["body"]
+
+
+# ── Gebeurtenisfilter ────────────────────────────────────────────────────────
+
+def test_alleen_gekozen_gebeurtenissen(nt, env, verzonden):
+ env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
+ "NOTIFY_EVENTS": ["disk_low"]})
+ assert nt.send("backup_failed", "x", "y") is False
+ assert nt.send("disk_low", "x", "y") is True
+ assert len(verzonden) == 1
+
+
+def test_testbericht_negeert_het_filter(nt, env, verzonden):
+ """De testknop moet werken ook als je nog niets hebt aangevinkt."""
+ env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
+ "NOTIFY_EVENTS": []})
+ assert nt.verstuur("test", "test")[0] is True
+
+
+def test_onbekende_gebeurtenis_wordt_genegeerd(nt, env, verzonden):
+ env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
+ "NOTIFY_EVENTS": list(nt.EVENTS)})
+ assert nt.send("bestaat_niet", "x", "y") is False
+
+
+# ── Nooit doorgeven ──────────────────────────────────────────────────────────
+
+def test_send_gooit_nooit(nt, env, monkeypatch):
+ """Een kapot meldingskanaal mag de taak niet alsnog laten omvallen."""
+ env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t"})
+
+ def _ontploft(*a, **kw):
+ raise RuntimeError("netwerk stuk")
+
+ monkeypatch.setattr(nt, "_post", _ontploft)
+ assert nt.send("backup_failed", "x", "y") is False # geen exception
+
+
+def test_mislukte_melding_komt_in_het_auditlog(nt, env, monkeypatch):
+ from core import audit
+ audit.init()
+ env["core"].patch({"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t"})
+ monkeypatch.setattr(nt, "_post", lambda *a, **kw: (False, "HTTP 500"))
+ nt.send("backup_failed", "x", "y")
+ regels = audit.query()
+ assert any(r["src"] == "notify" and r["status"] == "fout" for r in regels), regels
+
+
+# ── API ──────────────────────────────────────────────────────────────────────
+
+def test_token_lekt_niet_via_de_api(client, env):
+ csrf = login(client)
+ client.put("/api/settings",
+ json={"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t",
+ "NOTIFY_TOKEN": "tk_geheim",
+ "NOTIFY_EMAIL_PASSWORD": "smtp-geheim"},
+ headers={"X-CSRF-Token": csrf})
+
+ body = client.get("/api/notify").get_data(as_text=True)
+ assert "tk_geheim" not in body
+ assert "smtp-geheim" not in body
+ assert '"has_token":true' in body.replace(" ", "")
+
+ instellingen = client.get("/api/settings").get_data(as_text=True)
+ assert "tk_geheim" not in instellingen
+ assert "smtp-geheim" not in instellingen
+ assert env["core"].load()["NOTIFY_TOKEN"] == "tk_geheim"
+
+
+def test_leeg_token_wist_het_bestaande_niet(client, env):
+ csrf = login(client)
+ client.put("/api/settings", json={"NOTIFY_TOKEN": "blijf-staan"},
+ headers={"X-CSRF-Token": csrf})
+ client.put("/api/settings", json={"NOTIFY_TOKEN": ""},
+ headers={"X-CSRF-Token": csrf})
+ assert env["core"].load()["NOTIFY_TOKEN"] == "blijf-staan"
+
+
+@pytest.mark.parametrize("waarden,melding", [
+ ({"NOTIFY_CHANNEL": "duiven"}, "meldingskanaal"),
+ ({"NOTIFY_EVENTS": ["bestaat_niet"]}, "gebeurtenis"),
+ ({"NOTIFY_EVENTS": "geen lijst"}, "gebeurtenis"),
+ ({"NOTIFY_EMAIL_SECURITY": "rot13"}, "SMTP"),
+ ({"NOTIFY_EMAIL_PORT": 70000}, "NOTIFY_EMAIL_PORT"),
+ ({"DISK_WARN_PCT": 150}, "DISK_WARN_PCT"),
+ ({"DISK_MIN_FREE_GB": "veel"}, "DISK_MIN_FREE_GB"),
+ ({"BACKUP_OFFSITE_DIR": "relatief/pad"}, "absoluut"),
+])
+def test_ongeldige_instellingen_worden_geweigerd(client, waarden, melding):
+ csrf = login(client)
+ r = client.put("/api/settings", json=waarden, headers={"X-CSRF-Token": csrf})
+ assert r.status_code == 400, r.get_json()
+ assert melding in r.get_json()["msg"]
+
+
+def test_geldige_instellingen_worden_bewaard(client, env):
+ csrf = login(client)
+ r = client.put("/api/settings",
+ json={"NOTIFY_CHANNEL": "webhook",
+ "NOTIFY_EVENTS": ["disk_low", "job_failed"],
+ "DISK_WARN_PCT": 15, "BACKUP_OFFSITE_DIR": "/mnt/backup"},
+ headers={"X-CSRF-Token": csrf})
+ assert r.status_code == 200, r.get_json()
+ c = env["core"].load()
+ assert c["NOTIFY_CHANNEL"] == "webhook"
+ assert c["DISK_WARN_PCT"] == 15
+ assert c["BACKUP_OFFSITE_DIR"] == "/mnt/backup"
+
+
+def test_testknop_meldt_dat_er_geen_kanaal_is(client, env):
+ csrf = login(client)
+ r = client.post("/api/notify/test", headers={"X-CSRF-Token": csrf})
+ assert r.status_code == 400
+ assert "kanaal" in r.get_json()["msg"]
+
+
+def test_testknop_verstuurt(client, env, monkeypatch):
+ from core import notify
+ monkeypatch.setattr(notify, "_post", lambda *a, **kw: (True, "HTTP 200"))
+ csrf = login(client)
+ client.put("/api/settings",
+ json={"NOTIFY_CHANNEL": "ntfy", "NOTIFY_URL": "https://ntfy.sh/t"},
+ headers={"X-CSRF-Token": csrf})
+ r = client.post("/api/notify/test", headers={"X-CSRF-Token": csrf})
+ assert r.status_code == 200, r.get_json()
+
+
+def test_meldingen_zijn_alleen_voor_beheerders(client):
+ csrf = login(client)
+ client.post("/api/auth/users",
+ json={"username": "op", "password": "eenlangwachtwoord",
+ "role": "operator"},
+ headers={"X-CSRF-Token": csrf})
+ op = client.post("/api/auth/login",
+ json={"username": "op", "password": "eenlangwachtwoord"}
+ ).get_json()["csrf_token"]
+ r = client.post("/api/notify/test", headers={"X-CSRF-Token": op})
+ assert r.status_code == 403