diff --git a/docker-compose.yml b/docker-compose.yml index 522f029..5125fa4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -155,9 +155,20 @@ services: PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit} SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env} SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30} + SICHERUNG_DOKUMENTE_QUELLE: /dokumente TZ: Europe/Berlin volumes: - ./backups:/backups + # Die Dokumente, ausdruecklich NUR LESEND. + # + # Der Sicherungsdienst laeuft unbeaufsichtigt per cron. Ein Dienst, + # der die Daten zerstoeren kann, die er sichern soll, ist ein Risiko + # ohne Gegenwert - ein Fehler im Skript oder ein kompromittierter + # Behaelter wuerde beides auf einmal treffen. + # + # Zurueckspielen geschieht deshalb nicht hierher, sondern in ein + # Verzeichnis unter ./backups; siehe scripts/wiederherstellung.sh. + - minio-daten:/dokumente:ro - ./scripts/sicherung.sh:/sicherung.sh:ro - ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro entrypoint: ['/bin/sh', '-c'] diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index f1e307a..0aaaf8a 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -731,13 +731,19 @@ Erwartete Ausgabe, in dieser Reihenfolge: ``` Sicherung beginnt: /backups/cockpit_2026-08-22_1430.dump.enc -Sicherung fertig (1.2M) -Selbstpruefung bestanden. +Datenbank gesichert (1.2M) +Dokumente gesichert (18M) +Selbstpruefung bestanden - Datenbank und Dokumente. ``` Die **Selbstprüfung** ist der eigentliche Punkt: Das Skript entschlüsselt -die eben geschriebene Datei sofort wieder. Schlägt das fehl, endet der -Lauf mit einem Fehler statt mit einem stillen Scheinerfolg. +beide Dateien sofort wieder und liest sie einmal durch. Schlägt das fehl, +endet der Lauf mit einem Fehler statt mit einem stillen Scheinerfolg. + +Fehlt die Zeile `Dokumente gesichert`, ist das Ablageverzeichnis nicht in +den Sicherungsdienst eingehängt. Das Skript bricht dann ab und räumt den +angefangenen Datenbankstand wieder weg — eine Sicherung, die nur die halbe +Wahrheit enthält, aber vollständig aussieht, wäre schlimmer als gar keine. ### Und jetzt einmal zurückspielen @@ -760,6 +766,25 @@ docker compose exec db psql -U cockpit probe \ docker compose exec db dropdb -U cockpit probe ``` +Beim Zurückspielen packt das Skript auch das Dokumentenarchiv aus — nach +`backups/dokumente-wiederhergestellt_/`. An ihren Platz schreibt es +die Dateien **nicht**: Der Sicherungsdienst hat auf das Ablageverzeichnis +bewusst nur Lesezugriff, und in ein laufendes MinIO hineinzuschreiben würde +dessen Datenbestand beschädigen. Wie der letzte Handgriff aussieht, gibt +das Skript am Ende selbst aus. + +Für die Übung genügt ein Blick in das ausgepackte Verzeichnis: + +```bash +find backups/dokumente-wiederhergestellt_*/ -type f | head +rm -rf backups/dokumente-wiederhergestellt_* +``` + +> **Im Ernstfall gehört beides zusammen.** Eine Datenbank ohne die +> zugehörigen Dateien ergibt eine Akte, deren Prüfsummen Dokumente +> belegen, die es nicht mehr gibt. Spiel immer das Paar mit demselben +> Zeitstempel zurück. + ### Kontrolle ```bash @@ -767,12 +792,18 @@ ls -lh backups/ # cockpit_....dump.enc und cockpit_....dump.enc.sha256 ``` -> **Was das Backup nicht enthält:** den Schlüssel `ENC_KEY_V1` und die -> Dateien im Objektspeicher. Für die Dateien: +> **Was das Backup enthält:** die Datenbank *und* die Dokumente. Je Lauf +> entstehen zwei verschlüsselte Dateien mit demselben Zeitstempel: > -> ```bash -> docker compose exec minio mc mirror lokal/betreuungs-cockpit /backups/dokumente > ``` +> cockpit_2026-09-01_0315.dump.enc <- Akte, Fristen, Buchungen +> dokumente_2026-09-01_0315.tar.gz.enc <- Bescheide, Schreiben, Belege +> ``` +> +> **Was es nicht enthält:** den Schlüssel `ENC_KEY_V1`. Ohne ihn bleiben +> auch aus einer vollständig zurückgespielten Sicherung die Diagnosen, +> IBANs, Notizen und Dokumente unlesbar. Er gehört an die zwei Orte aus +> Schritt 11. > > Und kopier die Sicherungen regelmäßig **weg vom Server** — ein Backup, > das neben dem Original liegt, hilft bei einem Serverausfall nicht. diff --git a/scripts/sicherung.sh b/scripts/sicherung.sh index c5d320f..4b8737b 100755 --- a/scripts/sicherung.sh +++ b/scripts/sicherung.sh @@ -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." diff --git a/scripts/wiederherstellung.sh b/scripts/wiederherstellung.sh index c07197e..259e120 100755 --- a/scripts/wiederherstellung.sh +++ b/scripts/wiederherstellung.sh @@ -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." diff --git a/tests/skripte.test.ts b/tests/skripte.test.ts index a09af41..65706d8 100644 --- a/tests/skripte.test.ts +++ b/tests/skripte.test.ts @@ -132,3 +132,90 @@ describe('Skripte mit pipefail', () => { ).toEqual([]) }) }) + +/** + * Die Sicherung muss die Dokumente enthalten - nicht nur die Datenbank. + * + * Bis v1.11.0 sicherte scripts/sicherung.sh ausschliesslich 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. + * + * Aufgefallen ist das nicht durch einen Test, sondern beim Nachsehen, wo + * die Daten eigentlich liegen. Die folgenden Pruefungen lesen deshalb den + * Skripttext: Sie koennen die Sicherung nicht ausfuehren - dafuer braeuchte + * es Postgres, MinIO und einen Behaelter -, aber sie schlagen an, sobald + * einer der Bausteine wieder herausfaellt. + */ +describe('Sicherung umfasst die Dokumente', () => { + const sicherung = readFileSync('scripts/sicherung.sh', 'utf8') + const wiederherstellung = readFileSync('scripts/wiederherstellung.sh', 'utf8') + + it('packt das Ablageverzeichnis ein', () => { + expect(sicherung).toMatch(/tar -czf - -C "\$DOKUMENTE_QUELLE"/) + }) + + it('verschlüsselt das Archiv mit demselben Verfahren wie den Datenbankstand', () => { + // Zwei Aufrufe: einer fuer den Dump, einer fuer die Dokumente. + const verschluesselt = sicherung.match(/openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt/g) + expect(verschluesselt).toHaveLength(2) + }) + + it('legt zu beiden Teilen eine Prüfsumme', () => { + expect(sicherung).toMatch(/sha256sum "\$DATEI"/) + expect(sicherung).toMatch(/sha256sum "\$DOKUMENTE_DATEI"/) + }) + + it('bricht ab, wenn das Ablageverzeichnis nicht eingehängt ist', () => { + // Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber + // vollstaendig aussieht, ist schlimmer als gar keine. + expect(sicherung).toMatch(/if \[ ! -d "\$DOKUMENTE_QUELLE" \]/) + expect(sicherung).toMatch(/rm -f "\$DATEI" "\$DATEI\.sha256"/) + }) + + it('prüft am Ende beide Teile, nicht nur den Datenbankstand', () => { + expect(sicherung).toMatch(/tar -tzf -/) + expect(sicherung).toMatch(/Datenbank und Dokumente/) + }) + + it('räumt alte Dokumentenarchive genauso auf wie alte Datenbankstände', () => { + expect(sicherung).toMatch(/dokumente_\*\.tar\.gz\.enc/) + }) + + it('verschont beim Monatsstand beide Teile', () => { + expect(sicherung).toMatch(/touch "\$DATEI\.monatsstand" "\$DOKUMENTE_DATEI\.monatsstand"/) + }) + + it('holt die Dokumente beim Zurückspielen mit zurück', () => { + expect(wiederherstellung).toMatch(/DOKUMENTE_DATEI=/) + expect(wiederherstellung).toMatch(/tar -xzf - -C "\$AUSPACKZIEL"/) + }) + + it('sagt beim Zurückspielen deutlich, wenn kein Dokumentenarchiv dabei ist', () => { + // Sonst haelt man eine halbe Wiederherstellung fuer eine ganze. + expect(wiederherstellung).toMatch(/kein Dokumentenarchiv/) + }) + + it('schreibt die Dokumente nicht selbst an ihren Platz', () => { + // Der Sicherungsdienst hat auf das Ablageverzeichnis nur Lesezugriff. + // In ein laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand + // beschaedigen - der letzte Handgriff bleibt beim Menschen. + expect(wiederherstellung).toMatch(/docker compose stop minio app/) + }) +}) + +/** + * Der Sicherungsdienst darf die Daten, die er sichert, nicht zerstoeren + * koennen. + * + * 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. + */ +describe('Sicherungsdienst greift nur lesend auf die Dokumente zu', () => { + it('hängt das Ablageverzeichnis mit :ro ein', () => { + const compose = readFileSync('docker-compose.yml', 'utf8') + expect(compose).toMatch(/minio-daten:\/dokumente:ro/) + }) +})