Compare commits

...

2 commits

Author SHA1 Message Date
Ramon
ae99df2fc7 v0.5.20-beta - complete backups en updates per app
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 6s
Backups (core/backups.py, core/scheduler.py):
- Terugzetten met een klik: stack stoppen, huidige map opzij, uitpakken,
  starten. Mislukt het uitpakken, dan wordt de oude situatie teruggeplaatst.
- Bewaarbeleid: aantal per stack en/of maximale leeftijd; de nieuwste backup
  van een stack blijft altijd staan.
- Geplande backups (dagelijks/wekelijks) via een eigen planner in de app, geen
  cron. Een gemiste ronde loopt bij de eerstvolgende gelegenheid alsnog.
- Automatisch een backup voor het bijwerken of verwijderen van een stack.
  Mislukt die, dan gaat de actie door - anders kun je een kapotte stack niet
  meer opruimen.
- Uitpakken met tarfile + filter="data": absolute paden en ..-ingangen worden
  geweigerd. Met een kaal `tar xzf` kon een geprepareerd archief buiten de
  stackmap schrijven.
- Twee bugs in de oude implementatie: de returncode van tar werd genegeerd
  (mislukte backup gold als succes) en backups binnen dezelfde seconde
  overschreven elkaar.

Updates per app (core/stackupdates.py, core/registry.py):
- Badge op de stackkaart als er een nieuwer image is; bijwerken doet de
  bestaande update-knop.
- Vergelijking via de Registry API v2 (Docker-Content-Digest) in plaats van
  `docker manifest inspect`: dat laatste geeft per platform een aparte digest
  terwijl RepoDigests de manifest-list-digest bevat, wat bij elk multi-arch
  image permanent "update beschikbaar" zou opleveren.
- Drie statussen: update / current / unknown. Lokaal gebouwd, nog niet gepulld
  of registry onbereikbaar geeft unknown en dus geen badge.
- Dagelijkse achtergrondcheck, resultaten 6 uur gecached.

Verder:
- docs/backups.md, met nadruk op wat er niet in een backup zit: de
  Docker-volumes met de eigenlijke appdata.
- Backup-instellingen onder Instellingen; backup-geschiedenis per stack.
- 197 tests groen (40 nieuwe).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-26 19:54:51 +02:00
Ramon
fe7abce95b Vertalingen voor de nieuwe schermen + SSO-instelscherm
- De schermen die in v0.5.x zijn toegevoegd (login/setup, Beveiliging,
  Netwerken, Updates, containerpaneel, netwerkkeuze bij installeren) gebruikten
  harde Nederlandse teksten terwijl de rest van de UI via t() loopt. Op Engels
  gaf dat een mengelmoes. 80 nieuwe sleutels toegevoegd aan nl.json en en.json
  en alle literals vervangen.
- t() ondersteunt nu plaatshouders: t('port_taken', {port: 8080, suggested:
  8081}). Zo blijven zinnen heel in plaats van in losse stukjes geknipt.
- Nieuw instelscherm voor reverse-proxy-SSO onder Instellingen -> Beveiliging:
  modus lokaal/proxy/beide, identiteitsheader en de lijst met vertrouwde
  proxy-adressen. De backend (PUT /api/auth/mode) bestond al maar was alleen
  met curl te bereiken. Bij het omschakelen naar 'proxy' vraagt de UI om
  bevestiging, want die modus schakelt de lokale login uit.
- Nieuw tests/test_i18n.py: sleutelpariteit tussen nl en en, geen lege
  waarden, gelijke plaatshouders in beide talen, en een controle dat de UI
  geen t()-sleutels gebruikt die nergens gedefinieerd zijn. Die laatste vond
  meteen een fout in de eerste opzet.
- 157 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-26 19:43:30 +02:00
16 changed files with 1996 additions and 119 deletions

View file

@ -1,3 +1,60 @@
# v0.5.20-beta — Vertalingen, SSO-scherm, complete backups en app-updates
## 🌍 Vertalingen en SSO
- De schermen uit v0.5.x (login, Beveiliging, Netwerken, Updates,
containerpaneel) gebruikten harde Nederlandse teksten terwijl de rest van de
interface via `t()` loopt. Op Engels gaf dat een mengelmoes. Nu volledig
vertaald: 208 sleutels in `nl` en `en`.
- `t()` ondersteunt plaatshouders: `t('port_taken', {port: 8080})`. Zo blijven
zinnen heel in plaats van in losse stukjes geknipt.
- Nieuw instelscherm voor **reverse-proxy-SSO** onder Instellingen →
Beveiliging: modus lokaal/proxy/beide, identiteitsheader en de vertrouwde
proxy-adressen. De backend bestond al maar was alleen met `curl` te bereiken.
- Nieuwe test bewaakt dat `nl` en `en` dezelfde sleutels houden, dat de
plaatshouders in beide talen gelijk zijn, en dat de interface geen sleutels
gebruikt die nergens gedefinieerd staan.
## 💾 Backups compleet
Backup was één `tar`-commando waarvan de returncode genegeerd werd — een
mislukte backup gold als succes, en terugzetten kon alleen met de hand.
- **Terugzetten** met één klik: stack stoppen, huidige map opzij, uitpakken,
starten. Mislukt het uitpakken, dan komt de oude situatie terug.
- **Bewaarbeleid**: aantal per stack en/of maximale leeftijd. De nieuwste
backup van een stack blijft altijd staan.
- **Geplande backups**, dagelijks of wekelijks, via een eigen planner in de
applicatie (geen cron). Een gemiste ronde loopt bij de eerstvolgende
gelegenheid alsnog.
- **Automatisch een backup vóór het bijwerken of verwijderen** van een stack.
Mislukt die, dan gaat de actie door — anders kun je een kapotte stack niet
meer opruimen.
- Uitpakken gebeurt met `tarfile` en `filter="data"`: absolute paden en
`..`-ingangen worden geweigerd. Met een kaal `tar xzf` kon een geprepareerd
archief buiten de stackmap schrijven.
- Backups binnen dezelfde seconde overschreven elkaar; namen krijgen nu een
teller.
- `docs/backups.md` legt uit wat er wél en niet in zit — de compose- en
`.env`-bestanden, **niet** de Docker-volumes met de eigenlijke appdata.
## 📦 Updates per app
- Server Up controleert nu ook of er nieuwere images zijn voor de
geïnstalleerde stacks, met een badge op de stackkaart en een knop om te
controleren. Bijwerken doet de bestaande update-knop.
- De vergelijking loopt via de Registry API v2 (`Docker-Content-Digest`), niet
via `docker manifest inspect`: dat laatste geeft per platform een aparte
digest terug terwijl `RepoDigests` de digest van de manifest-list bevat, wat
bij elk multi-arch image permanent "update beschikbaar" zou opleveren.
- Drie statussen per service — `update`, `current` en `unknown`. Een lokaal
gebouwd image, een nog niet gepulld image of een onbereikbare registry levert
`unknown` op en dus géén badge, zodat er nooit ten onrechte een update wordt
gemeld.
- Dagelijkse achtergrondcheck via dezelfde planner; resultaten 6 uur gecached.
---
# v0.5.10-beta — Update-systeem met kanalen en één-klik bijwerken # v0.5.10-beta — Update-systeem met kanalen en één-klik bijwerken
## 🔄 Kanalen ## 🔄 Kanalen

View file

@ -1 +1 @@
0.5.10-beta 0.5.20-beta

138
docs/backups.md Normal file
View file

@ -0,0 +1,138 @@
# Backups
Server Up maakt backups van je stacks: handmatig, automatisch vóór risicovolle
acties, of op een schema. Terugzetten kan met één klik.
---
## ⚠️ Lees dit eerst: wat zit er wél en niet in
Een backup is een `.tar.gz` van de **stackmap** in je library. Daar zitten in:
- het compose-bestand
- het `.env`-bestand (met je wachtwoorden en tokens)
- eventuele configuratiebestanden die naast de compose staan
- `.serverup.json` met de metadata van de installatie
Wat er **niet** in zit: de Docker-volumes met de eigenlijke gegevens van de app.
| Voorbeeld | In de backup? |
|---|---|
| De compose van Vaultwarden | ✅ |
| Je `ADMIN_TOKEN` uit `.env` | ✅ |
| De wachtwoordkluis van Vaultwarden zelf | ❌ |
| De database van Paperless | ❌ |
| Je foto's in Immich | ❌ |
Je kunt hiermee dus een stack terugzetten zoals hij geconfigureerd stond, maar
niet de inhoud ervan herstellen. Wil je dat ook, dan zijn er twee routes:
1. **Bind mounts in plaats van named volumes.** Staat je data in
`/opt/serverup/appdata/<app>` en zit die map binnen je stackmap, dan gaat hij
wél mee. De meeste templates in `apps/` gebruiken `data_dir` daarvoor.
2. **Een aparte volume-backup** met bijvoorbeeld `restic` of `borg` naast Server
Up. Voor grote datasets is dat sowieso beter dan een tar per keer.
> Omdat een backup je `.env` bevat, staan er wachtwoorden in. `BACKUP_DIR` hoort
> dus dezelfde bescherming te hebben als de rest van `/data`.
---
## Een backup maken
**Per stack** — de archiefknop op de stackkaart maakt er direct een. De
geschiedenisknop ernaast toont alle backups van die stack, met de mogelijkheid
om terug te zetten of te verwijderen.
**Automatisch vóór risicovolle acties** — standaard aan voor:
- **bijwerken** van een stack (`compose pull` + `up`), want een nieuwe
image-versie kan een compose-wijziging nodig hebben;
- **verwijderen** van een stack, maar alleen als je "verwijder ook de bestanden"
kiest — bij het enkel stoppen van containers valt er niets te verliezen.
Uit te zetten onder **Instellingen → Backups**. Mislukt zo'n automatische
backup, dan gaat de actie alsnog door: anders zou je een kapotte stack niet meer
kunnen opruimen. De melding komt wel in het joblog.
**Gepland** — dagelijks of wekelijks, rond een uur dat je zelf kiest. De
planner draait in de applicatie zelf (geen cron nodig) en onthoudt in
`config.json` wanneer hij voor het laatst gelopen heeft. Stond de server uit op
het geplande moment, dan loopt de backup bij de eerstvolgende gelegenheid alsnog.
---
## Bewaarbeleid
Twee regels, die allebei tegelijk gelden:
| Instelling | Betekenis |
|---|---|
| `BACKUP_KEEP` | Hoeveel backups per stack je bewaart. `0` = onbeperkt. |
| `BACKUP_MAX_AGE_DAYS` | Alles ouder dan dit aantal dagen gaat weg. `0` = geen limiet. |
**De nieuwste backup van een stack blijft altijd staan**, ook als hij ouder is
dan de maximale leeftijd. Anders sta je na een lange vakantie met lege handen.
Opruimen gebeurt na elke nieuwe backup van die stack, en na een geplande ronde.
---
## Terugzetten
De geschiedenisknop op de stackkaart → **Terugzetten**. Wat er dan gebeurt:
```
1. docker compose down stack stoppen
2. huidige map opzij zetten als <stack>.restore-<tijdstempel>
3. archief uitpakken
4. opzijgezette map weggooien
5. docker compose up -d stack starten
```
Gaat stap 3 mis — een beschadigd archief, of een archief dat buiten de stackmap
probeert te schrijven — dan wordt de opzijgezette map teruggeplaatst. Je raakt
je stack dus niet kwijt aan een kapotte backup.
Een backup van stack A kun je niet over stack B heen zetten; dat wordt geweigerd
op basis van de metadata.
### Veiligheid
Archieven worden uitgepakt met Pythons `tarfile` en `filter="data"`. Dat weigert
absolute paden, `..`-ingangen, symlinks die buiten de map wijzen en
apparaatbestanden. Met een kaal `tar xzf` zou een geprepareerd archief bestanden
elders op de host kunnen neerzetten.
---
## Instellingen
| Sleutel | Standaard | Betekenis |
|---|---|---|
| `BACKUP_DIR` | `/opt/serverup/backups` | Waar de archieven komen |
| `BACKUP_KEEP` | `5` | Aantal per stack |
| `BACKUP_MAX_AGE_DAYS` | `0` | Maximale leeftijd in dagen |
| `BACKUP_SCHEDULE` | `off` | `off` / `daily` / `weekly` |
| `BACKUP_SCHEDULE_HOUR` | `3` | Rond welk uur de geplande ronde draait |
| `BACKUP_BEFORE_UPDATE` | `true` | Backup vóór het bijwerken van een stack |
| `BACKUP_BEFORE_REMOVE` | `true` | Backup vóór het verwijderen van bestanden |
---
## Handmatig
De archieven zijn gewone tarballs — je hebt Server Up niet nodig om erbij te
kunnen:
```bash
# Bekijken wat erin zit
tar tzf /opt/serverup/backups/vaultwarden_20260726_030000.tar.gz
# Ergens anders uitpakken
tar xzf /opt/serverup/backups/vaultwarden_20260726_030000.tar.gz -C /tmp/herstel
```
Naast elk archief staat een `.json` met de stacknaam, het tijdstip, de grootte
en de reden (handmatig, gepland, voor-update, voor-remove). Ontbreekt dat
bestand, dan leidt Server Up de gegevens af uit de bestandsnaam.

View file

@ -15,7 +15,8 @@ from flask import Flask, Response, jsonify, render_template, request, 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 compose_transform, networks, selfupdate from core import backups, compose_transform, networks, scheduler, selfupdate
from core import stackupdates
from core.modules import Module, CORE, discover from core.modules import Module, CORE, discover
app = Flask(__name__, static_folder="static", template_folder="templates") app = Flask(__name__, static_folder="static", template_folder="templates")
@ -507,6 +508,23 @@ def api_stacks():
return jsonify(out) return jsonify(out)
def _auto_backup(qq, stack: str, config_key: str, reden: str):
"""Backup vóór een actie die bestanden overschrijft of weggooit.
Faalt de backup, dan gaat de actie wél door anders kun je een kapotte
stack niet meer opruimen. De melding komt wel in het joblog.
"""
if not cfg.load().get(config_key):
return
try:
meta = backups.create(stack, reden=reden,
log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok", f"Backup vooraf: {meta['file']}")
backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m))
except Exception as e:
jobs.log(qq, "warn", f"Backup vooraf mislukt ({e}) — actie gaat door")
def _stack_dir(name: str) -> tuple[Path | None, Path | None]: def _stack_dir(name: str) -> tuple[Path | None, Path | None]:
"""Resolve een stacknaam naar een map binnen LIBRARY_DIR. """Resolve een stacknaam naar een map binnen LIBRARY_DIR.
@ -551,10 +569,13 @@ def api_stack_action(name, action):
docker.compose_down(pdir, log_fn=lf, name=name) docker.compose_down(pdir, log_fn=lf, name=name)
rc = docker.compose_up(pdir, log_fn=lf, name=name) rc = docker.compose_up(pdir, log_fn=lf, name=name)
elif action == "update": elif action == "update":
_auto_backup(qq, name, "BACKUP_BEFORE_UPDATE", "voor-update")
docker.compose_pull(pdir, log_fn=lf) docker.compose_pull(pdir, log_fn=lf)
rc = docker.compose_up(pdir, log_fn=lf, name=name) rc = docker.compose_up(pdir, log_fn=lf, name=name)
elif action == "remove": elif action == "remove":
mode = body.get("mode", "stack") mode = body.get("mode", "stack")
if mode in ("full", "all"):
_auto_backup(qq, name, "BACKUP_BEFORE_REMOVE", "voor-remove")
if docker.has_compose(pdir): if docker.has_compose(pdir):
docker.compose_down(pdir, log_fn=lf, volumes=True, name=name) docker.compose_down(pdir, log_fn=lf, volumes=True, name=name)
if mode in ("full", "all") and pdir.is_dir(): if mode in ("full", "all") and pdir.is_dir():
@ -566,14 +587,12 @@ def api_stack_action(name, action):
shutil.rmtree(dd) shutil.rmtree(dd)
jobs.log(qq, "ok", "Data verwijderd") jobs.log(qq, "ok", "Data verwijderd")
elif action == "backup": elif action == "backup":
bd = Path(cfg.load()["BACKUP_DIR"]).resolve() # Ging eerder via een tar-subprocess waarvan de returncode
bd.mkdir(parents=True, exist_ok=True) # genegeerd werd — een mislukte backup gold dan als succes.
ts = time.strftime("%Y%m%d_%H%M%S") meta = backups.create(name, reden="handmatig",
safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name) log_fn=lambda m: jobs.log(qq, "dim", m))
subprocess.run(["tar", "czf", str(bd / f"{safe_name}_{ts}.tar.gz"), jobs.log(qq, "ok", f"Backup → {meta['file']} ({meta['size']} bytes)")
"-C", str(pdir.parent), name], backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m))
capture_output=True, timeout=300)
jobs.log(qq, "ok", f"Backup → {bd / f'{safe_name}_{ts}.tar.gz'}")
jobs.log(qq, "ok" if rc == 0 else "error", jobs.log(qq, "ok" if rc == 0 else "error",
f"{action} {'klaar' if rc == 0 else 'mislukt'}") f"{action} {'klaar' if rc == 0 else 'mislukt'}")
audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name) audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name)
@ -1170,6 +1189,117 @@ def api_store_install():
return jsonify(ok=True, job_id=jid) return jsonify(ok=True, job_id=jid)
# ── Updates van geïnstalleerde stacks ────────────────────────────────────────
@app.route("/api/stacks/updates")
def api_stack_updates():
"""Bekende updatestatus per stack. `?force=1` doet een verse check."""
force = request.args.get("force") in ("1", "true", "yes")
if force:
return jsonify(ok=True, stacks=stackupdates.check_all(force=True))
return jsonify(ok=True, stacks=stackupdates.cached())
@app.route("/api/stacks/updates/check", methods=["POST"])
def api_stack_updates_check():
"""Verse check als job, want dit doet een netwerkverzoek per image."""
jid, q = jobs.create("stack-updates")
def _run(jid):
qq = jobs.get_queue(jid)
jobs.log(qq, "section", "Updates controleren")
res = stackupdates.check_all(force=True,
log_fn=lambda m: jobs.log(qq, "dim", m))
met_update = [n for n, r in res.items() if r.get("update_available")]
jobs.log(qq, "ok", f"{len(met_update)} van {len(res)} stacks heeft een update"
+ (f": {', '.join(met_update)}" if met_update else ""))
jobs.finish(jid, "done")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
# ── Backups ───────────────────────────────────────────────────────────────────
@app.route("/api/backups")
def api_backups():
stack = request.args.get("stack", "").strip()
lijst = backups.list_for(stack) if stack else backups.list_all()
c = cfg.load()
return jsonify(backups=lijst, settings={
k: c.get(k) for k in ("BACKUP_KEEP", "BACKUP_MAX_AGE_DAYS",
"BACKUP_SCHEDULE", "BACKUP_SCHEDULE_HOUR",
"BACKUP_BEFORE_UPDATE", "BACKUP_BEFORE_REMOVE")})
@app.route("/api/backups/<stack>/create", methods=["POST"])
def api_backup_create(stack):
jid, q = jobs.create(f"backup:{stack}")
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", f"Backup: {stack}")
meta = backups.create(stack, reden="handmatig",
log_fn=lambda m: jobs.log(qq, "dim", m))
for weg in backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m)):
pass
jobs.log(qq, "ok", f"Backup klaar ({meta['file']})")
audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr)
jobs.finish(jid, "done")
except backups.BackupError as e:
jobs.log(qq, "error", str(e))
audit.log("backups", "create", "error", ref=stack, detail=str(e))
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
@app.route("/api/backups/<stack>/restore", methods=["POST"])
def api_backup_restore(stack):
bestand = ((request.json or {}).get("file") or "").strip()
if not bestand:
return jsonify(ok=False, msg="Geen backup opgegeven"), 400
try:
backups._archive_path(bestand) # vroege validatie
except backups.BackupError as e:
return jsonify(ok=False, msg=str(e)), 400
jid, q = jobs.create(f"restore:{stack}")
def _run(jid):
qq = jobs.get_queue(jid)
try:
jobs.log(qq, "section", f"Terugzetten: {stack}")
ok, msg = backups.restore(stack, bestand,
log_fn=lambda m: jobs.log(qq, "dim", m))
jobs.log(qq, "ok" if ok else "error", msg)
audit.log("backups", "restore", "ok" if ok else "error", ref=stack,
detail=bestand)
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>", methods=["DELETE"])
def api_backup_delete(bestand):
try:
ok, msg = backups.delete(bestand)
except backups.BackupError as e:
return jsonify(ok=False, msg=str(e)), 400
if ok:
audit.log("backups", "delete", "ok", ref=bestand, ip=request.remote_addr)
return jsonify(ok=ok, msg=msg), (200 if ok else 404)
# ── 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:
@ -1981,6 +2111,41 @@ def _apply_boot_repos():
audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url")) audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url"))
def _geplande_backups():
"""Backup van alle stacks, aangestuurd door de scheduler."""
c = cfg.load()
plan = (c.get("BACKUP_SCHEDULE") or "off").lower()
if plan not in ("daily", "weekly"):
return
if not scheduler.binnen_venster(int(c.get("BACKUP_SCHEDULE_HOUR") or 3)):
return
lib = Path(c["LIBRARY_DIR"]).resolve()
if not lib.is_dir():
return
for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
continue
try:
meta = backups.create(d.name, reden=f"gepland-{plan}")
print(f" [backup] {meta['file']}")
audit.log("backups", "scheduled", "ok", ref=d.name)
except Exception as e:
print(f" [backup] {d.name} mislukt: {e}")
audit.log("backups", "scheduled", "error", ref=d.name, detail=str(e))
backups.prune()
def _registreer_taken():
"""Periodieke taken aanmelden bij de scheduler."""
c = cfg.load()
# Dagelijks/wekelijks; de venstercheck in _geplande_backups bepaalt het uur.
uren = 24.0 if (c.get("BACKUP_SCHEDULE") or "off") != "weekly" else 24.0 * 7
scheduler.register("backups", _geplande_backups, interval_uren=uren)
scheduler.register("stack_updates",
lambda: stackupdates.check_all(force=True),
interval_uren=24.0, enabled_key="STACK_UPDATE_CHECK")
def _boot_sync(): def _boot_sync():
"""Sync alle geconfigureerde repos bij elke start (achtergrond-thread). """Sync alle geconfigureerde repos bij elke start (achtergrond-thread).
@ -2033,6 +2198,10 @@ if __name__ == "__main__":
if auth.needs_setup(): if auth.needs_setup():
print("\n⚠ Nog geen account — open de webinterface en maak er één aan.") print("\n⚠ Nog geen account — open de webinterface en maak er één aan.")
print(" Tot dat moment kan iedereen die deze poort bereikt het account claimen.\n") print(" Tot dat moment kan iedereen die deze poort bereikt het account claimen.\n")
print("Geplande taken:")
_registreer_taken()
scheduler.start()
print(" backups + updatecheck geregistreerd")
print("Boot sync (achtergrond):") print("Boot sync (achtergrond):")
jobs.run(_boot_sync) jobs.run(_boot_sync)
print(f"\n→ http://localhost:{port}\n") print(f"\n→ http://localhost:{port}\n")

View file

@ -56,6 +56,18 @@ DEFAULTS = {
"AUTO_SYNC_ON_BOOT": False, "AUTO_SYNC_ON_BOOT": False,
# Macvlan/ipvlan-netwerken waarmee stacks een eigen IP-adres krijgen. # Macvlan/ipvlan-netwerken waarmee stacks een eigen IP-adres krijgen.
"NETWORKS": [], "NETWORKS": [],
# Backups. KEEP=0 of MAX_AGE=0 schakelt die regel uit; de nieuwste backup
# van een stack wordt nooit opgeruimd.
"BACKUP_KEEP": 5,
"BACKUP_MAX_AGE_DAYS": 0,
"BACKUP_SCHEDULE": "off", # off | daily | weekly
"BACKUP_SCHEDULE_HOUR": 3,
"BACKUP_BEFORE_UPDATE": True,
"BACKUP_BEFORE_REMOVE": True,
# Laatste uitvoering van geplande taken (beheerd door core.scheduler).
"SCHEDULER_LAST": {},
# Updatecheck voor de geïnstalleerde stacks (image-digests).
"STACK_UPDATE_CHECK": True,
} }
# Sleutels die de client noch mag lezen noch mag schrijven; AUTH bevat # Sleutels die de client noch mag lezen noch mag schrijven; AUTH bevat

267
server-up/core/backups.py Normal file
View file

@ -0,0 +1,267 @@
"""Backups van stacks: maken, tonen, terugzetten en opruimen.
Een backup is een `.tar.gz` van de stackmap plus een `.json` met metadata
ernaast. Dat betekent: **de compose- en `.env`-bestanden zitten erin, de
Docker-volumes met de eigenlijke appdata niet.** Zie docs/backups.md dat
verschil is het belangrijkste dat een gebruiker moet weten.
Terugzetten gebeurt met Pythons `tarfile` en `filter="data"` (PEP 706): dat
weigert absolute paden en `..` in archiefingangen. Met een kaal `tar xzf` kan
een geprepareerd archief buiten de stackmap schrijven.
"""
from __future__ import annotations
import json
import re
import shutil
import tarfile
import time
from pathlib import Path
import core as cfg
from core import docker
SUFFIX = ".tar.gz"
META_SUFFIX = ".json"
# <stack>_<JJJJMMDD>_<UUMMSS>[-n].tar.gz — ruimer dan cfg.safe_name() omdat de
# tijdstempel er nog achter komt, maar even streng op padtekens.
_BACKUP_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,120}_\d{8}_\d{6}(-\d+)?$")
class BackupError(Exception):
pass
# ── Paden ────────────────────────────────────────────────────────────────────
def backup_dir() -> Path:
return Path(cfg.load()["BACKUP_DIR"]).resolve()
def _archive_path(bestandsnaam: str) -> Path:
"""Valideer een backupnaam en geef het volledige pad.
De naam komt uit een request, dus hij mag nooit uit BACKUP_DIR wijzen.
"""
naam = (bestandsnaam or "").strip()
if not naam.endswith(SUFFIX):
raise BackupError("Geen geldige backupnaam.")
kern = naam[: -len(SUFFIX)]
if not _BACKUP_RE.match(kern):
raise BackupError("Geen geldige backupnaam.")
bd = backup_dir()
pad = (bd / naam).resolve()
if not pad.is_relative_to(bd):
raise BackupError("Geen geldige backupnaam.")
return pad
def _stack_dir(stack: str) -> Path:
if not cfg.safe_name(stack):
raise BackupError("Ongeldige stacknaam.")
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / stack).resolve()
if not d.is_relative_to(lib) or d == lib:
raise BackupError("Ongeldige stacknaam.")
return d
# ── Maken ────────────────────────────────────────────────────────────────────
def create(stack: str, reden: str = "handmatig", log_fn=None) -> dict:
"""Maak een backup van de stackmap. Retourneert de metadata."""
bron = _stack_dir(stack)
if not bron.is_dir():
raise BackupError(f"Stack '{stack}' bestaat niet.")
bd = backup_dir()
bd.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
naam = f"{stack}_{ts}{SUFFIX}"
doel = bd / naam
# Twee backups binnen dezelfde seconde (bv. handmatig vlak na een
# automatische backup vóór een update) zouden elkaar overschrijven.
teller = 2
while doel.exists():
naam = f"{stack}_{ts}-{teller}{SUFFIX}"
doel = bd / naam
teller += 1
if log_fn:
log_fn(f"Backup maken van {stack}")
try:
# Python's tarfile in plaats van het tar-commando: de returncode van dat
# commando werd eerder genegeerd, zodat een mislukte backup als succes
# gold.
with tarfile.open(doel, "w:gz") as tar:
tar.add(bron, arcname=stack)
except Exception as e:
doel.unlink(missing_ok=True)
raise BackupError(f"Backup mislukt: {e}") from e
meta = {"stack": stack, "file": naam, "ts": time.time(),
"size": doel.stat().st_size, "reason": reden}
doel.with_suffix("").with_suffix(META_SUFFIX).write_text(
json.dumps(meta, indent=2), encoding="utf-8")
if log_fn:
log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})")
return meta
def _leesbaar(bytes_: int) -> str:
eenheid = ["B", "KB", "MB", "GB"]
n = float(bytes_)
for e in eenheid:
if n < 1024 or e == eenheid[-1]:
return f"{n:.0f} {e}" if e == "B" else f"{n:.1f} {e}"
n /= 1024
return f"{n:.1f} GB"
# ── Tonen ────────────────────────────────────────────────────────────────────
def _meta_voor(archief: Path) -> dict:
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 = {}
st = archief.stat()
# Ontbreekt de metadata (backup van vóór deze versie), leid af uit de naam.
meta.setdefault("file", archief.name)
meta.setdefault("stack", archief.name.rsplit("_", 2)[0])
meta.setdefault("ts", st.st_mtime)
meta.setdefault("reason", "")
meta["size"] = st.st_size
meta["size_human"] = _leesbaar(st.st_size)
return meta
def list_all() -> list[dict]:
bd = backup_dir()
if not bd.is_dir():
return []
uit = []
for p in bd.iterdir():
if p.is_file() and p.name.endswith(SUFFIX):
try:
uit.append(_meta_voor(p))
except Exception:
continue
return sorted(uit, key=lambda m: m["ts"], reverse=True)
def list_for(stack: str) -> list[dict]:
return [m for m in list_all() if m.get("stack") == stack]
# ── Terugzetten ──────────────────────────────────────────────────────────────
def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
"""Zet een backup terug: stack stoppen, uitpakken, weer starten.
De huidige map wordt eerst opzijgezet. Mislukt het uitpakken, dan komt de
oude situatie terug je raakt dus nooit alles kwijt door een kapot archief.
"""
archief = _archive_path(bestandsnaam)
if not archief.is_file():
return False, "Backup niet gevonden."
doel = _stack_dir(stack)
meta = _meta_voor(archief)
if meta.get("stack") and meta["stack"] != stack:
return False, (f"Deze backup hoort bij '{meta['stack']}', niet bij "
f"'{stack}'.")
if doel.is_dir() and docker.has_compose(doel):
if log_fn:
log_fn("Stack stoppen…")
docker.compose_down(doel, log_fn=log_fn, name=stack)
opzij = doel.with_name(f"{doel.name}.restore-{int(time.time())}")
verplaatst = False
if doel.exists():
doel.rename(opzij)
verplaatst = True
if log_fn:
log_fn(f"Huidige map opzijgezet als {opzij.name}")
try:
with tarfile.open(archief, "r:gz") as tar:
# filter="data" weigert absolute paden, '..' en apparaatbestanden.
tar.extractall(doel.parent, filter="data")
if not doel.is_dir():
raise BackupError(
"Het archief bevatte geen map met de naam van de stack.")
except Exception as e:
# Terug naar de oude situatie.
if doel.exists():
shutil.rmtree(doel, ignore_errors=True)
if verplaatst:
opzij.rename(doel)
if log_fn:
log_fn("Oude situatie teruggezet.")
return False, f"Terugzetten mislukt: {e}"
if verplaatst:
shutil.rmtree(opzij, ignore_errors=True)
if log_fn:
log_fn("Bestanden teruggezet — stack starten…")
rc = docker.compose_up(doel, log_fn=log_fn, name=stack)
if rc != 0:
return False, ("Bestanden zijn teruggezet, maar de stack startte niet. "
"Bekijk de logs.")
return True, f"'{stack}' teruggezet uit {bestandsnaam}."
# ── Opruimen ─────────────────────────────────────────────────────────────────
def delete(bestandsnaam: str) -> tuple[bool, str]:
archief = _archive_path(bestandsnaam)
if not archief.is_file():
return False, "Backup niet gevonden."
archief.unlink()
archief.with_suffix("").with_suffix(META_SUFFIX).unlink(missing_ok=True)
return True, "Backup verwijderd."
def prune(stack: str | None = None, log_fn=None) -> list[str]:
"""Pas het retentiebeleid toe. Retourneert de verwijderde bestandsnamen.
Twee regels, die allebei mogen gelden: hoogstens BACKUP_KEEP backups per
stack, en niets ouder dan BACKUP_MAX_AGE_DAYS.
"""
c = cfg.load()
keep = int(c.get("BACKUP_KEEP") or 0)
max_age = int(c.get("BACKUP_MAX_AGE_DAYS") or 0)
if keep <= 0 and max_age <= 0:
return []
verwijderd: list[str] = []
per_stack: dict[str, list[dict]] = {}
for m in list_all():
if stack and m.get("stack") != stack:
continue
per_stack.setdefault(m.get("stack", "?"), []).append(m)
grens = time.time() - max_age * 86400 if max_age > 0 else None
for naam, lijst in per_stack.items():
lijst.sort(key=lambda m: m["ts"], reverse=True) # nieuwste eerst
for i, m in enumerate(lijst):
te_veel = keep > 0 and i >= keep
te_oud = grens is not None and m["ts"] < grens
# De nieuwste backup van een stack blijft altijd staan, ook als hij
# te oud is — anders houd je na een vakantie niets over.
if (te_veel or te_oud) and i > 0:
try:
delete(m["file"])
verwijderd.append(m["file"])
if log_fn:
log_fn(f"Opgeruimd: {m['file']}")
except Exception:
continue
return verwijderd

View file

@ -325,6 +325,37 @@ def validate_compose_text(content: str, d: Path) -> tuple[bool, str]:
pass pass
def image_digest_local(image: str) -> str:
"""Digest van het lokaal aanwezige image, of "" als het er niet is.
Een lokaal gebouwd image heeft geen RepoDigest dan valt er niets met een
registry te vergelijken.
"""
r = _run([_bin(), "image", "inspect", image,
"--format", "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}"],
timeout=15)
if r.returncode != 0:
return ""
uit = r.stdout.strip()
return uit.split("@", 1)[1] if "@" in uit else ""
def image_digest_remote(image: str) -> str:
"""Digest van het image in de registry, of "" als dat niet te bepalen is.
Vraagt de registry rechtstreeks (Registry API v2) en leest de
`Docker-Content-Digest`-header. Dat is precies de waarde die ook in
`RepoDigests` van een gepulld image staat, dus de twee zijn vergelijkbaar.
`docker manifest inspect --verbose` is hiervoor níét bruikbaar: dat geeft
per platform een aparte descriptor-digest terug, terwijl RepoDigests de
digest van de manifest-list bevat. Die vergelijken zou bij elk multi-arch
image permanent "update beschikbaar" opleveren.
"""
from core import registry
return registry.manifest_digest(image)
def used_ports() -> set[int]: def used_ports() -> set[int]:
r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10) r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10)
ports = set() ports = set()

127
server-up/core/registry.py Normal file
View file

@ -0,0 +1,127 @@
"""Registry API v2 — de manifest-digest van een image opvragen (stdlib only).
Wordt gebruikt om te bepalen of er een nieuwer image is voor een geïnstalleerde
stack. De `Docker-Content-Digest`-header die de registry teruggeeft is dezelfde
waarde die Docker in `RepoDigests` zet na een pull, dus die twee zijn direct
vergelijkbaar.
Ondersteunt anonieme toegang (Docker Hub, ghcr, publieke Forgejo-registries) en
Bearer-token-auth via de `WWW-Authenticate`-uitdaging.
"""
from __future__ import annotations
import base64
import json
import re
import urllib.error
import urllib.parse
import urllib.request
DOCKER_HUB = "registry-1.docker.io"
# Accept-header met alle manifest-types, zodat de registry de manifest-list
# teruggeeft in plaats van een enkel platform-manifest.
_ACCEPT = ", ".join([
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
])
_TIMEOUT = 10
def parse_image(image: str) -> tuple[str, str, str]:
"""Splits een image-referentie in (host, repository, tag).
nginx registry-1.docker.io, library/nginx, latest
bes-r/app:1.2 registry-1.docker.io, bes-r/app, 1.2
git.example.com:3000/a/b:dev git.example.com:3000, a/b, dev
"""
ref = (image or "").strip()
if not ref:
return "", "", ""
# Digest-referenties hebben geen tag om op te vragen.
if "@" in ref:
ref = ref.split("@", 1)[0]
tag = "latest"
delen = ref.split("/")
eerste = delen[0]
# Een host herken je aan een punt, een dubbele punt of 'localhost'.
heeft_host = len(delen) > 1 and ("." in eerste or ":" in eerste or eerste == "localhost")
host = eerste if heeft_host else DOCKER_HUB
rest = "/".join(delen[1:]) if heeft_host else ref
if ":" in rest.split("/")[-1]:
rest, tag = rest.rsplit(":", 1)
if host == DOCKER_HUB and "/" not in rest:
rest = f"library/{rest}"
return host, rest, tag
def _scheme(host: str) -> str:
"""Lokale registries draaien vaak zonder TLS."""
if host.startswith("localhost") or host.startswith("127."):
return "http"
return "https"
def _auth_header(creds: tuple[str, str] | None) -> dict:
if not creds:
return {}
blob = base64.b64encode(f"{creds[0]}:{creds[1]}".encode()).decode()
return {"Authorization": f"Basic {blob}"}
def _bearer_token(uitdaging: str, creds: tuple[str, str] | None) -> str:
"""Haal een token op met de gegevens uit een WWW-Authenticate-header."""
velden = dict(re.findall(r'(\w+)="([^"]*)"', uitdaging or ""))
realm = velden.get("realm")
if not realm:
return ""
params = {k: v for k, v in velden.items() if k in ("service", "scope") and v}
url = f"{realm}?{urllib.parse.urlencode(params)}" if params else realm
req = urllib.request.Request(url, headers=_auth_header(creds))
try:
with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
data = json.loads(r.read().decode("utf-8"))
except Exception:
return ""
return data.get("token") or data.get("access_token") or ""
def manifest_digest(image: str, creds: tuple[str, str] | None = None) -> str:
"""Digest van de manifest(-list) voor deze image-tag, of "" bij twijfel.
Retourneert bewust "" in plaats van te gokken: de aanroeper toont dan
"onbekend" en niet ten onrechte "update beschikbaar".
"""
host, repo, tag = parse_image(image)
if not repo:
return ""
url = f"{_scheme(host)}://{host}/v2/{repo}/manifests/{urllib.parse.quote(tag)}"
def _vraag(extra: dict) -> tuple[str, str]:
"""Retourneert (digest, www_authenticate)."""
req = urllib.request.Request(url, method="HEAD",
headers={"Accept": _ACCEPT, **extra})
try:
with urllib.request.urlopen(req, timeout=_TIMEOUT) as r:
return r.headers.get("Docker-Content-Digest", ""), ""
except urllib.error.HTTPError as e:
if e.code == 401:
return "", e.headers.get("WWW-Authenticate", "")
return "", ""
except Exception:
return "", ""
digest, uitdaging = _vraag(_auth_header(creds))
if digest:
return digest
if uitdaging:
token = _bearer_token(uitdaging, creds)
if token:
digest, _ = _vraag({"Authorization": f"Bearer {token}"})
return digest
return ""

110
server-up/core/scheduler.py Normal file
View file

@ -0,0 +1,110 @@
"""Periodieke taken in één achtergrondthread.
Geen cron en geen extra dependency: één daemon-thread die elke paar minuten
kijkt of er iets te doen is. De laatste uitvoering staat in `config.json`, dus
een herstart laat de planning niet opnieuw beginnen en een taak die door een
herstart gemist is, loopt bij de eerstvolgende tick alsnog.
Taken registreren zich met `register()`; `start()` zet de lus in gang.
"""
from __future__ import annotations
import threading
import time
import traceback
from datetime import datetime
import core as cfg
TICK_SECONDS = 300 # elke 5 minuten kijken of er iets moet
_TAKEN: dict[str, dict] = {}
_LOCK = threading.Lock()
_gestart = False
def register(naam: str, fn, interval_uren: float = 24.0, enabled_key: str = ""):
"""Registreer een periodieke taak.
`enabled_key` verwijst naar een config-sleutel; staat die op iets falsy, dan
wordt de taak overgeslagen (maar blijft hij geregistreerd).
"""
with _LOCK:
_TAKEN[naam] = {"fn": fn, "interval": max(0.05, float(interval_uren)),
"enabled_key": enabled_key}
def _laatste() -> dict:
d = cfg.load().get("SCHEDULER_LAST") or {}
return d if isinstance(d, dict) else {}
def _markeer(naam: str):
d = dict(_laatste())
d[naam] = time.time()
cfg.patch({"SCHEDULER_LAST": d})
def due(naam: str, taak: dict, nu: float | None = None) -> bool:
"""Moet deze taak nu draaien?"""
nu = nu if nu is not None else time.time()
if taak.get("enabled_key") and not cfg.load().get(taak["enabled_key"]):
return False
laatste = _laatste().get(naam)
if not laatste:
return True
return (nu - float(laatste)) >= taak["interval"] * 3600
def run_due(log_fn=None) -> list[str]:
"""Draai alle taken die aan de beurt zijn. Retourneert de namen."""
gedraaid = []
with _LOCK:
items = list(_TAKEN.items())
for naam, taak in items:
try:
if not due(naam, taak):
continue
if log_fn:
log_fn(f"[scheduler] {naam}")
taak["fn"]()
_markeer(naam)
gedraaid.append(naam)
except Exception:
# Eén kapotte taak mag de lus niet stoppen.
print(f" [scheduler] {naam} faalde:\n{traceback.format_exc()}")
_markeer(naam) # niet blijven herhalen in een lus
return gedraaid
def _lus():
# Even wachten tot de app volledig op is.
time.sleep(30)
while True:
try:
run_due()
except Exception:
print(f" [scheduler] tick faalde:\n{traceback.format_exc()}")
time.sleep(TICK_SECONDS)
def start():
"""Start de achtergrondlus (eenmalig)."""
global _gestart
if _gestart:
return
_gestart = True
threading.Thread(target=_lus, daemon=True, name="su-scheduler").start()
# ── Hulp voor dagelijkse taken op een vast tijdstip ──────────────────────────
def binnen_venster(uur: int, marge_minuten: int = 30, nu: datetime | None = None) -> bool:
"""True als de huidige tijd binnen `marge` minuten ná `uur`:00 ligt.
Voor 'draai dit rond 03:00'. De tick is 5 minuten, dus een venster van een
half uur vangt ruim af combineer met de interval-check zodat een taak niet
meerdere keren in hetzelfde venster draait.
"""
nu = nu or datetime.now()
minuten_na = (nu.hour - uur) * 60 + nu.minute
return 0 <= minuten_na < marge_minuten

View file

@ -0,0 +1,135 @@
"""Updatecheck voor de geïnstalleerde stacks.
Per stack worden de images uit het compose-bestand gehaald en vergeleken:
de lokaal aanwezige digest (`RepoDigests`) tegen die in de registry
(`Docker-Content-Digest`). Zijn ze verschillend, dan is er een nieuwer image.
Drie statussen per service, zodat de UI nooit ten onrechte een update meldt:
* `update` beide digests bekend en verschillend
* `current` beide bekend en gelijk
* `unknown` lokaal gebouwd image, registry onbereikbaar, of image nog niet
gepulld. Wordt niet als update getoond.
"""
from __future__ import annotations
import threading
import time
import yaml
import core as cfg
from core import docker
_CACHE: dict[str, dict] = {}
_CACHE_TS: float = 0.0
_CACHE_TTL = 6 * 3600
_LOCK = threading.Lock()
def _images_uit_compose(stack_dir) -> list[tuple[str, str]]:
"""[(servicenaam, image), …] uit het compose-bestand van deze stack."""
cf = docker.find_compose(stack_dir)
if not cf:
return []
try:
doc = yaml.safe_load(cf.read_text(encoding="utf-8")) or {}
except Exception:
return []
uit = []
for naam, svc in (doc.get("services") or {}).items():
if not isinstance(svc, dict):
continue
img = svc.get("image")
# Niet-ingevulde variabelen (${TAG}) kunnen we niet opzoeken.
if isinstance(img, str) and img and "${" not in img:
uit.append((naam, img))
return uit
def _registry_creds(image: str) -> tuple[str, str] | None:
"""Inloggegevens hergebruiken als het image uit dezelfde registry komt als
waar Server Up zelf vandaan wordt bijgewerkt."""
c = cfg.load()
gebruiker = (c.get("UPDATE_REGISTRY_USER") or "").strip()
token = (c.get("UPDATE_REGISTRY_TOKEN") or "").strip()
if not gebruiker or not token:
return None
eigen = (c.get("UPDATE_IMAGE") or "").strip()
if not eigen:
return None
from core import registry
host_eigen, _, _ = registry.parse_image(eigen)
host_img, _, _ = registry.parse_image(image)
return (gebruiker, token) if host_eigen and host_eigen == host_img else None
def check_stack(stack: str, stack_dir) -> dict:
"""Vergelijk alle images van één stack. Doet netwerkverzoeken."""
diensten = []
for service, image in _images_uit_compose(stack_dir):
lokaal = docker.image_digest_local(image)
if not lokaal:
# Nooit gepulld of lokaal gebouwd — niets om mee te vergelijken.
diensten.append({"service": service, "image": image,
"status": "unknown", "reason": "geen lokale digest"})
continue
from core import registry
extern = registry.manifest_digest(image, _registry_creds(image))
if not extern:
diensten.append({"service": service, "image": image,
"status": "unknown", "reason": "registry onbereikbaar"})
continue
diensten.append({"service": service, "image": image,
"status": "update" if extern != lokaal else "current",
"local": lokaal, "remote": extern})
return {
"stack": stack,
"services": diensten,
"update_available": any(d["status"] == "update" for d in diensten),
"checked_at": time.time(),
}
def check_all(force: bool = False, log_fn=None) -> dict[str, dict]:
"""Alle stacks langs. Resultaat wordt gecached (TTL 6 uur)."""
global _CACHE, _CACHE_TS
with _LOCK:
if not force and _CACHE and (time.time() - _CACHE_TS) < _CACHE_TTL:
return dict(_CACHE)
from pathlib import Path
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
resultaat: dict[str, dict] = {}
if lib.is_dir():
for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
continue
if log_fn:
log_fn(f"Controleren: {d.name}")
try:
resultaat[d.name] = check_stack(d.name, d)
except Exception as e:
# Eén onbereikbare registry mag de rest niet blokkeren.
resultaat[d.name] = {"stack": d.name, "services": [],
"update_available": False,
"error": str(e)[:200],
"checked_at": time.time()}
with _LOCK:
_CACHE = dict(resultaat)
_CACHE_TS = time.time()
return resultaat
def cached() -> dict[str, dict]:
"""Wat we weten zonder netwerkverzoeken te doen."""
with _LOCK:
return dict(_CACHE)
def invalidate():
global _CACHE, _CACHE_TS
with _LOCK:
_CACHE = {}
_CACHE_TS = 0.0

View file

@ -80,21 +80,18 @@ tailwind.config = {
<div class="card card-pad"> <div class="card card-pad">
<template x-if="auth.needsSetup"> <template x-if="auth.needsSetup">
<div> <div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1">Eerste account aanmaken</div> <div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1" x-text="t('setup_title')">Eerste account aanmaken</div>
<p class="text-xs text-t2 dark:text-t2-dark mb-4"> <p class="text-xs text-t2 dark:text-t2-dark mb-4" x-text="t('setup_hint')"></p>
Server Up beheert Docker op deze machine. Maak nu een beheerdersaccount aan —
zolang dat niet gebeurd is, kan iedereen die deze pagina bereikt het account claimen.
</p>
<form @submit.prevent="doSetup()" class="grid gap-3"> <form @submit.prevent="doSetup()" class="grid gap-3">
<div><label class="form-label">Gebruikersnaam</label> <div><label class="form-label" x-text="t('username')">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div> <input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label">Wachtwoord (min. 10 tekens)</label> <div><label class="form-label" x-text="t('password_min')">Wachtwoord</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div> <input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div>
<div><label class="form-label">Wachtwoord herhalen</label> <div><label class="form-label" x-text="t('password_repeat')">Wachtwoord herhalen</label>
<input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div> <input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div> <div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy"> <button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span>Account aanmaken <span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span><span x-text="t('create_account')">Account aanmaken</span>
</button> </button>
</form> </form>
</div> </div>
@ -102,15 +99,15 @@ tailwind.config = {
<template x-if="!auth.needsSetup"> <template x-if="!auth.needsSetup">
<div> <div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4">Inloggen</div> <div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4" x-text="t('login')">Inloggen</div>
<form @submit.prevent="doLogin()" class="grid gap-3"> <form @submit.prevent="doLogin()" class="grid gap-3">
<div><label class="form-label">Gebruikersnaam</label> <div><label class="form-label" x-text="t('username')">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div> <input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label">Wachtwoord</label> <div><label class="form-label" x-text="t('password')">Wachtwoord</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div> <input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div> <div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy"> <button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span>Inloggen <span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span><span x-text="t('login')">Inloggen</span>
</button> </button>
</form> </form>
</div> </div>
@ -162,7 +159,7 @@ tailwind.config = {
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? ''"></span> images</div> <div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? ''"></span> images</div>
</div> </div>
<button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button> <button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button>
<button @click="logout()" :title="'Uitloggen (' + auth.user + ')'" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-logout text-lg"></span></button> <button @click="logout()" :title="t('logout') + ' (' + auth.user + ')'" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-logout text-lg"></span></button>
</div> </div>
</div> </div>
</aside> </aside>
@ -246,6 +243,10 @@ tailwind.config = {
<input x-model="stackQuery" :placeholder="t('search')+'…'" class="form-input !pl-10"> <input x-model="stackQuery" :placeholder="t('search')+'…'" class="form-input !pl-10">
</div> </div>
<button @click="loadStacks()" class="btn"><span class="mdi mdi-refresh"></span> <span x-text="t('refresh')">Vernieuwen</span></button> <button @click="loadStacks()" class="btn"><span class="mdi mdi-refresh"></span> <span x-text="t('refresh')">Vernieuwen</span></button>
<button @click="checkStackUpdates()" class="btn" :disabled="checkingUpd">
<span class="mdi" :class="checkingUpd ? 'mdi-loading mdi-spin' : 'mdi-package-up'"></span>
<span x-text="t('check_updates')">Updates controleren</span>
</button>
</div> </div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="filteredStacks.length > 0"> <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="filteredStacks.length > 0">
<template x-for="s in filteredStacks" :key="s.name"> <template x-for="s in filteredStacks" :key="s.name">
@ -257,7 +258,10 @@ tailwind.config = {
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span> <span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
<!-- Draait deze stack op een eigen IP i.p.v. hostpoorten? --> <!-- Draait deze stack op een eigen IP i.p.v. hostpoorten? -->
<span x-show="s.ip" class="badge badge-info font-mono !text-[10px]" <span x-show="s.ip" class="badge badge-info font-mono !text-[10px]"
:title="'Eigen IP op netwerk ' + s.network" x-text="s.ip"></span> :title="t('own_ip') + ': ' + s.network" x-text="s.ip"></span>
<span x-show="stackUpd[s.name] && stackUpd[s.name].update_available"
class="badge badge-ok" :title="stackUpdTitle(s.name)"
x-text="t('update_available')">update</span>
<span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span> <span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span>
</div> </div>
<div class="p-4 flex flex-col gap-3 flex-1"> <div class="p-4 flex flex-col gap-3 flex-1">
@ -276,7 +280,7 @@ tailwind.config = {
class="rounded-xl border border-bd dark:border-bd-dark divide-y divide-bd dark:divide-bd-dark overflow-hidden"> class="rounded-xl border border-bd dark:border-bd-dark divide-y divide-bd dark:divide-bd-dark overflow-hidden">
<template x-if="cpanel[s.name] && cpanel[s.name].loading"> <template x-if="cpanel[s.name] && cpanel[s.name].loading">
<div class="px-3 py-2.5 text-xs text-t2 dark:text-t2-dark flex items-center gap-2"> <div class="px-3 py-2.5 text-xs text-t2 dark:text-t2-dark flex items-center gap-2">
<span class="mdi mdi-loading mdi-spin"></span> Laden… <span class="mdi mdi-loading mdi-spin"></span> <span x-text="t('loading')">Laden…</span>
</div> </div>
</template> </template>
<template x-for="c in (cpanel[s.name] && cpanel[s.name].items) || []" :key="c.name"> <template x-for="c in (cpanel[s.name] && cpanel[s.name].items) || []" :key="c.name">
@ -314,6 +318,7 @@ tailwind.config = {
<button class="btn-sm" title="compose" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button> <button class="btn-sm" title="compose" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button>
<button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button> <button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
<button class="btn-sm" :title="t('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button> <button class="btn-sm" :title="t('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button>
<button class="btn-sm" :title="t('backups')" @click="openBackups(s.name)"><span class="mdi mdi-history"></span></button>
<button class="btn-sm btn-danger ml-auto" :title="t('remove')" @click="confirmRemove(s.name)"><span class="mdi mdi-delete-outline"></span></button> <button class="btn-sm btn-danger ml-auto" :title="t('remove')" @click="confirmRemove(s.name)"><span class="mdi mdi-delete-outline"></span></button>
</div> </div>
</div> </div>
@ -467,12 +472,12 @@ tailwind.config = {
<div class="card"> <div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark"> <div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span> <span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm">Beveiliging</span> <span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('security')">Beveiliging</span>
<span class="badge badge-dim ml-auto" x-text="'ingelogd als ' + auth.user"></span> <span class="badge badge-dim ml-auto" x-text="t('logged_in_as', {user: auth.user})"></span>
</div> </div>
<div class="p-5 grid md:grid-cols-2 gap-6"> <div class="p-5 grid md:grid-cols-2 gap-6">
<div> <div>
<div class="form-label">Gebruikers</div> <div class="form-label" x-text="t('users')">Gebruikers</div>
<div class="space-y-1.5 mb-3"> <div class="space-y-1.5 mb-3">
<template x-for="u in users" :key="u"> <template x-for="u in users" :key="u">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
@ -484,36 +489,111 @@ tailwind.config = {
</template> </template>
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<input class="form-input" x-model="newUser.username" placeholder="Gebruikersnaam"> <input class="form-input" x-model="newUser.username" :placeholder="t('username')">
<input type="password" class="form-input" x-model="newUser.password" placeholder="Wachtwoord (min. 10 tekens)"> <input type="password" class="form-input" x-model="newUser.password" :placeholder="t('password_min')">
<button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> Gebruiker toevoegen</button> <button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> <span x-text="t('add_user')">Gebruiker toevoegen</span></button>
</div> </div>
</div> </div>
<div> <div>
<div class="form-label">Eigen wachtwoord wijzigen</div> <div class="form-label" x-text="t('change_own_password')">Eigen wachtwoord wijzigen</div>
<div class="grid gap-2"> <div class="grid gap-2">
<input type="password" class="form-input" x-model="pwForm.current" placeholder="Huidig wachtwoord"> <input type="password" class="form-input" x-model="pwForm.current" :placeholder="t('current_password')">
<input type="password" class="form-input" x-model="pwForm.new" placeholder="Nieuw wachtwoord"> <input type="password" class="form-input" x-model="pwForm.new" :placeholder="t('new_password')">
<input type="password" class="form-input" x-model="pwForm.new2" placeholder="Nieuw wachtwoord herhalen"> <input type="password" class="form-input" x-model="pwForm.new2" :placeholder="t('new_password_repeat')">
<button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> Wijzigen</button> <button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> <span x-text="t('change')">Wijzigen</span></button>
</div> </div>
</div> </div>
</div> </div>
<!-- Reverse-proxy-SSO. De header wordt alleen vertrouwd vanaf de
adressen hieronder; zonder die lijst weigert de server de modus. -->
<div class="px-5 pb-5 pt-4 border-t border-bd dark:border-bd-dark">
<div class="form-label" x-text="t('auth_mode')">Hoe wordt er ingelogd?</div>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1 mb-2">
<button class="seg" :class="authCfg.mode==='local' ? 'seg-on' : ''"
@click="authCfg.mode='local'"><span class="mdi mdi-account-key-outline"></span> <span x-text="t('auth_mode_local')">Lokaal</span></button>
<button class="seg" :class="authCfg.mode==='proxy' ? 'seg-on' : ''"
@click="authCfg.mode='proxy'"><span class="mdi mdi-shield-link-variant-outline"></span> <span x-text="t('auth_mode_proxy')">Proxy</span></button>
<button class="seg" :class="authCfg.mode==='both' ? 'seg-on' : ''"
@click="authCfg.mode='both'"><span class="mdi mdi-check-all"></span> <span x-text="t('auth_mode_both')">Beide</span></button>
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-3" x-text="t('auth_mode_hint')"></p>
<div x-show="authCfg.mode !== 'local'" x-cloak class="grid sm:grid-cols-2 gap-3">
<div>
<label class="form-label" x-text="t('proxy_header')">Identiteitsheader</label>
<input class="form-input font-mono" x-model="authCfg.proxy_header" placeholder="Remote-User">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('proxy_header_hint')"></p>
</div>
<div>
<label class="form-label" x-text="t('trusted_proxies')">Vertrouwde proxy-adressen</label>
<textarea class="form-input font-mono !h-auto py-2" rows="3"
x-model="authCfg.trusted_text" placeholder="10.0.20.1&#10;172.18.0.0/16"></textarea>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('trusted_proxies_hint')"></p>
</div>
</div>
<button class="btn btn-sm mt-3" @click="saveAuthMode()">
<span class="mdi mdi-content-save-outline"></span> <span x-text="t('save')">Opslaan</span>
</button>
</div>
</div>
<!-- Backups: bewaarbeleid en planning -->
<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-archive-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('backups')">Backups</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[11px] text-wr flex items-start gap-1.5">
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('backup_scope_warning')"></span>
</p>
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="form-label" x-text="t('backup_keep')">Aantal per stack</label>
<input type="number" min="0" class="form-input" x-model.number="settings.BACKUP_KEEP" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('backup_max_age')">Maximale leeftijd</label>
<input type="number" min="0" class="form-input" x-model.number="settings.BACKUP_MAX_AGE_DAYS" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('backup_schedule')">Geplande backups</label>
<select class="form-input" x-model="settings.BACKUP_SCHEDULE" @change="autoSave()">
<option value="off" x-text="t('backup_schedule_off')">Uit</option>
<option value="daily" x-text="t('backup_schedule_daily')">Dagelijks</option>
<option value="weekly" x-text="t('backup_schedule_weekly')">Wekelijks</option>
</select>
</div>
<div x-show="settings.BACKUP_SCHEDULE && settings.BACKUP_SCHEDULE !== 'off'">
<label class="form-label" x-text="t('backup_schedule_hour')">Rond welk uur</label>
<input type="number" min="0" max="23" class="form-input" x-model.number="settings.BACKUP_SCHEDULE_HOUR" @change="autoSave()">
</div>
</div>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.BACKUP_BEFORE_UPDATE" @change="settings.BACKUP_BEFORE_UPDATE=$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_before_update')"></span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.BACKUP_BEFORE_REMOVE" @change="settings.BACKUP_BEFORE_REMOVE=$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_before_remove')"></span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.STACK_UPDATE_CHECK" @change="settings.STACK_UPDATE_CHECK=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('stack_update_check')"></span>
</label>
</div>
</div> </div>
<!-- Netwerken: stacks een eigen IP-adres geven --> <!-- Netwerken: stacks een eigen IP-adres geven -->
<div class="card"> <div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark"> <div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span> <span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm">Netwerken</span> <span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('networks')">Netwerken</span>
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> Netwerk</button> <button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> <span x-text="t('network')">Netwerk</span></button>
</div> </div>
<div class="p-5"> <div class="p-5">
<p class="text-xs text-t2 dark:text-t2-dark mb-3"> <p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('networks_hint')"></p>
Met een macvlan- of ipvlan-netwerk krijgt een stack een eigen IP-adres in je LAN
in plaats van poorten op de host. Geen poortconflicten meer, en je kunt per app
firewallen. De Docker-host zelf bereikt macvlan-containers niet zonder shim-interface.
</p>
<div class="space-y-2"> <div class="space-y-2">
<template x-for="n in netList" :key="n.name"> <template x-for="n in netList" :key="n.name">
<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">
@ -521,12 +601,12 @@ tailwind.config = {
<span class="font-semibold text-sm text-wh dark:text-wh-dark" x-text="n.name"></span> <span class="font-semibold text-sm text-wh dark:text-wh-dark" x-text="n.name"></span>
<span class="badge badge-dim" x-text="n.driver"></span> <span class="badge badge-dim" x-text="n.driver"></span>
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="n.subnet + ' @ ' + n.parent"></span> <span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="n.subnet + ' @ ' + n.parent"></span>
<span class="text-[11px] text-dm dark:text-dm-dark" x-show="n.next_ip">volgend vrij: <span class="font-mono" x-text="n.next_ip"></span></span> <span class="text-[11px] text-dm dark:text-dm-dark" x-show="n.next_ip"><span x-text="t('next_free')"></span>: <span class="font-mono" x-text="n.next_ip"></span></span>
<button class="btn-sm !h-7 !px-2 btn-danger ml-auto" @click="deleteNetwork(n.name)"><span class="mdi mdi-delete-outline"></span></button> <button class="btn-sm !h-7 !px-2 btn-danger ml-auto" @click="deleteNetwork(n.name)"><span class="mdi mdi-delete-outline"></span></button>
</div> </div>
</template> </template>
</div> </div>
<div x-show="netList.length === 0" class="text-xs text-dm dark:text-dm-dark">Nog geen netwerken.</div> <div x-show="netList.length === 0" class="text-xs text-dm dark:text-dm-dark" x-text="t('no_networks')">Nog geen netwerken.</div>
</div> </div>
</div> </div>
@ -553,7 +633,7 @@ tailwind.config = {
<div class="flex flex-wrap gap-2" x-show="update.update_available"> <div class="flex flex-wrap gap-2" x-show="update.update_available">
<button class="btn btn-primary" @click="applyUpdate()" :disabled="!update.can_apply || updating"> <button class="btn btn-primary" @click="applyUpdate()" :disabled="!update.can_apply || updating">
<span class="mdi" :class="updating ? 'mdi-loading mdi-spin' : 'mdi-download'"></span> <span class="mdi" :class="updating ? 'mdi-loading mdi-spin' : 'mdi-download'"></span>
<span x-text="updating ? 'Bezig…' : ('Nu bijwerken naar v'+update.latest)"></span> <span x-text="updating ? t('updating') : t('update_now', {version: update.latest})"></span>
</button> </button>
<a :href="update.url" target="_blank" rel="noopener" class="btn"><span class="mdi mdi-open-in-new"></span> <span x-text="t('view_release')">Bekijk release</span></a> <a :href="update.url" target="_blank" rel="noopener" class="btn"><span class="mdi mdi-open-in-new"></span> <span x-text="t('view_release')">Bekijk release</span></a>
</div> </div>
@ -562,27 +642,24 @@ tailwind.config = {
<p x-show="update.update_available && !update.can_apply" <p x-show="update.update_available && !update.can_apply"
class="text-xs text-wr flex items-start gap-1.5"> class="text-xs text-wr flex items-start gap-1.5">
<span class="mdi mdi-alert-outline shrink-0"></span> <span class="mdi mdi-alert-outline shrink-0"></span>
<span><span x-text="update.apply_reason"></span> <span><span x-text="update.apply_reason"></span> <span x-text="t('update_cannot_apply')"></span></span>
Bijwerken kan wel via een git-tag; zie <span class="font-mono">docs/updates.md</span>.</span>
</p> </p>
<p x-show="update.error" class="text-xs text-er" x-text="update.error"></p> <p x-show="update.error" class="text-xs text-er" x-text="update.error"></p>
<p x-show="update.checked_at" class="text-[11px] text-dm dark:text-dm-dark"> <p x-show="update.checked_at" class="text-[11px] text-dm dark:text-dm-dark">
Laatst gecontroleerd: <span x-text="fmtTime(update.checked_at)"></span> <span x-text="t('last_checked')"></span>: <span x-text="fmtTime(update.checked_at)"></span>
</p> </p>
<div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3"> <div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3">
<div> <div>
<label class="form-label">Kanaal</label> <label class="form-label" x-text="t('channel')">Kanaal</label>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1"> <div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''" <button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''"
@click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button> @click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button>
<button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''" <button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''"
@click="setChannel('beta')"><span class="mdi mdi-flask-outline"></span> Beta</button> @click="setChannel('beta')"><span class="mdi mdi-flask-outline"></span> Beta</button>
</div> </div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"> <p class="text-[11px] text-dm dark:text-dm-dark mt-1"
<span x-show="updChannel()==='stable'">Alleen stabiele releases.</span> x-text="updChannel()==='beta' ? t('channel_beta_hint') : t('channel_stable_hint')"></p>
<span x-show="updChannel()==='beta'">Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar.</span>
</p>
</div> </div>
<div> <div>
<label class="form-label" x-text="t('update_api_url')">Releases-API URL</label> <label class="form-label" x-text="t('update_api_url')">Releases-API URL</label>
@ -590,31 +667,29 @@ tailwind.config = {
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_api_hint')">Forgejo/GitHub Releases-API. Leeg = uit.</p> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_api_hint')">Forgejo/GitHub Releases-API. Leeg = uit.</p>
</div> </div>
<div> <div>
<label class="form-label">Image (voor bijwerken vanuit de interface)</label> <label class="form-label" x-text="t('update_image')">Image</label>
<input class="form-input font-mono" x-model="settings.UPDATE_IMAGE" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="git.example.com/bes-r/server-up"> <input class="form-input font-mono" x-model="settings.UPDATE_IMAGE" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="git.example.com/bes-r/server-up">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_image_hint')"></p>
Zonder tag. Leeg = afleiden uit de draaiende container.
</p>
</div> </div>
<div class="grid sm:grid-cols-2 gap-3"> <div class="grid sm:grid-cols-2 gap-3">
<div> <div>
<label class="form-label">Registry-gebruiker (optioneel)</label> <label class="form-label" x-text="t('registry_user')">Registry-gebruiker</label>
<input class="form-input" x-model="settings.UPDATE_REGISTRY_USER" @change="autoSave()" placeholder="bes-r"> <input class="form-input" x-model="settings.UPDATE_REGISTRY_USER" @change="autoSave()" placeholder="bes-r">
</div> </div>
<div> <div>
<label class="form-label">Registry-token</label> <label class="form-label" x-text="t('registry_token')">Registry-token</label>
<input type="password" class="form-input" x-model="settings.UPDATE_REGISTRY_TOKEN" @change="autoSave()" <input type="password" class="form-input" x-model="settings.UPDATE_REGISTRY_TOKEN" @change="autoSave()"
:placeholder="settings.has_update_registry_token ? '•••••••• (ingesteld)' : 'alleen bij een privé registry'"> :placeholder="settings.has_update_registry_token ? t('registry_token_set') : t('registry_token_ph')">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">Leeg laten = ongewijzigd.</p> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('registry_token_hint')"></p>
</div> </div>
</div> </div>
<div x-show="update.previous_tag" class="pt-2 border-t border-bd dark:border-bd-dark"> <div x-show="update.previous_tag" class="pt-2 border-t border-bd dark:border-bd-dark">
<label class="form-label">Terugrollen</label> <label class="form-label" x-text="t('rollback')">Terugrollen</label>
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-t2 dark:text-t2-dark">Vorige versie: <span class="text-xs text-t2 dark:text-t2-dark"><span x-text="t('previous_version')"></span>:
<span class="font-mono text-tx dark:text-tx-dark" x-text="update.previous_tag"></span></span> <span class="font-mono text-tx dark:text-tx-dark" x-text="update.previous_tag"></span></span>
<button class="btn-sm btn-danger" @click="rollbackUpdate()" :disabled="updating"> <button class="btn-sm btn-danger" @click="rollbackUpdate()" :disabled="updating">
<span class="mdi mdi-undo-variant"></span> Terug naar deze versie <span class="mdi mdi-undo-variant"></span> <span x-text="t('rollback_button')">Terug naar deze versie</span>
</button> </button>
</div> </div>
</div> </div>
@ -739,37 +814,78 @@ tailwind.config = {
</div> </div>
</div> </div>
<!-- Backups van één stack -->
<div x-show="modal==='backups'" class="modal max-w-xl flex flex-col max-h-[85vh]">
<div class="modal-head">
<span class="mdi mdi-history text-ac dark:text-ac-dark"></span>
<span x-text="t('backups')">Backups</span>
<span class="font-mono text-xs text-t2 dark:text-t2-dark" x-text="bkStack"></span>
<button class="btn-sm ml-auto" @click="createBackup(bkStack)">
<span class="mdi mdi-archive-arrow-down-outline"></span> <span x-text="t('backup')">Backup</span>
</button>
</div>
<div class="modal-body overflow-y-auto">
<p class="text-[11px] text-wr mb-3 flex items-start gap-1.5">
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('backup_scope_warning')"></span>
</p>
<div class="space-y-2">
<template x-for="b in bkList" :key="b.file">
<div class="flex flex-wrap items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
<span class="mdi mdi-package-variant-closed text-t2 dark:text-t2-dark"></span>
<div class="min-w-0 flex-1">
<div class="text-xs font-mono text-tx dark:text-tx-dark truncate" x-text="b.file"></div>
<div class="text-[10px] text-dm dark:text-dm-dark">
<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>
</div>
</div>
<button class="btn-sm !h-8" @click="restoreBackup(bkStack, b.file)">
<span class="mdi mdi-restore"></span> <span x-text="t('restore')">Terugzetten</span>
</button>
<button class="btn-sm !h-8 !px-2 btn-danger" @click="deleteBackup(b.file)">
<span class="mdi mdi-delete-outline"></span>
</button>
</div>
</template>
</div>
<div x-show="bkList.length === 0" class="empty !py-8">
<span class="mdi mdi-archive-outline"></span><p x-text="t('no_backups')">Nog geen backups</p>
</div>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button>
</div>
</div>
<!-- Netwerk toevoegen --> <!-- Netwerk toevoegen -->
<div x-show="modal==='add-network'" class="modal max-w-md"> <div x-show="modal==='add-network'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> Netwerk toevoegen</div> <div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> <span x-text="t('add_network')">Netwerk toevoegen</span></div>
<div class="modal-body space-y-3"> <div class="modal-body space-y-3">
<div><label class="form-label">Naam</label><input class="form-input" x-model="newNet.name" placeholder="lan"></div> <div><label class="form-label" x-text="t('lang_name')">Naam</label><input class="form-input" x-model="newNet.name" placeholder="lan"></div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div><label class="form-label">Driver</label> <div><label class="form-label" x-text="t('driver')">Driver</label>
<select class="form-input" x-model="newNet.driver"> <select class="form-input" x-model="newNet.driver">
<option value="macvlan">macvlan</option> <option value="macvlan">macvlan</option>
<option value="ipvlan">ipvlan</option> <option value="ipvlan">ipvlan</option>
</select></div> </select></div>
<div><label class="form-label">Host-interface</label> <div><label class="form-label" x-text="t('host_interface')">Host-interface</label>
<input class="form-input" x-model="newNet.parent" placeholder="eth0" list="ifaces"> <input class="form-input" x-model="newNet.parent" placeholder="eth0" list="ifaces">
<datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist> <datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div><label class="form-label">Subnet</label><input class="form-input font-mono" x-model="newNet.subnet" placeholder="192.168.1.0/24"></div> <div><label class="form-label" x-text="t('subnet')">Subnet</label><input class="form-input font-mono" x-model="newNet.subnet" placeholder="192.168.1.0/24"></div>
<div><label class="form-label">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></div> <div><label class="form-label" x-text="t('gateway')">Gateway</label><input class="form-input font-mono" x-model="newNet.gateway" placeholder="192.168.1.1"></div>
</div> </div>
<div><label class="form-label">IP-range (optioneel)</label> <div><label class="form-label" x-text="t('ip_range')">IP-range</label>
<input class="form-input font-mono" x-model="newNet.ip_range" placeholder="192.168.1.240/28"> <input class="form-input font-mono" x-model="newNet.ip_range" placeholder="192.168.1.240/28">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('ip_range_hint')"></p>
Beperkt de adressen die Docker uitdeelt tot een blok dat je in je DHCP-server
hebt vrijgehouden. Zonder range gebruikt Docker het hele subnet.
</p>
</div> </div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button> <button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="addNetwork()"><span class="mdi mdi-plus"></span> Aanmaken</button> <button class="btn btn-primary" @click="addNetwork()"><span class="mdi mdi-plus"></span> <span x-text="t('add')">Aanmaken</span></button>
</div> </div>
</div> </div>
@ -790,28 +906,22 @@ tailwind.config = {
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) --> <!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
<div> <div>
<label class="form-label">Netwerk</label> <label class="form-label" x-text="t('network')">Netwerk</label>
<select class="form-input" x-model="install.network" @change="pickIp()"> <select class="form-input" x-model="install.network" @change="pickIp()">
<option value="">Poorten op de host (standaard)</option> <option value="" x-text="t('ports_on_host')">Poorten op de host</option>
<template x-for="n in netList" :key="n.name"> <template x-for="n in netList" :key="n.name">
<option :value="n.name" x-text="n.name + ' — eigen IP (' + n.subnet + ')'"></option> <option :value="n.name" x-text="n.name + ' — ' + t('own_ip') + ' (' + n.subnet + ')'"></option>
</template> </template>
</select> </select>
<template x-if="install.network"> <template x-if="install.network">
<div class="mt-2"> <div class="mt-2">
<label class="form-label">IP-adres</label> <label class="form-label" x-text="t('ip_address')">IP-adres</label>
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240"> <input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('own_ip_hint')"></p>
De stack krijgt dit adres in het LAN. Poortmappings vervallen — de app
is bereikbaar op zijn eigen poort. Let op: de Docker-host zelf kan een
macvlan-container niet bereiken zonder shim-interface (zie docs/netwerken.md).
</p>
</div> </div>
</template> </template>
<p x-show="!install.network && netList.length === 0" <p x-show="!install.network && netList.length === 0"
class="text-[11px] text-dm dark:text-dm-dark mt-1"> class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')"></p>
Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.
</p>
</div> </div>
<div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div> <div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div>
@ -845,13 +955,12 @@ tailwind.config = {
<template x-if="f.type === 'str' && f.secret"> <template x-if="f.type === 'str' && f.secret">
<div class="flex gap-2"> <div class="flex gap-2">
<input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder"> <input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder">
<button type="button" class="btn-sm shrink-0" title="Genereer een willekeurige waarde" <button type="button" class="btn-sm shrink-0" :title="t('generate_value')"
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button> @click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button>
</div> </div>
</template> </template>
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1"> <p x-show="f.port_taken" class="text-[11px] text-wr mt-1"
Poort <span x-text="f.port_taken"></span> is al in gebruik — <span x-text="f.suggested"></span> voorgesteld. x-text="t('port_taken', {port: f.port_taken, suggested: f.suggested})"></p>
</p>
<p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p> <p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p>
</div> </div>
</template> </template>
@ -1079,10 +1188,15 @@ function app() {
loginForm: { username: '', password: '', password2: '', error: '', busy: false }, loginForm: { username: '', password: '', password2: '', error: '', busy: false },
users: [], newUser: { username: '', password: '' }, users: [], newUser: { username: '', password: '' },
pwForm: { current: '', new: '', new2: '' }, pwForm: { current: '', new: '', new2: '' },
authCfg: { mode: 'local', proxy_header: 'Remote-User', trusted_text: '' },
// Uitklapbaar containerpaneel per stack: { <stack>: {open, loading, items} } // Uitklapbaar containerpaneel per stack: { <stack>: {open, loading, items} }
cpanel: {}, cpanel: {},
// Backups en updatestatus per stack
bkStack: '', bkList: [], bkSettings: {},
stackUpd: {}, checkingUpd: false,
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen // Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
netList: [], netIfaces: [], netList: [], netIfaces: [],
newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' }, newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' },
@ -1156,7 +1270,7 @@ function app() {
this.loadUpdate(); this.loadUpdate();
setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000); setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000);
this.$watch('page', async (p) => { this.$watch('page', async (p) => {
if (p === 'stacks') this.loadStacks(); if (p === 'stacks') { this.loadStacks(); this.loadStackUpdates(); }
if (p === 'app_store') this.loadAppStore(); if (p === 'app_store') this.loadAppStore();
if (p === 'docker') this.loadImages(); if (p === 'docker') this.loadImages();
if (p === 'audit') this.loadAudit(); if (p === 'audit') this.loadAudit();
@ -1171,7 +1285,15 @@ function app() {
setThemePref(p) { this.themePref = p; localStorage.setItem('theme', p); this.applyThemePref(); this.autoSave(); }, setThemePref(p) { this.themePref = p; localStorage.setItem('theme', p); this.applyThemePref(); this.autoSave(); },
toggleTheme() { this.setThemePref(this.theme === 'dark' ? 'light' : 'dark'); }, toggleTheme() { this.setThemePref(this.theme === 'dark' ? 'light' : 'dark'); },
t(k) { return this.T[k] || k; }, // t('key') zoekt de vertaling op; t('key', {n: 3}) vervangt daarnaast
// {n}-plaatshouders, zodat een zin niet in losse stukjes hoeft.
t(k, vars) {
let s = this.T[k] || k;
if (vars) for (const [naam, waarde] of Object.entries(vars)) {
s = s.split('{' + naam + '}').join(waarde);
}
return s;
},
async loadLangs() { try { const d = await fetch('/api/i18n').then(r=>r.json()); this.languages = d.languages || []; } catch(e) {} }, async loadLangs() { try { const d = await fetch('/api/i18n').then(r=>r.json()); this.languages = d.languages || []; } catch(e) {} },
async loadLang(code) { try { const d = await fetch('/api/i18n/'+code).then(r=>r.json()); this.T = d.strings || {}; } catch(e) {} }, async loadLang(code) { try { const d = await fetch('/api/i18n/'+code).then(r=>r.json()); this.T = d.strings || {}; } catch(e) {} },
setLang(c) { localStorage.setItem('lang', c); this.loadLang(c); }, setLang(c) { localStorage.setItem('lang', c); this.loadLang(c); },
@ -1248,7 +1370,7 @@ function app() {
const d = await this.req('/api/auth/login', { method:'POST', json: { const d = await this.req('/api/auth/login', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } }); username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false; this.loginForm.busy = false;
if (!d.ok) { this.loginForm.error = d.msg || 'Inloggen mislukt'; return; } if (!d.ok) { this.loginForm.error = d.msg || this.t('login_failed'); return; }
this.auth.authenticated = true; this.auth.authenticated = true;
this.auth.user = d.user; this.auth.csrf = d.csrf_token || ''; this.auth.user = d.user; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false }; this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
@ -1257,13 +1379,13 @@ function app() {
async doSetup() { async doSetup() {
this.loginForm.error = ''; this.loginForm.error = '';
if (this.loginForm.password !== this.loginForm.password2) { if (this.loginForm.password !== this.loginForm.password2) {
this.loginForm.error = 'De wachtwoorden komen niet overeen.'; return; this.loginForm.error = this.t('passwords_differ'); return;
} }
this.loginForm.busy = true; this.loginForm.busy = true;
const d = await this.req('/api/auth/setup', { method:'POST', json: { const d = await this.req('/api/auth/setup', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } }); username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false; this.loginForm.busy = false;
if (!d.ok) { this.loginForm.error = d.msg || 'Aanmaken mislukt'; return; } if (!d.ok) { this.loginForm.error = d.msg || this.t('login_failed'); return; }
this.auth.authenticated = true; this.auth.needsSetup = false; this.auth.authenticated = true; this.auth.needsSetup = false;
this.auth.user = this.loginForm.username; this.auth.csrf = d.csrf_token || ''; this.auth.user = this.loginForm.username; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false }; this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
@ -1273,23 +1395,47 @@ function app() {
await this.req('/api/auth/logout', { method:'POST', json: {} }); await this.req('/api/auth/logout', { method:'POST', json: {} });
this.auth.authenticated = false; this.auth.user = ''; this.auth.csrf = ''; this.auth.authenticated = false; this.auth.user = ''; this.auth.csrf = '';
}, },
async loadUsers() { const d = await this.rpc('/api/auth/users'); this.users = d.users || []; }, async loadUsers() {
const d = await this.rpc('/api/auth/users');
this.users = d.users || [];
this.authCfg = {
mode: d.mode || 'local',
proxy_header: d.proxy_header || 'Remote-User',
trusted_text: (d.trusted_proxies || []).join('\n'),
};
},
async saveAuthMode() {
const body = {
mode: this.authCfg.mode,
proxy_header: this.authCfg.proxy_header,
trusted_proxies: this.authCfg.trusted_text.split('\n').map(s => s.trim()).filter(Boolean),
};
// De server weigert 'proxy' zonder bevestiging: die modus schakelt de
// lokale login uit, dus je sluit jezelf buiten als de proxy niet werkt.
if (body.mode === 'proxy') {
if (!confirm(this.t('proxy_lockout_confirm'))) return;
body.confirm_lockout = true;
}
const d = await this.req('/api/auth/mode', { method: 'PUT', json: body });
this.toast(d.msg || this.t(d.ok ? 'save' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) this.loadUsers();
},
async addUser() { async addUser() {
const d = await this.req('/api/auth/users', { method:'POST', json: this.newUser }); const d = await this.req('/api/auth/users', { method:'POST', json: this.newUser });
this.toast(d.msg || (d.ok ? 'Toegevoegd' : 'Fout'), d.ok ? 'ok' : 'er'); this.toast(d.msg || this.t(d.ok ? 'add_user' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) { this.newUser = { username:'', password:'' }; this.loadUsers(); } if (d.ok) { this.newUser = { username:'', password:'' }; this.loadUsers(); }
}, },
async delUser(name) { async delUser(name) {
if (!confirm('Gebruiker ' + name + ' verwijderen?')) return; if (!confirm(this.t('delete_user_confirm', {name}))) return;
const d = await this.req('/api/auth/users/' + encodeURIComponent(name), { method:'DELETE' }); const d = await this.req('/api/auth/users/' + encodeURIComponent(name), { method:'DELETE' });
this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er'); this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
this.loadUsers(); this.loadUsers();
}, },
async changePassword() { async changePassword() {
if (this.pwForm.new !== this.pwForm.new2) { this.toast('Wachtwoorden komen niet overeen', 'er'); return; } if (this.pwForm.new !== this.pwForm.new2) { this.toast(this.t('passwords_differ'), 'er'); return; }
const d = await this.req('/api/auth/password', { method:'POST', json: { const d = await this.req('/api/auth/password', { method:'POST', json: {
current: this.pwForm.current, new: this.pwForm.new } }); current: this.pwForm.current, new: this.pwForm.new } });
this.toast(d.msg || (d.ok ? 'Gewijzigd' : 'Fout'), d.ok ? 'ok' : 'er'); this.toast(d.msg || this.t(d.ok ? 'change' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) this.pwForm = { current:'', new:'', new2:'' }; if (d.ok) this.pwForm = { current:'', new:'', new2:'' };
}, },
@ -1309,7 +1455,7 @@ function app() {
if (r.status === 401) { if (r.status === 401) {
this.auth.authenticated = false; this.auth.authenticated = false;
await this.loadAuth(); await this.loadAuth();
return { ok: false, msg: 'Sessie verlopen — log opnieuw in' }; return { ok: false, msg: this.t('session_expired') };
} }
return await r.json(); return await r.json();
} catch(e) { return { ok: false, error: e.message, msg: e.message }; } } catch(e) { return { ok: false, error: e.message, msg: e.message }; }
@ -1379,6 +1525,60 @@ function app() {
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; } if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
else this.toast(d.msg || 'Fout bij opslaan', 'er'); else this.toast(d.msg || 'Fout bij opslaan', 'er');
}, },
// ── Backups ──────────────────────────────────────────────────────────────
async openBackups(stack) {
this.bkStack = stack;
this.bkList = [];
this.modal = 'backups';
await this.loadBackups(stack);
},
async loadBackups(stack) {
const d = await this.rpc('/api/backups?stack=' + encodeURIComponent(stack));
this.bkList = d.backups || [];
this.bkSettings = d.settings || {};
},
async createBackup(stack) {
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/create`, {});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.pollJob(d.job_id, () => this.loadBackups(stack));
},
async restoreBackup(stack, file) {
if (!confirm(this.t('restore_confirm', {file}))) return;
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/restore`, {file});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null;
this.termOpen = true;
this.pollJob(d.job_id, () => { this.loadStacks(); this.toast(this.t('restore'), 'ok'); });
},
async deleteBackup(file) {
if (!confirm(this.t('delete_backup_confirm'))) return;
const d = await this.req('/api/backups/' + encodeURIComponent(file), { method:'DELETE' });
this.toast(d.msg || this.t(d.ok ? 'deleted' : 'error'), d.ok ? 'ok' : 'er');
this.loadBackups(this.bkStack);
},
// ── Updates van geïnstalleerde stacks ────────────────────────────────────
async loadStackUpdates() {
const d = await this.rpc('/api/stacks/updates');
this.stackUpd = d.stacks || {};
},
async checkStackUpdates() {
this.checkingUpd = true;
const d = await this.rpc('/api/stacks/updates/check', {});
if (!d.job_id) { this.checkingUpd = false; this.toast(d.msg || this.t('error'), 'er'); return; }
this.termOpen = true;
this.pollJob(d.job_id, async () => {
this.checkingUpd = false;
await this.loadStackUpdates();
});
},
stackUpdTitle(naam) {
const r = this.stackUpd[naam];
if (!r) return '';
return (r.services || []).filter(s => s.status === 'update')
.map(s => s.service + ': ' + s.image).join('\n');
},
// ── Netwerken ──────────────────────────────────────────────────────────── // ── Netwerken ────────────────────────────────────────────────────────────
async loadNetworks() { async loadNetworks() {
const d = await this.rpc('/api/networks'); const d = await this.rpc('/api/networks');
@ -1392,7 +1592,7 @@ function app() {
}, },
async addNetwork() { async addNetwork() {
const d = await this.rpc('/api/networks', this.newNet); const d = await this.rpc('/api/networks', this.newNet);
this.toast(d.msg || (d.ok ? 'Aangemaakt' : 'Fout'), d.ok ? 'ok' : 'er'); this.toast(d.msg || this.t(d.ok ? 'add' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) { if (d.ok) {
this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' }; this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' };
this.modal = null; this.modal = null;
@ -1400,9 +1600,9 @@ function app() {
} }
}, },
async deleteNetwork(name) { async deleteNetwork(name) {
if (!confirm('Netwerk ' + name + ' verwijderen?')) return; if (!confirm(this.t('delete_network_confirm', {name}))) return;
const d = await this.req('/api/networks/' + encodeURIComponent(name), { method:'DELETE' }); const d = await this.req('/api/networks/' + encodeURIComponent(name), { method:'DELETE' });
this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er'); this.toast(d.msg || this.t(d.ok ? 'deleted' : 'error'), d.ok ? 'ok' : 'er');
this.loadNetworks(); this.loadNetworks();
}, },
@ -1431,7 +1631,7 @@ function app() {
async containerAction(stack, container, action) { async containerAction(stack, container, action) {
const d = await this.rpc( const d = await this.rpc(
`/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/${action}`, {}); `/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/${action}`, {});
this.toast(d.ok ? this.t(action) + ' ✓' : (d.msg || 'Fout'), d.ok ? 'ok' : 'er'); this.toast(d.ok ? this.t(action) + ' ✓' : (d.msg || this.t('error')), d.ok ? 'ok' : 'er');
await this.loadContainers(stack); await this.loadContainers(stack);
this.loadStacks(); this.loadStacks();
}, },
@ -1611,8 +1811,7 @@ function app() {
// pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft. // pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft.
async applyUpdate() { async applyUpdate() {
const naar = this.update.latest_tag || this.update.latest; const naar = this.update.latest_tag || this.update.latest;
if (!confirm('Server Up bijwerken naar ' + naar + '?\n\n' if (!confirm(this.t('update_confirm', {tag: naar}))) return;
+ 'De container wordt vervangen en is enkele seconden niet bereikbaar.')) return;
this.updating = true; this.updating = true;
const d = await this.rpc('/api/update/apply', { tag: naar }); const d = await this.rpc('/api/update/apply', { tag: naar });
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; } if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
@ -1620,7 +1819,7 @@ function app() {
this.pollJob(d.job_id, () => this.waitForRestart()); this.pollJob(d.job_id, () => this.waitForRestart());
}, },
async rollbackUpdate() { async rollbackUpdate() {
if (!confirm('Terugrollen naar ' + this.update.previous_tag + '?')) return; if (!confirm(this.t('rollback_confirm', {tag: this.update.previous_tag}))) return;
this.updating = true; this.updating = true;
const d = await this.rpc('/api/update/rollback', {}); const d = await this.rpc('/api/update/rollback', {});
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; } if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
@ -1628,7 +1827,7 @@ function app() {
this.pollJob(d.job_id, () => this.waitForRestart()); this.pollJob(d.job_id, () => this.waitForRestart());
}, },
async waitForRestart() { async waitForRestart() {
this.toast('Server Up herstart…', 'in'); this.toast(this.t('restarting'), 'in');
// Even wachten tot de oude container écht weg is, anders zien we hem nog. // Even wachten tot de oude container écht weg is, anders zien we hem nog.
await new Promise(r => setTimeout(r, 8000)); await new Promise(r => setTimeout(r, 8000));
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
@ -1638,7 +1837,7 @@ function app() {
const d = await r.json(); const d = await r.json();
if (d.version && d.version !== this.update.current) { if (d.version && d.version !== this.update.current) {
this.updating = false; this.updating = false;
this.toast('Bijgewerkt naar v' + d.version, 'ok'); this.toast(this.t('updated_to', {version: d.version}), 'ok');
setTimeout(() => location.reload(), 1200); setTimeout(() => location.reload(), 1200);
return; return;
} }
@ -1647,7 +1846,7 @@ function app() {
await new Promise(r => setTimeout(r, 2000)); await new Promise(r => setTimeout(r, 2000));
} }
this.updating = false; this.updating = false;
this.toast('Herstart duurt lang — ververs de pagina zelf', 'wr'); this.toast(this.t('restart_slow'), 'wr');
}, },
async checkWizard() { async checkWizard() {

View file

@ -1,5 +1,9 @@
{ {
"_meta": {"code": "en", "name": "English", "flag": "🇬🇧"}, "_meta": {
"code": "en",
"name": "English",
"flag": "🇬🇧"
},
"dashboard": "Dashboard", "dashboard": "Dashboard",
"stacks": "Stacks", "stacks": "Stacks",
"app_store": "App Store", "app_store": "App Store",
@ -115,5 +119,97 @@
"view_release": "View release", "view_release": "View release",
"update_api_url": "Releases API URL", "update_api_url": "Releases API URL",
"update_api_hint": "Forgejo/GitHub Releases API returning the latest release. Empty = disabled.", "update_api_hint": "Forgejo/GitHub Releases API returning the latest release. Empty = disabled.",
"include_prerelease": "Include pre-releases (beta)" "include_prerelease": "Include pre-releases (beta)",
"login": "Sign in",
"login_failed": "Sign-in failed",
"logout": "Sign out",
"session_expired": "Session expired — please sign in again",
"setup_title": "Create the first account",
"setup_hint": "Server Up manages Docker on this machine. Create an administrator account now — until you do, anyone who can reach this page can claim it.",
"create_account": "Create account",
"username": "Username",
"password": "Password",
"password_min": "Password (min. 10 characters)",
"password_repeat": "Repeat password",
"passwords_differ": "The passwords do not match.",
"security": "Security",
"logged_in_as": "signed in as {user}",
"users": "Users",
"add_user": "Add user",
"delete_user_confirm": "Delete user {name}?",
"change_own_password": "Change your password",
"current_password": "Current password",
"new_password": "New password",
"new_password_repeat": "Repeat new password",
"change": "Change",
"auth_mode": "How do people sign in?",
"auth_mode_local": "Local login",
"auth_mode_proxy": "Reverse proxy",
"auth_mode_both": "Both",
"auth_mode_hint": "With a reverse proxy (Authelia, Authentik, Cloudflare Access) the identity comes from a header. Pick 'Both' to test that without losing your local login.",
"proxy_header": "Identity header",
"proxy_header_hint": "Usually Remote-User. Make sure your proxy strips this header from incoming requests and sets it itself.",
"trusted_proxies": "Trusted proxy addresses",
"trusted_proxies_hint": "One per line, IP or CIDR. The header is only trusted from these addresses — without this list anyone could impersonate an administrator.",
"proxy_lockout_confirm": "In 'Reverse proxy' mode the local login is disabled. Are you sure your proxy works?",
"networks": "Networks",
"networks_hint": "A macvlan or ipvlan network gives a stack its own IP address on your LAN instead of ports on the host. No more port conflicts, and you can firewall per app. Note: the Docker host itself cannot reach macvlan containers without a shim interface.",
"add_network": "Add network",
"network": "Network",
"no_networks": "No networks yet.",
"delete_network_confirm": "Delete network {name}?",
"driver": "Driver",
"host_interface": "Host interface",
"subnet": "Subnet",
"gateway": "Gateway",
"ip_range": "IP range (optional)",
"ip_range_hint": "Limits the addresses Docker hands out to a block you reserved in your DHCP server. Without a range Docker uses the whole subnet.",
"next_free": "next free",
"ports_on_host": "Ports on the host (default)",
"own_ip": "own IP",
"ip_address": "IP address",
"own_ip_hint": "The stack gets this address on your LAN. Port mappings are dropped — the app is reachable on its own port. The Docker host itself cannot reach a macvlan container without a shim interface (see docs/netwerken.md).",
"no_networks_hint": "Want to give stacks their own IP? Add a network first under Settings → Networks.",
"generate_value": "Generate a random value",
"port_taken": "Port {port} is already in use — {suggested} suggested.",
"channel": "Channel",
"channel_stable_hint": "Stable releases only.",
"channel_beta_hint": "Also pre-releases (e.g. v0.5.10-beta1). Useful for testing, less predictable.",
"update_now": "Update now to v{version}",
"updating": "Working…",
"update_confirm": "Update Server Up to {tag}?\n\nThe container will be replaced and unreachable for a few seconds.",
"update_cannot_apply": "You can still update with a git tag; see docs/updates.md.",
"update_image": "Image (for updating from the interface)",
"update_image_hint": "Without a tag. Empty = derive from the running container.",
"registry_user": "Registry user (optional)",
"registry_token": "Registry token",
"registry_token_set": "•••••••• (set)",
"registry_token_ph": "only for a private registry",
"registry_token_hint": "Leave empty = unchanged.",
"last_checked": "Last checked",
"rollback": "Roll back",
"previous_version": "Previous version",
"rollback_button": "Back to this version",
"rollback_confirm": "Roll back to {tag}?",
"restarting": "Server Up is restarting…",
"updated_to": "Updated to v{version}",
"restart_slow": "Restart is taking a while — please refresh the page",
"backups": "Backups",
"no_backups": "No backups yet",
"restore": "Restore",
"restore_confirm": "Restore backup {file}?\n\nThe current files of this stack will be replaced and the stack restarted.",
"delete_backup_confirm": "Delete this backup?",
"backup_scope_warning": "A backup contains the stack's compose and .env files, not the Docker volumes holding the actual app data. See docs/backups.md.",
"backup_retention": "Retention",
"backup_keep": "Backups to keep per stack",
"backup_max_age": "Maximum age (days, 0 = no limit)",
"backup_schedule": "Scheduled backups",
"backup_schedule_off": "Off",
"backup_schedule_daily": "Daily",
"backup_schedule_weekly": "Weekly",
"backup_schedule_hour": "Around which hour",
"backup_before_update": "Automatically back up before updating a stack",
"backup_before_remove": "Automatically back up before removing a stack",
"check_updates": "Check for updates",
"stack_update_check": "Check daily whether newer images are available"
} }

View file

@ -1,5 +1,9 @@
{ {
"_meta": {"code": "nl", "name": "Nederlands", "flag": "🇳🇱"}, "_meta": {
"code": "nl",
"name": "Nederlands",
"flag": "🇳🇱"
},
"dashboard": "Dashboard", "dashboard": "Dashboard",
"stacks": "Stacks", "stacks": "Stacks",
"app_store": "App Store", "app_store": "App Store",
@ -115,5 +119,97 @@
"view_release": "Bekijk release", "view_release": "Bekijk release",
"update_api_url": "Releases-API URL", "update_api_url": "Releases-API URL",
"update_api_hint": "Forgejo/GitHub Releases-API die de laatste release teruggeeft. Leeg = uitgeschakeld.", "update_api_hint": "Forgejo/GitHub Releases-API die de laatste release teruggeeft. Leeg = uitgeschakeld.",
"include_prerelease": "Pre-releases (beta) meenemen" "include_prerelease": "Pre-releases (beta) meenemen",
"login": "Inloggen",
"login_failed": "Inloggen mislukt",
"logout": "Uitloggen",
"session_expired": "Sessie verlopen — log opnieuw in",
"setup_title": "Eerste account aanmaken",
"setup_hint": "Server Up beheert Docker op deze machine. Maak nu een beheerdersaccount aan — zolang dat niet gebeurd is, kan iedereen die deze pagina bereikt het account claimen.",
"create_account": "Account aanmaken",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"password_min": "Wachtwoord (min. 10 tekens)",
"password_repeat": "Wachtwoord herhalen",
"passwords_differ": "De wachtwoorden komen niet overeen.",
"security": "Beveiliging",
"logged_in_as": "ingelogd als {user}",
"users": "Gebruikers",
"add_user": "Gebruiker toevoegen",
"delete_user_confirm": "Gebruiker {name} verwijderen?",
"change_own_password": "Eigen wachtwoord wijzigen",
"current_password": "Huidig wachtwoord",
"new_password": "Nieuw wachtwoord",
"new_password_repeat": "Nieuw wachtwoord herhalen",
"change": "Wijzigen",
"auth_mode": "Hoe wordt er ingelogd?",
"auth_mode_local": "Lokale login",
"auth_mode_proxy": "Reverse proxy",
"auth_mode_both": "Beide",
"auth_mode_hint": "Met een reverse proxy (Authelia, Authentik, Cloudflare Access) komt de identiteit uit een header. Kies 'Beide' om dat te testen zonder je lokale login te verliezen.",
"proxy_header": "Identiteitsheader",
"proxy_header_hint": "Meestal Remote-User. Zorg dat je proxy deze header wist en zelf opnieuw zet.",
"trusted_proxies": "Vertrouwde proxy-adressen",
"trusted_proxies_hint": "Eén per regel, IP of CIDR. Alleen vanaf deze adressen wordt de header vertrouwd — zonder deze lijst kan iedereen zich voordoen als beheerder.",
"proxy_lockout_confirm": "In modus 'Reverse proxy' vervalt de lokale login. Weet je zeker dat je proxy werkt?",
"networks": "Netwerken",
"networks_hint": "Met een macvlan- of ipvlan-netwerk krijgt een stack een eigen IP-adres in je LAN in plaats van poorten op de host. Geen poortconflicten meer, en je kunt per app firewallen. Let op: de Docker-host zelf bereikt macvlan-containers niet zonder shim-interface.",
"add_network": "Netwerk toevoegen",
"network": "Netwerk",
"no_networks": "Nog geen netwerken.",
"delete_network_confirm": "Netwerk {name} verwijderen?",
"driver": "Driver",
"host_interface": "Host-interface",
"subnet": "Subnet",
"gateway": "Gateway",
"ip_range": "IP-range (optioneel)",
"ip_range_hint": "Beperkt de adressen die Docker uitdeelt tot een blok dat je in je DHCP-server hebt vrijgehouden. Zonder range gebruikt Docker het hele subnet.",
"next_free": "volgend vrij",
"ports_on_host": "Poorten op de host (standaard)",
"own_ip": "eigen IP",
"ip_address": "IP-adres",
"own_ip_hint": "De stack krijgt dit adres in het LAN. Poortmappings vervallen — de app is bereikbaar op zijn eigen poort. De Docker-host zelf kan een macvlan-container niet bereiken zonder shim-interface (zie docs/netwerken.md).",
"no_networks_hint": "Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.",
"generate_value": "Genereer een willekeurige waarde",
"port_taken": "Poort {port} is al in gebruik — {suggested} voorgesteld.",
"channel": "Kanaal",
"channel_stable_hint": "Alleen stabiele releases.",
"channel_beta_hint": "Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar.",
"update_now": "Nu bijwerken naar v{version}",
"updating": "Bezig…",
"update_confirm": "Server Up bijwerken naar {tag}?\n\nDe container wordt vervangen en is enkele seconden niet bereikbaar.",
"update_cannot_apply": "Bijwerken kan wel via een git-tag; zie docs/updates.md.",
"update_image": "Image (voor bijwerken vanuit de interface)",
"update_image_hint": "Zonder tag. Leeg = afleiden uit de draaiende container.",
"registry_user": "Registry-gebruiker (optioneel)",
"registry_token": "Registry-token",
"registry_token_set": "•••••••• (ingesteld)",
"registry_token_ph": "alleen bij een privé registry",
"registry_token_hint": "Leeg laten = ongewijzigd.",
"last_checked": "Laatst gecontroleerd",
"rollback": "Terugrollen",
"previous_version": "Vorige versie",
"rollback_button": "Terug naar deze versie",
"rollback_confirm": "Terugrollen naar {tag}?",
"restarting": "Server Up herstart…",
"updated_to": "Bijgewerkt naar v{version}",
"restart_slow": "Herstart duurt lang — ververs de pagina zelf",
"backups": "Backups",
"no_backups": "Nog geen backups",
"restore": "Terugzetten",
"restore_confirm": "Backup {file} terugzetten?\n\nDe huidige bestanden van deze stack worden vervangen en de stack wordt herstart.",
"delete_backup_confirm": "Deze backup verwijderen?",
"backup_scope_warning": "Een backup bevat de compose- en .env-bestanden van de stack, niet de Docker-volumes met de eigenlijke appdata. Zie docs/backups.md.",
"backup_retention": "Bewaarbeleid",
"backup_keep": "Aantal backups per stack",
"backup_max_age": "Maximale leeftijd (dagen, 0 = geen limiet)",
"backup_schedule": "Geplande backups",
"backup_schedule_off": "Uit",
"backup_schedule_daily": "Dagelijks",
"backup_schedule_weekly": "Wekelijks",
"backup_schedule_hour": "Rond welk uur",
"backup_before_update": "Automatisch een backup vóór het bijwerken van een stack",
"backup_before_remove": "Automatisch een backup vóór het verwijderen van een stack",
"check_updates": "Updates controleren",
"stack_update_check": "Dagelijks controleren of er nieuwere images zijn"
} }

186
tests/test_backups.py Normal file
View file

@ -0,0 +1,186 @@
"""Backups: maken, tonen, terugzetten, retentie — en de veiligheid daarvan."""
import io
import json
import tarfile
import time
from pathlib import Path
import pytest
from conftest import login
@pytest.fixture
def bk(env, monkeypatch):
from core import backups, docker
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
# Geen echte docker in de tests: stack stoppen/starten is een no-op.
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", inhoud="services:\n app:\n image: nginx\n"):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(inhoud, encoding="utf-8")
(d / ".env").write_text("PORT=8222\n", encoding="utf-8")
return d
def test_maken_en_tonen(bk, env):
_maak_stack(env)
meta = bk.create("vaultwarden")
assert meta["stack"] == "vaultwarden"
assert meta["size"] > 0
lijst = bk.list_for("vaultwarden")
assert len(lijst) == 1
assert lijst[0]["file"] == meta["file"]
assert lijst[0]["size_human"]
def test_maken_van_onbekende_stack(bk):
with pytest.raises(bk.BackupError):
bk.create("bestaatniet")
def test_terugzetten_herstelt_gewijzigd_bestand(bk, env):
d = _maak_stack(env)
meta = bk.create("vaultwarden")
(d / "docker-compose.yml").write_text("KAPOT", encoding="utf-8")
ok, msg = bk.restore("vaultwarden", meta["file"])
assert ok, msg
assert "image: nginx" in (d / "docker-compose.yml").read_text()
assert (d / ".env").read_text() == "PORT=8222\n"
def test_terugzetten_van_backup_van_andere_stack(bk, env):
_maak_stack(env, "app-a")
_maak_stack(env, "app-b")
meta = bk.create("app-a")
ok, msg = bk.restore("app-b", meta["file"])
assert ok is False
assert "hoort bij 'app-a'" in msg
@pytest.mark.parametrize("naam", [
"../../etc/passwd.tar.gz", "/etc/shadow.tar.gz", "geen-tijdstempel.tar.gz",
"vaultwarden_20260101_120000.txt", "", "..tar.gz",
])
def test_ongeldige_backupnaam(bk, naam):
with pytest.raises(bk.BackupError):
bk._archive_path(naam)
def test_terugzetten_weigert_archief_dat_buiten_de_map_schrijft(bk, env):
"""Een geprepareerd archief met '..' mag niets buiten de stackmap raken.
Met een kaal `tar xzf` zou dit bestand op de host belanden; tarfile met
filter='data' weigert de ingang.
"""
_maak_stack(env)
bd = Path(bk.backup_dir())
bd.mkdir(parents=True, exist_ok=True)
boos = bd / "vaultwarden_20260101_120000.tar.gz"
with tarfile.open(boos, "w:gz") as tar:
data = b"ontsnapt"
info = tarfile.TarInfo("../../ontsnapt.txt")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
ok, msg = bk.restore("vaultwarden", boos.name)
assert ok is False
assert not (env["tmp"] / "ontsnapt.txt").exists()
assert not (env["lib"].parent / "ontsnapt.txt").exists()
def test_mislukte_restore_zet_de_oude_map_terug(bk, env):
"""Een kapot archief mag je niet zonder stack achterlaten."""
d = _maak_stack(env)
bd = Path(bk.backup_dir())
bd.mkdir(parents=True, exist_ok=True)
stuk = bd / "vaultwarden_20260101_120000.tar.gz"
stuk.write_bytes(b"dit is geen gzip")
ok, _ = bk.restore("vaultwarden", stuk.name)
assert ok is False
assert d.is_dir()
assert "image: nginx" in (d / "docker-compose.yml").read_text()
def test_verwijderen(bk, env):
_maak_stack(env)
meta = bk.create("vaultwarden")
ok, _ = bk.delete(meta["file"])
assert ok
assert bk.list_for("vaultwarden") == []
def test_retentie_houdt_er_hoogstens_keep(bk, env):
_maak_stack(env)
env["core"].patch({"BACKUP_KEEP": 3})
metas = []
for i in range(5):
m = bk.create("vaultwarden")
# Tijdstempels uit elkaar trekken; de naam heeft secondeprecisie.
m["ts"] = time.time() - (5 - i)
_herschrijf_ts(bk, m)
metas.append(m)
bk.prune("vaultwarden")
assert len(bk.list_for("vaultwarden")) == 3
def test_retentie_bewaart_altijd_de_nieuwste(bk, env):
"""Ook als alles te oud is, blijft er één over."""
_maak_stack(env)
env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 1})
for i in range(3):
m = bk.create("vaultwarden")
m["ts"] = time.time() - 30 * 86400
_herschrijf_ts(bk, m)
bk.prune("vaultwarden")
assert len(bk.list_for("vaultwarden")) == 1
def test_retentie_uit_doet_niets(bk, env):
_maak_stack(env)
env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 0})
for _ in range(3):
bk.create("vaultwarden")
assert bk.prune("vaultwarden") == []
def _herschrijf_ts(bk, meta):
"""Zet de tijdstempel in het metadata-bestand terug in de tijd."""
p = Path(bk.backup_dir()) / meta["file"]
mf = p.with_suffix("").with_suffix(".json")
d = json.loads(mf.read_text())
d["ts"] = meta["ts"]
mf.write_text(json.dumps(d))
# ── API ──────────────────────────────────────────────────────────────────────
def test_endpoint_lijst(client, env, bk):
login(client)
_maak_stack(env)
bk.create("vaultwarden")
d = client.get("/api/backups").get_json()
assert len(d["backups"]) == 1
assert "BACKUP_KEEP" in d["settings"]
def test_endpoint_restore_weigert_ongeldige_naam(client, bk):
csrf = login(client)
r = client.post("/api/backups/vaultwarden/restore",
json={"file": "../../etc/passwd.tar.gz"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_endpoint_restore_zonder_bestand(client, bk):
csrf = login(client)
r = client.post("/api/backups/vaultwarden/restore", json={},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400

81
tests/test_i18n.py Normal file
View file

@ -0,0 +1,81 @@
"""Vertalingen: nl en en moeten dezelfde sleutels hebben, en de UI mag geen
sleutels gebruiken die nergens gedefinieerd zijn.
Zonder deze test loopt de vertaling stilletjes scheef zodra iemand een scherm
toevoegt precies wat er bij de auth-, netwerk- en update-schermen gebeurde.
"""
import json
import re
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent / "server-up"
VERTALINGEN = WORTEL / "translations"
TEMPLATE = WORTEL / "templates" / "index.html"
def _laad(code: str) -> dict:
data = json.loads((VERTALINGEN / f"{code}.json").read_text(encoding="utf-8"))
return {k: v for k, v in data.items() if not k.startswith("_")}
def test_nl_en_en_hebben_dezelfde_sleutels():
nl, en = _laad("nl"), _laad("en")
alleen_nl = sorted(set(nl) - set(en))
alleen_en = sorted(set(en) - set(nl))
assert not alleen_nl, f"Ontbreekt in en.json: {alleen_nl}"
assert not alleen_en, f"Ontbreekt in nl.json: {alleen_en}"
@pytest.mark.parametrize("code", ["nl", "en"])
def test_geen_lege_vertalingen(code):
leeg = [k for k, v in _laad(code).items() if not str(v).strip()]
assert not leeg, f"Lege waarden in {code}.json: {leeg}"
@pytest.mark.parametrize("code", ["nl", "en"])
def test_meta_is_compleet(code):
data = json.loads((VERTALINGEN / f"{code}.json").read_text(encoding="utf-8"))
meta = data.get("_meta", {})
assert meta.get("code") == code
assert meta.get("name")
def test_plaatshouders_komen_overeen():
"""`{user}` in het Nederlands moet ook in het Engels staan, anders valt er
bij het vertalen een variabele weg."""
nl, en = _laad("nl"), _laad("en")
for sleutel, tekst in nl.items():
vars_nl = set(re.findall(r"\{(\w+)\}", str(tekst)))
vars_en = set(re.findall(r"\{(\w+)\}", str(en.get(sleutel, ""))))
assert vars_nl == vars_en, f"{sleutel}: nl={vars_nl} en={vars_en}"
def test_ui_gebruikt_alleen_bestaande_sleutels():
regels = TEMPLATE.read_text(encoding="utf-8").splitlines()
# Commentaarregels overslaan: die noemen t('key') als voorbeeld.
html = "\n".join(r for r in regels if not r.lstrip().startswith(("//", "*", "/*")))
gebruikt = set(re.findall(r"\bt\('([a-z0-9_]+)'", html))
bekend = set(_laad("nl"))
onbekend = sorted(gebruikt - bekend)
assert not onbekend, f"t() verwijst naar niet-bestaande sleutels: {onbekend}"
def test_nieuwe_schermen_zijn_vertaald():
"""Steekproef op de schermen die in v0.5.x zijn toegevoegd."""
nl = _laad("nl")
for sleutel in ("login", "setup_title", "security", "auth_mode",
"trusted_proxies", "networks", "own_ip_hint", "channel",
"update_now", "rollback", "port_taken", "loading"):
assert sleutel in nl, f"vertaalsleutel ontbreekt: {sleutel}"
def test_i18n_module_leest_beide_talen(env):
from core import i18n
i18n.load()
codes = {t["code"] for t in i18n.available()}
assert {"nl", "en"} <= codes
# strings() valt terug op het Engels voor ontbrekende sleutels.
assert i18n.strings("nl")["login"]
assert i18n.strings("en")["login"]

173
tests/test_stackupdates.py Normal file
View file

@ -0,0 +1,173 @@
"""Updates per app: image-parsing, digest-vergelijking en randgevallen."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from core import registry
# ── Image-referenties ────────────────────────────────────────────────────────
@pytest.mark.parametrize("ref,verwacht", [
("nginx", ("registry-1.docker.io", "library/nginx", "latest")),
("nginx:1.27", ("registry-1.docker.io", "library/nginx", "1.27")),
("bes-r/app:2.0", ("registry-1.docker.io", "bes-r/app", "2.0")),
("ghcr.io/org/app", ("ghcr.io", "org/app", "latest")),
("ghcr.io/org/app:dev", ("ghcr.io", "org/app", "dev")),
("git.example.com:3000/bes-r/server-up:0.5.10",
("git.example.com:3000", "bes-r/server-up", "0.5.10")),
("localhost:5000/mijn/app", ("localhost:5000", "mijn/app", "latest")),
("vaultwarden/server:latest", ("registry-1.docker.io", "vaultwarden/server", "latest")),
])
def test_parse_image(ref, verwacht):
assert registry.parse_image(ref) == verwacht
def test_parse_image_negeert_digest():
host, repo, tag = registry.parse_image("nginx@sha256:" + "a" * 64)
assert (host, repo) == ("registry-1.docker.io", "library/nginx")
def test_parse_image_leeg():
assert registry.parse_image("") == ("", "", "")
def test_lokale_registry_gebruikt_http():
assert registry._scheme("localhost:5000") == "http"
assert registry._scheme("127.0.0.1:5000") == "http"
assert registry._scheme("git.example.com") == "https"
# ── Digest-vergelijking ──────────────────────────────────────────────────────
@pytest.fixture
def su(env):
from core import stackupdates
stackupdates.invalidate()
return stackupdates
@pytest.fixture
def reg(env):
"""Het registry-module-object ná de herimport door de env-fixture."""
from core import registry as verse_registry
return verse_registry
def _stack(env, naam="web", compose="services:\n app:\n image: nginx:1.27\n"):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(compose, encoding="utf-8")
return d
def test_update_gedetecteerd(su, reg, env, monkeypatch):
from core import docker
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:oud")
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:nieuw")
res = su.check_stack("web", _stack(env))
assert res["update_available"] is True
assert res["services"][0]["status"] == "update"
def test_geen_update_bij_gelijke_digest(su, reg, env, monkeypatch):
from core import docker
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:zelfde")
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:zelfde")
res = su.check_stack("web", _stack(env))
assert res["update_available"] is False
assert res["services"][0]["status"] == "current"
def test_lokaal_gebouwd_image_is_onbekend_geen_update(su, reg, env, monkeypatch):
"""Zonder RepoDigest valt er niets te vergelijken — dat mag geen update
melden, anders staat er permanent een badge."""
from core import docker
monkeypatch.setattr(docker, "image_digest_local", lambda i: "")
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:x")
res = su.check_stack("web", _stack(env))
assert res["update_available"] is False
assert res["services"][0]["status"] == "unknown"
def test_onbereikbare_registry_is_onbekend_geen_update(su, reg, env, monkeypatch):
from core import docker
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:oud")
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "")
res = su.check_stack("web", _stack(env))
assert res["update_available"] is False
assert res["services"][0]["status"] == "unknown"
assert "registry" in res["services"][0]["reason"]
def test_variabele_in_image_wordt_overgeslagen(su, env):
d = _stack(env, compose="services:\n app:\n image: nginx:${TAG}\n")
assert su._images_uit_compose(d) == []
def test_meerdere_services(su, reg, env, monkeypatch):
from core import docker
compose = ("services:\n"
" web:\n image: nginx:1.27\n"
" db:\n image: postgres:16\n")
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:oud")
monkeypatch.setattr(reg, "manifest_digest",
lambda i, c=None: "sha256:nieuw" if "nginx" in i else "sha256:oud")
res = su.check_stack("web", _stack(env, compose=compose))
statussen = {d["service"]: d["status"] for d in res["services"]}
assert statussen == {"web": "update", "db": "current"}
assert res["update_available"] is True
def test_check_all_cachet(su, reg, env, monkeypatch):
from core import docker
_stack(env)
tellers = {"n": 0}
def _tel(image):
tellers["n"] += 1
return "sha256:oud"
monkeypatch.setattr(docker, "image_digest_local", _tel)
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:oud")
su.check_all(force=True)
n_na_eerste = tellers["n"]
su.check_all()
assert tellers["n"] == n_na_eerste
def test_kapotte_stack_blokkeert_de_rest_niet(su, reg, env, monkeypatch):
from core import docker
_stack(env, "goed")
_stack(env, "stuk")
def _ontploft(image):
raise RuntimeError("registry weg")
monkeypatch.setattr(docker, "image_digest_local",
lambda i: (_ontploft(i) if False else "sha256:x"))
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:x")
res = su.check_all(force=True)
assert set(res) == {"goed", "stuk"}
# ── Endpoints ────────────────────────────────────────────────────────────────
def test_endpoint_updates_botst_niet_met_stack_routes(client, su):
"""/api/stacks/updates mag niet als stacknaam 'updates' gelezen worden."""
from conftest import login
login(client)
r = client.get("/api/stacks/updates")
assert r.status_code == 200
assert "stacks" in r.get_json()
def test_endpoint_credentials_alleen_bij_zelfde_registry(su, env):
env["core"].patch({"UPDATE_REGISTRY_USER": "bes-r",
"UPDATE_REGISTRY_TOKEN": "geheim",
"UPDATE_IMAGE": "git.example.com/bes-r/server-up"})
assert su._registry_creds("git.example.com/bes-r/iets") == ("bes-r", "geheim")
assert su._registry_creds("nginx:1.27") is None