diff --git a/.env.example b/.env.example index 723870d..d64b232 100644 --- a/.env.example +++ b/.env.example @@ -16,9 +16,21 @@ DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockp AUTH_SECRET= # Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash). AUTH_URL=http://localhost:3000 -# Hinter einem Reverse Proxy (Caddy) auf true setzen. +# Hinter einem Reverse Proxy auf true setzen. AUTH_TRUST_HOST=true +# --- Erreichbarkeit --------------------------------------------------------- +# Auf welcher Adresse und welchem Port horcht die Anwendung? +# +# Proxy auf DEMSELBEN Server: 127.0.0.1 (Standard, nichts zu tun) +# Proxy auf einem ANDEREN: die Adresse des privaten Netzes bzw. des +# WireGuard-Tunnels, z.B. 10.0.0.5 +# +# 0.0.0.0 nur, wenn eine Firewall den Port ausschliesslich fuer die IP des +# Proxy-Servers oeffnet. Die Anwendung beendet kein TLS. +APP_BIND=127.0.0.1 +APP_PORT=3000 + # --- Anwendungsseitige Feldverschluesselung --------------------------------- # AES-256-GCM. 32 Bytes, base64. OHNE DIESEN SCHLUESSEL SIND DIE VERSCHLUESSELTEN # FELDER UNWIEDERBRINGLICH VERLOREN - unbedingt getrennt vom Backup sichern! diff --git a/README.md b/README.md index 95cbb06..28c0fca 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ eines Angehörigen: Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe, Berichte | Wofür | Wo | |---|---| -| **Einen Server aufsetzen** — von der leeren Maschine bis zur laufenden Anwendung, mit Kontrolle nach jedem Schritt | [`docs/INSTALLATION.md`](docs/INSTALLATION.md) | +| **Die Anwendung aufsetzen** — auf einem Server, der Docker und einen Proxy schon hat, mit Kontrolle nach jedem Schritt | [`docs/INSTALLATION.md`](docs/INSTALLATION.md) | | **Täglich damit arbeiten** — die zwölf Handgriffe, der Jahresrhythmus, was tun wenn etwas schiefgeht | [`docs/ANLEITUNG.md`](docs/ANLEITUNG.md), in der Anwendung unter `/anleitung` | | **Wissen, was sich ändert** — was neu ist, was du vor einem Update beachten musst | [`CHANGELOG.md`](CHANGELOG.md) | @@ -188,61 +188,45 @@ Backup anstoßen** (siehe unten) — eine Migration lässt sich nicht zurückrol ## Reverse Proxy -Die Anwendung erwartet, dass der Proxy TLS beendet und die echte Client-IP -weiterreicht. Sie liest `X-Forwarded-For` bzw. `X-Real-IP` für das -Rate-Limiting am Login und für das Audit-Log (dort nur als Hash). +Die Anwendung **beendet selbst kein TLS** und bringt keinen Proxy mit. Sie +horcht standardmäßig auf `127.0.0.1:3000`. -**Caddy** +**Läuft der Proxy auf demselben Server**, ist damit alles gesagt: Er zeigt +auf `127.0.0.1:3000`, fertig. -```caddyfile -cockpit.example.de { - encode zstd gzip - reverse_proxy 127.0.0.1:3000 +**Läuft er auf einem anderen Server**, setz in der `.env` die Adresse, über +die er herankommt — am besten die eines privaten Netzes oder eines +WireGuard-Tunnels: - # Uploads: Fotos von Behördenschreiben können groß sein. - request_body { - max_size 30MB - } -} +```bash +APP_BIND=10.0.0.5 +APP_PORT=3000 ``` -**nginx** +`0.0.0.0` nur, wenn eine Firewall den Port ausschließlich für die IP des +Proxy-Servers öffnet. Zwischen Proxy und Anwendung läuft unverschlüsseltes +HTTP — dieser Weg darf nie durchs offene Netz gehen. -```nginx -server { - listen 443 ssl http2; - server_name cockpit.example.de; +### Was der Proxy leisten muss - ssl_certificate /etc/letsencrypt/live/cockpit.example.de/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/cockpit.example.de/privkey.pem; +| Anforderung | Was ohne sie passiert | +|---|---| +| TLS beenden | Der Browser lehnt Cookies mit `Secure`-Kennzeichen ab, die Anmeldung schlägt stumm fehl | +| `Host` durchreichen | Auth.js vergleicht ihn mit `AUTH_URL` und weist die Anmeldung ab | +| `X-Forwarded-Proto: https` | Die Anwendung baut Adressen mit `http` | +| `X-Forwarded-For` | Die Login-Sperre zählt alle Zugriffe als denselben Anschluss | +| Uploads ≥ 30 MB | Fotos mehrseitiger Bescheide werden abgeschnitten | +| Zeitgrenze ≥ 10 Minuten | Belegmappe, Rechnungslegung und Vollexport brechen ab | +| Antwort nicht puffern | Der Vollexport kommt als Strom | - client_max_body_size 30M; +**Keine eigenen Sicherheitskopfzeilen setzen.** CSP, `X-Frame-Options`, +`nosniff` und HSTS kommen aus der Anwendung. Die CSP enthält eine Nonce, +die pro Anfrage neu ist — eine feste CSP aus dem Proxy blockiert die +eigenen Skripte. - location / { - proxy_pass http://127.0.0.1:3000; - 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 $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } -} -``` - -**Traefik** (Labels am `app`-Dienst) - -```yaml -labels: - - traefik.enable=true - - traefik.http.routers.cockpit.rule=Host(`cockpit.example.de`) - - traefik.http.routers.cockpit.tls.certresolver=letsencrypt - - traefik.http.services.cockpit.loadbalancer.server.port=3000 -``` - -In allen Fällen muss `AUTH_URL` in der `.env` auf die öffentliche Adresse -zeigen (`https://cockpit.example.de`), sonst schlägt die Anmeldung fehl. +**Fertige Konfigurationen** für Caddy, nginx und Traefik, mit Zeitgrenzen +und den Gegenproben, stehen in +[`docs/INSTALLATION.md`](docs/INSTALLATION.md), Schritt 7. --- diff --git a/docker-compose.yml b/docker-compose.yml index 6345766..02be03e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,13 @@ # # docker compose up -d --build # -# Kein Reverse Proxy in dieser Datei: der ist auf dem Server bereits -# vorhanden. Die Anwendung horcht nur auf 127.0.0.1:3000 - von aussen -# erreichbar wird sie ausschliesslich ueber den vorhandenen Proxy, der -# auch TLS beendet. Siehe README, Abschnitt "Reverse Proxy". +# Kein Reverse Proxy in dieser Datei: der ist bereits vorhanden. Die +# Anwendung beendet selbst kein TLS und horcht standardmaessig nur auf +# 127.0.0.1:3000. +# +# Liegt der Proxy auf einem anderen Server, setze APP_BIND in der .env auf +# die Adresse, ueber die er herankommt - siehe docs/INSTALLATION.md, +# Schritt 7. # # Die Datenbank hat bewusst KEINE Portfreigabe. Sie ist nur ueber das # interne Docker-Netz erreichbar. Wer mit psql heran muss, geht ueber @@ -111,9 +114,19 @@ services: SMTP_FROM: ${SMTP_FROM:-} TZ: Europe/Berlin ports: - # Nur Loopback. Der vorhandene Reverse Proxy auf dem Host reicht - # von aussen hierher durch und beendet TLS. - - '127.0.0.1:${APP_PORT:-3000}:3000' + # Standard ist die Loopback-Adresse: dann erreicht die Anwendung nur + # ein Proxy, der auf demselben Rechner laeuft. + # + # Liegt der Reverse Proxy auf einem ANDEREN Server, muss hier die + # Adresse stehen, ueber die er herankommt - am besten die eines + # privaten Netzes oder eines WireGuard-Tunnels: + # + # APP_BIND=10.0.0.5 + # + # 0.0.0.0 nur setzen, wenn eine Firewall den Port ausschliesslich + # fuer die IP des Proxy-Servers oeffnet. Die Anwendung beendet kein + # TLS und gehoert nie unverschluesselt ins offene Netz. + - '${APP_BIND:-127.0.0.1}:${APP_PORT:-3000}:3000' networks: [intern] healthcheck: test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/api/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 701a0f0..244f30d 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -1,181 +1,124 @@ # Installation Schritt für Schritt -Von einem leeren Server bis zur laufenden Anwendung. Rechne mit **60 bis -90 Minuten**, wenn du es zum ersten Mal machst. +Von einem vorbereiteten Server bis zur laufenden Anwendung. Rechne mit +**30 bis 45 Minuten**, wenn du es zum ersten Mal machst. -Diese Anleitung setzt voraus, dass du dich per SSH auf einem Linux-Server -anmelden und Befehle eintippen kannst. Mehr nicht — jeder Schritt steht -ausgeschrieben da, und nach jedem Abschnitt steht eine **Kontrolle**, mit -der du prüfst, ob es geklappt hat, bevor du weitermachst. +Diese Anleitung setzt einen Server voraus, auf dem **Docker bereits läuft**, +und einen **Reverse Proxy, der schon steht** — auch dann, wenn er auf einem +anderen Server liegt. Sie richtet nur das Betreuungs-Cockpit ein: kein +Docker, keine Betriebssystemhärtung, kein Proxy von Grund auf. + +Was genau auf dem Proxy hinterlegt werden muss, steht vollständig in +[Schritt 7](#schritt-7--in-den-reverse-proxy-eintragen). + +Nach jedem Schritt steht eine **Kontrolle**. Geh nicht weiter, bevor die +durchgeht — dann weißt du im Fehlerfall genau, wo es hakt. > Wenn du die Anwendung nur ausprobieren willst, ohne Server: Der > Abschnitt *Schnellstart* in der [README](../README.md) bringt dich in -> zehn Minuten auf dem eigenen Rechner zum Laufen. Diese Anleitung hier -> ist für den echten Betrieb. +> zehn Minuten auf dem eigenen Rechner zum Laufen. --- ## Inhalt -- [Schritt 0 — Was du vorher brauchst](#schritt-0--was-du-vorher-brauchst) -- [Schritt 1 — Server vorbereiten](#schritt-1--server-vorbereiten) -- [Schritt 2 — Docker installieren](#schritt-2--docker-installieren) -- [Schritt 3 — Anwendung holen](#schritt-3--anwendung-holen) -- [Schritt 4 — Schlüssel erzeugen](#schritt-4--schlüssel-erzeugen) -- [Schritt 5 — Konfiguration ausfüllen](#schritt-5--konfiguration-ausfüllen) -- [Schritt 6 — Starten](#schritt-6--starten) -- [Schritt 7 — Datenbank vorbereiten](#schritt-7--datenbank-vorbereiten) -- [Schritt 8 — Dateiablage einrichten](#schritt-8--dateiablage-einrichten) -- [Schritt 9 — Reverse Proxy und TLS](#schritt-9--reverse-proxy-und-tls) -- [Schritt 10 — Ersteinrichtung im Browser](#schritt-10--ersteinrichtung-im-browser) -- [Schritt 11 — Sicherung prüfen](#schritt-11--sicherung-prüfen) -- [Schritt 12 — Wartungslauf einrichten](#schritt-12--wartungslauf-einrichten) -- [Schritt 13 — Schlüssel in Sicherheit bringen](#schritt-13--schlüssel-in-sicherheit-bringen) +- [Schritt 0 — Voraussetzungen prüfen](#schritt-0--voraussetzungen-prüfen) +- [Schritt 1 — Anwendung holen](#schritt-1--anwendung-holen) +- [Schritt 2 — Schlüssel erzeugen](#schritt-2--schlüssel-erzeugen) +- [Schritt 3 — Konfiguration ausfüllen](#schritt-3--konfiguration-ausfüllen) +- [Schritt 4 — Starten](#schritt-4--starten) +- [Schritt 5 — Datenbank vorbereiten](#schritt-5--datenbank-vorbereiten) +- [Schritt 6 — Dateiablage einrichten](#schritt-6--dateiablage-einrichten) +- [Schritt 7 — In den Reverse Proxy eintragen](#schritt-7--in-den-reverse-proxy-eintragen) +- [Schritt 8 — Ersteinrichtung im Browser](#schritt-8--ersteinrichtung-im-browser) +- [Schritt 9 — Sicherung prüfen](#schritt-9--sicherung-prüfen) +- [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten) +- [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen) - [Aktualisieren](#aktualisieren) - [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt) - [Abnahmeliste](#abnahmeliste) --- -## Schritt 0 — Was du vorher brauchst +## Schritt 0 — Voraussetzungen prüfen -**Ein Server in Deutschland oder mindestens in der EU.** Für eine Betreuung -reicht die kleinste Größe bequem: +### Was der Server mitbringen muss | | Minimum | Bequem | |---|---|---| -| Arbeitsspeicher | 2 GB | 4 GB | -| Speicherplatz | 20 GB | 40 GB | -| Kerne | 2 | 2 | -| Betriebssystem | Debian 12 oder Ubuntu 24.04 | dasselbe | +| Arbeitsspeicher | 2 GB frei | 4 GB frei | +| Speicherplatz | 20 GB frei | 40 GB frei | +| Docker | 24 oder neuer | aktuell | +| Docker Compose | v2 (`docker compose`, nicht `docker-compose`) | aktuell | +| Reverse Proxy | vorhanden, mit TLS (auch auf einem anderen Server) | dasselbe | +| Netzweg zum Proxy | privates Netz, WireGuard oder Firewallregel | privates Netz | +| Standort | Deutschland oder mindestens EU | dasselbe | -Anbieter mit Rechenzentren in Deutschland: Hetzner (Falkenstein, -Nürnberg), netcup (Nürnberg), IONOS (Karlsruhe). Ein Server für rund fünf -Euro im Monat genügt. - -> **Bevor du bestellst:** Kläre den **Auftragsverarbeitungsvertrag nach -> Art. 28 DSGVO**. Bei Hetzner findest du ihn im Kundenkonto unter -> *Verträge → AV-Vertrag*, bei anderen ähnlich. Ohne ihn ist der Betrieb -> formal rechtswidrig — und es dauert fünf Minuten. - -**Eine Domain oder Subdomain**, zum Beispiel `cockpit.deine-domain.de`. -Setz einen A-Record auf die IP-Adresse des Servers, bevor du anfängst; -DNS-Änderungen brauchen manchmal eine Stunde. - -**Etwas zum Aufschreiben.** Du erzeugst gleich Schlüssel, die du nicht -verlieren darfst. Ein Passwortmanager ist das Richtige. +Der erste Bau lädt Tesseract mit den deutschen Sprachdaten — plan etwa +**1 GB Netzverkehr** und fünf bis zehn Minuten ein. ### Kontrolle -```bash -# Von deinem Rechner aus - zeigt die IP deines Servers? -dig +short cockpit.deine-domain.de +Melde dich per SSH auf dem Server an und prüf: -# Kommst du auf den Server? -ssh root@cockpit.deine-domain.de +```bash +docker --version # 24.x oder neuer +docker compose version # v2.x - mit Leerzeichen, nicht docker-compose +docker run --rm hello-world # "Hello from Docker!" +df -h / # genug Platz? +free -h # genug Speicher? ``` ---- - -## Schritt 1 — Server vorbereiten - -Alles Folgende läuft **auf dem Server**. +Kommt bei `docker run` ein *permission denied*, fehlt dir die +Docker-Gruppe: ```bash -# System aktuell bringen -apt update && apt upgrade -y - -# Werkzeuge, die wir brauchen -apt install -y git curl ca-certificates ufw - -# Zeitzone setzen - sonst rechnet der Server in UTC und -# verschiebt Fristen um einen Tag -timedatectl set-timezone Europe/Berlin -``` - -**Einen eigenen Benutzer anlegen.** Als `root` zu arbeiten ist unnötig -riskant: - -```bash -adduser --gecos "" cockpit -usermod -aG sudo cockpit - -# SSH-Schlüssel übernehmen, damit du dich weiter anmelden kannst -rsync --archive --chown=cockpit:cockpit ~/.ssh /home/cockpit/ -``` - -**Die Firewall einschalten.** Nur SSH, HTTP und HTTPS von außen: - -```bash -ufw allow OpenSSH -ufw allow 80/tcp -ufw allow 443/tcp -ufw --force enable -``` - -**SSH absichern** — Anmeldung nur mit Schlüssel, nicht mit Passwort. Nur -machen, wenn du dich sicher mit Schlüssel anmelden kannst: - -```bash -sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config -sed -i 's/^#*PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config -systemctl restart ssh -``` - -### Kontrolle - -```bash -timedatectl | grep "Time zone" # Europe/Berlin -ufw status # active, drei Regeln -``` - -Melde dich jetzt neu an, als `cockpit`: - -```bash -ssh cockpit@cockpit.deine-domain.de -``` - ---- - -## Schritt 2 — Docker installieren - -```bash -# Offizielles Paketverzeichnis von Docker eintragen -sudo install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/debian/gpg \ - | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg -sudo chmod a+r /etc/apt/keyrings/docker.gpg - -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ -https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") 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 - -# Ohne sudo arbeiten dürfen sudo usermod -aG docker $USER ``` -> Bei **Ubuntu** ersetzt du in den beiden URLs `debian` durch `ubuntu`. +Danach **abmelden und neu anmelden**, sonst greift es nicht. -**Melde dich ab und wieder an**, damit die Gruppenzugehörigkeit greift. +### Zeitzone -### Kontrolle +Die Behälter setzen `Europe/Berlin` selbst — der Host muss also nicht +umgestellt sein. Für die Logs und den cron-Eintrag aus Schritt 10 ist es +trotzdem angenehm: ```bash -docker --version # Docker version 27.x oder neuer -docker compose version # Docker Compose version v2.x -docker run --rm hello-world # "Hello from Docker!" +timedatectl | grep "Time zone" +# nicht Europe/Berlin? dann: +sudo timedatectl set-timezone Europe/Berlin ``` -Kommt bei `docker run` ein *permission denied*, hast du das Ab- und -Anmelden vergessen. +### Domain + +Du brauchst eine Adresse, unter der die Anwendung erreichbar sein soll, +zum Beispiel `cockpit.deine-domain.de`. + +Der A-Record zeigt auf den **Proxy-Server**, nicht auf diesen hier — der +Proxy nimmt die Anfragen entgegen und reicht sie weiter. Setz ihn +**jetzt**, DNS-Änderungen brauchen manchmal eine Stunde. + +```bash +dig +short cockpit.deine-domain.de # muss die IP des PROXY-Servers zeigen +``` + +### Und das Wichtigste + +> Kläre den **Auftragsverarbeitungsvertrag nach Art. 28 DSGVO** mit deinem +> Hoster. Bei Hetzner im Kundenkonto unter *Verträge → AV-Vertrag*, bei +> anderen ähnlich. Ohne ihn ist der Betrieb formal rechtswidrig — und es +> dauert fünf Minuten. +> +> Dasselbe gilt für den Objektspeicher, falls du einen externen benutzt +> statt des mitgelieferten MinIO. + +Halt außerdem etwas bereit, um Schlüssel zu notieren. Ein Passwortmanager +ist das Richtige — du erzeugst gleich Werte, die du nicht verlieren darfst. --- -## Schritt 3 — Anwendung holen +## Schritt 1 — Anwendung holen ```bash sudo mkdir -p /opt/betreuungs-cockpit @@ -186,8 +129,8 @@ git clone https://git.wilde-server.de/florian.wilde/betreuungs-cockpit.git \ cd /opt/betreuungs-cockpit -# Auf die letzte veröffentlichte Fassung wechseln, nicht auf den -# laufenden Entwicklungsstand +# Auf die letzte veröffentlichte Fassung wechseln, +# nicht auf den laufenden Entwicklungsstand git checkout v1.0.0 ``` @@ -200,37 +143,40 @@ git describe --tags # v1.0.0 --- -## Schritt 4 — Schlüssel erzeugen +## Schritt 2 — Schlüssel erzeugen -Die Anwendung braucht vier Geheimnisse. Erzeuge sie **auf dem Server**, +Die Anwendung braucht sieben Geheimnisse. Erzeuge sie **auf dem Server**, nicht auf dem eigenen Rechner: ```bash docker run --rm node:22-alpine node -e " const c = require('crypto'); const b = n => c.randomBytes(n).toString('base64'); +const w = n => b(n).replace(/[+/=]/g,'x'); console.log('AUTH_SECRET=' + b(32)); console.log('ENC_KEY_V1=' + b(32)); console.log('INDEX_KEY=' + b(32)); -console.log('POSTGRES_PASSWORD=' + b(24).replace(/[+/=]/g,'x')); -console.log('SICHERUNG_PASSWORT=' + b(24).replace(/[+/=]/g,'x')); -console.log('S3_ACCESS_KEY_ID=' + b(12).replace(/[+/=]/g,'x')); -console.log('S3_SECRET_ACCESS_KEY=' + b(24).replace(/[+/=]/g,'x')); +console.log('POSTGRES_PASSWORD=' + w(24)); +console.log('SICHERUNG_PASSWORT=' + w(24)); +console.log('S3_ACCESS_KEY_ID=' + w(12)); +console.log('S3_SECRET_ACCESS_KEY=' + w(24)); " ``` -Kopier die Ausgabe in einen Texteditor. Du brauchst sie gleich. +Kopier die Ausgabe in einen Texteditor. Du brauchst sie im nächsten +Schritt. > **`ENC_KEY_V1` ist der wichtigste Wert der ganzen Installation.** Mit ihm > sind Diagnosen, IBANs, Versicherungsnummern und alle Notizen -> verschlüsselt. Geht er verloren, sind diese Felder **unwiederbringlich** -> weg — auch aus jedem Backup. Es gibt keine Hintertür. +> verschlüsselt. Geht er verloren, sind diese Felder +> **unwiederbringlich** weg — auch aus jedem Backup. Es gibt keine +> Hintertür. > -> Schritt 13 sagt dir, wohin damit. Verlier ihn bis dahin nicht. +> Schritt 11 sagt dir, wohin damit. Verlier ihn bis dahin nicht. --- -## Schritt 5 — Konfiguration ausfüllen +## Schritt 3 — Konfiguration ausfüllen ```bash cp .env.example .env @@ -241,24 +187,24 @@ Diese Werte **musst** du setzen: ```bash # --- Datenbank --- -POSTGRES_PASSWORD= +POSTGRES_PASSWORD= # --- Anmeldung --- -AUTH_SECRET= +AUTH_SECRET= AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https AUTH_TRUST_HOST=true # --- Verschlüsselung --- -ENC_KEY_V1= +ENC_KEY_V1= ENC_KEY_AKTIV=1 -INDEX_KEY= +INDEX_KEY= # --- Objektspeicher --- -S3_ACCESS_KEY_ID= -S3_SECRET_ACCESS_KEY= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= # --- Sicherung --- -SICHERUNG_PASSWORT= +SICHERUNG_PASSWORT= ``` > **`AUTH_URL` muss exakt der Adresse entsprechen**, unter der du die @@ -267,6 +213,16 @@ SICHERUNG_PASSWORT= > Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular, > ohne Fehlermeldung. Das ist der Fehler, den man am längsten sucht. +**Läuft auf dem Server schon etwas auf Port 3000?** Dann weich aus: + +```bash +APP_PORT=3010 +``` + +Auf welcher Adresse die Anwendung horcht, legst du in +[Schritt 7](#schritt-7--in-den-reverse-proxy-eintragen) fest — dort steht +auch, warum `127.0.0.1` nicht reicht, wenn der Proxy woanders läuft. + Optional, aber empfohlen — Fristerinnerungen per E-Mail: ```bash @@ -277,9 +233,8 @@ SMTP_PASSWORD= SMTP_FROM="Betreuungs-Cockpit " ``` -Speichern mit `Strg+O`, `Enter`, `Strg+X`. - -**Die Datei absichern** — sie enthält alle Schlüssel: +Speichern mit `Strg+O`, `Enter`, `Strg+X`. Dann die Datei absichern — sie +enthält alle Schlüssel: ```bash chmod 600 .env @@ -289,8 +244,8 @@ chmod 600 .env ```bash # Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte. -grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=' .env \ - | sed 's/=.*/= gesetzt/' +grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \ + | sed 's/=.*/ = gesetzt/' ``` Acht Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht — @@ -298,7 +253,7 @@ und sagt dir beim Start auch, welche. --- -## Schritt 6 — Starten +## Schritt 4 — Starten ```bash cd /opt/betreuungs-cockpit @@ -309,9 +264,7 @@ BUILD_ZEIT=$(date -Iseconds) \ docker compose up -d --build ``` -Der erste Bau dauert **fünf bis zehn Minuten** — Tesseract und die -deutschen Sprachdaten wollen heruntergeladen werden. Danach geht es -schnell. +Der erste Bau dauert **fünf bis zehn Minuten**. Danach geht es schnell. ### Kontrolle @@ -319,7 +272,7 @@ schnell. docker compose ps ``` -Vier Dienste, alle `running`, die Datenbank `healthy`: +Vier Dienste, alle `running`, Datenbank und Anwendung `healthy`: ``` NAME STATUS @@ -330,7 +283,7 @@ betreuungs-cockpit-sicherung-1 Up ``` ```bash -# Antwortet die Anwendung? +# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) curl -s http://127.0.0.1:3000/api/gesundheit # {"status":"bereit","version":"1.0.0","commit":"..."} ``` @@ -343,7 +296,7 @@ docker compose logs app --tail 50 --- -## Schritt 7 — Datenbank vorbereiten +## Schritt 5 — Datenbank vorbereiten Die **Migrationen laufen beim Start automatisch mit**. Zwei SQL-Skripte kommen einmalig von Hand dazu — sie richten die Volltextsuche ein und @@ -374,16 +327,17 @@ ERROR: Das Audit-Log ist unveraenderlich. UPDATE auf audit_log ist nicht zulaes ``` Kommt stattdessen `UPDATE 0`, ist das Skript nicht gelaufen. Führ es -erneut aus. +erneut aus — beide Skripte sind so gebaut, dass mehrfaches Ausführen +nichts kaputtmacht. --- -## Schritt 8 — Dateiablage einrichten +## Schritt 6 — Dateiablage einrichten Der Objektspeicher braucht einen Ablagebereich für die Dokumente: ```bash -source .env +set -a && source .env && set +a docker compose exec minio mc alias set lokal \ http://localhost:9000 "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY" @@ -398,97 +352,245 @@ docker compose exec minio mc ls lokal # [...] betreuungs-cockpit/ ``` -> Wenn du statt MinIO einen externen Objektspeicher in der EU benutzt -> (z. B. Hetzner Object Storage), lass den `minio`-Dienst in der -> `docker-compose.yml` weg und zeig mit `S3_ENDPOINT` dorthin. **Auch -> dafür brauchst du einen AV-Vertrag.** +> **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 +> `depends_on` beim `app`-Dienst und zeig mit `S3_ENDPOINT` dorthin. Den +> Bucket legst du dann beim Anbieter an. **Auch dafür brauchst du einen +> AV-Vertrag.** --- -## Schritt 9 — Reverse Proxy und TLS +## Schritt 7 — In den Reverse Proxy eintragen -Die Anwendung horcht bewusst nur auf `127.0.0.1:3000` und bringt **keinen -eigenen Proxy** mit. Von außen erreichbar wird sie über den Proxy auf dem -Server, der auch das Zertifikat besorgt. +Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu +tun: die Anwendung für ihn erreichbar machen, und ihn richtig +konfigurieren. -### Wenn noch kein Proxy da ist: Caddy +### 7a — Erreichbar machen, aber nur für den Proxy -Caddy holt Let's-Encrypt-Zertifikate von selbst und erneuert sie: +Standardmäßig horcht die Anwendung auf `127.0.0.1` — von außen kommt dann +niemand heran, auch der Proxy nicht. Das musst du ändern. + +> **Die Anwendung beendet kein TLS.** Zwischen Proxy und Anwendung läuft +> unverschlüsseltes HTTP. Dieser Weg darf niemals durchs offene Netz +> gehen. Es gibt drei Möglichkeiten, in dieser Reihenfolge: + +**Erste Wahl: privates Netz.** Hetzner, netcup und die meisten anderen +bieten ein privates Netz zwischen deinen Servern an. Trag in die `.env` +die Adresse ein, die dieser Server dort hat: ```bash -sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ - | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ - | sudo tee /etc/apt/sources.list.d/caddy-stable.list -sudo apt update && sudo apt install -y caddy +APP_BIND=10.0.0.5 # die private Adresse DIESES Servers +APP_PORT=3000 ``` -Dann `/etc/caddy/Caddyfile` ersetzen: +**Zweite Wahl: WireGuard.** Gibt es kein privates Netz, bau einen Tunnel +zwischen beiden Servern und trag die Tunneladresse ein: ```bash -sudo tee /etc/caddy/Caddyfile > /dev/null <<'ENDE' +APP_BIND=10.8.0.2 +``` + +**Notlösung: öffentliche Adresse plus Firewall.** Nur, wenn beides nicht +geht — und dann zwingend mit einer Regel, die ausschließlich die IP des +Proxy-Servers durchlässt: + +```bash +# in der .env +APP_BIND=0.0.0.0 + +# und auf DIESEM Server, vor dem Neustart: +sudo ufw allow from to any port 3000 proto tcp +sudo ufw deny 3000/tcp +``` + +Prüf danach von einem dritten Rechner aus, dass der Port wirklich zu ist. +Vergiss die Regel, und deine Anwendung steht unverschlüsselt im Netz. + +Nach der Änderung: + +```bash +docker compose up -d +docker compose ps # unter PORTS muss die neue Adresse stehen +``` + +### 7b — Was auf dem Proxy-Server hinterlegt wird + +Das ist der Teil, den du auf dem **anderen** Server einträgst. Ersetz +`10.0.0.5` durch die Adresse aus 7a. + +#### Caddy + +```caddyfile cockpit.deine-domain.de { encode zstd gzip - reverse_proxy 127.0.0.1:3000 - # Fotos von Behördenschreiben können groß sein + reverse_proxy 10.0.0.5:3000 { + # Berichte und der Vollexport brauchen Zeit. Ohne diese Werte + # bricht der Proxy mitten im Erzeugen ab. + transport http { + read_timeout 10m + write_timeout 10m + } + # Der Vollexport kommt als Strom - nicht puffern, sonst wartet der + # Browser, bis alles im Speicher des Proxys liegt. + flush_interval -1 + } + + # Ein Handyfoto eines mehrseitigen Bescheids ist schnell 10 MB. request_body { max_size 30MB } } -ENDE - -sudo systemctl reload caddy ``` -### Wenn schon nginx läuft +Caddy setzt `Host`, `X-Forwarded-For` und `X-Forwarded-Proto` von selbst +richtig — dafür musst du nichts tun. + +#### nginx ```nginx server { - listen 443 ssl http2; + listen 443 ssl; + http2 on; server_name cockpit.deine-domain.de; ssl_certificate /etc/letsencrypt/live/cockpit.deine-domain.de/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/cockpit.deine-domain.de/privkey.pem; + # Uploads von Behördenschreiben client_max_body_size 30M; location / { - proxy_pass http://127.0.0.1:3000; + proxy_pass http://10.0.0.5:3000; proxy_http_version 1.1; + + # Ohne diese vier Zeilen funktioniert die Anwendung nicht richtig, + # siehe Tabelle unter 7c. 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 $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + + # PDF-Erzeugung und Vollexport dauern länger als die üblichen 60 s. + proxy_connect_timeout 30s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + + # Der Vollexport kommt als Strom. Ohne diese beiden Zeilen sammelt + # nginx erst alles ein, und der Browser sieht minutenlang nichts. + proxy_buffering off; + proxy_request_buffering off; } } + +# Alles Unverschlüsselte umleiten +server { + listen 80; + server_name cockpit.deine-domain.de; + return 301 https://$host$request_uri; +} ``` -Das Zertifikat holst du mit `sudo certbot --nginx -d cockpit.deine-domain.de`. +Aktivieren: -> Der Proxy **muss** `X-Forwarded-For` weiterreichen. Die Anwendung -> braucht die echte Adresse für die Login-Sperre und für das Audit-Log -> (dort nur als Hash). Ohne den Kopf zählt sie alle Zugriffe als denselben -> Anschluss. +```bash +sudo ln -s /etc/nginx/sites-available/cockpit /etc/nginx/sites-enabled/ +sudo certbot --nginx -d cockpit.deine-domain.de +sudo nginx -t && sudo systemctl reload nginx +``` + +#### Traefik + +Weil die Anwendung auf einem anderen Server läuft, geht es **nicht** über +Docker-Labels, sondern über eine dynamische Konfigurationsdatei: + +```yaml +http: + routers: + cockpit: + rule: "Host(`cockpit.deine-domain.de`)" + service: cockpit + entryPoints: [websecure] + tls: + certResolver: letsencrypt + + services: + cockpit: + loadBalancer: + servers: + - url: "http://10.0.0.5:3000" + # Vollexport als Strom ausliefern, nicht sammeln + responseForwarding: + flushInterval: -1 +``` + +Dazu in der statischen Konfiguration großzügige Zeitgrenzen: + +```yaml +entryPoints: + websecure: + address: ":443" + transport: + respondingTimeouts: + readTimeout: 600s + writeTimeout: 600s + idleTimeout: 180s +``` + +### 7c — Was der Proxy leisten muss, und warum + +| Anforderung | Was ohne sie passiert | +|---|---| +| **TLS beenden** | Der Browser lehnt Cookies mit `Secure`-Kennzeichen ab — die Anmeldung schlägt stumm fehl | +| **`Host` durchreichen** | Auth.js vergleicht den Host mit `AUTH_URL` und weist die Anmeldung ab | +| **`X-Forwarded-Proto: https`** | Die Anwendung baut Adressen mit `http` und setzt Cookies ohne `Secure` | +| **`X-Forwarded-For`** | Die Login-Sperre zählt alle Zugriffe als denselben Anschluss: Fünf Fehlversuche irgendwo sperren alle. Im Audit-Log fehlt die Herkunft | +| **Mindestens 30 MB Uploads** | Fotos mehrseitiger Bescheide werden abgeschnitten | +| **Zeitgrenze 10 Minuten** | Belegmappe, Rechnungslegung und Vollexport brechen mitten im Erzeugen ab | +| **Antwort nicht puffern** | Der Vollexport kommt als Strom. Puffert der Proxy, wartet der Browser, bis alles im Speicher des Proxys liegt — bei großen Beständen läuft er dabei in die Zeitgrenze | + +**Keine eigenen Sicherheitskopfzeilen setzen.** CSP, `X-Frame-Options`, +`nosniff` und HSTS kommen aus der Anwendung. Besonders die CSP darf der +Proxy nicht überschreiben: Sie enthält eine Nonce, die pro Anfrage neu +ist. Eine feste CSP aus dem Proxy blockiert die eigenen Skripte — die +Seite lädt dann zwar, lässt sich aber nicht bedienen. ### Kontrolle -Von deinem eigenen Rechner aus: +Erst **vom Proxy-Server aus** — kommt er überhaupt an? ```bash -curl -sI https://cockpit.deine-domain.de/anmelden | head -5 +curl -s http://10.0.0.5:3000/api/gesundheit +# {"status":"bereit","version":"1.0.0",...} ``` -Erwartet: `HTTP/2 200`, dazu die Kopfzeilen -`content-security-policy`, `x-frame-options: DENY` und +Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der +Behälter wurde nach der Änderung nicht neu gestartet. + +Dann **von deinem eigenen Rechner aus**: + +```bash +curl -sI https://cockpit.deine-domain.de/anmelden | head -8 +``` + +Erwartet: `HTTP/2 200` und die Kopfzeilen `content-security-policy`, +`x-frame-options: DENY`, `x-content-type-options: nosniff`, `strict-transport-security`. ---- +Und die Gegenprobe — der Port darf von außen **nicht** offen sein: -## Schritt 10 — Ersteinrichtung im Browser +```bash +curl -s --max-time 5 http://:3000/api/gesundheit +# muss in eine Zeitüberschreitung laufen oder abgewiesen werden +``` + +Antwortet er, ist deine Firewallregel falsch. Das ist kein +Schönheitsfehler: Über diesen Weg käme man unverschlüsselt an +Gesundheitsdaten. + +## Schritt 8 — Ersteinrichtung im Browser Ruf `https://cockpit.deine-domain.de` auf. Ein Assistent führt dich durch drei Schritte: Zugang anlegen, zweiten Faktor einrichten, betreute Person @@ -498,9 +600,10 @@ Die Einzelheiten stehen in der [Bedienungsanleitung](ANLEITUNG.md), Abschnitt *Der erste Tag*. Drei Dinge sind hier wichtig: 1. **Die Seite `/ersteinrichtung` sperrt sich selbst**, sobald ein Zugang - existiert. Niemand kann sich danach einen zweiten Administrator anlegen. -2. **Die Wiederherstellungscodes werden genau einmal angezeigt.** Druck sie - aus, bevor du weiterklickst. + existiert. Niemand kann sich danach von außen einen zweiten + Administrator anlegen. +2. **Die Wiederherstellungscodes werden genau einmal angezeigt.** Druck + sie aus, bevor du weiterklickst. 3. **Das Bestellungsdatum** muss stimmen — daraus entstehen alle gesetzlichen Fristen. @@ -515,7 +618,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**. --- -## Schritt 11 — Sicherung prüfen +## Schritt 9 — Sicherung prüfen Die tägliche Sicherung läuft ab jetzt um **03:15 Uhr**. Warte nicht bis morgen — stoß sie einmal von Hand an: @@ -546,7 +649,7 @@ gleich aus, solange nichts drinsteht, was weh tut: # Testdatenbank neben der echten anlegen docker compose exec db createdb -U cockpit probe -# Zurückspielen +# Zurückspielen (Dateinamen anpassen) docker compose exec -e PGDATABASE=probe -e SICHERUNG_OHNE_RUECKFRAGE=ja \ sicherung /bin/sh /wiederherstellung.sh /backups/cockpit_.dump.enc @@ -577,14 +680,13 @@ ls -lh backups/ --- -## Schritt 12 — Wartungslauf einrichten +## Schritt 10 — Wartungslauf einrichten Einmal täglich: Fristerinnerungen verschicken, Papierkorb leeren, alte Anmeldeversuche entfernen. ```bash # Schlüssel für den Endpunkt ausrechnen -source .env docker compose exec app node -e " console.log(require('crypto').createHmac('sha256', process.env.AUTH_SECRET) .update('wartung').digest('base64url')) @@ -616,7 +718,7 @@ wer die Adresse errät, soll nicht einmal erfahren, dass es sie gibt. --- -## Schritt 13 — Schlüssel in Sicherheit bringen +## Schritt 11 — Schlüssel in Sicherheit bringen Der letzte Schritt, und der wichtigste. @@ -670,8 +772,7 @@ docker compose up -d --build ``` Die Migrationen laufen beim Start mit. **Sagt der Changelog, dass sich ein -SQL-Skript geändert hat, führ die beiden Befehle aus Schritt 7 erneut aus** — -sie sind so gebaut, dass mehrfaches Ausführen nichts kaputtmacht. +SQL-Skript geändert hat, führ die beiden Befehle aus Schritt 5 erneut aus.** ### Kontrolle @@ -700,7 +801,16 @@ Fehlt eine Umgebungsvariable, sagt die Anwendung beim Start, welche. ### „Umgebungsvariable ENC_KEY_V1 fehlt" Die `.env` liegt nicht neben der `docker-compose.yml`, oder die Zeile ist -leer. Prüf mit dem Befehl aus Schritt 5. +leer. Prüf mit dem Befehl aus Schritt 3. + +### Port 3000 ist schon belegt + +```bash +sudo ss -tlnp | grep 3000 +``` + +Setz `APP_PORT=3010` in der `.env`, dann `docker compose up -d`, und zeig +mit dem Proxy auf den neuen Port. ### Die Anmeldung landet wieder auf dem Formular, ohne Fehlermeldung @@ -729,6 +839,7 @@ richtig geht: ```bash timedatectl +docker compose exec app date ``` ### Die Texterkennung findet nichts @@ -746,15 +857,21 @@ Fehlt sie, ist das Abbild nicht vollständig gebaut: Der Proxy schneidet ab. Bei Caddy `request_body max_size`, bei nginx `client_max_body_size` — beide auf mindestens 30 MB. -### Die Datenbank ist voll +### Die Seite lädt, aber ohne Gestaltung, und nichts lässt sich anklicken + +Dein Proxy überschreibt die Content-Security-Policy der Anwendung. Sie +enthält eine Nonce, die pro Anfrage neu ist — eine feste CSP aus dem Proxy +blockiert dann die eigenen Skripte. Nimm die Kopfzeile aus dem Proxy +heraus. + +### Der Speicherplatz wird knapp ```bash df -h -docker system prune -a --volumes # ACHTUNG: entfernt ungenutzte Volumes +docker system prune -a # entfernt ungenutzte Abbilder ``` -Vorher prüfen, was da ist. `docker system prune` ohne `--volumes` ist der -sichere Anfang. +**Ohne `--volumes`**, sonst löschst du die Datenbank. ### Ganz von vorn @@ -774,15 +891,15 @@ Hak das durch, bevor du echte Daten einträgst: - [ ] AV-Vertrag mit dem Hoster abgeschlossen - [ ] AV-Vertrag für den Objektspeicher abgeschlossen (falls extern) - [ ] Server steht in Deutschland oder mindestens in der EU -- [ ] Zeitzone auf `Europe/Berlin` -- [ ] Firewall aktiv, nur 22, 80 und 443 offen -- [ ] SSH-Anmeldung nur mit Schlüssel -- [ ] `https://` funktioniert, Zertifikat gültig - [ ] `.env` hat Rechte `600` -- [ ] Alle vier Dienste laufen, Datenbank `healthy` +- [ ] Alle vier Dienste laufen, Datenbank und Anwendung `healthy` - [ ] `/api/gesundheit` antwortet mit der erwarteten Version - [ ] **Audit-Schutz greift** — `UPDATE audit_log` endet mit Fehler - [ ] Bucket im Objektspeicher angelegt +- [ ] `https://` funktioniert, Zertifikat gültig +- [ ] Sicherheitskopfzeilen kommen durch den Proxy an +- [ ] **Der Anwendungsport ist von außen nicht erreichbar** (Gegenprobe aus Schritt 7) +- [ ] Der Weg Proxy → Anwendung läuft über ein privates Netz oder einen Tunnel - [ ] Zweiter Faktor eingerichtet - [ ] **Wiederherstellungscodes ausgedruckt** und weggelegt - [ ] Bestellungsdatum eingetragen, Pflichtfristen abgeleitet