Docker-Operations: Encryption-Bug, Deploy-Script und Comdirect Connection Test
Docker-Operations: Encryption-Bug, Deploy-Script und Comdirect Connection Test
In den letzten Tagen habe ich drei zusammenhängende Themen bearbeitet, die alle mit dem Betrieb von BudgetBuddy in Docker zu tun haben: Ein kritischer Bug, bei dem verschlüsselte Einstellungen nach jedem Container-Rebuild verloren gingen, ein Automatisierungsskript für Rule-Deployments, und ein neues Feature zum Testen der Comdirect-Verbindung. Diese scheinbar unterschiedlichen Aufgaben haben eine gemeinsame Eigenschaft: Sie alle betreffen die Schnittstelle zwischen Entwicklung und Produktion.
Ausgangslage
BudgetBuddy läuft als Docker-Container mit Tailscale-Integration für sicheren Remote-Zugriff. Die Architektur sieht so aus:
services:
budgetbuddy-app:
volumes:
- ${HOME}/my_apps/budgetbuddy:/app/data # Persistente Daten
environment:
- DATA_DIR=/app/data
tailscale-budgetbuddy:
network_mode: "service:budgetbuddy-app"
command: tailscale serve --https=443 http://127.0.0.1:5001
Die App speichert sensible Daten (YNAB-Token, Comdirect-Credentials) verschlüsselt in einer SQLite-Datenbank. Das Volume /app/data enthält diese Datenbank und überlebt Container-Rebuilds.
Das Problem: Nach jedem docker-compose up -d --build waren alle Einstellungen “weg” – die Datenbank war noch da, aber die verschlüsselten Werte konnten nicht mehr entschlüsselt werden.
Herausforderung 1: Der Encryption-Key-Bug
Das Problem
Die Verschlüsselung in Persistence.fs verwendete einen Key, der aus Environment.MachineName abgeleitet wurde:
let private getEncryptionKey () =
let machineKey = Environment.MachineName + "BudgetBuddy2025"
use sha = SHA256.Create()
sha.ComputeHash(Encoding.UTF8.GetBytes(machineKey))
Auf meinem Mac heißt die Maschine Sachses-MacBook-Pro. Der daraus abgeleitete Key ist deterministisch – solange der Hostname gleich bleibt.
Das Problem: In Docker erhält jeder Container einen zufälligen Hostnamen wie a1b2c3d4e5f6. Bei jedem Rebuild ändert sich dieser Name, und damit auch der Encryption Key.
# Container 1 (erste Woche)
MachineName: "a1b2c3d4e5f6"
Key: sha256("a1b2c3d4e5f6BudgetBuddy2025") = 0xAB12...
# Container 2 (nach Rebuild)
MachineName: "f6e5d4c3b2a1" # Neuer Container = neuer Hostname!
Key: sha256("f6e5d4c3b2a1BudgetBuddy2025") = 0xCD34... # Anderer Key!
Wenn Container 2 versucht, die von Container 1 verschlüsselten Daten zu lesen, scheitert die Entschlüsselung – die Daten sind “korrupt” (eigentlich: mit dem falschen Key verschlüsselt).
Optionen, die ich betrachtet habe
Option 1: hostname in docker-compose.yml festlegen
services:
budgetbuddy-app:
hostname: budgetbuddy-fixed
Pro:
- Einfach, eine Zeile Änderung
- Keine Code-Änderung nötig
Contra:
- Security through obscurity – der Key ist immer noch trivial ableitbar
- Jeder, der den Code liest, kann den Key berechnen
- Nicht dokumentiert, warum hostname wichtig ist
Option 2: Key als Environment Variable (gewählt)
environment:
- BUDGETBUDDY_ENCRYPTION_KEY=${BUDGETBUDDY_ENCRYPTION_KEY}
Pro:
- Echter zufälliger Key:
openssl rand -base64 32 - Key ist geheim und kann gesichert werden
- Explizit und dokumentiert
Contra:
- Erfordert
.envDatei mit dem Key - Bestehende Daten müssen neu eingegeben werden (einmalig)
Option 3: Key aus einer Datei lesen
let keyFile = Path.Combine(dataDir, ".encryption-key")
if File.Exists(keyFile) then
File.ReadAllBytes(keyFile)
else
let newKey = RandomNumberGenerator.GetBytes(32)
File.WriteAllBytes(keyFile, newKey)
newKey
Pro:
- Automatisch – kein manueller Schritt
- Key überlebt im Volume
Contra:
- Key-Datei liegt neben der Datenbank – wenn jemand die DB stiehlt, hat er auch den Key
- Backup-Komplexität steigt
- Ich müsste sicherstellen, dass die Datei die richtigen Permissions hat
Die Lösung
Ich habe mich für Option 2 entschieden und getEncryptionKey angepasst:
let private getEncryptionKey () =
let keyEnv = Environment.GetEnvironmentVariable("BUDGETBUDDY_ENCRYPTION_KEY")
if String.IsNullOrWhiteSpace(keyEnv) then
// Fallback: derive from machine name (for local development)
let machineKey = Environment.MachineName + "BudgetBuddy2025"
use sha = SHA256.Create()
sha.ComputeHash(Encoding.UTF8.GetBytes(machineKey))
else
Convert.FromBase64String(keyEnv)
Warum der Fallback?
Für lokale Entwicklung ist die MachineName-Variante völlig ausreichend. Der Hostname meines Macs ändert sich nicht. Nur in Docker muss der Key explizit gesetzt werden.
Setup für Docker:
# Einmalig: Key generieren
openssl rand -base64 32 >> .env
# Editieren: BUDGETBUDDY_ENCRYPTION_KEY=<generierter-wert>
# docker-compose.yml liest automatisch .env
Wichtiger Hinweis: Nach dieser Änderung sind alle bestehenden verschlüsselten Daten verloren. Der Benutzer muss YNAB-Token und Comdirect-Credentials neu eingeben. Das ist ein einmaliger Aufwand, aber es vermeidet das wiederkehrende Problem bei jedem Rebuild.
Herausforderung 2: Rule-Deployment-Script
Das Problem
BudgetBuddy hat Kategorisierungsregeln, die in rules.yml definiert sind. Um diese in die Live-Datenbank zu importieren, musste ich bisher:
- Docker-Container stoppen
DATA_DIREnvironment Variable setzendotnet fsi scripts/import-rules.fsx "Budget Name"ausführen- Container wieder starten
- Auf Healthcheck warten
Das sind 5 manuelle Schritte, bei denen man leicht etwas vergessen kann (besonders Schritt 2 – ohne DATA_DIR schreibt das Script in die lokale Dev-Datenbank!).
Die Lösung: deploy-rules.sh
Ich habe ein Bash-Script erstellt, das alle Schritte automatisiert:
#!/bin/bash
# deploy-rules.sh - Import rules from rules.yml to the live Docker database
set -e # Abbruch bei Fehler
CONTAINER_NAME="budgetbuddy-app"
DATA_DIR="${HOME}/my_apps/budgetbuddy"
# Prerequisite-Checks
check_prerequisites() {
if [[ ! -f "$PROJECT_DIR/.env" ]]; then
log_error ".env file not found"
exit 1
fi
if [[ ! -d "$DATA_DIR" ]]; then
log_error "Data directory not found: $DATA_DIR"
exit 1
fi
# ... weitere Checks
}
# Container-Management
stop_app() {
if is_container_running; then
log_info "Stopping $CONTAINER_NAME..."
docker-compose stop "$CONTAINER_NAME"
fi
}
start_app() {
log_info "Starting $CONTAINER_NAME..."
docker-compose start "$CONTAINER_NAME"
# Auf Healthcheck warten
for i in {1..30}; do
if docker ps | grep "$CONTAINER_NAME" | grep -q "healthy"; then
log_info "App is healthy!"
return 0
fi
sleep 1
done
}
# Import mit korrektem DATA_DIR
run_import() {
DATA_DIR="$DATA_DIR" dotnet fsi scripts/import-rules.fsx "$@"
}
# Hauptlogik
main() {
check_prerequisites
stop_app
run_import "$budget" "$clear_flag" && start_app
}
Architekturentscheidung: Warum Bash statt F#?
- Unix-Philosophie: Container-Management ist Shell-Arbeit
- Keine Build-Abhängigkeit: Das Script braucht kein
dotnet build - Komposition: Es ruft das existierende
import-rules.fsxauf, ohne es zu duplizieren - Portabilität: Bash läuft überall, wo Docker läuft
Features:
# Nur neue Regeln hinzufügen
./scripts/deploy-rules.sh "My Budget"
# Alle Regeln löschen und neu importieren
./scripts/deploy-rules.sh "My Budget" --clear
# Verfügbare Budgets anzeigen
./scripts/deploy-rules.sh --list
Safety-Feature: Das Script merkt sich, ob der Container vorher lief. Wenn er nicht lief, wird er auch nicht gestartet – man kann das Script zum Testen auch offline nutzen.
local was_running=false
if is_container_running; then
was_running=true
fi
# ... Import ...
if $was_running; then
start_app
else
log_warn "Container was not running before, not starting it"
fi
Herausforderung 3: Comdirect Connection Test
Das Problem
Benutzer konnten ihre Comdirect-Credentials in den Settings speichern, aber erst beim nächsten Sync erfahren, ob sie korrekt sind. Bei falschen Credentials scheitert der Sync mit einer kryptischen Fehlermeldung.
Ursprünglich wollte ich einen “Account Discovery”-Endpunkt implementieren: Der Benutzer gibt Client ID, Secret, Username und PIN ein, und BudgetBuddy zeigt ihm seine Konten zur Auswahl.
Die Realität: Nach stundenlanger Recherche und Tests stellte sich heraus, dass Comdirect keinen öffentlichen /api/banking/v1/accounts Endpunkt hat. Alle Varianten returnen 404:
GET /api/banking/v1/accounts→ 404GET /api/banking/v2/accounts→ 404GET /api/banking/accounts→ 404
Die pragmatische Lösung
Statt Account Discovery implementiere ich nur einen Connection Test: Der volle OAuth + TAN Flow wird durchlaufen, aber statt Accounts abzufragen, bestätigen wir nur, dass die Credentials korrekt sind.
API-Design:
type SettingsApi = {
/// Initiates Comdirect TAN authentication to test connection.
/// Returns: Challenge ID for TAN confirmation or SettingsError.
testComdirectConnection: unit -> Async<SettingsResult<string>>
/// Confirms TAN to complete credential validation.
/// Returns: Unit on success or SettingsError.
confirmComdirectTan: unit -> Async<SettingsResult<unit>>
}
UX-Flow:
- Benutzer speichert Comdirect-Credentials
- “Test Connection” Button erscheint (nur wenn Credentials gespeichert)
- Klick → Push-TAN wird angefordert → Orange “Waiting” UI
- Benutzer bestätigt TAN in der Comdirect-App
- Klick auf “I’ve Confirmed” → Validierung
- Grüne Erfolgsmeldung oder rote Fehlermeldung
Frontend Model:
type Model = {
// ... andere Felder ...
ComdirectConnectionValid: bool option // None = nicht getestet, Some true = valid, Some false = invalid
ComdirectAuthPending: bool // true = warte auf TAN-Bestätigung
}
State-Übergänge:
Initial: ComdirectConnectionValid = None, ComdirectAuthPending = false
TestConnection clicked:
→ ComdirectAuthPending = true
→ API: testComdirectConnection()
→ Bei Fehler: ComdirectConnectionValid = Some false
ConfirmTan clicked:
→ API: confirmComdirectTan()
→ Bei Erfolg: ComdirectConnectionValid = Some true, ComdirectAuthPending = false
→ Bei Fehler: ComdirectConnectionValid = Some false, ComdirectAuthPending = false
Warum zwei API-Calls statt einem?
Das Comdirect OAuth + TAN System ist asynchron:
testComdirectConnectionstartet den Flow und gibt sofort zurück- Der Benutzer bestätigt die TAN in der Banking-App (außerhalb unserer Kontrolle)
confirmComdirectTanfragt bei Comdirect nach, ob die TAN bestätigt wurde
Ein synchroner Call würde bedeuten, dass der Server auf die TAN-Bestätigung warten müsste – das können Minuten dauern, wenn der Benutzer sein Handy erst suchen muss.
Code im Backend
// In Api.fs
testComdirectConnection = fun () -> async {
match! Persistence.loadSettings() with
| None ->
return Error (SettingsError.ValidationFailed "Settings not found")
| Some settings ->
match settings.Comdirect with
| None ->
return Error (SettingsError.ValidationFailed "Comdirect not configured")
| Some creds ->
match! ComdirectAuthSession.startAuth creds with
| Error err ->
return Error (SettingsError.ComdirectError err)
| Ok challengeId ->
return Ok challengeId
}
confirmComdirectTan = fun () -> async {
match! ComdirectAuthSession.confirmTan() with
| Error err ->
return Error (SettingsError.ComdirectError err)
| Ok () ->
return Ok ()
}
Account-ID bleibt manuell: Da wir keine Accounts auslesen können, muss der Benutzer seine Account-ID selbst eingeben. Er findet sie im Comdirect Online-Banking unter Kontodetails.
Lessons Learned
1. Environment-abhängige Konfiguration gehört in Environment Variables
Der MachineName-Bug hätte nie passieren dürfen. Die Regel ist einfach: Alles, was sich zwischen Umgebungen unterscheiden kann (Dev/Prod, lokal/Docker), gehört in Environment Variables.
// Schlecht: Implizite Annahme über die Umgebung
let key = deriveFromMachineName()
// Gut: Explizite Konfiguration
let key = Environment.GetEnvironmentVariable("KEY") |> Option.ofObj |> Option.defaultWith fallback
2. Shell-Scripts für Container-Operations
Ich hätte versuchen können, das Rule-Import-Script in F# zu schreiben, inklusive Docker-Management über die Docker-API. Aber Bash ist das richtige Tool für diesen Job:
docker-compose stop/startsind Ein-Zeiler- Health-Check-Polling ist trivial mit einer Shell-Schleife
- Das Script ist lesbar für jeden, der Docker kennt
3. MVP statt Overengineering
Der ursprüngliche Plan für Account Discovery war ambitioniert. Die Realität (404 bei allen Endpunkten) hat mich zu einer einfacheren Lösung gezwungen. Das Ergebnis ist besser:
- Connection Test validiert Credentials → Hauptproblem gelöst
- Account-ID als manuelles Feld → Kein Code für nicht-existierende APIs
- Weniger Code = weniger Bugs
Fazit
Drei Änderungen, ein Thema: Production-Readiness. Der Encryption-Bug hätte im echten Betrieb zu Datenverlust geführt. Das Deploy-Script macht Updates sicherer und wiederholbar. Der Connection Test gibt Benutzern Feedback, bevor sie einen Sync starten.
Geänderte Dateien:
| Datei | Änderung |
|---|---|
src/Server/Persistence.fs |
getEncryptionKey mit Env-Variable |
docker-compose.yml |
BUDGETBUDDY_ENCRYPTION_KEY hinzugefügt |
scripts/deploy-rules.sh |
Neu: Automatisiertes Rule-Deployment |
src/Shared/Api.fs |
testComdirectConnection, confirmComdirectTan |
src/Server/Api.fs |
Backend-Implementation |
src/Client/Components/Settings/Types.fs |
ComdirectConnectionValid, ComdirectAuthPending |
src/Client/Components/Settings/State.fs |
Connection-Test-Logik |
src/Client/Components/Settings/View.fs |
TAN-Flow UI |
Statistiken:
- Encryption-Bug: 8 Zeilen Code-Änderung, verhindert wiederkehrenden Datenverlust
- Deploy-Script: 206 Zeilen Bash, ersetzt 5 manuelle Schritte
- Connection Test: ~100 Zeilen F# (Frontend + Backend), neues Feature
Key Takeaways für Neulinge
-
Container-Hostnames sind nicht stabil: Leite niemals kryptographische Keys aus Container-Metadaten ab. Verwende explizite Environment Variables für alles, was persistent sein muss.
-
Shell-Scripts für DevOps: Für Container-Management, Deployment-Automatisierung und ähnliche Aufgaben ist Bash oft besser geeignet als die Hauptsprache des Projekts. Es ist die Lingua Franca der Ops-Welt.
-
API-Realität akzeptieren: Nicht jede API bietet die Endpunkte, die du dir wünschst. Statt Workarounds zu bauen, überlege, ob ein einfacheres Feature (Connection Test statt Account Discovery) das eigentliche Problem genauso gut löst.