Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2m8s
- bij de installatie wordt gevraagd onder welk account Server Up draait: een nieuw systeemaccount 'serverup', het account waarmee je werkt, een bestaand account, of root zoals voorheen (--user NAAM slaat de vraag over) - nieuw docker-entrypoint.sh: start als root, zet /data, stacks en backups klaar, en zakt met setpriv af naar SU_UID:SU_GID met de capabilities die nodig blijven (chown, dac_override, fowner - alle drie al in Docker's standaardset) - appdata blijft daarbij bewust ongemoeid; die mappen zijn van de apps zelf - de gid van de docker-socket wordt uit de socket zelf gelezen, zodat het account niet in de docker-groep hoeft (dat zou root op de host geven) - installatieformulier vult PUID/PGID met de ids waaronder Server Up draait in plaats van de 1000 die 44 sjablonen blind noemen - een onleesbaar bestand laat de rest van de backup niet meer sneuvelen; wat ontbreekt komt in het log en in de metadata (skipped) - terugzetten herstelt het eigenaarschap van appdata, dat tarfile met filter="data" laat vallen - CI controleert in een echte container dat setpriv bestaat, dat er wordt afgezakt, dat de socket-gid meekomt, dat /data schrijfbaar is en dat SU_UID=0 root laat blijven - SU_UID=0 in .env is de ontsnappingsklep naar het oude gedrag Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
406 lines
15 KiB
Python
406 lines
15 KiB
Python
"""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
|
|
|
|
|
|
# ── De appdata en de databases ───────────────────────────────────────────────
|
|
# Een backup bevatte alleen de stackmap. LIBRARY_DIR en de appdata zijn buren,
|
|
# geen genestelde mappen, dus je zette een backup terug en hield een lege app
|
|
# over — met een groen vinkje erbij.
|
|
|
|
def _stack_met_appdata(env, naam="umami", inhoud=b"gegevens"):
|
|
"""Een geïnstalleerde stack met een appdata-map ernaast."""
|
|
import json as _json
|
|
d = env["lib"] / naam
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "docker-compose.yml").write_text(
|
|
"services:\n"
|
|
f" {naam}:\n"
|
|
" image: nginx\n"
|
|
f" {naam}-db:\n"
|
|
" image: postgres:16-alpine\n"
|
|
f" container_name: {naam}-db\n"
|
|
" environment:\n"
|
|
" - POSTGRES_USER=umami\n"
|
|
" - POSTGRES_DB=umami\n"
|
|
" - POSTGRES_PASSWORD=${DB_PASSWORD}\n")
|
|
(d / ".env").write_text("DB_PASSWORD=geheim123\n")
|
|
appdata = Path(str(env["tmp"])) / "appdata"
|
|
(appdata / naam).mkdir(parents=True, exist_ok=True)
|
|
(appdata / f"{naam}-db").mkdir(parents=True, exist_ok=True)
|
|
(appdata / naam / "bestand.txt").write_bytes(inhoud)
|
|
(d / ".serverup.json").write_text(_json.dumps({
|
|
"source": naam,
|
|
"values": {"appdata_dir": str(appdata), "service_name": naam},
|
|
}))
|
|
return d, appdata
|
|
|
|
|
|
def test_appdata_gaat_mee_in_het_archief(env, monkeypatch):
|
|
import tarfile
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
_stack_met_appdata(env)
|
|
|
|
meta = backups.create("umami")
|
|
with tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
|
namen = tar.getnames()
|
|
assert "umami/docker-compose.yml" in namen
|
|
assert "appdata/umami/bestand.txt" in namen
|
|
assert "appdata/umami-db" in namen
|
|
assert meta["appdata"] == ["umami", "umami-db"]
|
|
|
|
|
|
def test_appdata_kan_uit(env, monkeypatch):
|
|
"""Bij een mediabibliotheek loopt dit in de honderden gigabytes."""
|
|
import tarfile
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
_stack_met_appdata(env)
|
|
|
|
meta = backups.create("umami", appdata=False)
|
|
with tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
|
namen = tar.getnames()
|
|
assert not [n for n in namen if n.startswith("appdata/")]
|
|
assert meta["appdata"] == []
|
|
|
|
|
|
def test_terugzetten_herstelt_de_appdata(env, monkeypatch):
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
|
|
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
|
|
_d, appdata = _stack_met_appdata(env)
|
|
|
|
meta = backups.create("umami")
|
|
(appdata / "umami" / "bestand.txt").write_bytes(b"kapot")
|
|
|
|
ok, msg = backups.restore("umami", meta["file"])
|
|
assert ok, msg
|
|
assert (appdata / "umami" / "bestand.txt").read_bytes() == b"gegevens"
|
|
|
|
|
|
def test_databasedienst_wordt_herkend_met_wachtwoord_uit_env(env, monkeypatch):
|
|
"""Het wachtwoord staat als ${DB_PASSWORD} in compose en met de waarde in
|
|
.env — zonder die stap draait pg_dump met een lege sleutel."""
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
_stack_met_appdata(env)
|
|
|
|
diensten = backups.databasediensten("umami")
|
|
assert len(diensten) == 1
|
|
d = diensten[0]
|
|
assert d["container"] == "umami-db"
|
|
assert d["soort"] == "postgres"
|
|
assert d["gebruiker"] == "umami"
|
|
assert d["wachtwoord"] == "geheim123"
|
|
|
|
|
|
def test_dump_die_mislukt_wordt_gemeld_en_niet_stil_overgeslagen(env, monkeypatch):
|
|
"""Een archief dat compleet lijkt terwijl de database ontbreekt is het
|
|
ergste dat een backup kan doen."""
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt(b"container niet gestart"))
|
|
_stack_met_appdata(env)
|
|
|
|
regels = []
|
|
meta = backups.create("umami", log_fn=regels.append)
|
|
assert meta["dumps"] == []
|
|
assert any("Geen dump van umami-db" in r for r in regels), regels
|
|
|
|
|
|
def test_dump_belandt_in_het_archief(env, monkeypatch):
|
|
import tarfile
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run",
|
|
lambda *a, **k: _gelukt(b"-- pg_dump\nCREATE TABLE x;\n"))
|
|
_stack_met_appdata(env)
|
|
|
|
meta = backups.create("umami")
|
|
with tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
|
namen = tar.getnames()
|
|
inhoud = tar.extractfile("dumps/umami-db.sql").read()
|
|
assert "dumps/umami-db.sql" in namen
|
|
assert b"CREATE TABLE x;" in inhoud
|
|
assert meta["dumps"] == ["umami-db.sql"]
|
|
|
|
|
|
def _mislukt(stderr=b"fout"):
|
|
import subprocess
|
|
return subprocess.CompletedProcess([], 1, b"", stderr)
|
|
|
|
|
|
def _gelukt(stdout=b""):
|
|
import subprocess
|
|
return subprocess.CompletedProcess([], 0, stdout, b"")
|
|
|
|
|
|
# ── Rechten: wie is de eigenaar van wat er teruggezet wordt? ─────────────────
|
|
# Server Up draait vanaf v0.10 niet meer als root. Twee gevolgen die eerder
|
|
# geen rol speelden: appdata van een app met een eigen uid kan buiten bereik
|
|
# liggen, en `tarfile` met filter="data" geeft alles aan wie uitpakt.
|
|
|
|
def test_een_onleesbaar_bestand_laat_de_rest_van_de_backup_staan(env, monkeypatch):
|
|
"""Eén bestand waar we niet bij kunnen mocht niet de héle backup slopen —
|
|
juist dan heb je hem nodig."""
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
_d, appdata = _stack_met_appdata(env)
|
|
(appdata / "umami" / "tweede.txt").write_bytes(b"deze wel")
|
|
|
|
# Geen namaak: een bestand waar de eigenaar zelf niet in kan kijken. Precies
|
|
# wat je krijgt als een container zijn data als een andere uid wegschrijft.
|
|
onleesbaar = appdata / "umami" / "bestand.txt"
|
|
onleesbaar.chmod(0o000)
|
|
try:
|
|
meta = backups.create("umami")
|
|
finally:
|
|
onleesbaar.chmod(0o644)
|
|
|
|
assert meta["skipped"] == ["bestand.txt"], meta
|
|
with backups.tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
|
namen = tar.getnames()
|
|
assert "appdata/umami/tweede.txt" in namen, "de rest is ook weggevallen"
|
|
assert "appdata/umami/bestand.txt" not in namen
|
|
|
|
|
|
def test_wat_ontbreekt_staat_in_de_metadata(env, monkeypatch):
|
|
"""Een archief dat compleet lijkt maar het niet is, is erger dan een
|
|
archief dat zegt wat het mist."""
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
_stack_met_appdata(env)
|
|
meta = backups.create("umami")
|
|
assert meta["skipped"] == [], "een geslaagde backup meldt niets ontbrekends"
|
|
assert "skipped" in backups.list_for("umami")[0]
|
|
|
|
|
|
def test_teruggezette_appdata_krijgt_de_puid_van_de_stack(env, monkeypatch):
|
|
"""filter="data" laat uid/gid vallen, dus zonder chown wordt alles van wie
|
|
uitpakt — en start de app niet meer."""
|
|
import json as _json
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
|
|
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
|
|
d, appdata = _stack_met_appdata(env)
|
|
meta_pad = d / ".serverup.json"
|
|
inhoud = _json.loads(meta_pad.read_text())
|
|
inhoud["values"].update({"puid": "1005", "pgid": "1006"})
|
|
meta_pad.write_text(_json.dumps(inhoud))
|
|
|
|
meta = backups.create("umami")
|
|
gevraagd = []
|
|
monkeypatch.setattr(backups.os, "chown",
|
|
lambda p, u, g, **kw: gevraagd.append((str(p), u, g)))
|
|
ok, msg = backups.restore("umami", meta["file"])
|
|
|
|
assert ok, msg
|
|
assert gevraagd, "er is helemaal niet gechownd"
|
|
assert all(u == 1005 and g == 1006 for _p, u, g in gevraagd), gevraagd
|
|
|
|
|
|
def test_zonder_puid_valt_hij_terug_op_ons_eigen_account(env):
|
|
from core import backups
|
|
import os as _os
|
|
assert backups._verwachte_eigenaar({}) == (_os.getuid(), _os.getgid())
|
|
assert backups._verwachte_eigenaar({"puid": "1005", "pgid": "1006"}) == (1005, 1006)
|
|
# Een sjabloon met onzin erin mag geen TypeError geven.
|
|
assert backups._verwachte_eigenaar({"puid": "abc"}) == (_os.getuid(), _os.getgid())
|
|
|
|
|
|
def test_chown_die_niet_mag_stopt_het_terugzetten_niet(env, monkeypatch, tmp_path):
|
|
"""Draai je zonder de rechten om te chownen, dan is een waarschuwing beter
|
|
dan een mislukt herstel."""
|
|
from core import backups
|
|
monkeypatch.setattr(backups.os, "chown",
|
|
lambda *a, **k: (_ for _ in ()).throw(PermissionError("mag niet")))
|
|
map_ = tmp_path / "appdata-umami"
|
|
map_.mkdir()
|
|
(map_ / "a.txt").write_text("x")
|
|
regels = []
|
|
assert backups._zet_eigenaar(map_, 1005, 1006, log_fn=regels.append) is False
|
|
assert any("eigenaar" in r for r in regels), regels
|