From d62ff53062b8cff2785b97f460ffbfc32465b654 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Wed, 26 Aug 2026 00:25:11 +0200 Subject: [PATCH] Datensicherung: taeglicher Stand und geprueft Wiederherstellung Bisher gab es keine Sicherung. Ein Plattenfehler oder ein falsches DELETE haette Kunden, Projekte, Zahlungen und Widerrufsnachweise endgueltig gekostet. sicherung.sh legt taeglich einen Stand an -- mit SQLites eigenem ".backup", nicht mit "cp". Der Grund ist messbar: Die Datenbank ist 778 KB gross, ihr WAL 4,1 MB. Eine Kopie der .db allein waere also nicht bloss veraltet, sondern weitgehend leer. Ein Gegentest mit einer frisch beschriebenen Datenbank zeigte 4 KB in der .db gegen 2,1 MB im WAL. Jeder Stand wird sofort nach dem Anlegen geprueft (integrity_check und Mindestzahl an Tabellen) -- eine Sicherung, die niemand geoeffnet hat, ist keine. 14 taegliche Staende, sonntags zusaetzlich ein Wochenstand, 8 davon: Eine still fortschreitende Verfaelschung faellt manchmal erst nach Wochen auf, wenn alle taeglichen Staende sie schon enthalten. wiederherstellen.sh geht den Weg zurueck: Sicherung erst pruefen, dann Dienst anhalten, bisherigen Stand beiseiteraeumen statt loeschen, einspielen, Dienst starten und nachsehen, ob er laeuft. Am Server geprueft: 44 Tabellen gegen das Original verglichen, 0 Abweichungen; 7 von 7 Uploads im Archiv; Rotation 17 -> 14 entfernt genau die aeltesten; Wiederherstellung spielte 100 Kunden ueber 300 und rettete die 300 nach beiseite. Eine Annahme wurde dabei widerlegt und der Kommentar entsprechend korrigiert: Ein zurueckgelassenes WAL vermischt NICHT zwei Staende -- SQLite erkennt an der Kennung, dass es nicht dazugehoert, und verwirft es. Der echte Schutz ist das Anhalten des Dienstes. Co-Authored-By: Claude Opus 5 --- server-internal/sicherung.sh | 125 +++++++++++++++++++++++ server-internal/wiederherstellen.sh | 152 ++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100755 server-internal/sicherung.sh create mode 100755 server-internal/wiederherstellen.sh diff --git a/server-internal/sicherung.sh b/server-internal/sicherung.sh new file mode 100755 index 00000000..18288de7 --- /dev/null +++ b/server-internal/sicherung.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# ===================================================================== +# Taegliche Sicherung des internen Bereichs. +# +# WARUM NICHT EINFACH "cp" +# +# SQLite schreibt im WAL-Betrieb nicht direkt in die .db-Datei, sondern +# zuerst in eine Begleitdatei (.db-wal). Auf diesem Server ist dieses +# WAL 4,1 MB gross -- also groesser als die Datenbank selbst mit +# 778 KB. Eine Kopie der .db allein waere damit nicht bloss veraltet, +# sondern unvollstaendig: Alles seit dem letzten Zusammenfuehren fehlt. +# +# Kopiert man beide Dateien nacheinander, waehrend geschrieben wird, +# passen sie ausserdem womoeglich nicht zusammen -- das Ergebnis ist +# eine Datei, die erst beim Wiederherstellen als kaputt auffaellt. Und +# das ist der schlechteste Zeitpunkt. +# +# ".backup" macht es richtig: Der Befehl kennt das WAL, sperrt sauber +# und liefert einen in sich stimmigen Stand, auch waehrend der Dienst +# weiterlaeuft. +# +# WAS GESICHERT WIRD +# - die Datenbank (Kunden, Projekte, Zahlungen, Widerrufsnachweise) +# - die hochgeladenen Dateien (rund 16 MB) +# +# Beides gehoert zusammen: Eine Datenbank, die auf Dateien verweist, die +# es nicht mehr gibt, ist nur die halbe Wiederherstellung. +# ===================================================================== + +set -uo pipefail + +QUELLE_DB="/var/lib/dogfather-internal/dogfather-internal.db" +QUELLE_UPLOADS="/var/lib/dogfather-internal/uploads" +ZIEL="/var/backups/dogfather" +PROTOKOLL="$ZIEL/sicherung.log" + +# Wie viele Staende aufbewahrt werden. +TAEGLICH=14 +WOECHENTLICH=8 + +melde() { + echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$PROTOKOLL" + echo "$*" +} + +fehler() { + melde "FEHLER: $*" + exit 1 +} + +mkdir -p "$ZIEL/taeglich" "$ZIEL/woechentlich" || fehler "Zielverzeichnis nicht anlegbar" + +STEMPEL=$(date '+%Y-%m-%d') +ARBEIT="$ZIEL/taeglich/$STEMPEL" +mkdir -p "$ARBEIT" + +# --------------------------------------------------------------------- +# 1. Datenbank +# --------------------------------------------------------------------- +[ -f "$QUELLE_DB" ] || fehler "Datenbank nicht gefunden: $QUELLE_DB" + +sqlite3 "$QUELLE_DB" ".backup '$ARBEIT/dogfather-internal.db'" \ + || fehler "Sicherung der Datenbank fehlgeschlagen" + +# Eine Sicherung, die niemand geprueft hat, ist keine Sicherung. +# Der Test kostet Sekunden und beantwortet die einzige Frage, auf die es +# im Ernstfall ankommt: Laesst sich die Datei ueberhaupt oeffnen? +PRUEFUNG=$(sqlite3 "$ARBEIT/dogfather-internal.db" "PRAGMA integrity_check;" 2>&1) +[ "$PRUEFUNG" = "ok" ] || fehler "Sicherung ist beschaedigt: $PRUEFUNG" + +# Gegenprobe auf Inhalt: Eine formal heile, aber leere Datei waere der +# stillste aller Datenverluste. +TABELLEN=$(sqlite3 "$ARBEIT/dogfather-internal.db" \ + "SELECT count(*) FROM sqlite_master WHERE type='table';" 2>/dev/null) +[ "${TABELLEN:-0}" -gt 5 ] || fehler "Sicherung enthaelt nur $TABELLEN Tabellen -- zu wenig" + +# --------------------------------------------------------------------- +# 2. Hochgeladene Dateien +# --------------------------------------------------------------------- +if [ -d "$QUELLE_UPLOADS" ]; then + tar -czf "$ARBEIT/uploads.tar.gz" -C "$(dirname "$QUELLE_UPLOADS")" \ + "$(basename "$QUELLE_UPLOADS")" 2>/dev/null \ + || fehler "Sicherung der Dateien fehlgeschlagen" + tar -tzf "$ARBEIT/uploads.tar.gz" > /dev/null 2>&1 \ + || fehler "Dateisicherung ist nicht lesbar" +else + melde "Hinweis: kein Upload-Verzeichnis vorhanden" +fi + +GROESSE=$(du -sh "$ARBEIT" | cut -f1) +melde "Sicherung $STEMPEL angelegt ($GROESSE, $TABELLEN Tabellen)" + +# --------------------------------------------------------------------- +# 3. Wochenstand +# +# Sonntags wandert eine Ausfertigung in die laengere Aufbewahrung. Der +# Grund ist nicht Platz, sondern Zeit: Ein Fehler, der eine Datenbank +# still verdirbt, faellt manchmal erst nach Wochen auf -- und dann sind +# vierzehn taegliche Staende alle schon vom selben Fehler betroffen. +# --------------------------------------------------------------------- +if [ "$(date '+%u')" = "7" ]; then + cp -r "$ARBEIT" "$ZIEL/woechentlich/$STEMPEL" 2>/dev/null \ + && melde "Wochenstand $STEMPEL abgelegt" +fi + +# --------------------------------------------------------------------- +# 4. Alte Staende entfernen +# --------------------------------------------------------------------- +entferne_alte() { + local ordner="$1" behalten="$2" + local anzahl + anzahl=$(find "$ordner" -mindepth 1 -maxdepth 1 -type d | wc -l) + if [ "$anzahl" -gt "$behalten" ]; then + find "$ordner" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \ + | sort | head -n "$((anzahl - behalten))" \ + | while read -r alt; do + rm -rf "${ordner:?}/$alt" && melde "alten Stand entfernt: $alt" + done + fi +} +entferne_alte "$ZIEL/taeglich" "$TAEGLICH" +entferne_alte "$ZIEL/woechentlich" "$WOECHENTLICH" + +melde "fertig" +exit 0 diff --git a/server-internal/wiederherstellen.sh b/server-internal/wiederherstellen.sh new file mode 100755 index 00000000..c286b10e --- /dev/null +++ b/server-internal/wiederherstellen.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# ===================================================================== +# Wiederherstellung aus einer Sicherung. +# +# Aufruf: sudo bash wiederherstellen.sh 2026-08-26 +# sudo bash wiederherstellen.sh (zeigt die Auswahl) +# +# WARUM ES DIESES SKRIPT GIBT +# +# Wiederherstellen passiert immer im schlechtesten Moment: Etwas ist +# kaputt, es eilt, und man tippt Befehle, die man seit Monaten nicht +# gebraucht hat. Genau dann werden Fehler gemacht, die den Schaden +# vergroessern. Deshalb ist der Weg hier einmal in Ruhe aufgeschrieben +# und geprueft worden. +# +# DIE BEGLEITDATEIEN +# +# Neben der Datenbank liegen ".db-wal" und ".db-shm". Beide werden hier +# mit beiseitegeraeumt. +# +# Zur Ehrlichkeit: Das wurde nachgemessen, und SQLite schuetzt sich +# dagegen besser als oft behauptet. Jedes WAL traegt eine Kennung, die +# zu seiner Datenbank passen muss; stimmt sie nicht, wird das WAL +# verworfen statt angewendet. Ein zurueckgelassenes WAL fuehrt also in +# aller Regel NICHT zur gefuerchteten Vermischung zweier Staende. +# +# Der Grund fuers Aufraeumen ist ein anderer, und der ist real: Solange +# der Dienst laeuft, haelt er die alte Datenbank offen. Tauscht man die +# Datei unter ihm aus, schreibt er weiter -- gegen eine Datei, die es so +# nicht mehr gibt. Deshalb ist Schritt 2 (Dienst anhalten) der +# eigentliche Schutz, und das Wegraeumen der Begleitdateien sorgt +# dafuer, dass danach ein sauberer, eindeutiger Zustand vorliegt statt +# Resten, die beim naechsten Problem niemand mehr einordnen kann. +# +# NICHTS WIRD GELOESCHT +# +# Der bisherige Stand wird nicht ueberschrieben, sondern zur Seite +# gelegt. Stellt sich heraus, dass die Wiederherstellung die falsche +# Entscheidung war, ist der Weg zurueck noch offen. +# ===================================================================== + +set -uo pipefail + +ZIEL_DB="/var/lib/dogfather-internal/dogfather-internal.db" +ZIEL_UPLOADS="/var/lib/dogfather-internal/uploads" +SICHERUNGEN="/var/backups/dogfather" +DIENST="dogiintern.service" + +if [ "$(id -u)" != "0" ]; then + echo "Bitte mit sudo starten -- sonst koennen Rechte und Dienst nicht gesetzt werden." + exit 1 +fi + +STAND="${1:-}" + +if [ -z "$STAND" ]; then + echo "Verfuegbare Staende:" + echo + echo " taeglich:" + ls -1 "$SICHERUNGEN/taeglich" 2>/dev/null | sort -r | sed 's/^/ /' + echo " woechentlich:" + ls -1 "$SICHERUNGEN/woechentlich" 2>/dev/null | sort -r | sed 's/^/ /' + echo + echo "Aufruf: sudo bash $0 " + exit 0 +fi + +QUELLE="" +for ordner in "$SICHERUNGEN/taeglich/$STAND" "$SICHERUNGEN/woechentlich/$STAND"; do + [ -d "$ordner" ] && QUELLE="$ordner" && break +done +[ -n "$QUELLE" ] || { echo "Stand '$STAND' nicht gefunden."; exit 1; } + +echo "Stand: $QUELLE" + +# --------------------------------------------------------------------- +# 1. Die Sicherung PRUEFEN, bevor irgendetwas angefasst wird +# +# Erst pruefen, dann eingreifen. Andersherum stuende man mit einem +# gestoppten Dienst und einer unbrauchbaren Sicherung da. +# --------------------------------------------------------------------- +[ -f "$QUELLE/dogfather-internal.db" ] || { echo "Keine Datenbank in diesem Stand."; exit 1; } + +PRUEFUNG=$(sqlite3 "$QUELLE/dogfather-internal.db" "PRAGMA integrity_check;" 2>&1) +[ "$PRUEFUNG" = "ok" ] || { echo "Diese Sicherung ist beschaedigt: $PRUEFUNG"; exit 1; } + +TABELLEN=$(sqlite3 "$QUELLE/dogfather-internal.db" \ + "SELECT count(*) FROM sqlite_master WHERE type='table';") +echo "Geprueft: unversehrt, $TABELLEN Tabellen" +echo + +read -r -p "Diesen Stand einspielen? Der aktuelle wird beiseitegelegt. [ja/nein] " ANTWORT +[ "$ANTWORT" = "ja" ] || { echo "Abgebrochen."; exit 0; } + +BEISEITE="/var/backups/dogfather/vor-wiederherstellung-$(date '+%Y-%m-%d_%H%M%S')" +mkdir -p "$BEISEITE" + +# --------------------------------------------------------------------- +# 2. Dienst anhalten +# +# Eine Datenbank auszutauschen, waehrend ein Programm sie offen haelt, +# fuehrt zu genau der Vermischung, die oben beschrieben ist. +# --------------------------------------------------------------------- +systemctl stop "$DIENST" && echo "Dienst angehalten" +sleep 1 + +# --------------------------------------------------------------------- +# 3. Bisherigen Stand beiseiteraeumen -- samt Begleitdateien +# --------------------------------------------------------------------- +BESITZER=$(stat -c '%U:%G' "$ZIEL_DB" 2>/dev/null || echo "root:root") + +for datei in "$ZIEL_DB" "$ZIEL_DB-wal" "$ZIEL_DB-shm"; do + [ -f "$datei" ] && mv "$datei" "$BEISEITE/" && echo "beiseite: $(basename "$datei")" +done + +if [ -d "$ZIEL_UPLOADS" ] && [ -f "$QUELLE/uploads.tar.gz" ]; then + mv "$ZIEL_UPLOADS" "$BEISEITE/uploads" && echo "beiseite: uploads" +fi + +# --------------------------------------------------------------------- +# 4. Einspielen +# --------------------------------------------------------------------- +cp "$QUELLE/dogfather-internal.db" "$ZIEL_DB" +chown "$BESITZER" "$ZIEL_DB" +chmod 640 "$ZIEL_DB" +echo "Datenbank eingespielt" + +if [ -f "$QUELLE/uploads.tar.gz" ]; then + tar -xzf "$QUELLE/uploads.tar.gz" -C "$(dirname "$ZIEL_UPLOADS")" + chown -R "$BESITZER" "$ZIEL_UPLOADS" + echo "Dateien eingespielt ($(find "$ZIEL_UPLOADS" -type f | wc -l) Stueck)" +fi + +# --------------------------------------------------------------------- +# 5. Dienst starten und nachsehen, ob er auch laeuft +# --------------------------------------------------------------------- +systemctl start "$DIENST" +sleep 3 + +if systemctl is-active --quiet "$DIENST"; then + echo + echo "Fertig. Der Dienst laeuft." + echo "Der vorherige Stand liegt in: $BEISEITE" + echo "Bitte im Verwaltungsbereich nachsehen, ob die Daten stimmen," + echo "bevor dieser Ordner geloescht wird." +else + echo + echo "ACHTUNG: Der Dienst startet nicht." + echo " systemctl status $DIENST" + echo "Der vorherige Stand liegt unversehrt in: $BEISEITE" + exit 1 +fi