docs: Installationsanleitung fuer vorhandenes Docker und externen Proxy
Die Anleitung setzt jetzt voraus, was ohnehin da ist: ein Server mit laufendem Docker und ein Reverse Proxy, der schon steht - auch auf einem anderen Rechner. Docker-Installation und Betriebssystemhaertung sind raus, dafuer ist der Proxy-Abschnitt der ausfuehrlichste geworden. Damit ein externer Proxy die Anwendung ueberhaupt erreicht, war eine Aenderung noetig: Die Bindeadresse stand fest auf 127.0.0.1. Jetzt gibt es APP_BIND, Standard bleibt die Loopback-Adresse. Der Weg dorthin ist nach Vorzug sortiert - privates Netz, dann WireGuard, dann als Notloesung oeffentliche Adresse mit Firewallregel. Zwischen Proxy und Anwendung laeuft unverschluesseltes HTTP; das gehoert nicht ins offene Netz. Deshalb steht in der Kontrolle eine Gegenprobe, die fehlschlagen MUSS: Antwortet der Anwendungsport von aussen, ist die Firewallregel falsch. Fertige Konfigurationen fuer Caddy, nginx und Traefik, jeweils mit den Werten, die sonst uebersehen werden: - Zeitgrenze 10 Minuten - sonst brechen Belegmappe, Rechnungslegung und Vollexport mitten im Erzeugen ab. Die Standardwerte von nginx liegen bei 60 Sekunden. - Antwort nicht puffern - der Vollexport kommt als Strom. Puffert der Proxy, wartet der Browser, bis alles im Speicher des Proxys liegt, und laeuft dabei in die Zeitgrenze. - Und der Hinweis, dass der Proxy KEINE eigene CSP setzen darf: Sie enthaelt eine Nonce, die pro Anfrage neu ist. Eine feste CSP blockiert die eigenen Skripte - die Seite laedt dann, laesst sich aber nicht bedienen. Der A-Record zeigt auf den Proxy-Server, nicht auf den Anwendungsserver. Stand vorher falsch da. Etappe: 8 Phase: Haertung
This commit is contained in:
+13
-1
@@ -16,9 +16,21 @@ DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockp
|
|||||||
AUTH_SECRET=
|
AUTH_SECRET=
|
||||||
# Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash).
|
# Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash).
|
||||||
AUTH_URL=http://localhost:3000
|
AUTH_URL=http://localhost:3000
|
||||||
# Hinter einem Reverse Proxy (Caddy) auf true setzen.
|
# Hinter einem Reverse Proxy auf true setzen.
|
||||||
AUTH_TRUST_HOST=true
|
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 ---------------------------------
|
# --- Anwendungsseitige Feldverschluesselung ---------------------------------
|
||||||
# AES-256-GCM. 32 Bytes, base64. OHNE DIESEN SCHLUESSEL SIND DIE VERSCHLUESSELTEN
|
# AES-256-GCM. 32 Bytes, base64. OHNE DIESEN SCHLUESSEL SIND DIE VERSCHLUESSELTEN
|
||||||
# FELDER UNWIEDERBRINGLICH VERLOREN - unbedingt getrennt vom Backup sichern!
|
# FELDER UNWIEDERBRINGLICH VERLOREN - unbedingt getrennt vom Backup sichern!
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ eines Angehörigen: Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe, Berichte
|
|||||||
|
|
||||||
| Wofür | Wo |
|
| 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` |
|
| **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) |
|
| **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
|
## Reverse Proxy
|
||||||
|
|
||||||
Die Anwendung erwartet, dass der Proxy TLS beendet und die echte Client-IP
|
Die Anwendung **beendet selbst kein TLS** und bringt keinen Proxy mit. Sie
|
||||||
weiterreicht. Sie liest `X-Forwarded-For` bzw. `X-Real-IP` für das
|
horcht standardmäßig auf `127.0.0.1:3000`.
|
||||||
Rate-Limiting am Login und für das Audit-Log (dort nur als Hash).
|
|
||||||
|
|
||||||
**Caddy**
|
**Läuft der Proxy auf demselben Server**, ist damit alles gesagt: Er zeigt
|
||||||
|
auf `127.0.0.1:3000`, fertig.
|
||||||
|
|
||||||
```caddyfile
|
**Läuft er auf einem anderen Server**, setz in der `.env` die Adresse, über
|
||||||
cockpit.example.de {
|
die er herankommt — am besten die eines privaten Netzes oder eines
|
||||||
encode zstd gzip
|
WireGuard-Tunnels:
|
||||||
reverse_proxy 127.0.0.1:3000
|
|
||||||
|
|
||||||
# Uploads: Fotos von Behördenschreiben können groß sein.
|
```bash
|
||||||
request_body {
|
APP_BIND=10.0.0.5
|
||||||
max_size 30MB
|
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
|
### Was der Proxy leisten muss
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name cockpit.example.de;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/cockpit.example.de/fullchain.pem;
|
| Anforderung | Was ohne sie passiert |
|
||||||
ssl_certificate_key /etc/letsencrypt/live/cockpit.example.de/privkey.pem;
|
|---|---|
|
||||||
|
| 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 / {
|
**Fertige Konfigurationen** für Caddy, nginx und Traefik, mit Zeitgrenzen
|
||||||
proxy_pass http://127.0.0.1:3000;
|
und den Gegenproben, stehen in
|
||||||
proxy_http_version 1.1;
|
[`docs/INSTALLATION.md`](docs/INSTALLATION.md), Schritt 7.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+20
-7
@@ -4,10 +4,13 @@
|
|||||||
#
|
#
|
||||||
# docker compose up -d --build
|
# docker compose up -d --build
|
||||||
#
|
#
|
||||||
# Kein Reverse Proxy in dieser Datei: der ist auf dem Server bereits
|
# Kein Reverse Proxy in dieser Datei: der ist bereits vorhanden. Die
|
||||||
# vorhanden. Die Anwendung horcht nur auf 127.0.0.1:3000 - von aussen
|
# Anwendung beendet selbst kein TLS und horcht standardmaessig nur auf
|
||||||
# erreichbar wird sie ausschliesslich ueber den vorhandenen Proxy, der
|
# 127.0.0.1:3000.
|
||||||
# auch TLS beendet. Siehe README, Abschnitt "Reverse Proxy".
|
#
|
||||||
|
# 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
|
# Die Datenbank hat bewusst KEINE Portfreigabe. Sie ist nur ueber das
|
||||||
# interne Docker-Netz erreichbar. Wer mit psql heran muss, geht ueber
|
# interne Docker-Netz erreichbar. Wer mit psql heran muss, geht ueber
|
||||||
@@ -111,9 +114,19 @@ services:
|
|||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
ports:
|
ports:
|
||||||
# Nur Loopback. Der vorhandene Reverse Proxy auf dem Host reicht
|
# Standard ist die Loopback-Adresse: dann erreicht die Anwendung nur
|
||||||
# von aussen hierher durch und beendet TLS.
|
# ein Proxy, der auf demselben Rechner laeuft.
|
||||||
- '127.0.0.1:${APP_PORT:-3000}:3000'
|
#
|
||||||
|
# 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]
|
networks: [intern]
|
||||||
healthcheck:
|
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))"]
|
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))"]
|
||||||
|
|||||||
+346
-229
@@ -1,181 +1,124 @@
|
|||||||
# Installation Schritt für Schritt
|
# Installation Schritt für Schritt
|
||||||
|
|
||||||
Von einem leeren Server bis zur laufenden Anwendung. Rechne mit **60 bis
|
Von einem vorbereiteten Server bis zur laufenden Anwendung. Rechne mit
|
||||||
90 Minuten**, wenn du es zum ersten Mal machst.
|
**30 bis 45 Minuten**, wenn du es zum ersten Mal machst.
|
||||||
|
|
||||||
Diese Anleitung setzt voraus, dass du dich per SSH auf einem Linux-Server
|
Diese Anleitung setzt einen Server voraus, auf dem **Docker bereits läuft**,
|
||||||
anmelden und Befehle eintippen kannst. Mehr nicht — jeder Schritt steht
|
und einen **Reverse Proxy, der schon steht** — auch dann, wenn er auf einem
|
||||||
ausgeschrieben da, und nach jedem Abschnitt steht eine **Kontrolle**, mit
|
anderen Server liegt. Sie richtet nur das Betreuungs-Cockpit ein: kein
|
||||||
der du prüfst, ob es geklappt hat, bevor du weitermachst.
|
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
|
> Wenn du die Anwendung nur ausprobieren willst, ohne Server: Der
|
||||||
> Abschnitt *Schnellstart* in der [README](../README.md) bringt dich in
|
> Abschnitt *Schnellstart* in der [README](../README.md) bringt dich in
|
||||||
> zehn Minuten auf dem eigenen Rechner zum Laufen. Diese Anleitung hier
|
> zehn Minuten auf dem eigenen Rechner zum Laufen.
|
||||||
> ist für den echten Betrieb.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Inhalt
|
## Inhalt
|
||||||
|
|
||||||
- [Schritt 0 — Was du vorher brauchst](#schritt-0--was-du-vorher-brauchst)
|
- [Schritt 0 — Voraussetzungen prüfen](#schritt-0--voraussetzungen-prüfen)
|
||||||
- [Schritt 1 — Server vorbereiten](#schritt-1--server-vorbereiten)
|
- [Schritt 1 — Anwendung holen](#schritt-1--anwendung-holen)
|
||||||
- [Schritt 2 — Docker installieren](#schritt-2--docker-installieren)
|
- [Schritt 2 — Schlüssel erzeugen](#schritt-2--schlüssel-erzeugen)
|
||||||
- [Schritt 3 — Anwendung holen](#schritt-3--anwendung-holen)
|
- [Schritt 3 — Konfiguration ausfüllen](#schritt-3--konfiguration-ausfüllen)
|
||||||
- [Schritt 4 — Schlüssel erzeugen](#schritt-4--schlüssel-erzeugen)
|
- [Schritt 4 — Starten](#schritt-4--starten)
|
||||||
- [Schritt 5 — Konfiguration ausfüllen](#schritt-5--konfiguration-ausfüllen)
|
- [Schritt 5 — Datenbank vorbereiten](#schritt-5--datenbank-vorbereiten)
|
||||||
- [Schritt 6 — Starten](#schritt-6--starten)
|
- [Schritt 6 — Dateiablage einrichten](#schritt-6--dateiablage-einrichten)
|
||||||
- [Schritt 7 — Datenbank vorbereiten](#schritt-7--datenbank-vorbereiten)
|
- [Schritt 7 — In den Reverse Proxy eintragen](#schritt-7--in-den-reverse-proxy-eintragen)
|
||||||
- [Schritt 8 — Dateiablage einrichten](#schritt-8--dateiablage-einrichten)
|
- [Schritt 8 — Ersteinrichtung im Browser](#schritt-8--ersteinrichtung-im-browser)
|
||||||
- [Schritt 9 — Reverse Proxy und TLS](#schritt-9--reverse-proxy-und-tls)
|
- [Schritt 9 — Sicherung prüfen](#schritt-9--sicherung-prüfen)
|
||||||
- [Schritt 10 — Ersteinrichtung im Browser](#schritt-10--ersteinrichtung-im-browser)
|
- [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten)
|
||||||
- [Schritt 11 — Sicherung prüfen](#schritt-11--sicherung-prüfen)
|
- [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen)
|
||||||
- [Schritt 12 — Wartungslauf einrichten](#schritt-12--wartungslauf-einrichten)
|
|
||||||
- [Schritt 13 — Schlüssel in Sicherheit bringen](#schritt-13--schlüssel-in-sicherheit-bringen)
|
|
||||||
- [Aktualisieren](#aktualisieren)
|
- [Aktualisieren](#aktualisieren)
|
||||||
- [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt)
|
- [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt)
|
||||||
- [Abnahmeliste](#abnahmeliste)
|
- [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
|
### Was der Server mitbringen muss
|
||||||
reicht die kleinste Größe bequem:
|
|
||||||
|
|
||||||
| | Minimum | Bequem |
|
| | Minimum | Bequem |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Arbeitsspeicher | 2 GB | 4 GB |
|
| Arbeitsspeicher | 2 GB frei | 4 GB frei |
|
||||||
| Speicherplatz | 20 GB | 40 GB |
|
| Speicherplatz | 20 GB frei | 40 GB frei |
|
||||||
| Kerne | 2 | 2 |
|
| Docker | 24 oder neuer | aktuell |
|
||||||
| Betriebssystem | Debian 12 oder Ubuntu 24.04 | dasselbe |
|
| 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,
|
Der erste Bau lädt Tesseract mit den deutschen Sprachdaten — plan etwa
|
||||||
Nürnberg), netcup (Nürnberg), IONOS (Karlsruhe). Ein Server für rund fünf
|
**1 GB Netzverkehr** und fünf bis zehn Minuten ein.
|
||||||
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.
|
|
||||||
|
|
||||||
### Kontrolle
|
### Kontrolle
|
||||||
|
|
||||||
```bash
|
Melde dich per SSH auf dem Server an und prüf:
|
||||||
# Von deinem Rechner aus - zeigt die IP deines Servers?
|
|
||||||
dig +short cockpit.deine-domain.de
|
|
||||||
|
|
||||||
# Kommst du auf den Server?
|
```bash
|
||||||
ssh root@cockpit.deine-domain.de
|
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?
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Kommt bei `docker run` ein *permission denied*, fehlt dir die
|
||||||
|
Docker-Gruppe:
|
||||||
## Schritt 1 — Server vorbereiten
|
|
||||||
|
|
||||||
Alles Folgende läuft **auf dem Server**.
|
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
```bash
|
||||||
docker --version # Docker version 27.x oder neuer
|
timedatectl | grep "Time zone"
|
||||||
docker compose version # Docker Compose version v2.x
|
# nicht Europe/Berlin? dann:
|
||||||
docker run --rm hello-world # "Hello from Docker!"
|
sudo timedatectl set-timezone Europe/Berlin
|
||||||
```
|
```
|
||||||
|
|
||||||
Kommt bei `docker run` ein *permission denied*, hast du das Ab- und
|
### Domain
|
||||||
Anmelden vergessen.
|
|
||||||
|
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
|
```bash
|
||||||
sudo mkdir -p /opt/betreuungs-cockpit
|
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
|
cd /opt/betreuungs-cockpit
|
||||||
|
|
||||||
# Auf die letzte veröffentlichte Fassung wechseln, nicht auf den
|
# Auf die letzte veröffentlichte Fassung wechseln,
|
||||||
# laufenden Entwicklungsstand
|
# nicht auf den laufenden Entwicklungsstand
|
||||||
git checkout v1.0.0
|
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:
|
nicht auf dem eigenen Rechner:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm node:22-alpine node -e "
|
docker run --rm node:22-alpine node -e "
|
||||||
const c = require('crypto');
|
const c = require('crypto');
|
||||||
const b = n => c.randomBytes(n).toString('base64');
|
const b = n => c.randomBytes(n).toString('base64');
|
||||||
|
const w = n => b(n).replace(/[+/=]/g,'x');
|
||||||
console.log('AUTH_SECRET=' + b(32));
|
console.log('AUTH_SECRET=' + b(32));
|
||||||
console.log('ENC_KEY_V1=' + b(32));
|
console.log('ENC_KEY_V1=' + b(32));
|
||||||
console.log('INDEX_KEY=' + b(32));
|
console.log('INDEX_KEY=' + b(32));
|
||||||
console.log('POSTGRES_PASSWORD=' + b(24).replace(/[+/=]/g,'x'));
|
console.log('POSTGRES_PASSWORD=' + w(24));
|
||||||
console.log('SICHERUNG_PASSWORT=' + b(24).replace(/[+/=]/g,'x'));
|
console.log('SICHERUNG_PASSWORT=' + w(24));
|
||||||
console.log('S3_ACCESS_KEY_ID=' + b(12).replace(/[+/=]/g,'x'));
|
console.log('S3_ACCESS_KEY_ID=' + w(12));
|
||||||
console.log('S3_SECRET_ACCESS_KEY=' + b(24).replace(/[+/=]/g,'x'));
|
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
|
> **`ENC_KEY_V1` ist der wichtigste Wert der ganzen Installation.** Mit ihm
|
||||||
> sind Diagnosen, IBANs, Versicherungsnummern und alle Notizen
|
> sind Diagnosen, IBANs, Versicherungsnummern und alle Notizen
|
||||||
> verschlüsselt. Geht er verloren, sind diese Felder **unwiederbringlich**
|
> verschlüsselt. Geht er verloren, sind diese Felder
|
||||||
> weg — auch aus jedem Backup. Es gibt keine Hintertür.
|
> **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
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -241,24 +187,24 @@ Diese Werte **musst** du setzen:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# --- Datenbank ---
|
# --- Datenbank ---
|
||||||
POSTGRES_PASSWORD=<aus Schritt 4>
|
POSTGRES_PASSWORD=<aus Schritt 2>
|
||||||
|
|
||||||
# --- Anmeldung ---
|
# --- Anmeldung ---
|
||||||
AUTH_SECRET=<aus Schritt 4>
|
AUTH_SECRET=<aus Schritt 2>
|
||||||
AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https
|
AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https
|
||||||
AUTH_TRUST_HOST=true
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
# --- Verschlüsselung ---
|
# --- Verschlüsselung ---
|
||||||
ENC_KEY_V1=<aus Schritt 4>
|
ENC_KEY_V1=<aus Schritt 2>
|
||||||
ENC_KEY_AKTIV=1
|
ENC_KEY_AKTIV=1
|
||||||
INDEX_KEY=<aus Schritt 4>
|
INDEX_KEY=<aus Schritt 2>
|
||||||
|
|
||||||
# --- Objektspeicher ---
|
# --- Objektspeicher ---
|
||||||
S3_ACCESS_KEY_ID=<aus Schritt 4>
|
S3_ACCESS_KEY_ID=<aus Schritt 2>
|
||||||
S3_SECRET_ACCESS_KEY=<aus Schritt 4>
|
S3_SECRET_ACCESS_KEY=<aus Schritt 2>
|
||||||
|
|
||||||
# --- Sicherung ---
|
# --- Sicherung ---
|
||||||
SICHERUNG_PASSWORT=<aus Schritt 4>
|
SICHERUNG_PASSWORT=<aus Schritt 2>
|
||||||
```
|
```
|
||||||
|
|
||||||
> **`AUTH_URL` muss exakt der Adresse entsprechen**, unter der du die
|
> **`AUTH_URL` muss exakt der Adresse entsprechen**, unter der du die
|
||||||
@@ -267,6 +213,16 @@ SICHERUNG_PASSWORT=<aus Schritt 4>
|
|||||||
> Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular,
|
> Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular,
|
||||||
> ohne Fehlermeldung. Das ist der Fehler, den man am längsten sucht.
|
> 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:
|
Optional, aber empfohlen — Fristerinnerungen per E-Mail:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -277,9 +233,8 @@ SMTP_PASSWORD=<dein Mailpasswort>
|
|||||||
SMTP_FROM="Betreuungs-Cockpit <cockpit@deine-domain.de>"
|
SMTP_FROM="Betreuungs-Cockpit <cockpit@deine-domain.de>"
|
||||||
```
|
```
|
||||||
|
|
||||||
Speichern mit `Strg+O`, `Enter`, `Strg+X`.
|
Speichern mit `Strg+O`, `Enter`, `Strg+X`. Dann die Datei absichern — sie
|
||||||
|
enthält alle Schlüssel:
|
||||||
**Die Datei absichern** — sie enthält alle Schlüssel:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
@@ -289,7 +244,7 @@ chmod 600 .env
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte.
|
# 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 \
|
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/'
|
| sed 's/=.*/ = gesetzt/'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -298,7 +253,7 @@ und sagt dir beim Start auch, welche.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schritt 6 — Starten
|
## Schritt 4 — Starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/betreuungs-cockpit
|
cd /opt/betreuungs-cockpit
|
||||||
@@ -309,9 +264,7 @@ BUILD_ZEIT=$(date -Iseconds) \
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Der erste Bau dauert **fünf bis zehn Minuten** — Tesseract und die
|
Der erste Bau dauert **fünf bis zehn Minuten**. Danach geht es schnell.
|
||||||
deutschen Sprachdaten wollen heruntergeladen werden. Danach geht es
|
|
||||||
schnell.
|
|
||||||
|
|
||||||
### Kontrolle
|
### Kontrolle
|
||||||
|
|
||||||
@@ -319,7 +272,7 @@ schnell.
|
|||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
Vier Dienste, alle `running`, die Datenbank `healthy`:
|
Vier Dienste, alle `running`, Datenbank und Anwendung `healthy`:
|
||||||
|
|
||||||
```
|
```
|
||||||
NAME STATUS
|
NAME STATUS
|
||||||
@@ -330,7 +283,7 @@ betreuungs-cockpit-sicherung-1 Up
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```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
|
curl -s http://127.0.0.1:3000/api/gesundheit
|
||||||
# {"status":"bereit","version":"1.0.0","commit":"..."}
|
# {"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
|
Die **Migrationen laufen beim Start automatisch mit**. Zwei SQL-Skripte
|
||||||
kommen einmalig von Hand dazu — sie richten die Volltextsuche ein und
|
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
|
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:
|
Der Objektspeicher braucht einen Ablagebereich für die Dokumente:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source .env
|
set -a && source .env && set +a
|
||||||
|
|
||||||
docker compose exec minio mc alias set lokal \
|
docker compose exec minio mc alias set lokal \
|
||||||
http://localhost:9000 "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY"
|
http://localhost:9000 "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY"
|
||||||
@@ -398,97 +352,245 @@ docker compose exec minio mc ls lokal
|
|||||||
# [...] betreuungs-cockpit/
|
# [...] betreuungs-cockpit/
|
||||||
```
|
```
|
||||||
|
|
||||||
> Wenn du statt MinIO einen externen Objektspeicher in der EU benutzt
|
> **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der
|
||||||
> (z. B. Hetzner Object Storage), lass den `minio`-Dienst in der
|
> EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den
|
||||||
> `docker-compose.yml` weg und zeig mit `S3_ENDPOINT` dorthin. **Auch
|
> `minio`-Dienst aus der `docker-compose.yml`, entfern ihn aus
|
||||||
> dafür brauchst du einen AV-Vertrag.**
|
> `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
|
Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu
|
||||||
eigenen Proxy** mit. Von außen erreichbar wird sie über den Proxy auf dem
|
tun: die Anwendung für ihn erreichbar machen, und ihn richtig
|
||||||
Server, der auch das Zertifikat besorgt.
|
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
|
```bash
|
||||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
|
APP_BIND=10.0.0.5 # die private Adresse DIESES Servers
|
||||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
APP_PORT=3000
|
||||||
| 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```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 <IP-des-Proxy-Servers> 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 {
|
cockpit.deine-domain.de {
|
||||||
encode zstd gzip
|
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 {
|
request_body {
|
||||||
max_size 30MB
|
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
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
server_name cockpit.deine-domain.de;
|
server_name cockpit.deine-domain.de;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/cockpit.deine-domain.de/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/cockpit.deine-domain.de/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/cockpit.deine-domain.de/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/cockpit.deine-domain.de/privkey.pem;
|
||||||
|
|
||||||
|
# Uploads von Behördenschreiben
|
||||||
client_max_body_size 30M;
|
client_max_body_size 30M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://10.0.0.5:3000;
|
||||||
proxy_http_version 1.1;
|
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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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
|
```bash
|
||||||
> braucht die echte Adresse für die Login-Sperre und für das Audit-Log
|
sudo ln -s /etc/nginx/sites-available/cockpit /etc/nginx/sites-enabled/
|
||||||
> (dort nur als Hash). Ohne den Kopf zählt sie alle Zugriffe als denselben
|
sudo certbot --nginx -d cockpit.deine-domain.de
|
||||||
> Anschluss.
|
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
|
### Kontrolle
|
||||||
|
|
||||||
Von deinem eigenen Rechner aus:
|
Erst **vom Proxy-Server aus** — kommt er überhaupt an?
|
||||||
|
|
||||||
```bash
|
```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
|
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
|
||||||
`content-security-policy`, `x-frame-options: DENY` und
|
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`.
|
`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://<oeffentliche-IP-des-App-Servers>: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
|
Ruf `https://cockpit.deine-domain.de` auf. Ein Assistent führt dich durch
|
||||||
drei Schritte: Zugang anlegen, zweiten Faktor einrichten, betreute Person
|
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:
|
Abschnitt *Der erste Tag*. Drei Dinge sind hier wichtig:
|
||||||
|
|
||||||
1. **Die Seite `/ersteinrichtung` sperrt sich selbst**, sobald ein Zugang
|
1. **Die Seite `/ersteinrichtung` sperrt sich selbst**, sobald ein Zugang
|
||||||
existiert. Niemand kann sich danach einen zweiten Administrator anlegen.
|
existiert. Niemand kann sich danach von außen einen zweiten
|
||||||
2. **Die Wiederherstellungscodes werden genau einmal angezeigt.** Druck sie
|
Administrator anlegen.
|
||||||
aus, bevor du weiterklickst.
|
2. **Die Wiederherstellungscodes werden genau einmal angezeigt.** Druck
|
||||||
|
sie aus, bevor du weiterklickst.
|
||||||
3. **Das Bestellungsdatum** muss stimmen — daraus entstehen alle
|
3. **Das Bestellungsdatum** muss stimmen — daraus entstehen alle
|
||||||
gesetzlichen Fristen.
|
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
|
Die tägliche Sicherung läuft ab jetzt um **03:15 Uhr**. Warte nicht bis
|
||||||
morgen — stoß sie einmal von Hand an:
|
morgen — stoß sie einmal von Hand an:
|
||||||
@@ -546,7 +649,7 @@ gleich aus, solange nichts drinsteht, was weh tut:
|
|||||||
# Testdatenbank neben der echten anlegen
|
# Testdatenbank neben der echten anlegen
|
||||||
docker compose exec db createdb -U cockpit probe
|
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 \
|
docker compose exec -e PGDATABASE=probe -e SICHERUNG_OHNE_RUECKFRAGE=ja \
|
||||||
sicherung /bin/sh /wiederherstellung.sh /backups/cockpit_<datum>.dump.enc
|
sicherung /bin/sh /wiederherstellung.sh /backups/cockpit_<datum>.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
|
Einmal täglich: Fristerinnerungen verschicken, Papierkorb leeren, alte
|
||||||
Anmeldeversuche entfernen.
|
Anmeldeversuche entfernen.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Schlüssel für den Endpunkt ausrechnen
|
# Schlüssel für den Endpunkt ausrechnen
|
||||||
source .env
|
|
||||||
docker compose exec app node -e "
|
docker compose exec app node -e "
|
||||||
console.log(require('crypto').createHmac('sha256', process.env.AUTH_SECRET)
|
console.log(require('crypto').createHmac('sha256', process.env.AUTH_SECRET)
|
||||||
.update('wartung').digest('base64url'))
|
.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.
|
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
|
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** —
|
SQL-Skript geändert hat, führ die beiden Befehle aus Schritt 5 erneut aus.**
|
||||||
sie sind so gebaut, dass mehrfaches Ausführen nichts kaputtmacht.
|
|
||||||
|
|
||||||
### Kontrolle
|
### Kontrolle
|
||||||
|
|
||||||
@@ -700,7 +801,16 @@ Fehlt eine Umgebungsvariable, sagt die Anwendung beim Start, welche.
|
|||||||
### „Umgebungsvariable ENC_KEY_V1 fehlt"
|
### „Umgebungsvariable ENC_KEY_V1 fehlt"
|
||||||
|
|
||||||
Die `.env` liegt nicht neben der `docker-compose.yml`, oder die Zeile ist
|
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
|
### Die Anmeldung landet wieder auf dem Formular, ohne Fehlermeldung
|
||||||
|
|
||||||
@@ -729,6 +839,7 @@ richtig geht:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
timedatectl
|
timedatectl
|
||||||
|
docker compose exec app date
|
||||||
```
|
```
|
||||||
|
|
||||||
### Die Texterkennung findet nichts
|
### 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
|
Der Proxy schneidet ab. Bei Caddy `request_body max_size`, bei nginx
|
||||||
`client_max_body_size` — beide auf mindestens 30 MB.
|
`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
|
```bash
|
||||||
df -h
|
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
|
**Ohne `--volumes`**, sonst löschst du die Datenbank.
|
||||||
sichere Anfang.
|
|
||||||
|
|
||||||
### Ganz von vorn
|
### Ganz von vorn
|
||||||
|
|
||||||
@@ -774,15 +891,15 @@ Hak das durch, bevor du echte Daten einträgst:
|
|||||||
- [ ] AV-Vertrag mit dem Hoster abgeschlossen
|
- [ ] AV-Vertrag mit dem Hoster abgeschlossen
|
||||||
- [ ] AV-Vertrag für den Objektspeicher abgeschlossen (falls extern)
|
- [ ] AV-Vertrag für den Objektspeicher abgeschlossen (falls extern)
|
||||||
- [ ] Server steht in Deutschland oder mindestens in der EU
|
- [ ] 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`
|
- [ ] `.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
|
- [ ] `/api/gesundheit` antwortet mit der erwarteten Version
|
||||||
- [ ] **Audit-Schutz greift** — `UPDATE audit_log` endet mit Fehler
|
- [ ] **Audit-Schutz greift** — `UPDATE audit_log` endet mit Fehler
|
||||||
- [ ] Bucket im Objektspeicher angelegt
|
- [ ] 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
|
- [ ] Zweiter Faktor eingerichtet
|
||||||
- [ ] **Wiederherstellungscodes ausgedruckt** und weggelegt
|
- [ ] **Wiederherstellungscodes ausgedruckt** und weggelegt
|
||||||
- [ ] Bestellungsdatum eingetragen, Pflichtfristen abgeleitet
|
- [ ] Bestellungsdatum eingetragen, Pflichtfristen abgeleitet
|
||||||
|
|||||||
Reference in New Issue
Block a user