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