v0.7.50-beta - meldingen, schijfruimte, backups controleren en extern wegzetten
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m22s

Het onbewaakte deel was de zwakke plek: geplande taken draaiden 's nachts en
een storing kwam alleen in het auditlog terecht.

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

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

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

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

NOTIFY_TOKEN en NOTIFY_EMAIL_PASSWORD zijn write-only.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-28 19:41:35 +02:00
parent 8e53b90615
commit 87fb9d19d1
17 changed files with 1983 additions and 23 deletions

View file

@ -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

View file

@ -1 +1 @@
0.7.40-beta
0.7.50-beta

View file

@ -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. |

View file

@ -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/<bestand>/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.
---

120
docs/meldingen.md Normal file
View file

@ -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.

View file

@ -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/<stack>/create", methods=["POST"])
@ -1639,6 +1641,63 @@ def api_backup_restore(stack):
return jsonify(ok=True, job_id=jid)
@app.route("/api/backups/<path:bestand>/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/<path:bestand>/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/<path:bestand>/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/<path:bestand>", 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():

View file

@ -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()

View file

@ -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]:

136
server-up/core/diskspace.py Normal file
View file

@ -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)}

179
server-up/core/notify.py Normal file
View file

@ -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

View file

@ -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

View file

@ -765,6 +765,120 @@ tailwind.config = {
<input type="checkbox" :checked="!!settings.STACK_UPDATE_CHECK" @change="settings.STACK_UPDATE_CHECK=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('stack_update_check')"></span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.BACKUP_VERIFY" @change="settings.BACKUP_VERIFY=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('backup_verify')"></span>
</label>
<div>
<label class="form-label" x-text="t('backup_offsite_dir')">Tweede bestemming</label>
<input class="form-input" placeholder="/mnt/backup" x-model="settings.BACKUP_OFFSITE_DIR" @change="autoSave()">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('backup_offsite_hint')"></p>
</div>
<div class="grid sm:grid-cols-2 gap-3 pt-1 border-t border-bd dark:border-bd-dark">
<div>
<label class="form-label" x-text="t('disk_min_free')">Reserve houden (GB)</label>
<input type="number" min="0" max="1024" class="form-input" x-model.number="settings.DISK_MIN_FREE_GB" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('disk_warn_pct')">Waarschuwen onder (%)</label>
<input type="number" min="0" max="99" class="form-input" x-model.number="settings.DISK_WARN_PCT" @change="autoSave()">
</div>
</div>
</div>
</div>
<!-- Meldingen: je hoort het als er 's nachts iets misgaat -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-bell-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('notifications')">Meldingen</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[11px] text-dm dark:text-dm-dark" x-text="t('notify_intro')"></p>
<div>
<label class="form-label" x-text="t('notify_channel')">Kanaal</label>
<select class="form-input" x-model="settings.NOTIFY_CHANNEL" @change="autoSave()">
<option value="" x-text="t('notify_channel_off')">Uit</option>
<option value="ntfy">ntfy</option>
<option value="webhook" x-text="t('notify_channel_webhook')">Webhook (Discord, Slack, …)</option>
<option value="email" x-text="t('notify_channel_email')">E-mail</option>
</select>
</div>
<template x-if="settings.NOTIFY_CHANNEL === 'ntfy' || settings.NOTIFY_CHANNEL === 'webhook'">
<div class="space-y-3">
<div>
<label class="form-label" x-text="t('notify_url')">URL</label>
<input class="form-input" placeholder="https://ntfy.sh/mijn-topic"
x-model="settings.NOTIFY_URL" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('notify_token')">Token (optioneel)</label>
<input type="password" class="form-input" :placeholder="notify.has_token ? '••••••••' : ''"
x-model="settings.NOTIFY_TOKEN" @change="autoSave()">
</div>
</div>
</template>
<template x-if="settings.NOTIFY_CHANNEL === 'email'">
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="form-label" x-text="t('notify_smtp_host')">SMTP-server</label>
<input class="form-input" x-model="settings.NOTIFY_EMAIL_HOST" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('notify_smtp_port')">Poort</label>
<input type="number" min="1" max="65535" class="form-input" x-model.number="settings.NOTIFY_EMAIL_PORT" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('notify_smtp_security')">Beveiliging</label>
<select class="form-input" x-model="settings.NOTIFY_EMAIL_SECURITY" @change="autoSave()">
<option value="starttls">STARTTLS</option>
<option value="ssl">SSL/TLS</option>
<option value="geen" x-text="t('none')">Geen</option>
</select>
</div>
<div>
<label class="form-label" x-text="t('notify_smtp_user')">Gebruikersnaam</label>
<input class="form-input" x-model="settings.NOTIFY_EMAIL_USER" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('notify_smtp_password')">Wachtwoord</label>
<input type="password" class="form-input" :placeholder="notify.has_email_password ? '••••••••' : ''"
x-model="settings.NOTIFY_EMAIL_PASSWORD" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('notify_email_from')">Afzender</label>
<input class="form-input" x-model="settings.NOTIFY_EMAIL_FROM" @change="autoSave()">
</div>
<div class="sm:col-span-2">
<label class="form-label" x-text="t('notify_email_to')">Ontvanger</label>
<input class="form-input" x-model="settings.NOTIFY_EMAIL_TO" @change="autoSave()">
</div>
</div>
</template>
<template x-if="settings.NOTIFY_CHANNEL">
<div class="space-y-3">
<div>
<div class="form-label" x-text="t('notify_events')">Waarover melden</div>
<div class="space-y-1.5">
<template x-for="(label, key) in notify.available" :key="key">
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="(settings.NOTIFY_EVENTS||[]).includes(key)"
@change="toggleNotifyEvent(key, $event.target.checked)"
class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="label"></span>
</label>
</template>
</div>
</div>
<button class="btn" @click="testNotify()" :disabled="notifyTesting">
<span class="mdi" :class="notifyTesting ? 'mdi-loading mdi-spin' : 'mdi-send-outline'"></span>
<span x-text="t('notify_test')">Test versturen</span>
</button>
</div>
</template>
</div>
</div>
@ -1016,17 +1130,62 @@ tailwind.config = {
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('backup_scope_warning')"></span>
</p>
<!-- Schijfruimte: hier is het relevant, want dit is waar het vol loopt -->
<template x-if="bkDisk.volumes && bkDisk.volumes.length">
<div class="mb-3 space-y-1.5">
<template x-for="v in bkDisk.volumes" :key="v.path">
<div class="px-3 py-2 rounded-xl border"
:class="v.low ? 'border-wr/40 bg-wr/5' : 'border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40'">
<div class="flex items-center gap-2 text-[11px]">
<span class="mdi" :class="v.low ? 'mdi-alert text-wr' : 'mdi-harddisk text-t2 dark:text-t2-dark'"></span>
<span class="text-tx dark:text-tx-dark" x-text="v.label"></span>
<span class="ml-auto text-dm dark:text-dm-dark">
<span x-text="v.free_human"></span> <span x-text="t('disk_free_of')">vrij van</span> <span x-text="v.total_human"></span>
</span>
</div>
<div class="mt-1.5 h-1.5 rounded-full bg-bd dark:bg-bd-dark overflow-hidden">
<div class="h-full rounded-full" :class="v.low ? 'bg-wr' : 'bg-ac dark:bg-ac-dark'"
:style="`width: ${Math.min(100, v.pct_used)}%`"></div>
</div>
</div>
</template>
</div>
</template>
<div class="space-y-2">
<template x-for="b in bkList" :key="b.file">
<div class="flex flex-wrap items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
<span class="mdi mdi-package-variant-closed text-t2 dark:text-t2-dark"></span>
<div class="min-w-0 flex-1">
<div class="text-xs font-mono text-tx dark:text-tx-dark truncate" x-text="b.file"></div>
<div class="text-[10px] text-dm dark:text-dm-dark">
<div class="text-[10px] text-dm dark:text-dm-dark flex flex-wrap items-center gap-x-1.5">
<span x-text="fmtTime(b.ts)"></span> · <span x-text="b.size_human"></span>
<span x-show="b.reason"> · <span x-text="b.reason"></span></span>
<span x-show="b.reason">· <span x-text="b.reason"></span></span>
<!-- Geverifieerd en extern weggezet: precies wat je wil weten
vóórdat je op deze backup moet vertrouwen. -->
<span x-show="b.verified_ok === true" class="text-ok" :title="t('backup_verified')">
· <span class="mdi mdi-shield-check"></span><span x-show="b.verified_deep" class="mdi mdi-check"></span>
</span>
<span x-show="b.verified_ok === false" class="text-wr" :title="b.verified_msg">
· <span class="mdi mdi-shield-alert"></span> <span x-text="t('backup_broken')">beschadigd</span>
</span>
<span x-show="b.offsite_ok" class="text-ok" :title="b.offsite_path">
· <span class="mdi mdi-cloud-check-outline"></span>
</span>
</div>
</div>
<a class="btn-sm !h-8 !px-2" :href="`/api/backups/${encodeURIComponent(b.file)}/download`"
:title="t('download')" download>
<span class="mdi mdi-download"></span>
</a>
<button class="btn-sm !h-8 !px-2" @click="verifyBackup(b.file, false)" :title="t('verify')">
<span class="mdi mdi-shield-search"></span>
</button>
<button class="btn-sm !h-8 !px-2" x-show="bkSettings.BACKUP_OFFSITE_DIR"
@click="offsiteBackup(b.file)" :title="t('copy_offsite')">
<span class="mdi mdi-cloud-upload-outline"></span>
</button>
<button class="btn-sm !h-8" @click="restoreBackup(bkStack, b.file)">
<span class="mdi mdi-restore"></span> <span x-text="t('restore')">Terugzetten</span>
</button>
@ -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;

View file

@ -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"
}

View file

@ -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"
}

287
tests/test_backup_extra.py Normal file
View file

@ -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

136
tests/test_diskspace.py Normal file
View file

@ -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

263
tests/test_notify.py Normal file
View file

@ -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