Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea34294fa7 | ||
|
|
810716e4e4 | ||
|
|
88e9202c2f | ||
|
|
c0cb29bf53 | ||
|
|
dbc24d965d | ||
|
|
4d826ef55f | ||
|
|
6e6f022234 | ||
|
|
b3823094d6 |
@@ -109,6 +109,19 @@ S3_BUCKET=betreuungs-cockpit
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
|
||||
# Serverseitige Verschluesselung durch den Anbieter.
|
||||
#
|
||||
# Bleibt leer, und das ist die richtige Voreinstellung: Die Anwendung
|
||||
# verschluesselt jede Datei selbst, bevor sie den Server verlaesst. Eine
|
||||
# zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene
|
||||
# Umschlag nicht schon abdeckt.
|
||||
#
|
||||
# Das mitgelieferte MinIO kann sie ohnehin nicht - es laeuft ohne KMS und
|
||||
# lehnt jede Ablage mit "NotImplemented" ab, sobald hier etwas steht.
|
||||
#
|
||||
# Nur setzen, wenn ein externer Anbieter sie ausdruecklich verlangt:
|
||||
# S3_SSE=AES256
|
||||
|
||||
# --- E-Mail-Erinnerungen (optional, ab Etappe 2) ----------------------------
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
|
||||
@@ -93,6 +93,15 @@ services:
|
||||
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env}
|
||||
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
|
||||
AUTH_TRUST_HOST: 'true'
|
||||
# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen - siehe
|
||||
# src/server/actions/ersteinrichtung.ts. Leer heisst: gesperrt.
|
||||
ERSTEINRICHTUNG_GEHEIMNIS: ${ERSTEINRICHTUNG_GEHEIMNIS:-}
|
||||
# Bewusst OHNE Vorgabe "true": Die strengere Einstellung bleibt die
|
||||
# Voreinstellung. Wer einen Reverse Proxy davorstellt - der
|
||||
# dokumentierte Betrieb tut das -, sagt es in der .env ausdruecklich.
|
||||
# Ohne den Wert zaehlt die Anmeldesperre nur nach E-Mail-Adresse.
|
||||
VERTRAUE_PROXY: ${VERTRAUE_PROXY:-}
|
||||
VERTRAUE_PROXY_STUFEN: ${VERTRAUE_PROXY_STUFEN:-}
|
||||
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
|
||||
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
|
||||
# leere Schluesselvariablen - siehe src/lib/env.ts.
|
||||
@@ -110,6 +119,9 @@ services:
|
||||
S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
|
||||
# Leer lassen. Das mitgelieferte MinIO laeuft ohne KMS und lehnt jede
|
||||
# Ablage ab, sobald hier etwas steht - siehe .env.example.
|
||||
S3_SSE: ${S3_SSE:-}
|
||||
UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400}
|
||||
OCR_AKTIV: ${OCR_AKTIV:-true}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
@@ -155,9 +167,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']
|
||||
|
||||
+53
-8
@@ -184,6 +184,13 @@ cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
> **Die `.env` allein genügt nicht.** Die `docker-compose.yml` reicht die
|
||||
> Werte einzeln an den Behälter durch — sie hat kein `env_file:`. Ein
|
||||
> selbst ergänzter Wert, der dort nicht aufgeführt ist, erreicht die
|
||||
> Anwendung nicht, auch wenn er in der `.env` steht. Alle hier
|
||||
> beschriebenen Werte sind eingetragen; ein Test wacht darüber
|
||||
> (`tests/umgebung-durchreichen.test.ts`).
|
||||
|
||||
Diese Werte **musst** du setzen:
|
||||
|
||||
```bash
|
||||
@@ -379,6 +386,13 @@ docker compose exec minio mc ls lokal
|
||||
# [...] betreuungs-cockpit/
|
||||
```
|
||||
|
||||
> **Serverseitige Verschluesselung?** Brauchst du nicht. Die Anwendung
|
||||
> verschlüsselt jede Datei selbst, bevor sie den Server verlässt — mit
|
||||
> `ENC_KEY_V1`, der den Anbieter nie erreicht. Das mitgelieferte MinIO
|
||||
> läuft ohne KMS und würde jede Ablage ablehnen, sobald `S3_SSE` in der
|
||||
> `.env` steht. Setz den Wert nur, wenn ein externer Anbieter ihn
|
||||
> ausdrücklich verlangt.
|
||||
|
||||
> **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der
|
||||
> EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den
|
||||
> `minio`-Dienst aus der `docker-compose.yml`, entfern ihn aus
|
||||
@@ -731,13 +745,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 +780,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_<datum>/`. 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 +806,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.
|
||||
|
||||
+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."
|
||||
|
||||
@@ -24,6 +24,42 @@ export const WARTEZEIT_DRINGEND_TAGE = 21
|
||||
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
|
||||
const AUSSCHLUSSFRISTEN_MAX = 3
|
||||
|
||||
/**
|
||||
* Wie weit im Voraus eine Ausschlussfrist die Warnung ausloest.
|
||||
*
|
||||
* Frueher gab es diese Grenze nicht: Der Warnblock fragte nur, OB etwas
|
||||
* eine Ausschlussfrist ist, nie WANN sie faellig wird. Auf dem Dashboard
|
||||
* standen deshalb dauerhaft drei rote Kaesten mit Warndreieck - fuer
|
||||
* Aufwandspauschalen, die 2027, 2028 und 2029 ablaufen. Die Pflichtfristen
|
||||
* werden bewusst drei Jahre im Voraus angelegt (jahreVoraus in
|
||||
* pflichtfristen.ts), und alle landeten ungefiltert in der Warnung.
|
||||
*
|
||||
* Das ist nicht nur laestig, sondern gefaehrlich: Eine Warnung, die
|
||||
* monatelang dasteht und nichts von einem will, stumpft ab. Man uebersieht
|
||||
* die vierte, die etwas will - und bei § 1878 BGB ist das die
|
||||
* Jahrespauschale, die ersatzlos erlischt.
|
||||
*
|
||||
* 90 Tage, weil die kuerzeste hier vorkommende Ausschlussfrist sechs
|
||||
* Monate Zeit laesst. Drei Monate Vorlauf sind reichlich, und solange
|
||||
* nichts ansteht, bleibt der Kasten leer - dann bedeutet er etwas.
|
||||
*
|
||||
* Verschwinden tut nichts: Was ausserhalb des Horizonts liegt, steht
|
||||
* weiterhin in der Fristenliste.
|
||||
*/
|
||||
const AUSSCHLUSSFRIST_HORIZONT_TAGE = 90
|
||||
|
||||
/**
|
||||
* Gehoert diese Frist in den hervorgehobenen Warnblock?
|
||||
*
|
||||
* Abgelaufenes immer - eine versaeumte Ausschlussfrist bleibt eine
|
||||
* schlechte Nachricht, auch wenn sie lange zurueckliegt. Sonst nur, was
|
||||
* innerhalb des Horizonts faellig wird.
|
||||
*/
|
||||
function warntJetzt(eintrag: { ausschlussfrist: boolean; tage: number }): boolean {
|
||||
if (!eintrag.ausschlussfrist) return false
|
||||
return eintrag.tage < 0 || eintrag.tage <= AUSSCHLUSSFRIST_HORIZONT_TAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der
|
||||
* Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht,
|
||||
@@ -126,7 +162,7 @@ export function fristenEinordnen(fristen: FristRoh[], heute: Date): FristenAmpel
|
||||
dieseWoche: eintraege.filter((e) => e.stufe === 'orange'),
|
||||
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
|
||||
weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length,
|
||||
ausschlussfristen: eintraege.filter((e) => e.ausschlussfrist).slice(0, AUSSCHLUSSFRISTEN_MAX),
|
||||
ausschlussfristen: eintraege.filter(warntJetzt).slice(0, AUSSCHLUSSFRISTEN_MAX),
|
||||
gesamtOffen: eintraege.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,26 @@ export const env = {
|
||||
bucket: pflicht('S3_BUCKET'),
|
||||
accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'),
|
||||
|
||||
/**
|
||||
* Serverseitige Verschluesselung - nur, wenn ausdruecklich gewuenscht.
|
||||
*
|
||||
* Frueher stand im Treiber fest ServerSideEncryption: 'AES256'. Das
|
||||
* mitgelieferte MinIO laeuft ohne KMS und lehnt das mit
|
||||
* "NotImplemented" ab: Es liess sich kein einziges Dokument ablegen,
|
||||
* und wer der INSTALLATION.md folgte, landete zwangslaeufig dort.
|
||||
*
|
||||
* Gebraucht wird sie ohnehin nicht mehr. Seit v1.11.0 verschluesselt
|
||||
* die Anwendung jede Datei selbst, bevor sie den Server verlaesst
|
||||
* (src/lib/speicher/umschlag.ts). Serverseitige Verschluesselung
|
||||
* schuetzt vor nichts, was der eigene Umschlag nicht schon abdeckt -
|
||||
* sie ist hoechstens eine zweite Lage fuer Anbieter, die sie
|
||||
* anbieten.
|
||||
*
|
||||
* Leer bedeutet aus. Wer sie will, setzt S3_SSE=AES256 oder den Wert,
|
||||
* den sein Anbieter erwartet.
|
||||
*/
|
||||
serverseitigeVerschluesselung: process.env.S3_SSE?.trim() || null,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+11
-4
@@ -6,6 +6,7 @@ import {
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
type ServerSideEncryption,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
@@ -33,8 +34,13 @@ import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umsch
|
||||
* Kontoauszuege.
|
||||
*
|
||||
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
|
||||
* einem Schluessel aus der Umgebung. Die serverseitige Verschluesselung
|
||||
* bleibt zusaetzlich stehen; sie kostet nichts.
|
||||
* einem Schluessel aus der Umgebung.
|
||||
*
|
||||
* Die serverseitige Verschluesselung ist damit ueberfluessig und wird nur
|
||||
* noch gesendet, wenn S3_SSE gesetzt ist. Im Kommentar stand hier einmal,
|
||||
* sie "kostet nichts" - das war falsch: Das mitgelieferte MinIO laeuft
|
||||
* ohne KMS und lehnte jedes PutObject mit "NotImplemented" ab. Sie kostete
|
||||
* die gesamte Funktion.
|
||||
*
|
||||
* ## Und deshalb keine presigned URLs mehr
|
||||
*
|
||||
@@ -80,6 +86,7 @@ export async function schreiben(
|
||||
_mimeTyp: string,
|
||||
): Promise<void> {
|
||||
const geprueft = schluesselErfordern(schluessel)
|
||||
const sse = env.s3.serverseitigeVerschluesselung
|
||||
await s3().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: eimer(),
|
||||
@@ -91,8 +98,8 @@ export async function schreiben(
|
||||
ContentType: 'application/octet-stream',
|
||||
// Auch beim direkten Abruf niemals im Browser darstellen.
|
||||
ContentDisposition: 'attachment',
|
||||
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt.
|
||||
ServerSideEncryption: 'AES256',
|
||||
// Nur mitschicken, wenn konfiguriert - siehe env.s3.
|
||||
...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
+77
-1
@@ -92,9 +92,10 @@ describe('Fristen-Ampel', () => {
|
||||
[
|
||||
frist({ id: 'normal', datum: dbTag('2026-08-20') }),
|
||||
frist({
|
||||
// Weit genug weg fuer "gruen", nah genug fuer die Warnung.
|
||||
id: 'pauschale',
|
||||
titel: 'Aufwandspauschale 2026',
|
||||
datum: dbTag('2026-12-31'),
|
||||
datum: dbTag('2026-10-15'),
|
||||
ausschlussfrist: true,
|
||||
rechtsgrundlage: '§ 1878 BGB',
|
||||
}),
|
||||
@@ -103,6 +104,81 @@ describe('Fristen-Ampel', () => {
|
||||
)
|
||||
|
||||
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['pauschale'])
|
||||
// Nicht in einem der Faelligkeitstoepfe - der Warnblock steht daneben.
|
||||
expect(daten.weitereOffen).toBe(1)
|
||||
})
|
||||
|
||||
describe('Ausschlussfristen: nur warnen, wenn es etwas zu tun gibt', () => {
|
||||
/**
|
||||
* Der Anlass: Auf dem Dashboard standen dauerhaft drei rote Kaesten mit
|
||||
* Warndreieck - Aufwandspauschalen mit Verfall 2027, 2028 und 2029. Die
|
||||
* Pflichtfristen werden bewusst drei Jahre im Voraus angelegt, und der
|
||||
* Warnblock fragte nur, OB etwas eine Ausschlussfrist ist, nie WANN sie
|
||||
* faellig wird.
|
||||
*
|
||||
* Eine Warnung, die monatelang dasteht und nichts von einem will,
|
||||
* stumpft ab. Genau davor schuetzen diese Tests.
|
||||
*/
|
||||
function pauschale(id: string, datum: string) {
|
||||
return frist({
|
||||
id,
|
||||
titel: `Aufwandspauschale ${id}`,
|
||||
datum: dbTag(datum),
|
||||
ausschlussfrist: true,
|
||||
rechtsgrundlage: '§ 1878 BGB',
|
||||
})
|
||||
}
|
||||
|
||||
it('lässt Fristen weit in der Zukunft aus der Warnung heraus', () => {
|
||||
const daten = fristenEinordnen(
|
||||
[
|
||||
pauschale('2027', '2027-05-06'),
|
||||
pauschale('2028', '2028-05-06'),
|
||||
pauschale('2029', '2029-05-06'),
|
||||
],
|
||||
HEUTE,
|
||||
)
|
||||
|
||||
expect(daten.ausschlussfristen).toEqual([])
|
||||
// Verschwunden sind sie deshalb nicht - sie stehen in der Liste.
|
||||
expect(daten.weitereOffen).toBe(3)
|
||||
expect(daten.gesamtOffen).toBe(3)
|
||||
})
|
||||
|
||||
it('warnt, sobald eine in den Horizont rückt', () => {
|
||||
// 90 Tage nach dem 19.08.2026 ist der 17.11.2026.
|
||||
const daten = fristenEinordnen([pauschale('bald', '2026-11-17')], HEUTE)
|
||||
|
||||
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['bald'])
|
||||
})
|
||||
|
||||
it('warnt einen Tag davor noch nicht', () => {
|
||||
const daten = fristenEinordnen([pauschale('knapp-drueber', '2026-11-18')], HEUTE)
|
||||
|
||||
expect(daten.ausschlussfristen).toEqual([])
|
||||
})
|
||||
|
||||
it('warnt bei einer versäumten Frist immer, egal wie lange sie zurückliegt', () => {
|
||||
// Eine verfallene Aufwandspauschale bleibt eine schlechte Nachricht.
|
||||
// Sie stillschweigend aus der Warnung zu nehmen, waere das Gegenteil
|
||||
// von dem, was hier gewollt ist.
|
||||
const daten = fristenEinordnen([pauschale('verpasst', '2024-05-06')], HEUTE)
|
||||
|
||||
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst'])
|
||||
})
|
||||
|
||||
it('mischt Fälliges und Fernes richtig', () => {
|
||||
const daten = fristenEinordnen(
|
||||
[
|
||||
pauschale('verpasst', '2025-05-06'),
|
||||
pauschale('bald', '2026-10-01'),
|
||||
pauschale('fern', '2029-05-06'),
|
||||
],
|
||||
HEUTE,
|
||||
)
|
||||
|
||||
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst', 'bald'])
|
||||
})
|
||||
})
|
||||
|
||||
it('warnt bei einer Ausschlussfrist ausdrücklich vor dem ersatzlosen Verfall', () => {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Was der S3-Treiber tatsächlich an den Objektspeicher schickt.
|
||||
*
|
||||
* Anlass: Der Treiber setzte bei jedem PutObject fest
|
||||
* `ServerSideEncryption: 'AES256'`. Das mitgelieferte MinIO läuft ohne KMS
|
||||
* und lehnte das mit `NotImplemented` ab — es ließ sich kein einziges
|
||||
* Dokument ablegen, und wer der INSTALLATION.md folgte, landete
|
||||
* zwangsläufig dort.
|
||||
*
|
||||
* Gemerkt hat das kein Test, weil in der Entwicklung der Dateitreiber
|
||||
* läuft und der S3-Pfad nie ausgeführt wurde. Diese Datei schließt genau
|
||||
* diese Lücke: Sie fängt den Befehl ab, bevor er das Netz sieht, und
|
||||
* schaut hinein.
|
||||
*/
|
||||
|
||||
process.env.S3_ENDPOINT = 'http://minio:9000'
|
||||
process.env.S3_BUCKET = 'betreuungs-cockpit'
|
||||
process.env.S3_ACCESS_KEY_ID = 'probe-zugang'
|
||||
process.env.S3_SECRET_ACCESS_KEY = 'probe-geheimnis'
|
||||
|
||||
/** Der letzte Befehl, den der Treiber abgeschickt hat. */
|
||||
let letzterBefehl: Record<string, unknown> | null = null
|
||||
|
||||
vi.mock('@aws-sdk/client-s3', () => {
|
||||
class PutObjectCommand {
|
||||
input: Record<string, unknown>
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input
|
||||
}
|
||||
}
|
||||
class GetObjectCommand extends PutObjectCommand {}
|
||||
class DeleteObjectCommand extends PutObjectCommand {}
|
||||
class HeadObjectCommand extends PutObjectCommand {}
|
||||
class ListObjectsV2Command extends PutObjectCommand {}
|
||||
|
||||
class S3Client {
|
||||
async send(befehl: { input: Record<string, unknown> }) {
|
||||
letzterBefehl = befehl.input
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
}
|
||||
})
|
||||
|
||||
const SCHLUESSEL = 'betreute/cmt4ey0jm0003o001wq4rc3m9/dokumente/ba7a8c1a-d4c5-41c7-8f5f-ebfb4805b0e5.pdf'
|
||||
const INHALT = Buffer.from('%PDF-1.4 ein Bescheid', 'utf8')
|
||||
|
||||
/**
|
||||
* Der Treiber baut seinen Klienten einmal und merkt ihn sich. Damit die
|
||||
* Umgebungsvariable je Testfall neu gelesen wird, muss das Modul frisch
|
||||
* geladen werden.
|
||||
*/
|
||||
async function treiberFrisch() {
|
||||
vi.resetModules()
|
||||
return import('@/lib/speicher/s3')
|
||||
}
|
||||
|
||||
describe('S3-Treiber: serverseitige Verschlüsselung', () => {
|
||||
beforeEach(() => {
|
||||
letzterBefehl = null
|
||||
delete process.env.S3_SSE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.S3_SSE
|
||||
})
|
||||
|
||||
it('schickt ohne S3_SSE keinen Verschlüsselungskopf mit', async () => {
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl).not.toBeNull()
|
||||
expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption')
|
||||
})
|
||||
|
||||
it('schickt ihn mit, wenn S3_SSE gesetzt ist', async () => {
|
||||
process.env.S3_SSE = 'AES256'
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl?.ServerSideEncryption).toBe('AES256')
|
||||
})
|
||||
|
||||
it('übernimmt auch einen anderen Wert, den ein Anbieter verlangt', async () => {
|
||||
process.env.S3_SSE = 'aws:kms'
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl?.ServerSideEncryption).toBe('aws:kms')
|
||||
})
|
||||
|
||||
it('behandelt eine leere Angabe wie "aus"', async () => {
|
||||
// Sonst schickte eine leergelassene Zeile in der .env einen leeren
|
||||
// Kopf, und der Anbieter antwortete mit einem Fehler, den niemand
|
||||
// versteht.
|
||||
process.env.S3_SSE = ' '
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl).not.toHaveProperty('ServerSideEncryption')
|
||||
})
|
||||
})
|
||||
|
||||
describe('S3-Treiber: was sonst noch mitgeht', () => {
|
||||
beforeEach(() => {
|
||||
letzterBefehl = null
|
||||
delete process.env.S3_SSE
|
||||
})
|
||||
|
||||
it('legt den Inhalt verschlüsselt ab, nicht im Klartext', async () => {
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
const koerper = letzterBefehl?.Body as Buffer
|
||||
expect(koerper.subarray(0, 4).toString('ascii')).toBe('BCD1')
|
||||
expect(koerper.includes(Buffer.from('ein Bescheid', 'utf8'))).toBe(false)
|
||||
})
|
||||
|
||||
it('meldet den Inhaltstyp nicht als PDF - dort liegt Geheimtext', async () => {
|
||||
const s3 = await treiberFrisch()
|
||||
await s3.schreiben(SCHLUESSEL, INHALT, 'application/pdf')
|
||||
|
||||
expect(letzterBefehl?.ContentType).toBe('application/octet-stream')
|
||||
expect(letzterBefehl?.ContentDisposition).toBe('attachment')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Was die Anwendung liest, muss der Behälter auch bekommen.
|
||||
*
|
||||
* Die `docker-compose.yml` zählt die Umgebungsvariablen des Dienstes `app`
|
||||
* einzeln auf — sie hat kein `env_file:`. Wer einen neuen Wert einführt,
|
||||
* ihn in `.env.example` dokumentiert und in `env.ts` ausliest, hat damit
|
||||
* noch nichts erreicht: Im Behälter kommt er nicht an.
|
||||
*
|
||||
* Genau das ist mit v1.11.0 passiert. `ERSTEINRICHTUNG_GEHEIMNIS` und
|
||||
* `VERTRAUE_PROXY` standen in der Anleitung, in der Beispieldatei und im
|
||||
* Code — nur nicht in der Compose-Datei. Die Anleitung war damit schlicht
|
||||
* falsch, und der Fehler war von außen nicht zu sehen: `VERTRAUE_PROXY`
|
||||
* fehlt still, die Anmeldesperre zählt dann eine Dimension weniger.
|
||||
*
|
||||
* Dieser Test liest beide Seiten und vergleicht sie. Er prüft kein
|
||||
* Verhalten, sondern die Verpackung — wie `skripte.test.ts`.
|
||||
*/
|
||||
|
||||
const compose = readFileSync('docker-compose.yml', 'utf8')
|
||||
const envDatei = readFileSync('src/lib/env.ts', 'utf8')
|
||||
const beispiel = readFileSync('.env.example', 'utf8')
|
||||
|
||||
/**
|
||||
* Alles, was `env.ts` über seine Helfer ausliest.
|
||||
*
|
||||
* `process.env.X` direkt wird mitgenommen, ebenso `pflicht('X')`,
|
||||
* `zahl('X', …)`, `schalter('X', …)` und `schluessel32('X')`.
|
||||
*/
|
||||
function gelesen(): Set<string> {
|
||||
const namen = new Set<string>()
|
||||
|
||||
for (const treffer of envDatei.matchAll(/process\.env\.([A-Z_0-9]+)/g)) {
|
||||
namen.add(treffer[1]!)
|
||||
}
|
||||
for (const treffer of envDatei.matchAll(
|
||||
/(?:pflicht|zahl|schalter|schluessel32)\('([A-Z_0-9]+)'/g,
|
||||
)) {
|
||||
namen.add(treffer[1]!)
|
||||
}
|
||||
|
||||
return namen
|
||||
}
|
||||
|
||||
/** Die Variablen, die der Dienst `app` durchreicht. */
|
||||
function durchgereicht(): Set<string> {
|
||||
const abschnitt = compose.slice(compose.indexOf('\n app:'), compose.indexOf('\n sicherung:'))
|
||||
const namen = new Set<string>()
|
||||
for (const treffer of abschnitt.matchAll(/^ {6}([A-Z_0-9]+):/gm)) {
|
||||
namen.add(treffer[1]!)
|
||||
}
|
||||
return namen
|
||||
}
|
||||
|
||||
/**
|
||||
* Was absichtlich nicht durchgereicht wird - jeweils mit Grund.
|
||||
*
|
||||
* Wer hier etwas ergänzt, nimmt es bewusst aus der Prüfung. Das ist
|
||||
* erlaubt, aber es soll dabeistehen, warum.
|
||||
*/
|
||||
const AUSNAHMEN = new Map<string, string>([
|
||||
['NODE_ENV', 'wird im Dienst gesetzt, kommt nicht aus der .env'],
|
||||
['NEXT_PUBLIC_GIT_COMMIT', 'Bauargument, steckt schon im Abbild'],
|
||||
['NEXT_PUBLIC_BUILD_ZEIT', 'Bauargument, steckt schon im Abbild'],
|
||||
['DATEI_SPEICHER_PFAD', 'nur fuer den Dateitreiber; im Behaelter laeuft s3'],
|
||||
['TESSERACT_BEFEHL', 'Standard "tesseract" stimmt im Abbild immer'],
|
||||
])
|
||||
|
||||
describe('Umgebungsvariablen erreichen den Behälter', () => {
|
||||
it('reicht jede Variable durch, die die Anwendung ausliest', () => {
|
||||
const fehlend = [...gelesen()]
|
||||
.filter((name) => !durchgereicht().has(name))
|
||||
.filter((name) => !AUSNAHMEN.has(name))
|
||||
// ENC_KEY_V2, V3 ... werden dynamisch gesucht; nur V1 ist Pflicht.
|
||||
.filter((name) => !/^ENC_KEY_V\d+$/.test(name))
|
||||
.sort()
|
||||
|
||||
expect(
|
||||
fehlend,
|
||||
'Diese Werte liest die Anwendung, aber die docker-compose.yml gibt sie ' +
|
||||
'dem Dienst "app" nicht mit. In der .env stehen sie dann wirkungslos.',
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('erklärt jede Ausnahme', () => {
|
||||
for (const [name, grund] of AUSNAHMEN) {
|
||||
expect(grund.length, `Ausnahme ${name} ohne Begründung`).toBeGreaterThan(10)
|
||||
}
|
||||
})
|
||||
|
||||
it('dokumentiert die beiden Werte aus v1.11.0 in der Beispieldatei', () => {
|
||||
// Sie waren der Anlass. Verschwinden sie aus .env.example, merkt es
|
||||
// sonst niemand, bis jemand eine Neuinstallation versucht.
|
||||
expect(beispiel).toMatch(/ERSTEINRICHTUNG_GEHEIMNIS=/)
|
||||
expect(beispiel).toMatch(/VERTRAUE_PROXY=/)
|
||||
})
|
||||
|
||||
it('lässt VERTRAUE_PROXY ohne Vorgabewert', () => {
|
||||
// Die strengere Einstellung bleibt die Voreinstellung. Ein Vorgabewert
|
||||
// "true" waere bequem und falsch: Ohne Proxy ist der weitergereichte
|
||||
// Kopf frei erfunden.
|
||||
expect(compose).toMatch(/VERTRAUE_PROXY: \$\{VERTRAUE_PROXY:-\}/)
|
||||
})
|
||||
|
||||
it('findet überhaupt etwas - sonst prüft der Test nichts', () => {
|
||||
// Ein stiller Ausfall der Erkennung waere schlimmer als eine Luecke:
|
||||
// Der Test staende gruen da und pruefte nichts.
|
||||
expect(gelesen().size).toBeGreaterThan(15)
|
||||
expect(durchgereicht().size).toBeGreaterThan(15)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user