🚀 BSN-Chatsystem v2.1 — Implementierungsplan (extern geprüft)
| Datum | 14. Juli 2026 |
| Version | 5.1 — Externes Review eingearbeitet |
| Autor | Cody (Hermes Agent), mit Korrekturen von externem Prüfer |
| Repository | git@git.datenhimmel.work:danielkrause/BSN-Chatsystem.git |
| Status | ✅ Umsetzungsreif |
1. Ziel & Rahmenbedingungen
Ziel: Das BSN-Chatsystem (chat.datenhimmel.work) 1:1 funktional auf dedizierten dogado-Server migrieren, Architektur produktionsreif.
| Rahmenbedingung | Wert |
| Funktionsumfang | 100% identisch zum aktuellen System |
| LLM | OpenRouter → Mistral 4 (europäisch, DSGVO) |
| TTS | Deaktiviert (später optional) |
| Queue | Eigener systemd-Service (nicht Daemon-Thread) |
| Skalierungsziel | ~1000 Nutzer/h |
| Admin-Schutz | Cloudflare Access (vor Go-Live) |
| Backup | Offsite via rsync zum Hermes-Server |
| Service-User | bsn (nicht root) |
2. Server
| Eigenschaft | Wert |
| IP | 89.22.110.107 (dogado GmbH) |
| CPU | Xeon E5-2680 v3, 6 Cores @ 2.50 GHz |
| RAM | 12 GB |
| Disk | 492 GB SSD |
| OS Ziel | Ubuntu 24.04 LTS minimal (KEIN Plesk) |
| Offene Ports | NUR 22 (SSH), 80, 443 |
3. Architektur
┌──────────────────────────────────────────────────┐
│ 🌍 Cloudflare │
│ Cache Rules: / + /beitrag/* → 5min │
│ Bypass: /api/*, /webhook, /admin, /telegram │
│ Access (OTP): /admin/* │
│ DDoS, WAF, weltweit Edge │
└────────┬─────────────────────────────────────────┘
│ cloudflared Tunnel → localhost:80
┌────────▼─────────────────────────────────────────┐
│ nginx Reverse-Proxy :80 │
│ limit_req_zone im HTTP-Kontext │
│ /static/* → direkt von Disk │
│ /media/* → direkt von Disk │
│ proxy_pass → gunicorn :8000 │
│ KEIN nginx-Cache (macht Cloudflare) │
└────────┬─────────────────────────────────────────┘
│ HTTP :8000
┌────────▼──────────┐ ┌─────────────────────────┐
│ bsn-chatbot │ │ bsn-worker │
│ gunicorn 4w×2t │ │ eigener systemd- │
│ wsgi:app │ │ Service │
│ User: bsn │ │ pollt submission_queue │
│ Flask-Routen │ │ Whisper 1× geladen │
└────────┬──────────┘ │ kein Recycling mid- │
│ │ Verarbeitung │
│ └───────────┬─────────────┘
│ │
┌────────▼───────────────────────────▼─────────────┐
│ SQLite (WAL-Mode) │
│ bsn_intake.db │
└──────────────────────────────────────────────────┘
Extern: OpenRouter API (Mistral 4), Meta Cloud API (WhatsApp send)
4. Datenfluss
READ (~95%)
User → Cloudflare Edge (Cache HIT, 85%) → <50ms → FERTIG
User → Cloudflare (Cache MISS) → nginx → gunicorn → SQLite → HTML
→ Cloudflare cached für 5min → nächster Request = HIT
⚠️ Flask DARF KEIN Set-Cookie auf / + /beitrag/* senden!
WRITE (~5%)
WhatsApp → Meta → POST /whatsapp/webhook → nginx → gunicorn:
1. Signatur-Prüfung (HMAC) → 403 bei Fehler
2. INSERT INTO submission_queue → 200 OK in <10ms
bsn-worker (eigener Prozess, pollt alle 2s):
1. SELECT pending LIMIT 3
2. UPDATE status=processing
3. Media-Download + Transkription (Whisper) + LLM-Triage
4. INSERT INTO submissions
5. UPDATE status=completed
5. Komponenten
| Komponente | Technologie | Zweck |
| OS | Ubuntu 24.04 LTS | Minimal, kein Plesk |
| Python | 3.13 (deadsnakes PPA) | |
| WSGI | gunicorn 23.x | 4 Worker × 2 Threads, User: bsn |
| Reverse-Proxy | nginx 1.24+ | Buffering, Static, Rate-Limit |
| Framework | Flask 3.x | app.py (angepasst) |
| DB | SQLite 3.45+ (WAL) | bsn_intake.db |
| Queue-Worker | Eigener systemd-Service | bsn-worker.service |
| LLM | OpenRouter Mistral 4 | Triage + Chat-Assistant |
| Transkription | faster-whisper base | 1× geladen, kein Reload |
| Bild | Pillow 11.x | Resize 1920×1080 |
| Tunnel | cloudflared | Gleiche Credentials auf beiden Servern |
| Prozess | systemd | 3 Services: nginx, bsn-chatbot, bsn-worker |
| Firewall | ufw | Nur 22, 80, 443 |
6. Datenbank
submissions (Haupttabelle)
CREATE TABLE submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL, -- whatsapp, telegram, web
sender_id TEXT NOT NULL,
sender_name TEXT,
type TEXT NOT NULL, -- text, image, audio, video
content TEXT,
media_path TEXT,
media_publish_flags TEXT,
status TEXT DEFAULT 'new', -- new, done, published, flagged, gdpr_deleted
category TEXT,
summary TEXT,
publish_name TEXT,
halle TEXT,
spiel_titel TEXT,
display_text TEXT,
triage_tier INTEGER, -- 1=🟢, 2=🟡, 3=🔴
triage_reasons TEXT, -- JSON-Array
triage_at TEXT,
auto_response_sent INTEGER DEFAULT 0,
public INTEGER DEFAULT 0,
published_at TEXT,
deleted_at TEXT, -- Soft-Delete für DSGVO-Löschroutine
created_at TEXT DEFAULT (datetime('now','localtime')),
-- weitere Felder: email, phone, likes, views
);
submission_queue (Worker-Queue)
CREATE TABLE submission_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL,
message_id TEXT UNIQUE NOT NULL, -- WhatsApp msg ID / Telegram update_id
payload TEXT NOT NULL, -- JSON: Webhook-Body
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
attempt_count INTEGER DEFAULT 0,
last_error TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
updated_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE INDEX idx_queue_status ON submission_queue(status);
7. API-Endpunkte
| Methode | Pfad | Funktion | Cloudflare | Set-Cookie? |
| GET | / | Frontend-Feed (Masonry) | Cache 5min | ❌ NEIN |
| GET | /beitrag/<id> | Beitrag-Detailseite | Cache 5min | ❌ NEIN |
| GET | /health | Health-Check (schlank) | BYPASS | ❌ |
| GET/POST | /admin/* | Admin-Dashboard | BYPASS + Access OTP | ✅ Login-Cookie |
| POST | /api/submit | Web-Formular | BYPASS | ❌ |
| POST | /api/chat | Chat-Assistant | BYPASS | ❌ |
| GET/POST | /whatsapp/webhook | WhatsApp-Intake | BYPASS | ❌ |
| POST | /telegram/webhook | Telegram-Intake | BYPASS | ❌ |
8. Queue-System
✅ v5.1-Fix: Queue-Worker ist ein eigener systemd-Service (bsn-worker.service), kein Daemon-Thread in gunicorns __main__. Whisper-Modell wird 1× beim Start geladen und nicht mitten in der Verarbeitung recycled.
Queue-Flow
POST /whatsapp/webhook → HMAC-Prüfung → INSERT submission_queue → 200 OK (<10ms)
bsn-worker (eigener systemd-Prozess):
1. Whisper-Modell laden (1× beim Start)
2. WHILE True:
SELECT ... WHERE status='pending' LIMIT 3
FOR EACH:
UPDATE status='processing'
→ Media-Download
→ Transkription (faster-whisper, Modell bereits geladen)
→ LLM-Triage (OpenRouter)
→ INSERT INTO submissions
→ UPDATE status='completed'
SLEEP 2
Idempotenz
message_id UNIQUE verhindert Doppelverarbeitung. Meta wiederholt Webhooks? → INSERT OR IGNORE.
Fehlertoleranz
| Szenario | Verhalten |
| Worker crashed | systemd startet neu. Whisper wird neu geladen. Einträge mit status=processing >10min → Reset auf pending. |
| Transkription fehlgeschlagen | status=failed, attempt_count++. Nach 3 Versuchen → endgültig failed. |
| LLM nicht erreichbar | Eintrag bleibt pending, Worker retried beim nächsten Poll. |
| Server-Neustart | Alle Zustände in SQLite persistent. Worker setzt fort. |
9. LLM (OpenRouter Mistral 4)
# .env:
OPENROUTER_API_KEY=sk-or-v1-...n
# API: POST https://openrouter.ai/api/v1/chat/completions
# Modell: "mistralai/mistral-large"
| Funktion | Calls/h | Tokens/Call |
| Triage (🟢🟡🔴) | ~50 | ~500 in, ~200 out |
| Zusammenfassung | ~30 | ~800 in, ~150 out |
| Chat-Assistant | ~20 | ~2000 in, ~300 out |
| Total | ~100 | ~130k Tokens/h |
Kosten: ~$0.26/h bei Volllast.
10. Cloudflare Cache + Access
✅ v5.1-Fix: Cache Rules als expliziter Installationsschritt. Ohne diese Regeln cached Cloudflare KEIN HTML — die gesamte Skalierungsrechnung bricht zusammen.
10.1 Cache Rules (Cloudflare Dashboard → Caching → Cache Rules)
| Rule | URI Path | Cache-Status | Edge TTL |
| Frontend + Details | /* + /beitrag/* | Eligible for Cache | 5 min |
| API + Webhooks + Admin | /api/* + /whatsapp/* + /telegram/* + /admin/* | Bypass Cache | — |
| Statische Assets | /static/* | Eligible for Cache | 365 Tage |
10.2 Kein Set-Cookie auf öffentlichen Routen
⚠️ Flask darf auf / und /beitrag/* KEINE Cookies setzen. Sonst überspringt Cloudflare den Cache (Cookie = private response). Ggf. @app.after_request prüfen und Session-Cookies nur für /admin/* setzen.
10.3 Cloudflare Access für /admin/*
✅ v5.1-Fix: Cloudflare Access wird VOR Go-Live eingerichtet, nicht als Zukunftsfeature.
Cloudflare Dashboard → Zero Trust → Access → Applications → Add:
Name: "BSN Admin"
Domain: chat.datenhimmel.work
Path: /admin/*
Policy: Allow, One-time PIN → E-Mail an daniel@brettspiel-news.de
So ist /admin/* ab Go-Live durch OTP geschützt. Kein Passwort im Flask nötig.
11. Installation (14 Schritte)
Schritt 1: OS
# dogado-Kundencenter → Neuinstallation → Ubuntu 24.04 LTS minimal
# KEIN Plesk. Root-Passwort selbst setzen.
Schritt 2: Basis
apt update && apt upgrade -y
apt install -y python3.13 python3.13-venv python3.13-dev \
git nginx ufw fail2ban curl unzip build-essential restic
Schritt 3: Service-User
✅ v5.1-Fix: Eigener User bsn statt root.
useradd --system --home /opt/bsn-chatbot --shell /bin/bash bsn
mkdir -p /opt/bsn-chatbot/{src,data,logs,media,backups}
chown -R bsn:bsn /opt/bsn-chatbot
Schritt 4: Firewall
ufw default deny incoming
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
Schritt 5: venv
su - bsn
python3.13 -m venv /opt/bsn-chatbot/venv
source /opt/bsn-chatbot/venv/bin/activate
pip install --upgrade pip wheel
Schritt 6: Repo
cd /opt/bsn-chatbot/src
git clone git@git.datenhimmel.work:danielkrause/BSN-Chatsystem.git .
pip install -r requirements.txt
pip install faster-whisper gunicorn
Schritt 7: .env
cat > /opt/bsn-chatbot/src/.env << 'EOF'
DATABASE_PATH=/opt/bsn-chatbot/data/bsn_intake.db
MEDIA_DIR=/opt/bsn-chatbot/data/media
META_TOKEN=EAAx.....n
OPENR....I_KEY=sk-or-v1-...n
QUEUE_POLL_INTERVAL=2
QUEUE_BATCH_SIZE=3
TRIAGE_DELAY_SECONDS=360
EOF
chmod 600 /opt/bsn-chatbot/src/.env
Schritt 8: Daten migrieren
scp hermes@185.162.249.159:~/workspace/bsn-chatbot/bsn_intake.db /opt/bsn-chatbot/data/
scp -r hermes@185.162.249.159:~/workspace/bsn-chatbot/media/* /opt/bsn-chatbot/data/media/
chown -R bsn:bsn /opt/bsn-chatbot/data
Schritt 9: cloudflared (EINMAL)
✅ v5.1-Fix: Gleiche Tunnel-Credentials für beide Server. Tunnel wird EINMAL erstellt, Credentials auf beide Server kopiert.
wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
chmod +x cloudflared-linux-amd64
mv cloudflared-linux-amd64 /usr/local/bin/cloudflared
# Login (einmalig, Browser):
cloudflared tunnel login
# Tunnel ERSTELLEN:
cloudflared tunnel create bsn-chat
# DNS-Route (CNAME):
cloudflared tunnel route dns bsn-chat chat.datenhimmel.work
# Tunnel-ID notieren (z.B. e84dedca-...)
Schritt 10: cloudflared Config
cat > ~/.cloudflared/config.yml << 'CFEOF'
tunnel: <TUNNEL_ID>
credentials-file: ~/.cloudflared/<TUNNEL_ID>.json
ingress:
- hostname: chat.datenhimmel.work
service: http://localhost:80
- service: http_status:404
CFEOF
cloudflared service install
systemctl enable --now cloudflared
Schritt 11: Tunnel-Credentials auf Ketchup kopieren
✅ v5.1-Fix: Gleiche Credentials auf beiden Servern — Rollback ist DNS-Umschaltung, kein Tunnel-Wechsel.
# Auf dogado:
scp ~/.cloudflared/<TUNNEL_ID>.json hermes@185.162.249.159:~/.cloudflared/
scp ~/.cloudflared/cert.pem hermes@185.162.249.159:~/.cloudflared/
# Auf Ketchup:
cp <TUNNEL_ID>.json ~/workspace/bsn-chatbot/
# config.yml auf Ketchup auf localhost:5002 zeigen lassen
Schritt 12: Cloudflare Cache Rules
Cloudflare Dashboard → Caching → Cache Rules → Create Rule:
Field: URI Path, Operator: contains, Value: /beitrag/
(plus separate rule for /static/*)
→ Eligible for Cache, Edge TTL: 5 min / 365 days
Schritt 13: Cloudflare Access
Cloudflare Dashboard → Zero Trust → Access → Applications → Add:
Application: BSN Admin
Domain: chat.datenhimmel.work
Path: /admin/*
→ Policy: Allow, One-time PIN → daniel@brettspiel-news.de
Schritt 14: Flask prüfen — kein Set-Cookie auf /
# In app.py prüfen: after_request setzt keine Cookies auf öffentlichen Routen
grep -n "set_cookie\|Set-Cookie\|session\[" /opt/bsn-chatbot/src/app.py
# Falls Cookies gesetzt werden → auf /admin/* beschränken
12. Konfigurationsdateien
12.1 nginx: /etc/nginx/sites-available/bsn-chatbot
✅ v5.1-Fix: limit_req_zone im HTTP-Kontext (nicht Server), nginx-Cache-Direktiven entfernt (macht Cloudflare), Webhook-Pfade korrigiert.
# /etc/nginx/nginx.conf (HTTP-Kontext):
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=webhook:10m rate=10r/s;
# ...
}
# /etc/nginx/sites-available/bsn-chatbot:
server {
listen 80;
server_name _; # cloudflared verbindet per IP
client_max_body_size 50M;
# Static Files
location /static/ {
alias /opt/bsn-chatbot/src/static/;
expires 365d;
add_header Cache-Control "public, immutable";
}
# Media Files
location /media/ {
alias /opt/bsn-chatbot/data/media/;
expires 7d;
}
# Webhooks — exakte Pfade
location ~ ^/(whatsapp|telegram)/webhook {
proxy_pass http://127.0.0.1:8000;
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;
limit_req zone=webhook burst=20 nodelay;
}
# Admin
location /admin {
proxy_pass http://127.0.0.1:8000;
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_buffering off;
}
# API
location /api/ {
proxy_pass http://127.0.0.1:8000;
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;
limit_req zone=api burst=20 nodelay;
}
# Frontend (KEIN nginx-Cache, macht Cloudflare)
location / {
proxy_pass http://127.0.0.1:8000;
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;
}
}
12.2 gunicorn: /opt/bsn-chatbot/gunicorn.conf.py
bind = "127.0.0.1:8000"
workers = 4
threads = 2
worker_class = "gthread"
timeout = 120
graceful_timeout = 30
keepalive = 5
user = "bsn"
group = "bsn"
accesslog = "/opt/bsn-chatbot/logs/gunicorn-access.log"
errorlog = "/opt/bsn-chatbot/logs/gunicorn-error.log"
loglevel = "info"
proc_name = "bsn-chatbot"
max_requests = 1000
max_requests_jitter = 100
13. systemd-Services
13.1 bsn-chatbot.service (gunicorn)
[Unit]
Description=BSN Chatbot — gunicorn WSGI
After=network.target
[Service]
Type=notify
User=bsn
Group=bsn
WorkingDirectory=/opt/bsn-chatbot/src
EnvironmentFile=/opt/bsn-chatbot/src/.env
ExecStart=/opt/bsn-chatbot/venv/bin/gunicorn --config /opt/bsn-chatbot/gunicorn.conf.py app:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=5
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/bsn-chatbot/data /opt/bsn-chatbot/logs /opt/bsn-chatbot/backups
ReadOnlyPaths=/opt/bsn-chatbot/src
[Install]
WantedBy=multi-user.target
13.2 bsn-worker.service (Queue)
✅ v5.1-Fix: Worker als eigener systemd-Service — kein Daemon-Thread unter gunicorn.
[Unit]
Description=BSN Queue Worker — Submission-Verarbeitung
After=bsn-chatbot.service
Wants=bsn-chatbot.service
[Service]
Type=simple
User=bsn
Group=bsn
WorkingDirectory=/opt/bsn-chatbot/src
EnvironmentFile=/opt/bsn-chatbot/src/.env
ExecStart=/opt/bsn-chatbot/venv/bin/python worker.py
Restart=always
RestartSec=10
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/bsn-chatbot/data /opt/bsn-chatbot/logs
ReadOnlyPaths=/opt/bsn-chatbot/src
[Install]
WantedBy=multi-user.target
13.3 worker.py (neue Datei)
#!/usr/bin/env python3
"""Standalone Queue-Worker — eigener Prozess, nicht unter gunicorn."""
import os, sys, time, json, sqlite3
sys.path.insert(0, '/opt/bsn-chatbot/src')
from dotenv import load_dotenv
load_dotenv()
DB = os.environ['DATABASE_PATH']
POLL = int(os.environ.get('QUEUE_POLL_INTERVAL', 2))
BATCH = int(os.environ.get('QUEUE_BATCH_SIZE', 3))
# Whisper 1× laden (kein Recycling mitten in der Verarbeitung)
from faster_whisper import WhisperModel
model = WhisperModel("base", device="cpu", compute_type="int8")
def get_db():
db = sqlite3.connect(DB)
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA busy_timeout=5000")
return db
def process_one(db, row):
# → Media-Download, Transkription, LLM-Triage, DB-Insert
# (Implementierung aus queue_worker.py übernehmen)
pass
def main():
print("[worker] Gestartet, Whisper-Modell geladen.", flush=True)
db = get_db()
while True:
try:
rows = db.execute(
"SELECT * FROM submission_queue WHERE status='pending' ORDER BY created_at LIMIT ?",
(BATCH,)).fetchall()
for row in rows:
db.execute("UPDATE submission_queue SET status='processing' WHERE id=?", (row['id'],))
db.commit()
try:
process_one(db, row)
db.execute("UPDATE submission_queue SET status='completed' WHERE id=?", (row['id'],))
except Exception as e:
db.execute(
"UPDATE submission_queue SET status='failed', last_error=?, attempt_count=attempt_count+1 WHERE id=?",
(str(e)[:500], row['id']))
db.commit()
except Exception as e:
print(f"[worker] Fehler: {e}", flush=True)
time.sleep(POLL)
if __name__ == '__main__':
main()
13.4 Aktivierung
systemctl daemon-reload
systemctl enable --now bsn-chatbot bsn-worker nginx
# cloudflared ist bereits aktiv (Schritt 10)
14. Monitoring
| Komponente | Log |
| gunicorn Access | /opt/bsn-chatbot/logs/gunicorn-access.log |
| gunicorn Error | /opt/bsn-chatbot/logs/gunicorn-error.log |
| Worker | journalctl -u bsn-worker |
| Flask | journalctl -u bsn-chatbot |
| nginx | /var/log/nginx/access.log |
/health (abgespeckt)
✅ v5.1-Fix: /health wurde entschlackt — nur noch status, db, whatsapp_configured.
GET /health → {"status":"ok","db":"bsn_intake.db","whatsapp_configured":true}
Watchdog (Hermes-Server)
# Cronjob alle 5min:
curl -s -o /dev/null -w "%{http_code}" https://chat.datenhimmel.work/health | grep -q 200
# Bei Fehler → Telegram an Daniel
15. Sicherheit
| Aspekt | Maßnahme |
| Firewall | Nur 22, 80, 443. Flask (8000) nur localhost. |
| SSH | Key-basiert, kein Passwort |
| Webhook | HMAC-SHA256 Signatur-Prüfung |
| Rate-Limit | nginx: 30 r/s (api), 10 r/s (webhook) |
| SQL-Injection | Parameterisierte Queries |
| XSS | Jinja2 Autoescaping |
| Secrets | .env, chmod 600, nicht in Git |
| systemd | ProtectSystem=strict, NoNewPrivileges |
| Admin | Cloudflare Access OTP (E-Mail) |
| Service-User | bsn (nicht root) |
16. Backup & Recovery
✅ v5.1-Fix: Offsite-Backup via rsync zum Hermes-Server (185.162.249.159) statt nur lokal.
16.1 Backup-Script (/opt/bsn-chatbot/backup.sh)
#!/bin/bash
set -e
BACKUP_DIR=/opt/bsn-chatbot/backups
HERMES="hermes@185.162.249.159:/home/hermes/backups/bsn-chatbot"
DATE=$(date +%Y%m%d-%H%M)
mkdir -p $BACKUP_DIR
# Lokales DB-Backup
sqlite3 /opt/bsn-chatbot/data/bsn_intake.db \
".backup $BACKUP_DIR/bsn_intake_$DATE.db"
# Offsite (zum Hermes-Server)
rsync -avz --delete $BACKUP_DIR/ $HERMES/db/
rsync -avz /opt/bsn-chatbot/data/media/ $HERMES/media/
# Rotation: 72 lokale Backups behalten
ls -t $BACKUP_DIR/bsn_intake_*.db | tail -n +73 | xargs rm -f
16.2 Cron für Backup
# /etc/cron.d/bsn-backup
0 * * * * bsn /opt/bsn-chatbot/backup.sh
16.3 Recovery
LAST=$(ls -t /opt/bsn-chatbot/backups/bsn_intake_*.db | head -1)
systemctl stop bsn-chatbot bsn-worker
cp $LAST /opt/bsn-chatbot/data/bsn_intake.db
systemctl start bsn-chatbot bsn-worker
17. DSGVO-Löschroutine
✅ v5.1-Fix: Automatische Löschroutine ist Teil von v1, nicht Zukunftsfeature.
17.1 WhatsApp: „Meine Daten löschen"
# Bereits implementiert in app.py:
# Nutzer sendet "Meine Daten löschen" → alle Submissions dieser Nummer:
# UPDATE submissions SET deleted_at=CURRENT_TIMESTAMP, status='gdpr_deleted'
# Medien von Platte löschen
# Auto-Reply: Bestätigung
17.2 Web: Referenz-ID
# Bereits implementiert:
# Nach Web-Submit: Referenz-ID anzeigen + Kopier-Button
# Löschung aktuell via Kontaktaufnahme Redaktion
# ⚠️ Selbstbedienungs-Löschformular: jetzt in v1 umsetzen
# → /loeschen Route: ID eingeben → DELETE
17.3 Löschroutine (Cron)
# /opt/bsn-chatbot/purge.py (täglich via Cron):
# SELECT * FROM submissions WHERE deleted_at IS NOT NULL
# AND deleted_at < date('now', '-30 days')
# → Medien von Platte löschen, DB-Eintrag hart löschen
# → DSGVO-Vermerk bleibt in separater Tabelle
18. Go-Live
- Ketchup cloudflared STOPPEN:
systemctl stop cloudflared
- Dogado cloudflared STARTEN:
systemctl start cloudflared
- DNS prüfen: CNAME chat.datenhimmel.work → Tunnel-ID (Cloudflare Dashboard)
- Warten: 2-5 Min DNS-Propagation
- Verifikation:
curl https://chat.datenhimmel.work/health → 200
- 10-Punkte-Checkliste durchgehen
- 72h warten bevor alter Server endgültig abgeschaltet wird
19. Go-Live-Checkliste
| # | Test | Erwartet |
| 1 | curl https://chat.datenhimmel.work/health | 200, "status":"ok" |
| 2 | curl -sI https://chat.datenhimmel.work/ | 200, cf-cache-status: HIT/MISS |
| 3 | curl https://chat.datenhimmel.work/beitrag/1 | 200, HTML |
| 4 | curl "https://chat.datenhimmel.work/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=br3ttsp1el&hub.challenge=test" | "test" |
| 5 | Web-Submit POST | 200, "status":"queued" |
| 6 | Nach ~30s: Admin prüfen | Submission mit Triage |
| 7 | Admin lädt | 200, Submissions-Tabelle |
| 8 | Cloudflare Access | OTP-Mail bei /admin |
| 9 | Chat-Assistant | 200, JSON mit answer |
| 10 | WhatsApp „Meine Daten löschen" | Bestätigung, Daten gelöscht |
20. Fallback
✅ v5.1-Fix: Gleiche Tunnel-Credentials auf beiden Servern. Rollback = systemctl start cloudflared auf Ketchup, systemctl stop cloudflared auf dogado. Kein DNS-Change nötig — Cloudflare routet zum aktiven Connector.
Rollback (innerhalb 72h)
# Auf Ketchup:
systemctl start cloudflared
# Auf dogado:
systemctl stop cloudflared
# Fertig. Cloudflare sieht den aktiven Connector auf Ketchup.
Nach 72 Stunden
# Auf Ketchup:
systemctl stop bsn-chatbot
rm ~/.cloudflared/<TUNNEL_ID>.json # Credentials entfernen
21. Code-Änderungen
| Datei | Änderung | Dauer |
worker.py (NEU) | Standalone Queue-Worker (Extrakt aus app.py + queue_worker.py) | 45 min |
app.py | Webhook-Routen: Queue.enqueue() statt sync. LLM: OpenRouter. TTS raus. Kein Set-Cookie auf /. | 45 min |
app.py | /health entschlacken (3 Felder) | 5 min |
purge.py (NEU) | DSGVO-Löschroutine (Cronjob) | 30 min |
app.py | /loeschen Route (Selbstbedienung Web-ID) | 20 min |
requirements.txt | gunicorn hinzufügen, DeepSeek entfernen | 5 min |
Gesamt: ~2,5 Stunden
22. Skalierung (1000/h)
| Pfad | Anteil | Req/h | Serverlast |
| Frontend (Cache HIT) | 85% | 850 | 0% (Cloudflare) |
| Details (Cache MISS) | 8% | 80 | ~1% CPU |
| Daten senden | 5% | 50 | ~2% CPU |
| Chat-Assistant | 2% | 20 | ~1% CPU + Netzwerk |
| Total | | 1000 | ~4% CPU |
Server (12 GB, 6 Cores, NVMe) ist für 1000/h komfortabel überdimensioniert.
23. Zukunft
| Erweiterung | Auslöser |
| Lokales LLM | OpenRouter-Kosten >100 €/Monat |
| TTS (Piper) | WhatsApp-Sprachantworten gewünscht |
| SQLite → PostgreSQL | >500 Submissions/h |
24. Änderungen v5 → v5.1
| Punkt | v5.0 | v5.1 |
| Queue-Worker | Daemon-Thread in gunicorn __main__ | Eigener systemd-Service (worker.py) |
| nginx limit_req_zone | Im server-Kontext (geht nicht) | Im http-Kontext |
| nginx Cache | proxy_cache_valid gesetzt | Entfernt (macht Cloudflare) |
| Webhook nginx | location /webhook | location ~ ^/(whatsapp|telegram)/webhook |
| Cloudflare Cache | Nur Strategie beschrieben | Expliziter Installationsschritt + Set-Cookie-Prüfung |
| Admin-Schutz | Zukunftsfeature | Cloudflare Access vor Go-Live |
| Tunnel-Failover | Zwei getrennte Tunnel | Gleiche Credentials, beide Server |
| Offsite-Backup | Nur lokal | rsync zum Hermes-Server |
| Service-User | root | bsn |
| DSGVO-Löschroutine | Nicht in v1 | In v1: purge.py + Cron |
| /health | 8 Felder | 3 Felder (status, db, whatsapp) |
| Port-5002 | Mehrfach referenziert | Nur noch :8000 (gunicorn) + :80 (nginx) |
Ende des Plans v5.1
Cody (Hermes Agent) + externes Review — 14.07.2026
Status: ✅ Umsetzungsreif