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
|
# v0.7.40-beta — Account en bereikbaarheid meteen goed
|
||||||
|
|
||||||
Na het installeren was je nog niet klaar: je moest zelf naar de webinterface om
|
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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [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_SCHEDULE_HOUR` | `3` | Rond welk uur de geplande ronde draait |
|
||||||
| `BACKUP_BEFORE_UPDATE` | `true` | Backup vóór het bijwerken van een stack |
|
| `BACKUP_BEFORE_UPDATE` | `true` | Backup vóór het bijwerken van een stack |
|
||||||
| `BACKUP_BEFORE_REMOVE` | `true` | Backup vóór het verwijderen van bestanden |
|
| `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))
|
sys.path.insert(0, str(APP))
|
||||||
|
|
||||||
import yaml
|
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
|
import core as cfg
|
||||||
from core import audit, jobs, i18n
|
from core import audit, jobs, i18n
|
||||||
from core import auth, docker, git, boilerplates, updater
|
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 scheduler, selfupdate
|
||||||
from core import stackupdates
|
from core import stackupdates
|
||||||
from core.modules import Module, CORE, discover
|
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/auth/users", "/api/auth/mode", "/api/settings", "/api/networks",
|
||||||
"/api/repos", "/api/modrepos", "/api/modules", "/api/wizard",
|
"/api/repos", "/api/modrepos", "/api/modules", "/api/wizard",
|
||||||
"/api/update/", "/api/audit/clear", "/api/docker/restart",
|
"/api/update/", "/api/audit/clear", "/api/docker/restart",
|
||||||
"/api/docker/images",
|
"/api/docker/images", "/api/notify/",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Uitzondering: je eigen wachtwoord wijzigen mag iedereen.
|
# Uitzondering: je eigen wachtwoord wijzigen mag iedereen.
|
||||||
|
|
@ -1578,10 +1579,11 @@ def api_backups():
|
||||||
stack = request.args.get("stack", "").strip()
|
stack = request.args.get("stack", "").strip()
|
||||||
lijst = backups.list_for(stack) if stack else backups.list_all()
|
lijst = backups.list_for(stack) if stack else backups.list_all()
|
||||||
c = cfg.load()
|
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",
|
k: c.get(k) for k in ("BACKUP_KEEP", "BACKUP_MAX_AGE_DAYS",
|
||||||
"BACKUP_SCHEDULE", "BACKUP_SCHEDULE_HOUR",
|
"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"])
|
@app.route("/api/backups/<stack>/create", methods=["POST"])
|
||||||
|
|
@ -1639,6 +1641,63 @@ def api_backup_restore(stack):
|
||||||
return jsonify(ok=True, job_id=jid)
|
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"])
|
@app.route("/api/backups/<path:bestand>", methods=["DELETE"])
|
||||||
def api_backup_delete(bestand):
|
def api_backup_delete(bestand):
|
||||||
try:
|
try:
|
||||||
|
|
@ -1650,6 +1709,52 @@ def api_backup_delete(bestand):
|
||||||
return jsonify(ok=ok, msg=msg), (200 if ok else 404)
|
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) ─────────────────────────────────────
|
# ── Netwerken (eigen IP-adres per stack) ─────────────────────────────────────
|
||||||
|
|
||||||
def _network_by_name(name: str) -> dict | None:
|
def _network_by_name(name: str) -> dict | None:
|
||||||
|
|
@ -2119,6 +2224,36 @@ def api_settings_put():
|
||||||
updates.pop(key)
|
updates.pop(key)
|
||||||
if (updates.get("UPDATE_CHANNEL") or "stable") not in updater.CHANNELS:
|
if (updates.get("UPDATE_CHANNEL") or "stable") not in updater.CHANNELS:
|
||||||
return jsonify(ok=False, msg="Onbekend update-kanaal"), 400
|
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"):
|
for key in ("APP_REPOS", "MODULE_REPOS"):
|
||||||
if key not in updates:
|
if key not in updates:
|
||||||
continue
|
continue
|
||||||
|
|
@ -2577,6 +2712,7 @@ def _geplande_backups() -> bool:
|
||||||
lib = Path(c["LIBRARY_DIR"]).resolve()
|
lib = Path(c["LIBRARY_DIR"]).resolve()
|
||||||
if not lib.is_dir():
|
if not lib.is_dir():
|
||||||
return False
|
return False
|
||||||
|
mislukt = []
|
||||||
for d in sorted(lib.iterdir()):
|
for d in sorted(lib.iterdir()):
|
||||||
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
|
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
|
||||||
continue
|
continue
|
||||||
|
|
@ -2587,10 +2723,90 @@ def _geplande_backups() -> bool:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [backup] {d.name} mislukt: {e}")
|
print(f" [backup] {d.name} mislukt: {e}")
|
||||||
audit.log("backups", "scheduled", "error", ref=d.name, detail=str(e))
|
audit.log("backups", "scheduled", "error", ref=d.name, detail=str(e))
|
||||||
|
mislukt.append(f"{d.name}: {e}")
|
||||||
backups.prune()
|
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
|
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:
|
def _check_stack_updates() -> bool:
|
||||||
"""Updatecheck voor alle stacks; altijd True zodat de scheduler afstempelt
|
"""Updatecheck voor alle stacks; altijd True zodat de scheduler afstempelt
|
||||||
(check_all geeft een lege dict terug als er geen stacks zijn)."""
|
(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("backups", _geplande_backups, interval_uren=0.5)
|
||||||
scheduler.register("stack_updates", _check_stack_updates,
|
scheduler.register("stack_updates", _check_stack_updates,
|
||||||
interval_uren=24.0, enabled_key="STACK_UPDATE_CHECK")
|
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():
|
def _boot_sync():
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,32 @@ DEFAULTS = {
|
||||||
"BACKUP_SCHEDULE_DAY": 0, # weekly: 0 = maandag … 6 = zondag
|
"BACKUP_SCHEDULE_DAY": 0, # weekly: 0 = maandag … 6 = zondag
|
||||||
"BACKUP_BEFORE_UPDATE": True,
|
"BACKUP_BEFORE_UPDATE": True,
|
||||||
"BACKUP_BEFORE_REMOVE": 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).
|
# Laatste uitvoering van geplande taken (beheerd door core.scheduler).
|
||||||
"SCHEDULER_LAST": {},
|
"SCHEDULER_LAST": {},
|
||||||
# Updatecheck voor de geïnstalleerde stacks (image-digests).
|
# 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
|
# Credentials die je wél mag instellen maar nooit terugkrijgt. Bij het opslaan
|
||||||
# betekent een lege waarde "ongewijzigd" — net als bij de git-tokens.
|
# 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"))
|
_path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json"))
|
||||||
_lock = threading.RLock()
|
_lock = threading.RLock()
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,12 @@ import json
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import core as cfg
|
import core as cfg
|
||||||
from core import docker
|
from core import diskspace, docker
|
||||||
|
|
||||||
SUFFIX = ".tar.gz"
|
SUFFIX = ".tar.gz"
|
||||||
META_SUFFIX = ".json"
|
META_SUFFIX = ".json"
|
||||||
|
|
@ -77,6 +78,12 @@ def create(stack: str, reden: str = "handmatig", log_fn=None) -> dict:
|
||||||
bd = backup_dir()
|
bd = backup_dir()
|
||||||
bd.mkdir(parents=True, exist_ok=True)
|
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")
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||||
naam = f"{stack}_{ts}{SUFFIX}"
|
naam = f"{stack}_{ts}{SUFFIX}"
|
||||||
doel = bd / naam
|
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(),
|
meta = {"stack": stack, "file": naam, "ts": time.time(),
|
||||||
"size": doel.stat().st_size, "reason": reden}
|
"size": doel.stat().st_size, "reason": reden}
|
||||||
doel.with_suffix("").with_suffix(META_SUFFIX).write_text(
|
_schrijf_meta(doel, meta)
|
||||||
json.dumps(meta, indent=2), encoding="utf-8")
|
|
||||||
if log_fn:
|
if log_fn:
|
||||||
log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})")
|
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
|
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]
|
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 ──────────────────────────────────────────────────────────────
|
# ── Terugzetten ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
|
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
|
continue # overgeslagen: niet afstempelen
|
||||||
_markeer(naam)
|
_markeer(naam)
|
||||||
gedraaid.append(naam)
|
gedraaid.append(naam)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# Eén kapotte taak mag de lus niet stoppen. Wél afstempelen, anders
|
# Eén kapotte taak mag de lus niet stoppen. Wél afstempelen, anders
|
||||||
# blijft een structureel falende taak elke tick opnieuw proberen.
|
# 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)
|
_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
|
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">
|
<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>
|
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('stack_update_check')"></span>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1016,17 +1130,62 @@ tailwind.config = {
|
||||||
<span class="mdi mdi-information-outline shrink-0"></span>
|
<span class="mdi mdi-information-outline shrink-0"></span>
|
||||||
<span x-text="t('backup_scope_warning')"></span>
|
<span x-text="t('backup_scope_warning')"></span>
|
||||||
</p>
|
</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">
|
<div class="space-y-2">
|
||||||
<template x-for="b in bkList" :key="b.file">
|
<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">
|
<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>
|
<span class="mdi mdi-package-variant-closed text-t2 dark:text-t2-dark"></span>
|
||||||
<div class="min-w-0 flex-1">
|
<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-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-text="fmtTime(b.ts)"></span> · <span x-text="b.size_human"></span>
|
||||||
<span x-show="b.reason">· <span x-text="b.reason"></span></span>
|
<span x-show="b.reason">· <span x-text="b.reason"></span></span>
|
||||||
|
<!-- Geverifieerd en extern weggezet: precies wat je wil weten
|
||||||
|
vóórdat je op deze backup moet vertrouwen. -->
|
||||||
|
<span x-show="b.verified_ok === true" class="text-ok" :title="t('backup_verified')">
|
||||||
|
· <span class="mdi mdi-shield-check"></span><span x-show="b.verified_deep" class="mdi mdi-check"></span>
|
||||||
|
</span>
|
||||||
|
<span x-show="b.verified_ok === false" class="text-wr" :title="b.verified_msg">
|
||||||
|
· <span class="mdi mdi-shield-alert"></span> <span x-text="t('backup_broken')">beschadigd</span>
|
||||||
|
</span>
|
||||||
|
<span x-show="b.offsite_ok" class="text-ok" :title="b.offsite_path">
|
||||||
|
· <span class="mdi mdi-cloud-check-outline"></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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)">
|
<button class="btn-sm !h-8" @click="restoreBackup(bkStack, b.file)">
|
||||||
<span class="mdi mdi-restore"></span> <span x-text="t('restore')">Terugzetten</span>
|
<span class="mdi mdi-restore"></span> <span x-text="t('restore')">Terugzetten</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1636,7 +1795,9 @@ function app() {
|
||||||
connectApps: [], sharedNetwork: 'serverup', fieldInfo: null,
|
connectApps: [], sharedNetwork: 'serverup', fieldInfo: null,
|
||||||
|
|
||||||
// Backups en updatestatus per stack
|
// 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,
|
stackUpd: {}, checkingUpd: false,
|
||||||
|
|
||||||
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
|
// 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));
|
const d = await this.rpc('/api/backups?stack=' + encodeURIComponent(stack));
|
||||||
this.bkList = d.backups || [];
|
this.bkList = d.backups || [];
|
||||||
this.bkSettings = d.settings || {};
|
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) {
|
async createBackup(stack) {
|
||||||
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/create`, {});
|
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/create`, {});
|
||||||
|
|
@ -2478,14 +2674,19 @@ function app() {
|
||||||
await this.loadAppStore();
|
await this.loadAppStore();
|
||||||
await this.loadUsers();
|
await this.loadUsers();
|
||||||
await this.loadNetworks();
|
await this.loadNetworks();
|
||||||
|
await this.loadNotify();
|
||||||
},
|
},
|
||||||
autoSave() {
|
// Meteen opslaan. autoSave() wacht bewust even zodat typen niet elk teken
|
||||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
// een verzoek oplevert, maar de testknop moet de nieuwe waarden gebruiken.
|
||||||
this.saveTimer = setTimeout(async () => {
|
async saveSettings() {
|
||||||
|
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
|
||||||
const body = { ...this.settings, LANGUAGE: this.settings.LANGUAGE, THEME: this.theme };
|
const body = { ...this.settings, LANGUAGE: this.settings.LANGUAGE, THEME: this.theme };
|
||||||
await this.req('/api/settings', { method:'PUT', json: body });
|
await this.req('/api/settings', { method:'PUT', json: body });
|
||||||
this.saved = true; setTimeout(() => this.saved = false, 1500);
|
this.saved = true; setTimeout(() => this.saved = false, 1500);
|
||||||
}, 400);
|
},
|
||||||
|
autoSave() {
|
||||||
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = setTimeout(() => this.saveSettings(), 400);
|
||||||
},
|
},
|
||||||
async restartSelf() {
|
async restartSelf() {
|
||||||
if (!confirm('Server Up container herstarten?')) return;
|
if (!confirm('Server Up container herstarten?')) return;
|
||||||
|
|
|
||||||
|
|
@ -267,5 +267,35 @@
|
||||||
"last_week": "7 days",
|
"last_week": "7 days",
|
||||||
"last_month": "30 days",
|
"last_month": "30 days",
|
||||||
"audit_count": "{shown} of {total}",
|
"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_week": "7 dagen",
|
||||||
"last_month": "30 dagen",
|
"last_month": "30 dagen",
|
||||||
"audit_count": "{shown} van {total}",
|
"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