diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..6612acd --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,944 @@ +# Production Deployment – Serienbrief + +Schritt‑für‑Schritt‑Anleitung für den Go‑Live des Serienbrief‑Stacks. Strukturiert nach **Pflicht / Empfehlung / Optional**, damit du selbst entscheiden kannst, wie weit du gehen willst. + +Zielumgebung: Ubuntu Server LTS 22.04/24.04, Docker Engine 27.x+, Compose v2, externer Reverse‑Proxy mit TLS‑Terminierung (typisch: Nginx Proxy Manager auf separatem Host). + +--- + +## Inhalt + +1. [Vorbereitung](#1-vorbereitung) +2. [Pflicht: Konfiguration](#2-pflicht-konfiguration) +3. [Pflicht: Erststart in Produktion](#3-pflicht-erststart-in-produktion) +4. [Pflicht: Reverse‑Proxy](#4-pflicht-reverseproxy) +5. [Pflicht: Backup verifizieren](#5-pflicht-backup-verifizieren) +6. [Pflicht: Host‑Hardening](#6-pflicht-hosthardening) +7. [Pflicht: Smoke‑Test](#7-pflicht-smoketest) +8. [Empfehlung: Betriebsautomatisierung](#8-empfehlung-betriebsautomatisierung) +9. [Empfehlung: Monitoring & Logging](#9-empfehlung-monitoring--logging) +10. [Empfehlung: Image‑Scanning](#10-empfehlung-imagescanning) +11. [Optional: Verschlüsselung at‑rest](#11-optional-verschl%C3%BCsselung-atrest) +12. [Optional: AppArmor‑Profile](#12-optional-apparmorprofile) +13. [Datenschutz‑Dokumentation](#13-datenschutzdokumentation) +14. [Update & Rollback](#14-update--rollback) +15. [Pre‑Go‑Live‑Checkliste](#15-pregolivecheckliste) + +--- + +## 1. Vorbereitung + +### Voraussetzungen am Host + +```bash +# Host‑Patchstand +sudo apt update && sudo apt full-upgrade -y && sudo reboot + +# Docker Engine + Compose v2 (Snap‑Paket nicht verwenden — fehlende cgroups‑v2‑Unterstützung) +sudo apt install -y ca-certificates curl gnupg +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \ + sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list >/dev/null +sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io \ + docker-buildx-plugin docker-compose-plugin + +# Host‑User in docker‑Gruppe + re‑login +sudo usermod -aG docker $USER +exec su -l $USER + +# Versionen verifizieren +docker version +docker compose version +``` + +### Repository / Quellbaum platzieren + +```bash +mkdir -p ~/projekte +cd ~/projekte +# z.B. git clone serienbrief +# oder tar -xzf serienbrief.tgz +cd serienbrief +``` + +--- + +## 2. Pflicht: Konfiguration + +### 2.1 `.env` aus Template ableiten + +```bash +cp .env.example .env +chmod 600 .env +``` + +### 2.2 Secrets generieren + +**Django Secret Key** (kein Newline, kein `=`, kein `+`/`/` falls sed im DATABASE_URL stört — Base64 hier okay, weil keine URL): + +```bash +DJANGO_KEY=$(openssl rand -base64 64 | tr -d '\n') +sed -i "s|^DJANGO_SECRET_KEY=.*|DJANGO_SECRET_KEY=${DJANGO_KEY}|" .env +unset DJANGO_KEY +``` + +**Redis‑Passwort** (hex statt base64 → keine URL‑Parsing‑Probleme): + +```bash +REDIS_PW=$(openssl rand -hex 32) +sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=${REDIS_PW}|" .env +unset REDIS_PW +``` + +**Postgres‑Passwort** als Datei UND in `DATABASE_URL` — beide Werte müssen identisch sein: + +```bash +PG_PW=$(openssl rand -hex 32) +echo -n "$PG_PW" > secrets/postgres_password.txt +chmod 600 secrets/postgres_password.txt +sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgres://serienbrief:${PG_PW}@db:5432/serienbrief|" .env +unset PG_PW + +# Verifikation: identische Werte? +diff <(grep -oP '(?<=://serienbrief:)[^@]+' .env) secrets/postgres_password.txt +# Output leer = identisch +``` + +> **Warum kein Newline?** Postgres liest das Secret‑File als Passwort‑String 1:1. Ein abschließendes `\n` macht das Passwort ungültig — du bekommst `FATAL: password authentication failed`. + +> **Warum hex statt base64?** Base64 kann `=`, `+`, `/` enthalten, die in `DATABASE_URL` percent‑encoded werden müssen. Hex ist URL‑safe by design. + +### 2.3 Production‑Werte in `.env` + +Folgende Zeilen anpassen: + +```env +DJANGO_DEBUG=False +DJANGO_ALLOWED_HOSTS=serienbrief.deine-domain.lan +CSRF_TRUSTED_ORIGINS=https://serienbrief.deine-domain.lan + +# WICHTIG: nicht 127.0.0.1, wenn der externe Proxy auf einem anderen Host läuft +APP_BIND_IP=192.168.x.y # LAN-IP des Docker-Hosts +APP_BIND_PORT=8080 + +# UIDs an Host‑User anpassen (sonst Permission‑denied auf Volumes/Bind‑Mounts) +APP_UID=1000 +APP_GID=1000 + +JOB_RETENTION_DAYS=30 +``` + +LAN‑IP ermitteln: + +```bash +ip -4 -br a | grep -v lo +``` + +### 2.4 Verifikation der Settings + +```bash +# Zeigt, wie Compose die finale Konfiguration zusammenstellt (inkl. expanded ENV) +docker compose -f docker-compose.yml config | less +``` + +Prüfen: + +- `DJANGO_SETTINGS_MODULE: config.settings.production` bei `web`, `worker`, `beat` +- `APP_UID`/`APP_GID` sind `1000`, nicht `10001` +- `CELERY_BROKER_URL` enthält das echte Redis‑Passwort (nicht `${REDIS_PASSWORD}`) +- `DATABASE_URL` enthält das echte Postgres‑Passwort + +--- + +## 3. Pflicht: Erststart in Produktion + +### 3.1 Image bauen + +```bash +docker compose build --pull +``` + +`--pull` holt das aktuellste Base‑Image (Sicherheitsfixes). + +### 3.2 Stack starten — **explizit ohne Dev‑Override** + +```bash +docker compose -f docker-compose.yml up -d +``` + +> **Wichtig:** Niemals `docker compose up -d` ohne `-f` in Produktion verwenden. Compose merged `docker-compose.override.yml` automatisch, das hat Dev‑Settings (`runserver`, Bind‑Mount, debugpy). In Dev ist `docker compose up -d` korrekt — hier in Produktion **muss** `-f docker-compose.yml` gesetzt sein. + +### 3.3 Status prüfen + +```bash +# Alle Services healthy? +docker compose -f docker-compose.yml ps + +# Erwartet: status=running, health=healthy für web, nginx, db, redis +# worker, beat, backup haben keinen Healthcheck, müssen aber laufen +``` + +### 3.4 Migrationen verifizieren + +Der Entrypoint führt `migrate` automatisch aus. Manuell nachfahren: + +```bash +docker compose -f docker-compose.yml exec web python manage.py migrate +docker compose -f docker-compose.yml exec web python manage.py showmigrations mailmerge +``` + +`0002_retention_cleanup_periodic_task` muss als `[X]` markiert sein. + +### 3.5 Superuser anlegen + +```bash +docker compose -f docker-compose.yml exec web python manage.py createsuperuser +``` + +Starkes Passwort (Passwort‑Manager). Kein admin/admin. + +### 3.6 Periodic Task verifizieren + +Im Browser: `https://serienbrief.deine-domain.lan/admin/django_celery_beat/periodictask/` + +Eintrag **„Retention‑Cleanup: abgelaufene Jobs löschen"** muss vorhanden sein, `Enabled = True`, Crontab `15 3 * * *`. + +Optional: Beat‑Logs beobachten + +```bash +docker compose -f docker-compose.yml logs -f beat +``` + +--- + +## 4. Pflicht: Reverse‑Proxy + +Der Stack liefert intern HTTP auf `${APP_BIND_IP}:${APP_BIND_PORT}` aus. Der externe Proxy übernimmt TLS. + +### 4.1 Firewall‑Regel für den Proxy + +```bash +# Nur der Proxy‑Host darf auf 8080 +sudo ufw allow from to any port 8080 proto tcp comment 'reverse proxy' +``` + +Siehe [6.1 Firewall](#61-firewall-ufw). + +### 4.2 Variante A: Nginx Proxy Manager + +Im NPM‑Webinterface → **Proxy Hosts → Add Proxy Host**: + +| Feld | Wert | +|---|---| +| Domain Names | `serienbrief.deine-domain.lan` | +| Scheme | `http` | +| Forward Hostname / IP | LAN‑IP des Docker‑Hosts (z.B. `192.168.10.42`) | +| Forward Port | `8080` | +| Cache Assets | aus | +| Block Common Exploits | an | +| Websockets Support | an | + +Tab **Advanced**: + +```nginx +client_max_body_size 50M; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_read_timeout 300s; +proxy_send_timeout 300s; +``` + +`X-Forwarded-Proto $scheme` ist **Pflicht**, sonst greift Djangos `SECURE_PROXY_SSL_HEADER` nicht und HTTPS‑Redirects laufen in eine Schleife. + +Tab **SSL**: Zertifikat zuweisen (Let's Encrypt via DNS‑Challenge für interne Domains oder eigene CA). + +### 4.3 Variante B: Generisches Nginx + +```nginx +server { + listen 443 ssl http2; + server_name serienbrief.deine-domain.lan; + + ssl_certificate /etc/ssl/lan/serienbrief.crt; + ssl_certificate_key /etc/ssl/lan/serienbrief.key; + + # HSTS — der externe Proxy ist der TLS-Terminator + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + client_max_body_size 50M; + + location / { + proxy_pass http://:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } +} +``` + +### 4.4 Diagnose bei 502 Bad Gateway + +```bash +# Vom Proxy-Host aus testen: +nc -zv 8080 +curl -v -H "Host: serienbrief.deine-domain.lan" http://:8080/ +``` + +| Symptom | Ursache | Fix | +|---|---|---| +| `Connection refused` | `APP_BIND_IP=127.0.0.1` | LAN‑IP setzen, `docker compose up -d` | +| `Timeout` | UFW/iptables blockt | Regel ergänzen | +| 502 ohne Connect‑Fehler | falsche Upstream‑IP im Proxy | Proxy‑Konfig prüfen | +| 400 Bad Request | Domain fehlt in `DJANGO_ALLOWED_HOSTS` | `.env` anpassen, `restart web` | +| CSRF 403 beim POST | Domain fehlt in `CSRF_TRUSTED_ORIGINS` | `.env` anpassen, `restart web` | + +--- + +## 5. Pflicht: Backup verifizieren + +**Ein Backup, das nie restored wurde, ist kein Backup.** + +### 5.1 Backup manuell triggern + +```bash +docker compose -f docker-compose.yml restart backup +docker compose -f docker-compose.yml logs --tail=80 backup + +ls -la backups/ +``` + +Erwartet: + +- `db_YYYYMMDD_HHMMSS.dump` (pg_dump im Custom‑Format) +- `media_YYYYMMDD_HHMMSS.tar.gz` + +### 5.2 Restore‑Test auf Wegwerf‑Container + +```bash +# Wegwerf‑Postgres +docker run --rm -d --name pgtest \ + -e POSTGRES_PASSWORD=test \ + -e POSTGRES_USER=serienbrief \ + -e POSTGRES_DB=serienbrief \ + postgres:16-alpine + +# Warten bis ready +sleep 5 + +# Dump reinkopieren und restoren +LATEST=$(ls -t backups/db_*.dump | head -1) +docker cp "$LATEST" pgtest:/tmp/restore.dump +docker exec pgtest pg_restore -U serienbrief -d serienbrief --clean --if-exists /tmp/restore.dump + +# Verifikation: existieren Tabellen? +docker exec pgtest psql -U serienbrief -d serienbrief -c "\dt mailmerge_*" + +# Aufräumen +docker rm -f pgtest +``` + +### 5.3 Backup‑Retention prüfen + +`docker-compose.yml`: `backup`‑Service hat Default 14 Tage. Bei `JOB_RETENTION_DAYS=30` ist das knapp — Backup‑Retention sollte `>= JOB_RETENTION_DAYS + ein paar Tage` sein, sonst gehen DSGVO‑relevante Audit‑Spuren verloren, bevor du sie aus dem Backup wiederherstellen könntest. + +Anpassen über die `BACKUP_RETENTION_DAYS`‑Variable (falls vorgesehen) oder direkt im Service‑Skript. + +### 5.4 Off‑Site‑Kopie + +Backups liegen aktuell **nur** auf der Host‑Disk. Für produktiven Einsatz: mindestens nightly auf ein zweites Ziel. + +```bash +# Beispiel: rsync auf zweiten Server, key-based auth, dediziertes Restricted-Account +rsync -avz --delete \ + -e "ssh -i /home/hans/.ssh/backup_ed25519" \ + /home/hans/projekte/serienbrief/backups/ \ + backup@backup-host.lan:/srv/backups/serienbrief/ +``` + +Cron: + +```cron +30 4 * * * /home/hans/scripts/serienbrief-offsite.sh +``` + +--- + +## 6. Pflicht: Host‑Hardening + +### 6.1 Firewall (UFW) + +```bash +sudo ufw default deny incoming +sudo ufw default allow outgoing + +# SSH — nur vom Admin-Netz +sudo ufw allow from 192.168.0.0/16 to any port 22 proto tcp comment 'ssh admin lan' + +# Docker-App-Port — nur vom Reverse-Proxy +sudo ufw allow from to any port 8080 proto tcp comment 'reverse proxy' + +sudo ufw enable +sudo ufw status verbose +``` + +> **Docker + UFW**: Docker umgeht UFW, weil es eigene `iptables`‑Regeln in der `DOCKER-USER`‑Chain schreibt. Solange du `APP_BIND_IP` auf eine konkrete LAN‑IP (nicht `0.0.0.0`) setzt, ist der Port nur auf diesem Interface offen und UFW greift zuverlässig. + +### 6.2 SSH‑Hardening + +```bash +sudo tee /etc/ssh/sshd_config.d/99-hardening.conf >/dev/null <<'EOF' +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +KbdInteractiveAuthentication no +X11Forwarding no +MaxAuthTries 3 +LoginGraceTime 30 +AllowGroups ssh-users +EOF + +sudo groupadd -f ssh-users +sudo usermod -aG ssh-users hans + +sudo sshd -t # Konfig syntaktisch ok? +sudo systemctl reload ssh +``` + +**Vorher**: SSH‑Key des Admin‑Workplatzes in `~/.ssh/authorized_keys` hinterlegen und in einer **zweiten** SSH‑Session testen, bevor die alte Session geschlossen wird. + +### 6.3 fail2ban + +```bash +sudo apt install -y fail2ban +sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF' +[DEFAULT] +bantime = 1h +findtime = 10m +maxretry = 5 +backend = systemd + +[sshd] +enabled = true +EOF + +sudo systemctl enable --now fail2ban +sudo fail2ban-client status sshd +``` + +### 6.4 Unattended Upgrades + +```bash +sudo apt install -y unattended-upgrades apt-listchanges + +sudo tee /etc/apt/apt.conf.d/52unattended-serienbrief >/dev/null <<'EOF' +Unattended-Upgrade::Allowed-Origins { + "${distro_id}:${distro_codename}-security"; + "${distro_id}ESMApps:${distro_codename}-apps-security"; + "${distro_id}ESM:${distro_codename}-infra-security"; +}; +Unattended-Upgrade::Automatic-Reboot "false"; +Unattended-Upgrade::Mail "ops@deine-domain.lan"; +Unattended-Upgrade::MailReport "on-change"; +EOF + +sudo systemctl enable --now unattended-upgrades +sudo unattended-upgrade --dry-run --debug | tail -30 +``` + +Reboot bewusst manuell, weil Docker‑Stack‑Restart sonst zur Unzeit passiert. + +### 6.5 Audit‑Logging + +```bash +sudo apt install -y auditd +# Default-Regeln reichen für Standard-Compliance. +sudo systemctl enable --now auditd +``` + +--- + +## 7. Pflicht: Smoke‑Test + +Vor dem offiziellen Go‑Live: einmal vollständig durchklicken. + +### 7.1 Funktionaler Smoke‑Test + +| Schritt | URL | Erwartung | +|---|---|---| +| 1. Startseite | `https://serienbrief.deine-domain.lan/` | Redirect zu `/accounts/login/` | +| 2. Login | `/accounts/login/` | Login mit Superuser funktioniert | +| 3. Template hochladen | `/admin/mailmerge/lettertemplate/add/` | DOCX akzeptiert, `placeholders` automatisch erkannt | +| 4. Job‑Form | `/jobs/new/` (o.ä.) | Vorlage + CSV auswählbar | +| 5. Preview | Klick „Vorschau (erste Zeile)" | iframe zeigt PDF inline, < 5 s | +| 6. Job starten | „Job starten" | Job läuft, Status wechselt `queued → running → done` | +| 7. Download | Klick auf Output‑PDF | PDF lädt herunter | +| 8. Logout | Logout‑Button | redirect zu Login | +| 9. Lockout | 5× falsches Passwort | `django-axes` lockt aus | + +### 7.2 Sicherheits‑Smoke‑Test + +```bash +# Direktzugriff auf den Docker-Host umgehend den Proxy — muss blockiert sein +curl -kv https://:443/ # ConnectionRefused erwartet, weil 443 nicht offen + +# Direkt auf 8080 von einem Nicht-Proxy-Host — muss blockiert sein +curl -v http://:8080/ # Timeout/ConnectionRefused erwartet + +# Postgres / Redis dürfen NICHT von außen erreichbar sein +nc -zv 5432 # erwartet: closed +nc -zv 6379 # erwartet: closed + +# CSRF-Token Pflicht +curl -k -X POST https://serienbrief.deine-domain.lan/accounts/login/ \ + -d "username=admin&password=test" +# erwartet: 403 (CSRF verification failed) +``` + +### 7.3 Test‑Suite im Container + +```bash +docker compose -f docker-compose.yml exec web pytest mailmerge/tests/ -v +# erwartet: alle Tests grün +``` + +--- + +## 8. Empfehlung: Betriebsautomatisierung + +### 8.1 systemd‑Unit für den Stack + +Damit der Stack nach Host‑Reboot deterministisch startet (vor allem nach Kernel‑Updates). `restart: unless-stopped` allein reicht nicht, wenn die Container vor dem Reboot gestoppt waren. + +`/etc/systemd/system/serienbrief.service`: + +```ini +[Unit] +Description=Serienbrief Docker Compose Stack +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +User=hans +Group=hans +WorkingDirectory=/home/hans/projekte/serienbrief +ExecStart=/usr/bin/docker compose -f docker-compose.yml up -d +ExecStop=/usr/bin/docker compose -f docker-compose.yml down +TimeoutStartSec=180 +TimeoutStopSec=120 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now serienbrief +sudo systemctl status serienbrief +``` + +> Beachte: `-f docker-compose.yml` ohne Override — verhindert, dass nach einem Reboot das Dev‑Override greift. + +### 8.2 Health‑Watchdog (Cron) + +`~/projekte/serienbrief/scripts/health-check.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +cd /home/hans/projekte/serienbrief + +UNHEALTHY=$(docker compose -f docker-compose.yml ps --format json \ + | jq -r 'select(.Health == "unhealthy" or .State == "exited") | .Service') + +if [[ -n "$UNHEALTHY" ]]; then + { + echo "Serienbrief: ungesunde Services auf $(hostname)" + echo "Zeitpunkt: $(date -Iseconds)" + echo + echo "$UNHEALTHY" + echo + echo "--- ps ---" + docker compose -f docker-compose.yml ps + } | mail -s "[ALERT] Serienbrief unhealthy" ops@deine-domain.lan +fi +``` + +```bash +chmod +x ~/projekte/serienbrief/scripts/health-check.sh +crontab -e +# Eintrag: +*/15 * * * * /home/hans/projekte/serienbrief/scripts/health-check.sh +``` + +Voraussetzungen: `jq` und ein lokaler Mail‑Setup (`msmtp`, `postfix`, etc.) oder Anbindung an Uptime Kuma / Healthchecks.io. + +### 8.3 Tägliche Job‑Statistik (optional) + +Cron‑Eintrag, der einmal täglich eine Status‑Mail schickt: + +```cron +0 7 * * * cd /home/hans/projekte/serienbrief && \ + docker compose -f docker-compose.yml exec -T web python manage.py shell -c \ + "from mailmerge.models import MailMergeJob; \ + from django.utils import timezone; from datetime import timedelta; \ + cutoff=timezone.now()-timedelta(days=1); \ + qs=MailMergeJob.objects.filter(created_at__gte=cutoff); \ + print(f'Last 24h: total={qs.count()} done={qs.filter(status=\"done\").count()} failed={qs.filter(status=\"failed\").count()}')" \ + | mail -s "Serienbrief daily" ops@deine-domain.lan +``` + +--- + +## 9. Empfehlung: Monitoring & Logging + +### 9.1 Log‑Rotation des Docker‑Daemons + +Aktuell: `json-file` mit Rotation auf `5x10 MB` (siehe `x-logging` in `docker-compose.yml`). Für längeren Aufbewahrungsbedarf: + +```bash +sudo tee /etc/docker/daemon.json >/dev/null <<'EOF' +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "10" + } +} +EOF +sudo systemctl restart docker +# Container müssen anschließend neu erstellt werden, damit die neue Default-Policy greift +docker compose -f docker-compose.yml up -d --force-recreate +``` + +### 9.2 Zentralisiertes Logging (optional) + +- **Minimal**: `journald` als Log‑Driver, dann via `rsyslog`/`vector`/`promtail` weiterreichen +- **Komfort**: Loki + Grafana, bei bestehender Infrastruktur einfach anzubinden + +Konfig pro Service in `docker-compose.yml`: + +```yaml +logging: + driver: journald + options: + tag: "serienbrief.{{.Name}}" +``` + +### 9.3 Metriken (optional) + +cAdvisor + node_exporter + Prometheus für Container‑Metriken; Django‑Prometheus für App‑interne Counter (Job‑Anzahl, Render‑Dauer). + +--- + +## 10. Empfehlung: Image‑Scanning + +### 10.1 Trivy vor jedem Build + +```bash +# Installation (host-side, einmalig) +sudo apt install -y wget apt-transport-https gnupg +wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - +echo deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main | \ + sudo tee /etc/apt/sources.list.d/trivy.list +sudo apt update && sudo apt install -y trivy + +# Scan +trivy image --severity HIGH,CRITICAL --exit-code 1 \ + --ignore-unfixed serienbrief/web:1.0.0 +``` + +### 10.2 pip‑audit für Python‑Dependencies + +```bash +docker compose -f docker-compose.yml run --rm web pip-audit +``` + +In `requirements-dev.txt` aufnehmen, damit es im Dev‑Image vorhanden ist. + +### 10.3 Build‑Pipeline (Skizze) + +`~/projekte/serienbrief/scripts/deploy.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +cd /home/hans/projekte/serienbrief + +git pull +docker compose -f docker-compose.yml build --pull + +# Sicherheits-Gate +trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed \ + serienbrief/web:$(grep ^APP_VERSION .env | cut -d= -f2) + +# Tests im neuen Image +docker compose -f docker-compose.yml run --rm web pytest mailmerge/tests/ --tb=line + +# Migrieren + Deployen +docker compose -f docker-compose.yml up -d +docker compose -f docker-compose.yml exec -T web python manage.py migrate --noinput + +# Health-Check nach Rollout +sleep 10 +docker compose -f docker-compose.yml ps +``` + +--- + +## 11. Optional: Verschlüsselung at‑rest + +Backups, `media_files` und `postgres_data` liegen aktuell unverschlüsselt auf der Host‑Disk. Bei Hardware‑Diebstahl / Wiederverwendung sind personenbezogene Daten lesbar. + +### 11.1 LUKS auf der Daten‑Partition + +Auf dem Host **vor** der Erstinstallation einrichten. Nachträglich nur mit Daten‑Migration auf neuen Volumes möglich. + +- Separate Partition für `/var/lib/docker` (Volumes) und `~/projekte/serienbrief/backups` +- LUKS2 mit Argon2id KDF +- Schlüssel via **TPM2 + clevis** automatisch beim Boot entsperren, kein Passphrase‑Prompt +- Recovery‑Key in Tresor + +### 11.2 Alternative: Filesystem‑Encryption + +- ZFS mit nativer Encryption (`zfs set encryption=on …`) +- ext4 + fscrypt (Per‑Directory‑Encryption) + +### 11.3 Backup‑Verschlüsselung + +Falls die Daten‑Partition nicht verschlüsselt wird, mindestens die Backups verschlüsseln: + +```bash +gpg --encrypt --recipient ops-backup@deine-domain.lan \ + --output backups/db_$(date +%Y%m%d).dump.gpg \ + backups/db_$(date +%Y%m%d).dump +``` + +Empfänger‑Key sicher aufbewahrt, **nicht** auf demselben Host. + +--- + +## 12. Optional: AppArmor‑Profile + +Default‑Docker‑AppArmor‑Profil ist okay. Für `web`/`worker` lassen sich striktere Profile schreiben (kein `mount`, kein `ptrace`, kein Zugriff außerhalb `/app`, `/tmp`, `/var/log`): + +```yaml +# in docker-compose.yml unter web/worker: +security_opt: + - apparmor=docker-serienbrief +``` + +Profil unter `/etc/apparmor.d/docker-serienbrief` ablegen, mit `sudo apparmor_parser -r` laden. Skizze: + +```text +#include + +profile docker-serienbrief flags=(attach_disconnected,mediate_deleted) { + #include + #include + + /app/** rk, + /tmp/** rwk, + /usr/local/lib/python3.12/** rk, + /usr/lib/libreoffice/** rk, + + deny capability sys_admin, + deny capability sys_module, + deny capability sys_ptrace, + deny mount, +} +``` + +> Erst im **Staging** testen, falsche Pfade führen sofort zu Container‑Crashes. + +--- + +## 13. Datenschutz‑Dokumentation + +Vor Echtbetrieb folgende Dokumente aktualisieren: + +### 13.1 VVT‑Eintrag (Verzeichnis von Verarbeitungstätigkeiten) + +| Feld | Beispielinhalt | +|---|---| +| Bezeichnung | Serienbrief‑System | +| Zweck | Erzeugung personalisierter Anschreiben aus DOCX‑Vorlagen | +| Rechtsgrundlage | Art. 6 Abs. 1 lit. b/c/e DSGVO (anwendungsabhängig) | +| Datenkategorien | Anrede, Vor‑/Nachname, Anschrift, ggf. Personalnummer, Funktion | +| Betroffene | Mitarbeiter:innen / Kunden / etc. (use‑case‑abhängig) | +| Empfänger | Postlogistik (sofern PDF gedruckt/versandt) | +| Aufbewahrung | 30 Tage (`JOB_RETENTION_DAYS`), automatische Löschung | +| Drittstaaten | nein | +| TOMs | siehe 13.2 | + +### 13.2 Technisch‑organisatorische Maßnahmen (TOMs) + +| Schutzziel | Maßnahme | +|---|---| +| Vertraulichkeit | TLS am externen Proxy, Auth via Django + django‑axes, Container non‑root | +| Integrität | CSRF, signed Cookies, Audit‑Log pro Job | +| Verfügbarkeit | tägliches Backup + verifizierter Restore, Healthchecks | +| Belastbarkeit | Compose‑Restart‑Policy, systemd‑Unit, Resource‑Limits | +| Wiederherstellbarkeit | pg_restore aus Backup, dokumentiert | +| Eingabekontrolle | `JobLogEntry` protokolliert `created_by`, Zeitstempel, Statuswechsel | +| Auftragskontrolle | n/a (kein Auftragsverarbeiter) | +| Trennungskontrolle | Mandanten‑/Rollenmodell ⚠️ noch offen — siehe Roadmap | +| Löschung | `mailmerge.cleanup_expired_jobs` täglich, manuell per `cleanup_jobs` Command | + +### 13.3 DSFA‑Trigger + +Eine Datenschutz‑Folgenabschätzung gemäß Art. 35 DSGVO ist erforderlich, wenn: + +- besondere Datenkategorien (Art. 9) verarbeitet werden (Gesundheitsdaten etc.) +- umfangreiche Profilbildung erfolgt +- systematische Überwachung großer Personenmengen stattfindet + +→ Vor Echtbetrieb durch DSB/DPO bewerten. + +### 13.4 Löschnachweis + +Der Retention‑Cleanup hinterlässt **keinen** persistenten Audit‑Eintrag, weil der Job selbst gelöscht wird. Falls Nachweispflicht: Beat‑Logs zentralisieren und mindestens `JOB_RETENTION_DAYS + Aufbewahrungsfrist Log` aufbewahren. + +--- + +## 14. Update & Rollback + +### 14.1 Standard‑Update + +```bash +cd ~/projekte/serienbrief +git pull +docker compose -f docker-compose.yml build --pull +docker compose -f docker-compose.yml up -d +docker compose -f docker-compose.yml exec web python manage.py migrate --noinput +``` + +### 14.2 Image vor Update sichern (für Rollback) + +```bash +OLD_VERSION=$(grep ^APP_VERSION .env | cut -d= -f2) +docker tag serienbrief/web:${OLD_VERSION} serienbrief/web:rollback-${OLD_VERSION}-$(date +%Y%m%d) +``` + +### 14.3 Rollback + +```bash +# .env: APP_VERSION auf alte Version zurücksetzen +sed -i "s|^APP_VERSION=.*|APP_VERSION=${OLD_VERSION}|" .env +docker tag serienbrief/web:rollback-${OLD_VERSION}-YYYYMMDD serienbrief/web:${OLD_VERSION} +docker compose -f docker-compose.yml up -d + +# DB-Migration zurückrollen (nur wenn nötig, vorsichtig): +docker compose -f docker-compose.yml exec web python manage.py migrate mailmerge +``` + +> Backwards‑Compatibility der Migrationen ist kein Selbstläufer. Vor Schema‑Änderungen, die nicht reversibel sind, **immer** vorher pg_dump erstellen und im Notfall einspielen. + +--- + +## 15. Pre‑Go‑Live‑Checkliste + +Abhaken vor dem offiziellen Go‑Live: + +### Konfiguration + +- [ ] `.env` enthält echte Secrets, kein `CHANGE_ME` mehr +- [ ] `.env` und `secrets/postgres_password.txt` sind `0600`, gehören `hans:hans` +- [ ] `DJANGO_DEBUG=False` +- [ ] `DJANGO_ALLOWED_HOSTS` enthält die produktive Domain +- [ ] `CSRF_TRUSTED_ORIGINS` enthält `https://` +- [ ] `APP_BIND_IP` auf LAN‑IP gesetzt (nicht 127.0.0.1, falls Proxy extern) +- [ ] `APP_UID=1000` / `APP_GID=1000` (an Host‑User) +- [ ] `JOB_RETENTION_DAYS` an Geschäftsanforderung angepasst +- [ ] Postgres‑Passwort in `.env` und Secret‑File identisch + +### Stack‑Lauf + +- [ ] `docker compose -f docker-compose.yml ps` zeigt alle Services `healthy` +- [ ] Periodic Task `Retention-Cleanup` in Admin sichtbar und enabled +- [ ] Superuser angelegt, starkes Passwort +- [ ] Test‑Suite läuft grün + +### Reverse‑Proxy + +- [ ] TLS‑Zertifikat gültig +- [ ] `X-Forwarded-Proto $scheme` gesetzt +- [ ] `client_max_body_size` >= 50M +- [ ] Externe URL liefert Login‑Seite (kein 502) +- [ ] HSTS am externen Proxy aktiv + +### Host + +- [ ] UFW aktiv, nur 22 + 8080 (vom Proxy) erlaubt +- [ ] SSH: keine Passwort‑Auth, kein Root, AllowGroups gesetzt +- [ ] fail2ban läuft +- [ ] Unattended‑Upgrades aktiv +- [ ] systemd‑Unit `serienbrief.service` enabled + +### Backup + +- [ ] Backup‑Service läuft, schreibt Dumps +- [ ] Restore‑Test auf Wegwerf‑Container erfolgreich +- [ ] Off‑Site‑Kopie eingerichtet (cron auf zweiten Server) +- [ ] Backup‑Retention >= `JOB_RETENTION_DAYS` + Sicherheitspuffer + +### Monitoring + +- [ ] Health‑Check‑Cron läuft, Test‑Alarm verifiziert +- [ ] Log‑Driver konfiguriert (Rotation oder zentral) + +### Sicherheits‑Smoke‑Test + +- [ ] Postgres‑Port 5432 nicht von außen erreichbar +- [ ] Redis‑Port 6379 nicht von außen erreichbar +- [ ] Direkter Zugriff auf 8080 nur vom Proxy möglich +- [ ] 5 Fehlversuche → django‑axes Lockout funktioniert +- [ ] CSRF wird erzwungen + +### Funktionaler Smoke‑Test + +- [ ] Template hochladen +- [ ] CSV hochladen, Preview erzeugt PDF inline +- [ ] Vollständiger Job läuft durch, PDF downloadbar +- [ ] Logout funktioniert +- [ ] Retention‑Cleanup im Dry‑Run liefert plausibles Ergebnis + +### Dokumentation + +- [ ] VVT‑Eintrag aktualisiert +- [ ] TOMs dokumentiert +- [ ] DSFA‑Pflicht bewertet (ggf. durchgeführt) +- [ ] Notfall‑Kontakte hinterlegt +- [ ] Restore‑Prozedur dokumentiert und im Notfallhandbuch verlinkt + +--- + +## Anhang: Häufige Fehler & Fixes + +| Symptom | Ursache | Fix | +|---|---|---| +| `permission denied` bei makemigrations | UID‑Mismatch Container vs. Host | `APP_UID=1000` in `.env`, `docker compose build --no-cache web` | +| `502 Bad Gateway` am externen Proxy | `APP_BIND_IP=127.0.0.1` oder Firewall | LAN‑IP setzen, UFW‑Regel | +| `CSRF verification failed` beim Login | Domain fehlt in `CSRF_TRUSTED_ORIGINS` | `.env` ergänzen, `restart web` | +| Logout liefert `405 Method Not Allowed` | GET‑Logout in Django 5 abgeschafft | POST‑Form (bereits in `base.html`) | +| `network ... not found` beim Compose‑Start | `docker compose -f docker-compose.yml` ohne Override → fehlende Bridge | Override behalten oder Netzwerk in `docker-compose.yml` definieren | +| Preview‑PDF leer/kaputt | LibreOffice‑User‑Profil verwaist im tmpfs | Container neu starten, `--max-tasks-per-child` greift normalerweise | +| `FATAL: password authentication failed` (Postgres) | Newline am Ende von `secrets/postgres_password.txt` | `echo -n` statt `echo`, `tr -d '\n'`, Datei erneut schreiben | +| Cleanup löscht keine Dateien (nur DB‑Zeilen) | Bulk‑`qs.delete()` ohne FileField‑Hooks | Aktuelle `retention.py` iteriert einzeln — Code‑Stand verifizieren | + +--- + +**Verantwortlich:** Datenschutzkoordination / IT‑Systemadministration +**Letzte Überprüfung:** _Datum hier eintragen_ +**Nächste Überprüfung:** _spätestens nach Major‑Update oder einmal jährlich_ diff --git a/README.md b/README.md index 4c903d5..697aa02 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Zielumgebung: internes LAN, hinter zentralem Reverse‑Proxy (TLS‑Terminierung - Audit‑Log pro Job (Status‑Wechsel, Fehler, abgearbeitete Zeilen) - Tägliches Backup (pg_dump + Media‑Tar, 14 Tage Retention) - **Test‑Suite** mit pytest‑django (Service‑Unit, View‑Layer, Integration mit echtem LibreOffice) — siehe [TEST.md](TEST.md) +- **Production‑Anleitung** mit Go‑Live‑Checkliste — siehe [DEPLOYMENT.md](DEPLOYMENT.md) ---