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:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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=
|
||||
Executable
+191
@@ -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"
|
||||
Executable
+103
@@ -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
|
||||
Reference in New Issue
Block a user