feat: Phase 0 – Gerüst (FastAPI+SQLite, Multi-User-Login, React-App-Shell, Deploy)

Grundgerüst für Certain Finance:
- Backend: FastAPI + SQLAlchemy + SQLite, Alembic-Migrationen (Batch-Mode),
  argon2-Passwort-Hashing, server-seitige Sitzungen im Cookie, Erst-Admin,
  Benutzerverwaltung mit Temporär-Passwort, Einstellungen je Benutzer.
- Ein Prozess, ein Port: uvicorn liefert API (/api) und gebautes SPA aus,
  inkl. SPA-Deeplink-Fallback.
- Frontend: React 18 + Vite + JSX, Design-Tokens (Light/Dark), i18n DE/EN,
  Inline-SVG-Icons (keine Emojis), Login, Passwort-ändern, Dashboard-Platzhalter,
  Benutzer- und Einstellungsseiten.
- Deploy: nativer LXC-Installer + Update-Skript (systemd), env-Vorlage, Doku.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 18:27:42 +02:00
co-authored by Claude Opus 4.8
commit 2c795a57c8
59 changed files with 6419 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# Deployment (Proxmox-LXC + Nginx Proxy Manager)
## Installation
Im LXC (Debian/Ubuntu) das Repo klonen und als root installieren:
```bash
git clone <repo-url> /opt/certain-finance
cd /opt/certain-finance
sudo ./deploy/install.sh
```
Das Skript installiert Python/Node, richtet eine venv ein, baut das Frontend,
erzeugt `/etc/certain-finance/env` (mit Zufallsgeheimnissen), legt den systemd-
Dienst `certain-finance` an und startet ihn. Node wird **nur zum Bauen** gebraucht;
der laufende Dienst ist reines Python.
## Nginx Proxy Manager
Neuen **Proxy Host** anlegen:
- **Domain Names:** deine (Sub-)Domain
- **Scheme:** `http`
- **Forward Hostname / IP:** IP dieses LXC
- **Forward Port:** Wert von `APP_PORT` (Standard `8071`)
- **Block Common Exploits:** an
- **Websockets Support:** nicht nötig
- **SSL:** Let's Encrypt-Zertifikat anfordern, „Force SSL" an
Für spätere CSV-/PDF-Uploads in den *Advanced*-Einstellungen des Hosts
`client_max_body_size 20m;` setzen.
Da HTTPS extern terminiert, sind die Sitzungs-Cookies `Secure`
(`APP_COOKIE_SECURE=true`). Der Dienst selbst lauscht nur auf `127.0.0.1`.
## Betrieb
```bash
systemctl status certain-finance # Status
journalctl -u certain-finance -f # Logs
systemctl restart certain-finance # Neustart
sudo ./deploy/update.sh # Update (git pull + build + Migration + Neustart)
```
## Backup
Die Datenbank liegt unter `/var/lib/certain-finance/app.db`. `update.sh` legt vor
jeder Migration automatisch eine Kopie unter `/var/lib/certain-finance/backups/`
an. Zusätzlich empfiehlt sich ein Proxmox-`vzdump`-Snapshot des Containers.
+31
View File
@@ -0,0 +1,31 @@
# systemd-Unit für Certain Finance (Referenz).
# install.sh erzeugt die tatsächliche Unit unter /etc/systemd/system/certain-finance.service
# mit den konkreten Pfaden deines Klons. Diese Datei zeigt die Standardform
# (Repo unter /opt/certain-finance).
[Unit]
Description=Certain Finance
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=certain-finance
Group=certain-finance
WorkingDirectory=/opt/certain-finance/backend
EnvironmentFile=/etc/certain-finance/env
# Der Port kommt aus der EnvironmentFile (APP_PORT). Nur an 127.0.0.1 gebunden –
# von außen ist der Dienst ausschließlich über den Nginx Proxy Manager erreichbar.
ExecStart=/opt/certain-finance/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port ${APP_PORT}
Restart=on-failure
RestartSec=3
# Härtung
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/certain-finance
[Install]
WantedBy=multi-user.target
+29
View File
@@ -0,0 +1,29 @@
# Konfiguration für Certain Finance (Umgebungsvariablen, Präfix APP_).
# install.sh erzeugt daraus /etc/certain-finance/env mit sicheren Zufallswerten.
# Nach Änderungen den Dienst neu starten: systemctl restart certain-finance
# Ablageort für SQLite-Datenbank und Backups (muss dem Dienst-Benutzer gehören).
APP_DATA_DIR=/var/lib/certain-finance
# Interner Port, auf dem der Dienst lauscht. Der Nginx Proxy Manager leitet
# von außen (HTTPS) hierher weiter. Nur an 127.0.0.1 gebunden (siehe Unit).
APP_PORT=8071
# Signiert/HMACt Sitzungen. UNBEDINGT zufällig setzen (install.sh macht das).
APP_SESSION_SECRET=change-me-please
# Cookies nur über HTTPS senden. Hinter dem Nginx Proxy Manager: true.
APP_COOKIE_SECURE=true
# Erster Admin – wird NUR beim allerersten Start angelegt (wenn noch kein Nutzer da ist).
APP_ADMIN_USERNAME=admin
APP_ADMIN_PASSWORD=changeme
# Standard-Hauptwährung für neue Benutzer (EUR oder CHF).
APP_DEFAULT_BASE_CURRENCY=EUR
# Beim Start die aktuellen EZB-Wechselkurse laden (ab Phase 1 relevant).
APP_ECB_FETCH_ON_START=true
# Wird vom Install-/Update-Skript gesetzt (kurzer Git-Commit, für /api/version).
APP_GIT_SHA=
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
#
# Certain Finance – nativer Installer für einen Debian/Ubuntu-LXC (Proxmox).
# Richtet Python-venv, gebautes Frontend, systemd-Dienst und Konfiguration ein.
# HTTPS macht dein vorhandener Nginx Proxy Manager – hier wird KEIN Reverse-Proxy
# installiert; der Dienst lauscht nur intern auf 127.0.0.1:<APP_PORT>.
#
# Nutzung (als root im geklonten Repo):
# ./deploy/install.sh interaktiv (fragt Port/Admin)
# ./deploy/install.sh --yes nicht-interaktiv (Zufalls-Admin-Passwort)
#
set -euo pipefail
APP_SLUG="certain-finance" # Einzige Stelle mit dem Betriebs-Slug (Dienst/Pfade)
DEFAULT_PORT=8071
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ETC_DIR="/etc/$APP_SLUG"
ENV_FILE="$ETC_DIR/env"
DATA_DIR="/var/lib/$APP_SLUG"
SERVICE_USER="$APP_SLUG"
UNIT_FILE="/etc/systemd/system/$APP_SLUG.service"
ASSUME_YES=0
[[ "${1:-}" == "--yes" || "${1:-}" == "-y" ]] && ASSUME_YES=1
info() { printf '\033[0;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[0;33m[!]\033[0m %s\n' "$*"; }
error() { printf '\033[0;31m[x]\033[0m %s\n' "$*" >&2; }
rand() { LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c "${1:-32}"; }
if [[ "$(id -u)" -ne 0 ]]; then
error "Bitte als root ausführen (z. B. 'sudo ./deploy/install.sh')."
exit 1
fi
if ! command -v apt-get >/dev/null 2>&1; then
error "Dieses Skript ist für Debian/Ubuntu (apt) gedacht. Kein apt-get gefunden."
exit 1
fi
# --- Systempakete ------------------------------------------------------------
info "Installiere Systempakete (Python, Build-Tools, git, curl) …"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y python3 python3-venv python3-dev build-essential curl ca-certificates git
# --- Node.js (nur zum Bauen des Frontends nötig) -----------------------------
ensure_node() {
local major=0
if command -v node >/dev/null 2>&1; then
major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
fi
if [[ "$major" -ge 18 ]]; then
info "Node.js gefunden: $(node --version)"
return
fi
warn "Node.js fehlt oder ist zu alt – installiere Node 20 LTS (NodeSource) …"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
info "Node.js installiert: $(node --version)"
}
ensure_node
# --- Dienst-Benutzer und Verzeichnisse --------------------------------------
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
info "Lege Dienst-Benutzer '$SERVICE_USER' an …"
useradd --system --user-group --home-dir "$DATA_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
fi
mkdir -p "$DATA_DIR" "$ETC_DIR"
chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR"
# --- Python-venv + Abhängigkeiten -------------------------------------------
info "Richte Python-venv ein und installiere Abhängigkeiten …"
python3 -m venv "$REPO_DIR/backend/.venv"
"$REPO_DIR/backend/.venv/bin/pip" install --upgrade pip >/dev/null
"$REPO_DIR/backend/.venv/bin/pip" install -r "$REPO_DIR/backend/requirements.txt"
# --- Frontend bauen ----------------------------------------------------------
info "Baue das Frontend (Node wird nur hier gebraucht) …"
(
cd "$REPO_DIR/web"
if [[ -f package-lock.json ]]; then npm ci; else npm install; fi
npm run build
)
# --- Konfiguration (.env) ----------------------------------------------------
GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo '')"
if [[ -f "$ENV_FILE" ]]; then
info "Konfiguration existiert bereits: $ENV_FILE (unverändert übernommen)."
# Git-Commit aktualisieren (für /api/version).
if grep -q '^APP_GIT_SHA=' "$ENV_FILE"; then
sed -i "s|^APP_GIT_SHA=.*|APP_GIT_SHA=$GIT_SHA|" "$ENV_FILE"
else
echo "APP_GIT_SHA=$GIT_SHA" >> "$ENV_FILE"
fi
ADMIN_USER="$(grep -E '^APP_ADMIN_USERNAME=' "$ENV_FILE" | cut -d= -f2-)"
GENERATED_PASS=""
else
local_port="$DEFAULT_PORT"
admin_user="admin"
admin_pass="$(rand 16)"
if [[ "$ASSUME_YES" -eq 0 ]]; then
read -r -p "Interner Port [$DEFAULT_PORT]: " x; local_port="${x:-$DEFAULT_PORT}"
read -r -p "Admin-Benutzername [admin]: " x; admin_user="${x:-admin}"
read -r -s -p "Admin-Passwort [leer = zufällig]: " x; echo
[[ -n "$x" ]] && admin_pass="$x"
fi
info "Erzeuge Konfiguration: $ENV_FILE"
cat > "$ENV_FILE" <<EOF
APP_DATA_DIR=$DATA_DIR
APP_PORT=$local_port
APP_SESSION_SECRET=$(rand 48)
APP_COOKIE_SECURE=true
APP_ADMIN_USERNAME=$admin_user
APP_ADMIN_PASSWORD=$admin_pass
APP_DEFAULT_BASE_CURRENCY=EUR
APP_ECB_FETCH_ON_START=true
APP_GIT_SHA=$GIT_SHA
EOF
ADMIN_USER="$admin_user"
GENERATED_PASS="$admin_pass"
fi
chown root:root "$ENV_FILE"
chmod 600 "$ENV_FILE"
# --- systemd-Unit ------------------------------------------------------------
info "Schreibe systemd-Unit: $UNIT_FILE"
cat > "$UNIT_FILE" <<EOF
[Unit]
Description=Certain Finance
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
Group=$SERVICE_USER
WorkingDirectory=$REPO_DIR/backend
EnvironmentFile=$ENV_FILE
ExecStart=$REPO_DIR/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port \${APP_PORT}
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
ReadWritePaths=$DATA_DIR
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "$APP_SLUG" >/dev/null 2>&1 || true
info "Starte Dienst … (wendet Migrationen an und legt ggf. den ersten Admin an)"
systemctl restart "$APP_SLUG"
# --- Gesundheitscheck --------------------------------------------------------
PORT="$(grep -E '^APP_PORT=' "$ENV_FILE" | cut -d= -f2-)"
PORT="${PORT:-$DEFAULT_PORT}"
info "Warte auf das Backend (127.0.0.1:$PORT) …"
ok=0
for _ in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then ok=1; break; fi
sleep 2
done
echo
if [[ "$ok" -eq 1 ]]; then
info "Fertig – Certain Finance läuft. 🎉"
else
warn "Backend antwortet noch nicht. Logs: journalctl -u $APP_SLUG -e"
fi
echo " Interner Dienst: http://127.0.0.1:$PORT (nur lokal)"
echo " Admin-Login: ${ADMIN_USER:-admin}"
if [[ -n "${GENERATED_PASS:-}" ]]; then
echo " Admin-Passwort: ${GENERATED_PASS}"
warn "Bitte dieses Passwort notieren – es wird nicht erneut angezeigt."
fi
echo
echo " Nginx Proxy Manager: neuen Proxy-Host anlegen ->"
echo " Forward Hostname/IP: <IP-dieses-LXC>"
echo " Forward Port: $PORT"
echo " SSL: Let's Encrypt aktivieren; 'Block Common Exploits' ok."
echo " (Websockets nicht nötig. Für spätere Uploads client_max_body_size >= 20M.)"
echo
echo " Logs: journalctl -u $APP_SLUG -f"
echo " Neustart: systemctl restart $APP_SLUG"
echo " Update: ./deploy/update.sh"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
#
# Certain Finance – Update-Skript.
# Holt den neuesten Stand aus Git, baut das Frontend neu, sichert die Datenbank
# und startet den Dienst neu (der beim Start die Migrationen anwendet).
#
# Nutzung (als root im geklonten Repo):
# ./deploy/update.sh Update holen, bauen, neu starten
# ./deploy/update.sh --no-pull nur neu bauen (ohne git pull)
# ./deploy/update.sh --no-build ohne Frontend-Neubau
#
set -euo pipefail
APP_SLUG="certain-finance"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ETC_DIR="/etc/$APP_SLUG"
ENV_FILE="$ETC_DIR/env"
DATA_DIR="/var/lib/$APP_SLUG"
DO_PULL=1
DO_BUILD=1
for arg in "$@"; do
case "$arg" in
--no-pull) DO_PULL=0 ;;
--no-build) DO_BUILD=0 ;;
-h|--help) sed -n '2,12p' "${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "Unbekannte Option: $arg" >&2; exit 1 ;;
esac
done
info() { printf '\033[0;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[0;33m[!]\033[0m %s\n' "$*"; }
error() { printf '\033[0;31m[x]\033[0m %s\n' "$*" >&2; }
if [[ "$(id -u)" -ne 0 ]]; then
error "Bitte als root ausführen (z. B. 'sudo ./deploy/update.sh')."
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
error "Keine Konfiguration in $ENV_FILE – bitte zuerst ./deploy/install.sh ausführen."
exit 1
fi
# --- Git-Update --------------------------------------------------------------
if [[ "$DO_PULL" -eq 1 ]]; then
if [[ ! -d "$REPO_DIR/.git" ]]; then
warn "$REPO_DIR ist kein Git-Repository – überspringe git pull."
else
info "Hole Updates aus Git …"
before="$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo '?')"
if ! git -C "$REPO_DIR" pull --ff-only; then
error "git pull fehlgeschlagen (lokale Änderungen?)."
echo " Stand ansehen: git -C \"$REPO_DIR\" status"
exit 1
fi
after="$(git -C "$REPO_DIR" rev-parse --short HEAD)"
[[ "$before" == "$after" ]] && info "Bereits aktuell ($after)." || info "Aktualisiert: $before -> $after"
fi
fi
# --- Datenbank sichern (vor der Migration) ----------------------------------
if [[ -f "$DATA_DIR/app.db" ]]; then
mkdir -p "$DATA_DIR/backups"
ts="$(date +%Y%m%d-%H%M%S)"
cp "$DATA_DIR/app.db" "$DATA_DIR/backups/pre-update-$ts.db"
info "Datenbank gesichert: backups/pre-update-$ts.db"
fi
# --- Python-Abhängigkeiten ---------------------------------------------------
info "Aktualisiere Python-Abhängigkeiten …"
"$REPO_DIR/backend/.venv/bin/pip" install -r "$REPO_DIR/backend/requirements.txt" >/dev/null
# --- Frontend neu bauen ------------------------------------------------------
if [[ "$DO_BUILD" -eq 1 ]]; then
info "Baue das Frontend neu …"
( cd "$REPO_DIR/web"; if [[ -f package-lock.json ]]; then npm ci; else npm install; fi; npm run build )
fi
# --- Git-Commit in die Konfiguration schreiben (für /api/version) -----------
GIT_SHA="$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo '')"
if grep -q '^APP_GIT_SHA=' "$ENV_FILE"; then
sed -i "s|^APP_GIT_SHA=.*|APP_GIT_SHA=$GIT_SHA|" "$ENV_FILE"
else
echo "APP_GIT_SHA=$GIT_SHA" >> "$ENV_FILE"
fi
# --- Neu starten (wendet Migrationen an) ------------------------------------
info "Starte Dienst neu …"
systemctl restart "$APP_SLUG"
PORT="$(grep -E '^APP_PORT=' "$ENV_FILE" | cut -d= -f2-)"
PORT="${PORT:-8071}"
ok=0
for _ in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then ok=1; break; fi
sleep 2
done
if [[ "$ok" -eq 1 ]]; then
info "Update fertig – Dienst läuft."
else
warn "Backend antwortet noch nicht. Logs: journalctl -u $APP_SLUG -e"
fi