Dokumente mitsichern - eine Akte ohne ihre Dateien ist wertlos
Der Sicherungsdienst machte taeglich ein pg_dump und sonst nichts. Nach einem Plattenschaden waere die Akte vollstaendig wiederhergestellt worden - mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das der schlechteste denkbare Zustand. Je Lauf entstehen jetzt zwei verschluesselte Dateien mit demselben Zeitstempel: der Datenbankstand und ein Archiv des Ablageverzeichnisses. Beide mit Pruefsumme, beide in der Aufbewahrung, beide im Monatsstand, beide in der Selbstpruefung. Schlaegt der Dokumententeil fehl, bricht der Lauf ab und raeumt den angefangenen Datenbankstand wieder weg. Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber vollstaendig aussieht, ist schlimmer als gar keine: Man verlaesst sich darauf und merkt es erst im Ernstfall. ## Abweichung von der urspruenglichen Idee Im Issue stand "mc mirror statt rohem Verzeichnis". Beim Umsetzen zeigte sich, dass der MinIO-Client in diesem Behaelter fehlt und bei jedem Lauf aus dem Netz geholt werden muesste. Eine Internetabhaengigkeit im Sicherungspfad ist genau das, was man dort nicht will. Stattdessen wird das Verzeichnis direkt eingepackt. Das ist vertretbar, weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp schreibt und dann an seinen Platz umbenennt: Ein laufender Upload liegt nie halbfertig im Archiv, er fehlt hoechstens. Derselbe Weg deckt den Dateitreiber mit ab. ## Nur lesend Der Sicherungsdienst bekommt das Ablageverzeichnis mit ":ro" eingehaengt. Er laeuft unbeaufsichtigt per cron; ein Fehler im Skript oder ein kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal treffen - genau der Fall, gegen den eine Sicherung da ist. Deshalb schreibt die Wiederherstellung die Dateien auch nicht selbst an ihren Platz, sondern packt sie nach ./backups aus und gibt den letzten Handgriff als Einzeiler aus. In ein laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand ohnehin beschaedigen. ## Geprueft Die Kernlogik einmal von Hand durchgespielt: packen, verschluesseln, Selbstpruefung, zurueckholen - der Inhalt kam bitgleich zurueck, der Klartext war im Archiv nicht auffindbar, und die Selbstpruefung schlug sowohl bei einem beschaedigten Archiv als auch bei falschem Passwort an. Dazu elf Pruefungen in tests/skripte.test.ts gegen vier Mutationen: Dokumententeil entfernt, Abbruch entfernt, Aufraeumen halbiert, Schreibrecht erteilt - jede wird rot. Refs #41
This commit is contained in:
+81
-18
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Taegliche Sicherung der Datenbank
|
||||
# Taegliche Sicherung: Datenbank UND Dokumente
|
||||
# =============================================================================
|
||||
#
|
||||
# Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand:
|
||||
@@ -10,17 +10,44 @@
|
||||
# Was passiert:
|
||||
# 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen
|
||||
# wiederherstellbar bleiben.
|
||||
# 2. Verschluesselung mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
|
||||
# 2. Die abgelegten Dokumente als Archiv.
|
||||
# 3. Beides verschluesselt mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
|
||||
# Das Backup liegt oft auf fremdem Speicher - unverschluesselt waere
|
||||
# es ein Datenleck mit Ansage.
|
||||
# 3. SHA-256-Pruefsumme daneben.
|
||||
# 4. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
|
||||
# 4. SHA-256-Pruefsumme je Datei.
|
||||
# 5. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
|
||||
# Der jeweils erste Stand eines Monats bleibt stehen.
|
||||
#
|
||||
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder, aber NICHT
|
||||
# den Schluessel ENC_KEY_V1. Ohne ihn sind Diagnosen, IBANs und Notizen
|
||||
# auch aus einer wiederhergestellten Datenbank nicht lesbar. Den Schluessel
|
||||
# getrennt sichern - Passwortmanager oder ausgedruckt im Ordner.
|
||||
# ## Warum die Dokumente dazugehoeren
|
||||
#
|
||||
# Bis v1.11.0 sicherte dieses Skript nur die Datenbank. Nach einem
|
||||
# Plattenschaden waere die Akte vollstaendig wiederhergestellt worden -
|
||||
# mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
|
||||
# Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
|
||||
# belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
|
||||
# der schlechteste denkbare Zustand.
|
||||
#
|
||||
# ## Warum das Ablageverzeichnis roh eingepackt wird
|
||||
#
|
||||
# Der naheliegende Weg waere "mc mirror" aus dem MinIO-Client. Der ist in
|
||||
# diesem Behaelter aber nicht vorhanden und muesste bei jedem Lauf aus dem
|
||||
# Netz geholt werden - eine Internetabhaengigkeit im Sicherungspfad ist
|
||||
# genau das, was man dort nicht will.
|
||||
#
|
||||
# Stattdessen wird das Datenverzeichnis direkt eingepackt. Das ist
|
||||
# vertretbar, weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp
|
||||
# schreibt und dann an seinen Platz umbenennt: Ein laufender Upload liegt
|
||||
# also nie halbfertig im Archiv, er fehlt hoechstens. Bei einem Lauf um
|
||||
# 03:15 Uhr auf einem Einzelplatzsystem ist auch das unwahrscheinlich.
|
||||
#
|
||||
# Derselbe Weg deckt den Dateitreiber mit ab - dort liegen die Dateien
|
||||
# ebenfalls einfach im Verzeichnis.
|
||||
#
|
||||
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder und die
|
||||
# verschluesselten Dateien, aber NICHT den Schluessel ENC_KEY_V1. Ohne ihn
|
||||
# sind Diagnosen, IBANs, Notizen und Dokumente auch aus einer
|
||||
# wiederhergestellten Sicherung nicht lesbar. Den Schluessel getrennt
|
||||
# sichern - Passwortmanager oder ausgedruckt im Ordner.
|
||||
# =============================================================================
|
||||
|
||||
set -eu
|
||||
@@ -30,6 +57,11 @@ AUFBEWAHRUNG="${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}"
|
||||
ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M)"
|
||||
TAG_IM_MONAT="$(date +%d)"
|
||||
DATEI="$ZIEL/cockpit_${ZEITSTEMPEL}.dump.enc"
|
||||
DOKUMENTE_DATEI="$ZIEL/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
|
||||
|
||||
# Wo die Dokumente liegen. Der Behaelter bekommt das Ablageverzeichnis
|
||||
# eingehaengt - siehe docker-compose.yml, Dienst "sicherung".
|
||||
DOKUMENTE_QUELLE="${SICHERUNG_DOKUMENTE_QUELLE:-/dokumente}"
|
||||
|
||||
if [ -z "${SICHERUNG_PASSWORT:-}" ]; then
|
||||
echo "$(date -Iseconds) FEHLER: SICHERUNG_PASSWORT ist nicht gesetzt. Abbruch." >&2
|
||||
@@ -50,17 +82,39 @@ pg_dump --format=custom --compress=6 --no-owner --no-privileges \
|
||||
GROESSE="$(du -h "$DATEI" | cut -f1)"
|
||||
sha256sum "$DATEI" > "$DATEI.sha256"
|
||||
|
||||
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
|
||||
# beim Aufraeumen verschont.
|
||||
if [ "$TAG_IM_MONAT" = "01" ]; then
|
||||
touch "$DATEI.monatsstand"
|
||||
echo "$(date -Iseconds) Datenbank gesichert ($GROESSE)"
|
||||
|
||||
# --- Dokumente ---------------------------------------------------------------
|
||||
# Schlaegt dieser Teil fehl, bricht der ganze Lauf ab (set -e). Das ist
|
||||
# Absicht: Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber
|
||||
# vollstaendig aussieht, ist schlimmer als gar keine - man verlaesst sich
|
||||
# darauf und merkt es erst im Ernstfall.
|
||||
if [ ! -d "$DOKUMENTE_QUELLE" ]; then
|
||||
echo "$(date -Iseconds) FEHLER: Das Ablageverzeichnis $DOKUMENTE_QUELLE ist nicht eingehaengt." >&2
|
||||
echo " Ohne die Dokumente ist die Sicherung unvollstaendig." >&2
|
||||
echo " Siehe docker-compose.yml, Dienst \"sicherung\"." >&2
|
||||
rm -f "$DATEI" "$DATEI.sha256"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$(date -Iseconds) Sicherung fertig ($GROESSE)"
|
||||
# "tar" ohne Leitung an einen Verbraucher, der frueh schliesst - sonst
|
||||
# SIGPIPE, siehe die Anmerkung zur Selbstpruefung weiter unten.
|
||||
tar -czf - -C "$DOKUMENTE_QUELLE" . | openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -pass env:SICHERUNG_PASSWORT -out "$DOKUMENTE_DATEI"
|
||||
|
||||
DOK_GROESSE="$(du -h "$DOKUMENTE_DATEI" | cut -f1)"
|
||||
sha256sum "$DOKUMENTE_DATEI" > "$DOKUMENTE_DATEI.sha256"
|
||||
|
||||
echo "$(date -Iseconds) Dokumente gesichert ($DOK_GROESSE)"
|
||||
|
||||
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
|
||||
# beim Aufraeumen verschont. Beide Teile, sonst faellt die Haelfte weg.
|
||||
if [ "$TAG_IM_MONAT" = "01" ]; then
|
||||
touch "$DATEI.monatsstand" "$DOKUMENTE_DATEI.monatsstand"
|
||||
fi
|
||||
|
||||
# --- Aufraeumen --------------------------------------------------------------
|
||||
ENTFERNT=0
|
||||
for alt in "$ZIEL"/cockpit_*.dump.enc; do
|
||||
for alt in "$ZIEL"/cockpit_*.dump.enc "$ZIEL"/dokumente_*.tar.gz.enc; do
|
||||
[ -e "$alt" ] || continue
|
||||
[ -e "$alt.monatsstand" ] && continue
|
||||
|
||||
@@ -87,9 +141,18 @@ KOPF="$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
|
||||
| dd bs=1 count=5 2>/dev/null || true)"
|
||||
|
||||
if [ "$KOPF" = "PGDMP" ]; then
|
||||
echo "$(date -Iseconds) Selbstpruefung bestanden."
|
||||
else
|
||||
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2
|
||||
if [ "$KOPF" != "PGDMP" ]; then
|
||||
echo "$(date -Iseconds) FEHLER: Der Datenbankstand laesst sich nicht entschluesseln!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Dasselbe fuer die Dokumente: entschluesseln und das Archiv auflisten
|
||||
# lassen. "tar -tzf" liest die ganze Datei und meldet jeden Schaden - das
|
||||
# ist bei den Groessenordnungen hier bezahlbar und sagt mehr aus als ein
|
||||
# Blick auf die ersten Bytes.
|
||||
if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" 2>/dev/null | tar -tzf - >/dev/null 2>&1; then
|
||||
echo "$(date -Iseconds) FEHLER: Das Dokumentenarchiv laesst sich nicht lesen!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$(date -Iseconds) Selbstpruefung bestanden - Datenbank und Dokumente."
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# ausdruecklich nach, weil dabei der aktuelle Datenbestand ueberschrieben
|
||||
# wird.
|
||||
#
|
||||
# ZWEI DINGE, DIE MAN VORHER WISSEN MUSS:
|
||||
# DREI DINGE, DIE MAN VORHER WISSEN MUSS:
|
||||
#
|
||||
# 1. Die Sicherung enthaelt die anwendungsseitig verschluesselten Felder,
|
||||
# aber NICHT den Schluessel. Ohne den passenden ENC_KEY_Vn aus der .env
|
||||
@@ -20,6 +20,14 @@
|
||||
# das hier einmal im Jahr auf einer Testdatenbank aus, bevor du es im
|
||||
# Ernstfall zum ersten Mal brauchst. Siehe README, Abschnitt
|
||||
# "Wiederherstellung ueben".
|
||||
#
|
||||
# 3. Die Dokumente werden NICHT selbsttaetig an ihren Platz geschrieben.
|
||||
# Dieses Skript packt sie nach ./backups aus und sagt, wie es
|
||||
# weitergeht. Grund: Der Sicherungsdienst hat auf das Ablageverzeichnis
|
||||
# bewusst nur Lesezugriff (siehe docker-compose.yml), und in ein
|
||||
# laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand
|
||||
# beschaedigen. Der letzte Handgriff bleibt beim Menschen - er ist ein
|
||||
# Einzeiler und im Klartext beschrieben.
|
||||
# =============================================================================
|
||||
|
||||
set -eu
|
||||
@@ -87,7 +95,44 @@ openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
| pg_restore --clean --if-exists --no-owner --no-privileges \
|
||||
--dbname="${PGDATABASE:-postgres}" --exit-on-error
|
||||
|
||||
echo "$(date -Iseconds) Wiederherstellung abgeschlossen."
|
||||
echo "$(date -Iseconds) Datenbank wiederhergestellt."
|
||||
|
||||
# --- Dokumente ---------------------------------------------------------------
|
||||
# Zu "cockpit_2026-09-01_0315.dump.enc" gehoert
|
||||
# "dokumente_2026-09-01_0315.tar.gz.enc" - gleicher Zeitstempel, anderer
|
||||
# Name. Fehlt sie, stammt die Sicherung aus einer Fassung vor v1.11.1.
|
||||
ZEITSTEMPEL="$(basename "$DATEI" | sed -n 's/^cockpit_\(.*\)\.dump\.enc$/\1/p')"
|
||||
DOKUMENTE_DATEI="$(dirname "$DATEI")/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
|
||||
AUSPACKZIEL="$(dirname "$DATEI")/dokumente-wiederhergestellt_${ZEITSTEMPEL}"
|
||||
|
||||
if [ ! -f "$DOKUMENTE_DATEI" ]; then
|
||||
echo ""
|
||||
echo "WARNUNG: Zu dieser Sicherung gibt es kein Dokumentenarchiv."
|
||||
echo " Erwartet wurde: $DOKUMENTE_DATEI"
|
||||
echo " Sicherungen aus Fassungen vor v1.11.1 enthalten nur die"
|
||||
echo " Datenbank. Die Akte ist dann vollstaendig, die Dateien"
|
||||
echo " dahinter fehlen."
|
||||
else
|
||||
if [ -f "$DOKUMENTE_DATEI.sha256" ]; then
|
||||
echo "Pruefsumme des Dokumentenarchivs wird geprueft ..."
|
||||
if sha256sum -c "$DOKUMENTE_DATEI.sha256" >/dev/null 2>&1; then
|
||||
echo " in Ordnung."
|
||||
else
|
||||
echo "FEHLER: Die Pruefsumme des Dokumentenarchivs stimmt nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$AUSPACKZIEL"
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||
-pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" \
|
||||
| tar -xzf - -C "$AUSPACKZIEL"
|
||||
|
||||
ANZAHL="$(find "$AUSPACKZIEL" -type f | wc -l)"
|
||||
echo "$(date -Iseconds) Dokumente ausgepackt: $ANZAHL Dateien nach"
|
||||
echo " $AUSPACKZIEL"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Naechste Schritte:"
|
||||
echo " 1. Die SQL-Skripte erneut ausfuehren (Volltextsuche und Audit-Schutz):"
|
||||
@@ -95,6 +140,21 @@ echo " docker compose exec app npm run db:sql"
|
||||
echo " 2. Pruefen, ob ENC_KEY_V1 in der .env zu diesem Bestand passt -"
|
||||
echo " ohne den richtigen Schluessel bleiben die verschluesselten"
|
||||
echo " Felder unlesbar."
|
||||
echo " 3. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
|
||||
echo " die Diagnosen im Klartext erscheinen. Erst dann ist die"
|
||||
echo " 3. Die Dokumente an ihren Platz bringen. Dazu MinIO anhalten, das"
|
||||
echo " Datenverzeichnis ersetzen und wieder starten - auf dem Host,"
|
||||
echo " nicht in diesem Behaelter:"
|
||||
echo ""
|
||||
echo " docker compose stop minio app"
|
||||
echo " docker run --rm \\"
|
||||
echo " -v betreuungs-cockpit_minio-daten:/ziel \\"
|
||||
echo " -v \"\$PWD/backups:/quelle:ro\" alpine \\"
|
||||
echo " sh -c 'rm -rf /ziel/* /ziel/..?* && cp -a /quelle/dokumente-wiederhergestellt_*/. /ziel/'"
|
||||
echo " docker compose up -d"
|
||||
echo ""
|
||||
echo " Steht mehr als ein ausgepacktes Verzeichnis unter ./backups,"
|
||||
echo " vorher das richtige auswaehlen statt des Sternchens."
|
||||
echo ""
|
||||
echo " 4. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
|
||||
echo " die Diagnosen im Klartext erscheinen. Dann ein Dokument"
|
||||
echo " herunterladen. Erst wenn beides klappt, ist die"
|
||||
echo " Wiederherstellung wirklich gelungen."
|
||||
|
||||
Reference in New Issue
Block a user