v0.7.50-beta - meldingen, schijfruimte, backups controleren en extern wegzetten
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m22s
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:
parent
8e53b90615
commit
87fb9d19d1
17 changed files with 1983 additions and 23 deletions
58
CHANGELOG.md
58
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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.7.40-beta
|
||||
0.7.50-beta
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
120
docs/meldingen.md
Normal 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.
|
||||
230
server-up/app.py
230
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/<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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
136
server-up/core/diskspace.py
Normal 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
179
server-up/core/notify.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<!-- 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();
|
||||
},
|
||||
autoSave() {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(async () => {
|
||||
// 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);
|
||||
}, 400);
|
||||
},
|
||||
autoSave() {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(() => this.saveSettings(), 400);
|
||||
},
|
||||
async restartSelf() {
|
||||
if (!confirm('Server Up container herstarten?')) return;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
287
tests/test_backup_extra.py
Normal 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
136
tests/test_diskspace.py
Normal 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
263
tests/test_notify.py
Normal 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
|
||||
Loading…
Reference in a new issue