#!/bin/sh # ============================================================================= # Taegliche Sicherung: Datenbank UND Dokumente # ============================================================================= # # Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand: # # docker compose exec sicherung /bin/sh /sicherung.sh # # Was passiert: # 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen # wiederherstellbar bleiben. # 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. # 4. SHA-256-Pruefsumme je Datei. # 5. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen. # Der jeweils erste Stand eines Monats bleibt stehen. # # ## 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 ZIEL="${SICHERUNG_ZIEL:-/backups}" 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 exit 1 fi mkdir -p "$ZIEL" echo "$(date -Iseconds) Sicherung beginnt: $DATEI" # pg_dump schreibt direkt in die Verschluesselung - der Klartext landet # nie auf der Platte. pg_dump --format=custom --compress=6 --no-owner --no-privileges \ | openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt \ -pass env:SICHERUNG_PASSWORT \ -out "$DATEI" GROESSE="$(du -h "$DATEI" | cut -f1)" sha256sum "$DATEI" > "$DATEI.sha256" 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 # "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 "$ZIEL"/dokumente_*.tar.gz.enc; do [ -e "$alt" ] || continue [ -e "$alt.monatsstand" ] && continue if [ -n "$(find "$alt" -mtime "+$AUFBEWAHRUNG" 2>/dev/null)" ]; then rm -f "$alt" "$alt.sha256" ENTFERNT=$((ENTFERNT + 1)) fi done [ "$ENTFERNT" -gt 0 ] && echo "$(date -Iseconds) $ENTFERNT alte Staende entfernt" # --- Selbstpruefung ---------------------------------------------------------- # Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup. # Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist. # Kein "| head -c 5 | grep -q": Beide schliessen die Leitung, sobald sie # genug haben. openssl schreibt dann in ein geschlossenes Rohr, bekommt # SIGPIPE - und mit "set -o pipefail" gilt die ganze Kette als # gescheitert. Bei einer kleinen Datenbank faellt das nicht auf, weil # alles schon im Puffer steht; mit wachsender Datenbank kippt es, und # dann meldet die Selbstpruefung eine INTAKTE Sicherung als kaputt. # # "dd" liest eine feste Menge und beendet sich regulaer. 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) 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."