Compare commits

...

14 Commits

Author SHA256 Message Date
224baba326 fix: Grafana-Zeitfenster deckt Projektionshorizont ab (Szenario-Vergleich sichtbar)
Das Szenario-Vergleich-Panel zeichnet die Projektion (Zukunft, horizon_days ab
heute). Kein Panel nutzt $__timeFilter, daher begrenzt allein das
Dashboard-Zeitfenster die Anzeige. Mit time.to='now' wurde die gesamte Kurve ab
morgen abgeschnitten (nur ~4 Tage <= heute sichtbar, Y-Achse auf deren schmalen
Bereich fixiert). Fix: time.to='now+19M' deckt den 548-Tage-Horizont ab.
Regressionstest tests/test_grafana_dashboard.py haelt die Invariante fest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 20:39:23 +02:00
dec1cb9075 chore: stop_finance_pod.sh - Naechste-Schritte-Hinweis auskommentiert
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 10:37:09 +02:00
81a0ebaf9d docs: Reverse-Proxy-Doku auf reales Live-Setup (fbwl.creature-go.com via sv005/Traefik)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 09:45:06 +02:00
6ba6ffa9df feat: stop_finance_pod.sh - kontrolliertes Herunterfahren des Pods
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:32:26 +02:00
51d85c8de6 chore: Release v0.10.0 (Reverse-Proxy-Tauglichkeit)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 07:31:27 +02:00
208c0a4df4 docs: Reverse-Proxy-Snippets (Traefik/Apache) + .env-Schalter 2026-07-21 07:25:41 +02:00
fa45527faf docs: Plan A10 praezisiert (Domain-Policy + GF_SUBPATH-Platzierung vor API-Container) 2026-07-21 07:21:57 +02:00
2610b71b06 feat: create_pod_finance.sh Grafana-Sub-Pfad + interne URLs sub-path-bewusst 2026-07-21 07:19:42 +02:00
df5b29d8ff feat: uvicorn Proxy-Headers fuer Reverse-Proxy-Betrieb 2026-07-21 07:14:53 +02:00
a575c57d5b feat: Grafana-Links aus FB_GRAFANA_PUBLIC_URL (Sub-Pfad-tauglich) 2026-07-21 07:11:27 +02:00
52f527a375 feat: Session-Cookie secure-Flag per FB_SESSION_COOKIE_SECURE 2026-07-21 07:06:42 +02:00
fa2d32aa8f fix: config-Kommentar auf neutrale Beispiel-Domain (fb.example.de) 2026-07-21 07:04:47 +02:00
e061796997 feat: FB_GRAFANA_PUBLIC_URL + FB_SESSION_COOKIE_SECURE in Settings 2026-07-20 22:56:26 +02:00
7a8e63dc76 docs: Plan Ausbaustufe 10 (Reverse-Proxy-Tauglichkeit) 2026-07-20 22:54:59 +02:00
20 changed files with 1331 additions and 10 deletions

View File

@@ -131,7 +131,15 @@ App muss sie **in-place** überschreiben (`open` im Modus `r+`, `flock`,
`truncate` — siehe `services/admin.py::_rewrite_env_file`), niemals über
Temp-Datei+`rename` (neuer Inode, vom laufenden Mount nicht mehr gesehen).
Host-seitige `sed`-Edits in `create_pod_finance.sh` selbst sind unkritisch,
weil das Skript die Container ohnehin bei jedem Lauf neu erstellt.
weil das Skript die Container ohnehin bei jedem Lauf neu erstellt. Das Tool ist
**produktiv über den Reverse-Proxy erreichbar** unter
`https://fbwl.creature-go.com/` (App) + `/grafana/` (Grafana-Unterpfad);
aktiviert rein über die zwei optionalen `.env`-Variablen
(`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`), leer ⇒ Lokalbetrieb.
Kette sv005 (Apache/TLS) → WireGuard → sv006 Traefik → Pod, **vollständig
dokumentiert in `docs/reverse-proxy.md`** (dort auch die Regel: Traefik-Backends
`10.0.2.2:PORT`, nicht `127.0.0.1`). Achtung: mit `FB_SESSION_COOKIE_SECURE=true`
geht Browser-Login nur noch über die HTTPS-Domain (curl-Tests weiter ok).
**Disaster Recovery = `BIND_DIR`-Backup + Repo + Skript.** Für vollständige
Wiederherstellung werden **beide** gebraucht: ein Backup von

View File

@@ -211,6 +211,29 @@ podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;"
echo "Role 'finance_read' is ready."
# --- Reverse-Proxy-Betrieb (optional) ----------------------------------------
# FB_GRAFANA_PUBLIC_URL wird nur gesetzt, wenn das Tool hinter einem Reverse
# Proxy unter einem Sub-Pfad laufen soll (z.B.
# 'https://fb.example.de/grafana/'). Ist sie leer (Default,
# Direktbetrieb auf 127.0.0.1), bleibt alles wie bisher: Grafana serviert an
# der Wurzel, Health-Check und Passwort-Sync sprechen /api/... ohne Praefix.
# Muss VOR dem API-Container berechnet werden, da FB_GRAFANA_URL (unten) den
# Praefix schon dort braucht - nicht erst vor dem Grafana-Container.
GF_SUBPATH=''
GRAFANA_SUBPATH_ARGS=()
if [ -n "${FB_GRAFANA_PUBLIC_URL:-}" ]; then
# Pfadanteil der oeffentlichen URL extrahieren, Slash(es) am Ende entfernen:
# 'https://host/grafana/' -> '/grafana'. Dieser Praefix wird sowohl fuer den
# internen Health-Check als auch fuer die interne Grafana-API-URL
# (FB_GRAFANA_URL, Passwort-Sync in der GUI) gebraucht, weil
# serve_from_sub_path ALLE Grafana-Routen unter den Sub-Pfad haengt.
GF_SUBPATH=$(printf '%s' "$FB_GRAFANA_PUBLIC_URL" | sed -E 's#^[a-z]+://[^/]+##; s#/+$##')
GRAFANA_SUBPATH_ARGS=(
-e "GF_SERVER_ROOT_URL=$FB_GRAFANA_PUBLIC_URL"
-e "GF_SERVER_SERVE_FROM_SUB_PATH=true"
)
fi
# API container (runs alembic upgrade head on start via entrypoint.sh)
# The extra "-v $ENV_FILE:/data/.env:Z" below (Ausbaustufe 4 Task 2, Admin
# password change) is a SINGLE-FILE bind mount. Unlike a directory mount,
@@ -226,6 +249,9 @@ podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \
-e FB_GUI_PASSWORD_HASH \
-e FB_INBOX_DIR=/data/inbox \
-e FB_UPLOADS_DIR=/data/uploads \
-e FB_GRAFANA_PUBLIC_URL="${FB_GRAFANA_PUBLIC_URL:-}" \
-e FB_SESSION_COOKIE_SECURE="${FB_SESSION_COOKIE_SECURE:-}" \
-e FB_GRAFANA_URL="http://localhost:3000${GF_SUBPATH}" \
-v "$DATA_DIR:/data:Z" \
-v "$ENV_FILE:/data/.env:Z" \
"$API_IMAGE"
@@ -240,6 +266,7 @@ podman run -d --name "$GRAFANA_CTR_NAME" --pod "$POD_NAME" \
-e GF_SECURITY_ADMIN_PASSWORD="$FB_PASSWORD" \
-e GF_SECURITY_ALLOW_EMBEDDING=true \
-e GF_SECURITY_COOKIE_SAMESITE=lax \
"${GRAFANA_SUBPATH_ARGS[@]}" \
-e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \
-e FINANCE_READ_PASSWORD \
-v "$GRAFANA_PROVISIONING_DIR:/etc/grafana/provisioning:Z,ro" \
@@ -326,7 +353,7 @@ echo "To view logs: journalctl --user -u pod-${POD_NAME}.service -f"
# Wait for API and Grafana readiness
CHECK_URL_API="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT${GF_SUBPATH}/api/health"
for attempt in $(seq 1 30); do
API_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_API" || true)
GRAFANA_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_GRAFANA" || true)

View File

@@ -196,6 +196,11 @@ sequenceDiagram
## 3. Deployment-Diagramm
> Reverse-Proxy-Betrieb (eine Subdomain, Grafana als `/grafana/`-Unterpfad):
> siehe **`docs/reverse-proxy.md`** (Ausbaustufe 10). Aktivierung rein über
> `.env` (`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`); leer = lokaler
> Direktbetrieb unverändert.
```mermaid
flowchart TB
subgraph HOST["Host wlfb (rootless Podman)"]

258
docs/reverse-proxy.md Normal file
View File

@@ -0,0 +1,258 @@
# Reverse-Proxy-Betrieb (Ausbaustufe 10) — LIVE
Das Finanzberatungs-Tool ist öffentlich erreichbar unter
**`https://fbwl.creature-go.com/`** (App/GUI) mit **Grafana als Unterpfad
`https://fbwl.creature-go.com/grafana/`**. Aktiviert wird das ausschließlich
über zwei `.env`-Schalter auf sv006; ohne sie läuft der unveränderte
Lokalbetrieb auf `127.0.0.1` weiter (per Live-Redeploy + Sub-Pfad-Smoke belegt).
Die Proxy-Konfiguration (sv005 Apache, sv006 Traefik) liegt außerhalb dieses
Repos — dieses Dokument hält den realen Stand fest, damit Änderungen am Tool
die Kette nicht brechen.
## Kette (Datenfluss)
```
Browser ──HTTPS──► sv005 (DesTEngSsv005)
Virtualmin/Apache, TLS-Terminierung, WireGuard-IP 10.8.0.1
ServerName fbwl.creature-go.com ──ProxyPass──► http://10.8.0.6:8080
──WireGuard (wg0)──►
sv006 (DesTEngSsv006 — DIESER Rechner)
Traefik: rootless-Podman-Pod "traefik_pod" (User trf),
Entrypoint "wghttp" published auf 10.8.0.6:8080
Router Host(fbwl.creature-go.com) ──► http://10.0.2.2:8096 (App/API)
Router Host(fbwl.creature-go.com) && /grafana ──► http://10.0.2.2:8097 (Grafana)
finance_pod: 127.0.0.1:8096 (App), 127.0.0.1:8097 (Grafana)
```
**Warum `10.0.2.2`?** Traefik läuft in einem *rootless*-Podman-Pod mit
`slirp4netns:allow_host_loopback=true`. Aus Sicht des Traefik-Containers ist
`127.0.0.1` der **Container selbst**; die auf dem Host an `127.0.0.1:8096/8097`
veröffentlichten Pod-Ports erreicht er über die slirp4netns-Host-Loopback-Adresse
**`10.0.2.2`**. Backend-URLs im Traefik-Router deshalb IMMER `10.0.2.2:PORT`,
niemals `127.0.0.1:PORT`.
**Warum funktioniert `X-Forwarded-Proto` sauber?** sv005 hat die WireGuard-IP
`10.8.0.1`, und Traefiks Entrypoint `wghttp` vertraut per
`forwardedHeaders.trustedIPs: 10.8.0.1/32` genau dieser Quelle. Das von sv005
gesetzte `X-Forwarded-Proto: https` wird also übernommen und (via uvicorn
`--proxy-headers`) bis zur App durchgereicht.
## Teil 1 — finance_pod (`.env` auf sv006)
In `~/.local/share/finance_pod/.env` (alle Werte **single-quoted**):
```sh
FB_GRAFANA_PUBLIC_URL='https://fbwl.creature-go.com/grafana/'
FB_SESSION_COOKIE_SECURE='true'
```
Übernehmen: `./stop_finance_pod.sh``.env` editieren → `./create_pod_finance.sh`.
Wirkung von `FB_GRAFANA_PUBLIC_URL` (leer ⇒ Lokalbetrieb, Grafana-Fallback
`http://<host>:8097`, Grafana an der Wurzel):
- `create_pod_finance.sh` startet Grafana mit `GF_SERVER_ROOT_URL` +
`GF_SERVER_SERVE_FROM_SUB_PATH=true`;
- der **Pfadanteil** der URL (`/grafana`) wird zum Präfix für den internen
Grafana-Health-Check UND für die interne `FB_GRAFANA_URL`
(`http://localhost:3000/grafana`, für die GUI-Passwortänderung) — beides in
`create_pod_finance.sh` automatisch abgeleitet;
- alle GUI-Links/iframes bilden sich aus dieser URL (Jinja-Global
`grafana_public_base`, siehe `app/routers/gui.py`).
**Achtung Secure-Cookie:** Mit `FB_SESSION_COOKIE_SECURE='true'` akzeptiert ein
**Browser** das Session-Cookie nur über HTTPS — ein direkter Login über
`http://127.0.0.1:8096` funktioniert dann nicht mehr, nur noch über die
HTTPS-Domain. Automatisierte Tests mit `curl` (`-c/-b`) laufen weiter, weil curl
das Secure-Flag ignoriert (bewährt bei Redeploy-Smoke-Tests).
## Teil 2 — Traefik-Router (sv006, verwaltet als User `trf`)
Traefik ist ein **geteilter** rootless-Podman-Pod `traefik_pod` (User `trf`,
Config unter `/home/trf/.local/share/traefik_pod/`; als `wlfb` NICHT lesbar).
Statik: Entrypoint `wghttp` auf `:8080` (published `10.8.0.6:8080`),
`forwardedHeaders.trustedIPs: 10.8.0.1/32`; File-Provider `dynamic/` mit
`watch: true` (Änderungen laden automatisch, kein Neustart, andere Dienste
unberührt).
**Wichtig — Black Hole:** Es gibt einen Catch-all-Router
(`HostRegexp('{any:.*}')`, `priority: 1`, Middleware `ipAllowList 127.0.0.1/32`),
der jeden **nicht explizit gerouteten** Host sofort mit **403** abweist. Jeder
öffentliche Dienst braucht daher einen eigenen Router mit
`entryPoints: ["wghttp"]` und `priority > 1`.
Router-Datei `dynamic/fbwl.yml`:
```yaml
http:
routers:
finance-grafana:
rule: "Host(`fbwl.creature-go.com`) && PathPrefix(`/grafana`)"
priority: 120 # ueber dem Black Hole (priority 1)
service: finance-grafana
entryPoints: ["wghttp"]
finance-app:
rule: "Host(`fbwl.creature-go.com`)"
priority: 110
service: finance-app
entryPoints: ["wghttp"]
services:
finance-grafana:
loadBalancer:
servers:
- url: "http://10.0.2.2:8097" # slirp4netns-Host-Loopback, NICHT 127.0.0.1
finance-app:
loadBalancer:
servers:
- url: "http://10.0.2.2:8096"
```
## Teil 3 — sv005 Apache (Virtualmin, Domain fbwl.creature-go.com)
Virtualmin → Server `fbwl.creature-go.com` → „Edit Directives" (bearbeitet
`/etc/apache2/sites-available/fbwl.creature-go.com.conf`). Aufgebaut nach dem
Muster des bestehenden `affine.creature-go.com`-vhosts: `/.well-known` lokal
(ACME/Let's-Encrypt-Erneuerung), HTTP→HTTPS-Redirect, dann Weiterleitung der
ganzen Domain an Traefik (`10.8.0.6:8080`) mit erhaltenem Host-Header. TLS-Cert
verwaltet Virtualmin/Let's Encrypt.
### Port 80
```apache
SuexecUserGroup #1015 #1011
ServerName fbwl.creature-go.com
ServerAlias www.fbwl.creature-go.com
ServerAlias mail.fbwl.creature-go.com
ServerAlias webmail.fbwl.creature-go.com
ServerAlias admin.fbwl.creature-go.com
DocumentRoot /home/fbwl/public_html
ErrorLog /var/log/virtualmin/fbwl.creature-go.com_error_log
CustomLog /var/log/virtualmin/fbwl.creature-go.com_access_log combined
ScriptAlias /cgi-bin/ /home/fbwl/cgi-bin/
DirectoryIndex index.php index.htm index.html
<Directory /home/fbwl/public_html>
Options -Indexes +IncludesNOEXEC +SymLinksIfOwnerMatch +ExecCGI
Require all granted
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
AddHandler fcgid-script .php
AddHandler fcgid-script .php8.4
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php8.4
</Directory>
<Directory /home/fbwl/cgi-bin>
Require all granted
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
</Directory>
ProxyPass /.well-known !
RewriteEngine on
RewriteCond %{HTTP_HOST} =webmail.fbwl.creature-go.com
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:20000/ [R=301,L]
RewriteCond %{HTTP_HOST} =admin.fbwl.creature-go.com
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:10000/ [R=301,L]
# --- Finanzberatungs-Tool: HTTP -> HTTPS (ausser ACME-Challenge) ---
RewriteCond %{HTTPS} off
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
RemoveHandler .php
RemoveHandler .php8.4
FcgidMaxRequestLen 1073741824
```
### Port 443
```apache
SuexecUserGroup #1015 #1011
ServerName fbwl.creature-go.com
ServerAlias www.fbwl.creature-go.com
ServerAlias mail.fbwl.creature-go.com
ServerAlias webmail.fbwl.creature-go.com
ServerAlias admin.fbwl.creature-go.com
DocumentRoot /home/fbwl/public_html
ErrorLog /var/log/virtualmin/fbwl.creature-go.com_error_log
CustomLog /var/log/virtualmin/fbwl.creature-go.com_access_log combined
ScriptAlias /cgi-bin/ /home/fbwl/cgi-bin/
DirectoryIndex index.php index.htm index.html
<Directory /home/fbwl/public_html>
Options -Indexes +IncludesNOEXEC +SymLinksIfOwnerMatch +ExecCGI
Require all granted
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
AddHandler fcgid-script .php
AddHandler fcgid-script .php8.4
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php
FCGIWrapper /home/fbwl/fcgi-bin/php8.4.fcgi .php8.4
</Directory>
<Directory /home/fbwl/cgi-bin>
Require all granted
AllowOverride All Options=ExecCGI,Includes,IncludesNOEXEC,Indexes,MultiViews,SymLinksIfOwnerMatch
</Directory>
ProxyPass /.well-known !
RewriteEngine on
RewriteCond %{HTTP_HOST} =webmail.fbwl.creature-go.com
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:20000/ [R=301,L]
RewriteCond %{HTTP_HOST} =admin.fbwl.creature-go.com
RewriteRule ^/(?!\.well-known)(.*)$ https://fbwl.creature-go.com:10000/ [R=301,L]
# --- Finanzberatungs-Tool: Weiterleitung an Traefik (sv006, wghttp) ---
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
# WebSocket (Grafana Live) an Traefik durchreichen
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule .* ws://10.8.0.6:8080%{REQUEST_URI} [P,L]
# Haupt-Weiterleitung (Traefik trennt App vs. /grafana selbst)
ProxyPass / http://10.8.0.6:8080/
ProxyPassReverse / http://10.8.0.6:8080/
RemoveHandler .php
RemoveHandler .php8.4
FcgidMaxRequestLen 1073741824
SSLEngine on
SSLCertificateFile /etc/ssl/virtualmin/17848735353695059/ssl.cert
SSLCertificateKeyFile /etc/ssl/virtualmin/17848735353695059/ssl.key
SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
SSLCACertificateFile /etc/ssl/virtualmin/17848735353695059/ssl.ca
```
Hinweis: `www.`/`mail.fbwl…` gehen (wie bei affine) über den Proxy an Traefik
und landen dort mangels Router im Black Hole (403) — fürs Tool irrelevant.
## Cutover-Reihenfolge (falls die Kette neu aufgebaut wird)
1. DNS `fbwl.creature-go.com` → sv005; Cert via Virtualmin/Let's Encrypt.
2. Traefik-Router (Teil 2) ablegen — `watch` lädt automatisch.
3. sv005 Apache (Teil 3) eintragen — Apache reload.
4. Testen (unten), solange Tool noch im Lokalmodus (`/grafana` kommt erst mit Schritt 5).
5. `.env`-Cutover (Teil 1) + Redeploy — **`FB_SESSION_COOKIE_SECURE='true'` zuletzt**.
## Verifikation
```bash
# Von aussen:
curl -sI https://fbwl.creature-go.com/login | head -1 # -> 200
curl -sI https://fbwl.creature-go.com/grafana/api/health | head -1 # -> 200
curl -I http://fbwl.creature-go.com/ 2>&1 | head -3 # -> 301 auf https
# Auf sv006 (Kette bis Traefik, Host-Header simuliert):
for p in /login /grafana/api/health; do
curl -sS --max-time 5 -o /dev/null -w "$p -> %{http_code}\n" \
-H 'Host: fbwl.creature-go.com' "http://10.8.0.6:8080$p"
done
```
Der Grafana-iframe erfordert eine Grafana-Session (Embedding an, anonym aus): in
der GUI einmal „Grafana anmelden" (→ `…/grafana`) mit dem gemeinsamen Passwort.
## Für künftige Claude-Sessions — was das für Änderungen am Tool bedeutet
- **Domain/Links des Tools hängen ausschließlich an `FB_GRAFANA_PUBLIC_URL`**
(+ Templates via `grafana_public_base`). Kein Hostname im Code/Image — ein
Domainwechsel ist eine reine `.env`-Änderung + Redeploy (plus je ein Eintrag
in Traefik-Router und sv005-Apache).
- **Neuer öffentlicher Pfad/Dienst hinter derselben Domain**: Traefik-Router in
`dynamic/` mit `entryPoints: ["wghttp"]`, `priority > 1`, Backend
`http://10.0.2.2:PORT`; an sv005 meist nichts nötig, weil `ProxyPass /` die
ganze Domain weiterreicht und Traefik nach Pfad trennt.
- **Neuer Port am Pod**: in `create_pod_finance.sh` als `127.0.0.1:PORT`
veröffentlichen → Traefik erreicht ihn als `10.0.2.2:PORT`.
- **Grafana-Sub-Pfad**: `GF_SERVER_ROOT_URL` + `serve_from_sub_path` und die
präfixierten internen URLs werden in `create_pod_finance.sh` aus
`FB_GRAFANA_PUBLIC_URL` abgeleitet — den Pfad (`/grafana`) nicht ohne Grund
ändern; er steckt auch im Health-Check und in `FB_GRAFANA_URL`.
- **Lokaler Test trotz Live-Proxy**: `.env`-Schalter leeren (echter Lokalmodus)
ODER mit `curl` testen; ein echter Browser braucht die HTTPS-Domain, weil das
Session-Cookie `Secure` ist.
- **Traefik ist geteilt** (User `trf`): dessen Config ist als `wlfb` nicht
editierbar. Router-/Entrypoint-Änderungen müssen als `trf` erfolgen; den
Black-Hole-Router und fremde Dienste nicht anfassen.

View File

@@ -0,0 +1,776 @@
# Ausbaustufe 10: Reverse-Proxy-Tauglichkeit — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Das Finanzberatungs-Tool wird über genau eine Subdomain
`https://fb.wolfundlaemmlein.de/` erreichbar, mit Grafana als Unterpfad
`/grafana/` — ohne dass der Lokalbetrieb (Direktzugriff auf `127.0.0.1:8096`/
`:8097`) sich ändert.
**Architecture:** Alle proxy-spezifischen Werte kommen ausschließlich aus der
`.env` (kein Hostname in Image/Repo). Ist `FB_GRAFANA_PUBLIC_URL` leer, läuft
alles exakt wie in v0.9.0. Ist sie gesetzt, (a) betreibt `create_pod_finance.sh`
Grafana nativ unter dem Sub-Pfad (`GF_SERVER_ROOT_URL` + `serve_from_sub_path`)
und passt internen Health-Check + interne Grafana-API-URL um den `/grafana`-
Präfix an, (b) bilden die Templates alle Grafana-Links/iframes aus der
öffentlichen URL statt aus `<host>:8097`, (c) setzt der Login das
Session-Cookie mit `secure`-Flag, (d) vertraut uvicorn den Proxy-Headern
(`X-Forwarded-Proto`/`-Host`). Die eigentliche Traefik-/Apache-Konfiguration
liegt außerhalb dieses Repos; docs/ liefert nur Beispiel-Snippets.
**Tech Stack:** FastAPI/Starlette, Jinja2, uvicorn, Podman (rootless Pod,
systemd `--user`), Grafana OSS 12.1.0, Bash-Deployskript.
## Global Constraints
- **Lokalbetrieb bleibt bit-identisch**, solange `FB_GRAFANA_PUBLIC_URL` leer
ist. Jede Änderung MUSS diesen Fall als No-Op behandeln.
- **Kein Hostname/keine Domain als Funktionswert ins Repo oder Image.**
Proxy-Werte nur via `.env` (`FB_GRAFANA_PUBLIC_URL`,
`FB_SESSION_COOKIE_SECURE`). **Quellcode inkl. Kommentaren und Tests bleibt
domain-neutral** — nur die Beispiel-Domain `fb.example.de`, niemals die echte
Domain. **Ausnahme: die Deployment-Doku `docs/reverse-proxy.md`** beschreibt
genau diese Installation und darf die echte Domain
`fb.wolfundlaemmlein.de` nennen (öffentlicher DNS-Name, keine Kontodaten).
(Nutzerentscheidung 2026-07-21: Code neutral, Doku echt.)
- **`.env`-Werte single-quoted** (`KEY='wert'`) — `create_pod_finance.sh`
sourced per `set -a; . "$ENV_FILE"`.
- **Zwei getrennte Grafana-URLs:** `FB_GRAFANA_URL` (intern, Server→Grafana,
Passwort-Sync) ≠ `FB_GRAFANA_PUBLIC_URL` (öffentlich, Browser-Links). Nie
vermischen.
- **Geld = `decimal.Decimal`** (hier nicht berührt, gilt generell).
- **Deutsch** für GUI-Texte, Kommentare, Commit-Messages. Datumsformat
TT.MM.JJJJ.
- **Test-Suite bleibt grün, Basis 199 Tests** (`cd finance && .venv/bin/python
-m pytest -q`). Jede neue Funktion bekommt Tests → Endzahl > 199.
- **Fable-Testagent-Gate je Task vor dem Commit** (Nutzer-Vorgabe, CLAUDE.md).
- **Release am Ende als v0.10.0** (`finance/VERSION`), Redeploy, Live-Check
Lokalbetrieb unverändert.
---
### Task 1: Config — neue Settings `grafana_public_url` + `session_cookie_secure`
**Files:**
- Modify: `finance/app/config.py`
- Test: `finance/tests/test_config.py`
**Interfaces:**
- Produces: `Settings.grafana_public_url: str` (aus `FB_GRAFANA_PUBLIC_URL`,
Default `""`), `Settings.session_cookie_secure: bool` (aus
`FB_SESSION_COOKIE_SECURE`, Default `False`). Bool-Parsing via Modul-Helfer
`_env_bool(value: str) -> bool` (truthy: `1/true/yes/on`, case-insensitiv).
- [x] **Step 1: Failing Tests schreiben** — an `finance/tests/test_config.py`
anhängen (oben `import pytest` ergänzen, falls nicht vorhanden):
```python
def test_grafana_public_url_default(monkeypatch):
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
get_settings.cache_clear()
try:
assert get_settings().grafana_public_url == ""
finally:
get_settings.cache_clear()
def test_grafana_public_url_from_env(monkeypatch):
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
get_settings.cache_clear()
try:
assert get_settings().grafana_public_url == "https://fb.example.de/grafana/"
finally:
get_settings.cache_clear()
def test_session_cookie_secure_default_false(monkeypatch):
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
get_settings.cache_clear()
try:
assert get_settings().session_cookie_secure is False
finally:
get_settings.cache_clear()
@pytest.mark.parametrize("val,expected", [
("true", True), ("True", True), ("1", True), ("yes", True), ("on", True),
("false", False), ("0", False), ("", False), ("nope", False),
])
def test_session_cookie_secure_parsing(monkeypatch, val, expected):
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", val)
get_settings.cache_clear()
try:
assert get_settings().session_cookie_secure is expected
finally:
get_settings.cache_clear()
```
- [x] **Step 2: Tests laufen lassen, Fehlschlag prüfen**
Run: `cd finance && .venv/bin/python -m pytest tests/test_config.py -q`
Expected: FAIL (`AttributeError: ... 'grafana_public_url'` / `session_cookie_secure`).
- [x] **Step 3: Implementieren** — `finance/app/config.py`. Bool-Helfer vor
`get_settings` einfügen, zwei Felder ins `@dataclass Settings` und zwei
Zeilen in den `Settings(...)`-Konstruktor:
```python
def _env_bool(value: str) -> bool:
"""Interpretiert einen Env-Wert als Wahrheitswert. Truthy sind (case-
insensitiv) 1/true/yes/on; alles andere (inkl. leer) ist False."""
return value.strip().lower() in ("1", "true", "yes", "on")
```
Ins `Settings`-Dataclass (nach `grafana_url: str`) ergänzen:
```python
grafana_public_url: str
session_cookie_secure: bool
```
In `get_settings()` in den `return Settings(...)`-Aufruf (nach
`grafana_url=...`) ergänzen:
```python
# Öffentliche Grafana-Basis-URL für Browser-Links/iframes hinter einem
# Reverse Proxy (z.B. 'https://fb.wolfundlaemmlein.de/grafana/'). Leer =
# Direktbetrieb, Templates fallen auf http://<host>:8097 zurück. NICHT
# zu verwechseln mit grafana_url (intern, Server→Grafana).
grafana_public_url=e("FB_GRAFANA_PUBLIC_URL", ""),
# Session-Cookie mit secure-Flag ausliefern (nur über HTTPS gültig).
# Für den Reverse-Proxy-Betrieb; Default False für lokalen HTTP-Zugriff.
session_cookie_secure=_env_bool(e("FB_SESSION_COOKIE_SECURE", "")),
```
- [x] **Step 4: Tests grün**
Run: `cd finance && .venv/bin/python -m pytest tests/test_config.py -q`
Expected: PASS.
- [x] **Step 5: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS (> 199).
- [x] **Step 6: Fable-Gate + Commit**
```bash
git add finance/app/config.py finance/tests/test_config.py
git commit -m "feat: FB_GRAFANA_PUBLIC_URL + FB_SESSION_COOKIE_SECURE in Settings"
```
---
### Task 2: Session-Cookie `secure`-Flag im Login setzen
**Files:**
- Modify: `finance/app/main.py:44-53` (Login-Handler)
- Test: `finance/tests/test_auth.py`
**Interfaces:**
- Consumes: `Settings.session_cookie_secure` (Task 1).
- Löst die OFFENE ENTSCHEIDUNG aus Ausbaustufe 2 (Session-Cookie ohne
`secure`) auf: jetzt per Env konfigurierbar, Default unverändert (kein
`secure` im Lokalbetrieb).
- [x] **Step 1: Failing Tests** — an `finance/tests/test_auth.py` anhängen.
Sicherstellen, dass oben importiert ist: `from app.config import get_settings`.
Die Tests nutzen die vorhandene `client`-Fixture (setzt Passwort-Hash) und
schalten das Flag zur Laufzeit um (Login liest `get_settings()` je Request):
```python
def test_login_cookie_secure_when_enabled(client, monkeypatch):
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", "true")
get_settings.cache_clear()
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert "secure" in r.headers["set-cookie"].lower()
def test_login_cookie_not_secure_by_default(client, monkeypatch):
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
get_settings.cache_clear()
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert "secure" not in r.headers["set-cookie"].lower()
```
- [x] **Step 2: Fehlschlag prüfen**
Run: `cd finance && .venv/bin/python -m pytest tests/test_auth.py::test_login_cookie_secure_when_enabled -q`
Expected: FAIL (`assert "secure" in ...`, Cookie hat noch kein secure-Flag).
- [x] **Step 3: Implementieren** — in `finance/app/main.py` den
`set_cookie`-Aufruf im Login um `secure=` ergänzen:
```python
@app.post("/login")
def login(username: str = Form(...), password: str = Form(...)):
s = get_settings()
if not (hmac.compare_digest(username, s.gui_user)
and auth.verify_password(password, auth.current_password_hash())):
return HTMLResponse("Login fehlgeschlagen", status_code=401)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
max_age=auth.MAX_AGE, samesite="lax",
secure=s.session_cookie_secure)
return resp
```
- [x] **Step 4: Tests grün**
Run: `cd finance && .venv/bin/python -m pytest tests/test_auth.py -q`
Expected: PASS.
- [x] **Step 5: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS.
- [x] **Step 6: Fable-Gate + Commit**
```bash
git add finance/app/main.py finance/tests/test_auth.py
git commit -m "feat: Session-Cookie secure-Flag per FB_SESSION_COOKIE_SECURE"
```
---
### Task 3: Grafana-Links über `FB_GRAFANA_PUBLIC_URL` (Jinja-Global + Templates)
**Files:**
- Modify: `finance/app/routers/gui.py` (Import + Jinja-Global)
- Modify: `finance/app/templates/base.html:20`
- Modify: `finance/app/templates/index.html:80,82`
- Modify: `finance/app/templates/szenarien.html:191`
- Test: `finance/tests/test_gui.py`
**Interfaces:**
- Consumes: `Settings.grafana_public_url` (Task 1).
- Produces: Jinja-Global `grafana_public_base(request) -> str`. Rückgabe: bei
gesetzter `FB_GRAFANA_PUBLIC_URL` diese ohne abschließende Slashes; sonst
`http://<request-host>:8097`. Templates hängen bei Bedarf `/d/finanzen/...`
an — mit sub-path-URL ergibt das `.../grafana/d/finanzen/...`.
- [x] **Step 1: Failing Tests** — an `finance/tests/test_gui.py` anhängen. Oben
`from app.config import get_settings` ergänzen, falls nicht vorhanden:
```python
def test_grafana_links_use_public_url_when_set(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
# iframe + Anmelde-Link nutzen die öffentliche URL, kein :8097 mehr.
assert "https://fb.example.de/grafana/d/finanzen/finanzen" in r.text
assert ":8097" not in r.text
# Nav-Link im base-Template ebenfalls.
assert 'href="https://fb.example.de/grafana"' in r.text
finally:
get_settings.cache_clear()
def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
assert ":8097/d/finanzen/finanzen" in r.text
assert "fb.example.de" not in r.text
finally:
get_settings.cache_clear()
```
- [x] **Step 2: Fehlschlag prüfen**
Run: `cd finance && .venv/bin/python -m pytest tests/test_gui.py::test_grafana_links_use_public_url_when_set -q`
Expected: FAIL (Templates nutzen noch `<host>:8097`, `:8097` ist im Text).
- [x] **Step 3a: Jinja-Global implementieren** — `finance/app/routers/gui.py`.
Import ergänzen (bei den `from app...`-Imports):
```python
from app.config import get_settings
```
Nach dem Block, der `templates` konfiguriert (`templates.env.globals[...]`),
einfügen:
```python
def grafana_public_base(request: Request) -> str:
"""Öffentliche Grafana-Basis-URL für Browser-Links/iframes. Ist
FB_GRAFANA_PUBLIC_URL gesetzt (Reverse-Proxy-Betrieb, z.B.
'https://fb.example.de/grafana/'), wird sie ohne abschließenden
Slash zurückgegeben; sonst der lokale Fallback http://<host>:8097
(Direktbetrieb ohne Proxy). Templates hängen bei Bedarf '/d/...' an."""
public = get_settings().grafana_public_url
if public:
return public.rstrip("/")
host = request.url.hostname or "127.0.0.1"
return f"http://{host}:8097"
templates.env.globals["grafana_public_base"] = grafana_public_base
```
- [x] **Step 3b: Templates umstellen.**
`finance/app/templates/base.html` Zeile 20:
```html
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
```
`finance/app/templates/index.html` Zeilen 80 und 82:
```html
<iframe class="grafana" src="{{ grafana_public_base(request) }}/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
```
```html
<a href="{{ grafana_public_base(request) }}" target="_blank">Grafana anmelden</a>
```
`finance/app/templates/szenarien.html` Zeile 191:
```html
Kurven in <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a> ansehen.
```
- [x] **Step 4: Tests grün**
Run: `cd finance && .venv/bin/python -m pytest tests/test_gui.py -q`
Expected: PASS.
- [x] **Step 5: Kontrollgriff — keine hartkodierten `:8097` mehr in Templates**
Run: `grep -rn ":8097" finance/app/templates`
Expected: keine Treffer.
- [x] **Step 6: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS.
- [x] **Step 7: Fable-Gate + Commit**
```bash
git add finance/app/routers/gui.py finance/app/templates/base.html \
finance/app/templates/index.html finance/app/templates/szenarien.html \
finance/tests/test_gui.py
git commit -m "feat: Grafana-Links aus FB_GRAFANA_PUBLIC_URL (Sub-Pfad-tauglich)"
```
---
### Task 4: uvicorn Proxy-Headers im Entrypoint
**Files:**
- Modify: `finance/entrypoint.sh`
- Test: `finance/tests/test_entrypoint.py` (Create)
**Interfaces:**
- Produces: uvicorn startet mit `--proxy-headers --forwarded-allow-ips='*'`,
damit Starlette hinter dem Proxy `X-Forwarded-Proto`/`-Host` auswertet
(korrektes https/Host in `request.url`). `*` ist vertretbar, weil der Pod
nur an `127.0.0.1` gebunden ist und ausschließlich der lokale Traefik ihn
erreicht (dokumentiert in docs/reverse-proxy.md, Task 6).
- [x] **Step 1: Failing Test** — `finance/tests/test_entrypoint.py` anlegen:
```python
from pathlib import Path
ENTRYPOINT = Path(__file__).resolve().parent.parent / "entrypoint.sh"
def test_entrypoint_enables_proxy_headers():
text = ENTRYPOINT.read_text()
assert "--proxy-headers" in text
assert "--forwarded-allow-ips" in text
```
- [x] **Step 2: Fehlschlag prüfen**
Run: `cd finance && .venv/bin/python -m pytest tests/test_entrypoint.py -q`
Expected: FAIL (Flags fehlen noch).
- [x] **Step 3: Implementieren** — `finance/entrypoint.sh` vollständig:
```sh
#!/bin/sh
set -e
alembic upgrade head
# --proxy-headers + --forwarded-allow-ips='*': hinter dem Reverse Proxy
# (Traefik→Apache/TLS) wertet uvicorn X-Forwarded-Proto/-Host aus, damit
# request.url das öffentliche https/Host statt des pod-internen http sieht.
# '*' ist vertretbar, weil der Pod nur an 127.0.0.1 gebunden ist und nur der
# lokale Traefik ihn erreicht (siehe docs/reverse-proxy.md). Im Direktbetrieb
# ohne Proxy sendet niemand X-Forwarded-*, also bleibt das Verhalten gleich.
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips='*'
```
- [x] **Step 4: Test grün + Shell-Syntaxcheck**
Run: `cd finance && .venv/bin/python -m pytest tests/test_entrypoint.py -q && sh -n entrypoint.sh && echo OK`
Expected: PASS + `OK`.
- [x] **Step 5: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS.
- [x] **Step 6: Fable-Gate + Commit**
```bash
git add finance/entrypoint.sh finance/tests/test_entrypoint.py
git commit -m "feat: uvicorn Proxy-Headers fuer Reverse-Proxy-Betrieb"
```
---
### Task 5: `create_pod_finance.sh` — Grafana Sub-Pfad + sub-path-bewusste interne URLs
**Files:**
- Modify: `create_pod_finance.sh` (Repo-Wurzel)
**Interfaces:**
- Consumes: `FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE` aus der `.env`
(via `set -a; . "$ENV_FILE"`). Beide sind NICHT Teil der generierten
Standard-`.env` — der Nutzer trägt sie beim Umzug hinter den Proxy manuell
(single-quoted) ein. Fehlen sie, ist der Lokalbetrieb unverändert.
- Produces: Im Sub-Pfad-Modus lauffähige Grafana-Instanz unter `/grafana/`,
interner Health-Check + interne Grafana-API-URL (`FB_GRAFANA_URL`) mit
`/grafana`-Präfix. Reines Shell-Skript ohne Unit-Test; Verifikation über
`bash -n`, isolierten Test der Sub-Pfad-Extraktion und den Live-Redeploy in
Task 7.
- [x] **Step 1: Sub-Pfad-Berechnung einfügen** — in `create_pod_finance.sh`
VOR dem **API**-`podman run` (`podman run -d --name "$API_CTR_NAME" ...`)
diesen Block einfügen. WICHTIG: nicht erst vor dem Grafana-Container — der
API-Container läuft im Skript zuerst und verwendet bereits `${GF_SUBPATH}`
(siehe Step 3), also muss der Block davor stehen, sonst wäre `FB_GRAFANA_URL`
im Sub-Pfad-Modus fälschlich präfixlos:
```bash
# --- Reverse-Proxy-Betrieb (optional) ----------------------------------------
# FB_GRAFANA_PUBLIC_URL wird nur gesetzt, wenn das Tool hinter einem Reverse
# Proxy unter einem Sub-Pfad laufen soll (z.B.
# 'https://fb.example.de/grafana/'). Ist sie leer (Default,
# Direktbetrieb auf 127.0.0.1), bleibt alles wie bisher: Grafana serviert an
# der Wurzel, Health-Check und Passwort-Sync sprechen /api/... ohne Praefix.
GF_SUBPATH=''
GRAFANA_SUBPATH_ARGS=()
if [ -n "${FB_GRAFANA_PUBLIC_URL:-}" ]; then
# Pfadanteil der oeffentlichen URL extrahieren, Slash(es) am Ende entfernen:
# 'https://host/grafana/' -> '/grafana'. Dieser Praefix wird sowohl fuer den
# internen Health-Check als auch fuer die interne Grafana-API-URL
# (FB_GRAFANA_URL, Passwort-Sync in der GUI) gebraucht, weil
# serve_from_sub_path ALLE Grafana-Routen unter den Sub-Pfad haengt.
GF_SUBPATH=$(printf '%s' "$FB_GRAFANA_PUBLIC_URL" | sed -E 's#^[a-z]+://[^/]+##; s#/+$##')
GRAFANA_SUBPATH_ARGS=(
-e "GF_SERVER_ROOT_URL=$FB_GRAFANA_PUBLIC_URL"
-e "GF_SERVER_SERVE_FROM_SUB_PATH=true"
)
fi
```
- [x] **Step 2: Grafana-Container um die Sub-Pfad-Args ergänzen** — im
`podman run -d --name "$GRAFANA_CTR_NAME" ...`-Aufruf eine Zeile
einfügen (z.B. direkt nach `-e GF_SECURITY_COOKIE_SAMESITE=lax \`):
```bash
"${GRAFANA_SUBPATH_ARGS[@]}" \
```
(Bei leerem Array expandiert das unter bash zu nichts — Lokalbetrieb
unverändert.)
- [x] **Step 3: API-Container um die drei Env-Durchreichungen ergänzen** — im
`podman run -d --name "$API_CTR_NAME" ...`-Aufruf nach der Zeile
`-e FB_UPLOADS_DIR=/data/uploads \` einfügen:
```bash
-e FB_GRAFANA_PUBLIC_URL="${FB_GRAFANA_PUBLIC_URL:-}" \
-e FB_SESSION_COOKIE_SECURE="${FB_SESSION_COOKIE_SECURE:-}" \
-e FB_GRAFANA_URL="http://localhost:3000${GF_SUBPATH}" \
```
(`FB_GRAFANA_URL` erhält im Lokalbetrieb `http://localhost:3000` — identisch
zum bisherigen Config-Default; im Sub-Pfad-Modus `http://localhost:3000/grafana`,
damit die GUI-Passwortänderung die Grafana-Admin-API unter dem Präfix trifft.)
- [x] **Step 4: Grafana-Health-Check-URL sub-path-bewusst machen** — die Zeile
```bash
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"
```
ersetzen durch:
```bash
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT${GF_SUBPATH}/api/health"
```
- [x] **Step 5: Statische Verifikation**
Run: `bash -n create_pod_finance.sh && echo SYNTAX-OK`
Expected: `SYNTAX-OK`.
Sub-Pfad-Extraktion isoliert prüfen (darf NICHT die echte Domain benutzen):
```bash
for u in 'https://fb.example.de/grafana/' 'https://fb.example.de/grafana' 'http://host.tld/g/' ''; do
printf '%s -> [%s]\n' "$u" "$(printf '%s' "$u" | sed -E 's#^[a-z]+://[^/]+##; s#/+$##')"
done
```
Expected:
```
https://fb.example.de/grafana/ -> [/grafana]
https://fb.example.de/grafana -> [/grafana]
http://host.tld/g/ -> [/g]
-> []
```
- [x] **Step 6: Fable-Gate (adversariale Skript-Review) + Commit**
Fable prüft insbesondere: Lokalbetrieb (leere Variable) ist echter No-Op
(leeres Array, leerer Präfix, `FB_GRAFANA_URL=http://localhost:3000`);
Sub-Pfad-Extraktion robust; keine Domain im Skript.
```bash
git add create_pod_finance.sh
git commit -m "feat: create_pod_finance.sh Grafana-Sub-Pfad + interne URLs sub-path-bewusst"
```
---
### Task 6: Doku — Reverse-Proxy-Snippets (Traefik + Apache) und Env-Variablen
**Files:**
- Create: `docs/reverse-proxy.md`
- Modify: `docs/ARCHITEKTUR.md` (Verweis im Deployment-Abschnitt)
- Modify: `CLAUDE.md` (zwei neue `.env`-Variablen in den Betriebsnotizen)
**Interfaces:**
- Produces: Betriebsdoku für den Proxy-Umzug. Beispiel-Snippets sind
Vorlagen — die eigentliche Proxy-Konfiguration macht der Nutzer.
- [x] **Step 1: `docs/reverse-proxy.md` anlegen** mit folgendem Inhalt:
````markdown
# Reverse-Proxy-Betrieb (Ausbaustufe 10)
Ziel: Erreichbarkeit über **eine** Subdomain
`https://fb.wolfundlaemmlein.de/`, Grafana als Unterpfad `/grafana/`.
Kette: Internet → **sv003** (Apache, TLS-Terminierung) → WireGuard-VPN →
**sv006** (Traefik, ohne TLS, `10.8.0.6:8080`) → finance_pod
(`127.0.0.1:8096` GUI/API, `127.0.0.1:8097` Grafana).
Die Proxy-Konfiguration selbst (Traefik, Apache) liegt **außerhalb** dieses
Repos — die folgenden Snippets sind Vorlagen. Tool-seitig genügt es, in der
`.env` unter `~/.local/share/finance_pod/.env` zwei Variablen zu setzen und
`./create_pod_finance.sh` erneut auszuführen.
## `.env`-Schalter (single-quoted!)
```sh
FB_GRAFANA_PUBLIC_URL='https://fb.wolfundlaemmlein.de/grafana/'
FB_SESSION_COOKIE_SECURE='true'
```
- `FB_GRAFANA_PUBLIC_URL` — öffentliche Grafana-Basis-URL (mit `/grafana/`,
abschließender Slash empfohlen). Bewirkt:
- `create_pod_finance.sh` startet Grafana mit `GF_SERVER_ROOT_URL` +
`GF_SERVER_SERVE_FROM_SUB_PATH=true`;
- interner Health-Check und interne Grafana-API-URL (`FB_GRAFANA_URL`, für
die GUI-Passwortänderung) erhalten automatisch den `/grafana`-Präfix;
- alle GUI-Links/iframes zeigen auf die öffentliche URL statt `<host>:8097`.
- **Leer lassen** = unveränderter Direktbetrieb auf `127.0.0.1`.
- `FB_SESSION_COOKIE_SECURE='true'` — Session-Cookie nur über HTTPS. Vor der
Internet-Freigabe setzen. Default (leer/`false`) für lokalen HTTP-Zugriff.
Nach dem Editieren: `./create_pod_finance.sh` (übernimmt die `.env`
unverändert, baut Pod/Container neu). HTTPS/HSTS und Login-Rate-Limiting sind
Proxy-Sache (Apache auf sv003 bzw. fail2ban) — außerhalb dieses Repos.
## Traefik (sv006) — Datei-Provider, Beispiel
Kein Prefix-Stripping für `/grafana` (Grafana serviert dank
`serve_from_sub_path` selbst unter dem Sub-Pfad):
```yaml
# /etc/traefik/dynamic/finance.yml
http:
routers:
finance-grafana:
rule: "Host(`fb.wolfundlaemmlein.de`) && PathPrefix(`/grafana`)"
priority: 20
service: finance-grafana
entryPoints: [web]
finance-app:
rule: "Host(`fb.wolfundlaemmlein.de`)"
priority: 10
service: finance-app
entryPoints: [web]
services:
finance-grafana:
loadBalancer:
servers:
- url: "http://127.0.0.1:8097"
finance-app:
loadBalancer:
servers:
- url: "http://127.0.0.1:8096"
```
## Apache (sv003) — VHost, Beispiel
TLS terminiert hier; Weiterleitung an Traefik über die WireGuard-IP. `X-
Forwarded-Proto https` ist wichtig, damit uvicorn (mit `--proxy-headers`) das
öffentliche Schema erkennt:
```apache
<VirtualHost *:443>
ServerName fb.wolfundlaemmlein.de
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/fb.wolfundlaemmlein.de/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/fb.wolfundlaemmlein.de/privkey.pem
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass / http://10.8.0.6:8080/
ProxyPassReverse / http://10.8.0.6:8080/
</VirtualHost>
```
## Zurück in den Lokalbetrieb
`FB_GRAFANA_PUBLIC_URL` und `FB_SESSION_COOKIE_SECURE` aus der `.env`
entfernen (oder leeren) und `./create_pod_finance.sh` erneut ausführen.
````
- [x] **Step 2: Verweis in `docs/ARCHITEKTUR.md`** — im Deployment-Abschnitt
(Abschnitt 3) einen Hinweis auf die neue Doku ergänzen, z.B.:
```markdown
> Reverse-Proxy-Betrieb (eine Subdomain, Grafana als `/grafana/`-Unterpfad):
> siehe **`docs/reverse-proxy.md`** (Ausbaustufe 10). Aktivierung rein über
> `.env` (`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`); leer = lokaler
> Direktbetrieb unverändert.
```
- [x] **Step 3: `CLAUDE.md`** — im Abschnitt zur `.env` die zwei neuen
Variablen kurz erwähnen (ein Satz), inkl. Verweis auf `docs/reverse-proxy.md`.
- [x] **Step 4: Fable-Gate (Faktencheck gegen Skript/Config/Templates) + Commit**
Fable prüft: Snippets stimmen mit dem tatsächlichen Verhalten aus Task 15
überein (Ports 8096/8097, kein Prefix-Stripping, `serve_from_sub_path`,
Env-Namen exakt).
```bash
git add docs/reverse-proxy.md docs/ARCHITEKTUR.md CLAUDE.md
git commit -m "docs: Reverse-Proxy-Snippets (Traefik/Apache) + .env-Schalter"
```
---
### Task 7: Release v0.10.0 — Version, Redeploy, Live-Check Lokalbetrieb
**Files:**
- Modify: `finance/VERSION` (0.9.0 → 0.10.0)
- Modify: `docs/superpowers/plans/2026-07-20-ausbaustufe-10.md` (Haken)
- Modify: `.superpowers/sdd/progress.md` (Ledger-Eintrag)
**Interfaces:**
- Consumes: alle vorherigen Tasks.
- Live-Verifikation, dass der **Lokalbetrieb** (leere Proxy-Variablen) mit
v0.10.0 unverändert funktioniert.
- [x] **Step 1: Version hochzählen**
`finance/VERSION`:
```
0.10.0
```
- [x] **Step 2: Volle Suite**
Run: `cd finance && .venv/bin/python -m pytest -q`
Expected: PASS (> 199).
- [x] **Step 3: Redeploy (Lokalbetrieb, `.env` OHNE Proxy-Variablen)**
Run: `./create_pod_finance.sh`
Expected: Läuft durch bis „API is reachable ... (200)" und „Grafana is
reachable ... (200)"; Service enabled+active. Der Health-Check spricht ohne
Präfix `:8097/api/health` (weil `FB_GRAFANA_PUBLIC_URL` leer).
- [x] **Step 4: Live-Check Lokalbetrieb unverändert**
```bash
# Login → 303 + Cookie (Cookie OHNE Secure, da FB_SESSION_COOKIE_SECURE leer)
curl -si -c /tmp/fb_cookies -X POST http://127.0.0.1:8096/login \
-d 'username=admin' -d "password=$FB_PASSWORD" | grep -i 'HTTP/\|set-cookie'
# Version-Endpoint
curl -s -b /tmp/fb_cookies http://127.0.0.1:8096/api/version
# Grafana-Fallback-Link im Dashboard zeigt :8097 (kein fb.* / kein /grafana)
curl -s -b /tmp/fb_cookies http://127.0.0.1:8096/ | grep -o 'http://[^"]*:8097[^"]*d/finanzen[^"]*' | head -1
# Seiten 200
for p in / /buchungen /salden /planung /szenarien /import /admin /hilfe; do
printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/fb_cookies http://127.0.0.1:8096$p)"
done
# Grafana direkt erreichbar (Wurzel, kein Sub-Pfad)
curl -s -o /dev/null -w 'grafana:%{http_code}\n' http://127.0.0.1:8097/api/health
rm -f /tmp/fb_cookies
```
Expected: Login `303` + `set-cookie: fb_session=...` **ohne** `Secure`;
`/api/version` → `{"version":"0.10.0"}`; Grafana-Link enthält `:8097/d/finanzen`
und **kein** `/grafana`; alle Seiten `200`; `grafana:200`.
- [x] **Step 5: Plan-Haken + Ledger** — alle Task-Checkboxen dieses Plans
setzen; in `.superpowers/sdd/progress.md` einen A10-Abschluss-Eintrag
ergänzen (Commit-Range, Fable-Befunde je Task, Live-Check-Ergebnis; **keine**
echten Kontodaten/Domain-Secrets).
- [x] **Step 6: Fable-Release-Gate + Commit**
Fable verifiziert: Suite grün, Version live `0.10.0`, Lokalbetrieb im
Live-Check unverändert (Cookie ohne Secure, Grafana-Fallback `:8097`),
Bestandsdaten (3 Konten / 1968 Buchungen) unberührt.
```bash
git add finance/VERSION docs/superpowers/plans/2026-07-20-ausbaustufe-10.md \
.superpowers/sdd/progress.md
git commit -m "chore: Release v0.10.0 (Reverse-Proxy-Tauglichkeit)"
```
---
## Self-Review (Controller)
**Spec-Coverage:**
1. Grafana Sub-Pfad (`GF_SERVER_ROOT_URL` + `serve_from_sub_path`) → Task 5 ✓
2. `FB_GRAFANA_PUBLIC_URL` in Templates, leer ⇒ `<host>:8097`-Fallback,
kein Hostname im Repo → Task 1 (Config) + Task 3 (Global/Templates) ✓
3. Härtung: secure-Cookie per Env (löst A2-Entscheidung) → Task 1+2;
uvicorn Proxy-Headers → Task 4 ✓
4. Doku-Snippets Traefik + Apache in docs/ → Task 6 ✓
- Zusätzlich abgedeckt (Folgewirkung von serve_from_sub_path): interner
Health-Check + interne Grafana-API-URL sub-path-bewusst → Task 5 ✓
- Release v0.10.0 + Live-Check Lokalbetrieb → Task 7 ✓
**Typ-/Namens-Konsistenz:** `grafana_public_url`/`session_cookie_secure`
(Task 1) → konsumiert in Task 2 (`s.session_cookie_secure`) und Task 3
(`grafana_public_base`). Env-Namen `FB_GRAFANA_PUBLIC_URL`,
`FB_SESSION_COOKIE_SECURE`, `FB_GRAFANA_URL` überall identisch. `GF_SUBPATH`
konsistent in Task 5.

View File

@@ -1 +1 @@
0.9.0
0.10.0

View File

@@ -5,6 +5,12 @@ from functools import lru_cache
from pathlib import Path
def _env_bool(value: str) -> bool:
"""Interpretiert einen Env-Wert als Wahrheitswert. Truthy sind (case-
insensitiv) 1/true/yes/on; alles andere (inkl. leer) ist False."""
return value.strip().lower() in ("1", "true", "yes", "on")
@dataclass(frozen=True)
class Settings:
database_url: str
@@ -18,6 +24,8 @@ class Settings:
horizon_days: int
env_file: Path
grafana_url: str
grafana_public_url: str
session_cookie_secure: bool
@lru_cache
@@ -38,4 +46,12 @@ def get_settings() -> Settings:
# 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env".
env_file=Path(e("FB_ENV_FILE", "/data/.env")),
grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"),
# Öffentliche Grafana-Basis-URL für Browser-Links/iframes hinter einem
# Reverse Proxy (z.B. 'https://fb.example.de/grafana/'). Leer =
# Direktbetrieb, Templates fallen auf http://<host>:8097 zurück. NICHT
# zu verwechseln mit grafana_url (intern, Server→Grafana).
grafana_public_url=e("FB_GRAFANA_PUBLIC_URL", ""),
# Session-Cookie mit secure-Flag ausliefern (nur über HTTPS gültig).
# Für den Reverse-Proxy-Betrieb; Default False für lokalen HTTP-Zugriff.
session_cookie_secure=_env_bool(e("FB_SESSION_COOKIE_SECURE", "")),
)

View File

@@ -49,7 +49,8 @@ def login(username: str = Form(...), password: str = Form(...)):
return HTMLResponse("Login fehlgeschlagen", status_code=401)
resp = RedirectResponse("/", status_code=303)
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
max_age=auth.MAX_AGE, samesite="lax")
max_age=auth.MAX_AGE, samesite="lax",
secure=s.session_cookie_secure)
return resp

View File

@@ -7,6 +7,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import COOKIE, session_valid
from app.config import get_settings
from app.db import get_session
from app.engine.loans import add_months
from app.engine.recurrence import occurrences
@@ -30,6 +31,22 @@ PAGE_SIZE = 50
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["app_version"] = get_version()
def grafana_public_base(request: Request) -> str:
"""Öffentliche Grafana-Basis-URL für Browser-Links/iframes. Ist
FB_GRAFANA_PUBLIC_URL gesetzt (Reverse-Proxy-Betrieb, z.B.
'https://fb.example.de/grafana/'), wird sie ohne abschließenden
Slash zurückgegeben; sonst der lokale Fallback http://<host>:8097
(Direktbetrieb ohne Proxy). Templates hängen bei Bedarf '/d/...' an."""
public = get_settings().grafana_public_url
if public:
return public.rstrip("/")
host = request.url.hostname or "127.0.0.1"
return f"http://{host}:8097"
templates.env.globals["grafana_public_base"] = grafana_public_base
templates.env.filters["eur"] = eur
templates.env.filters["de_label"] = de_label
router = APIRouter()

View File

@@ -17,7 +17,7 @@
<a href="/szenarien">Szenarien</a>
<a href="/admin">Admin</a>
<a href="/hilfe">Hilfe</a>
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
<a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>
<form method="post" action="/logout">
<button type="submit">Logout</button>
</form>

View File

@@ -77,8 +77,8 @@
</table>
<h2>Grafana-Dashboard</h2>
<iframe class="grafana" src="http://{{ request.url.hostname or '127.0.0.1' }}:8097/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
<iframe class="grafana" src="{{ grafana_public_base(request) }}/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
<p class="hint">Kein Diagramm sichtbar? Einmal in
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank">Grafana anmelden</a>
<a href="{{ grafana_public_base(request) }}" target="_blank">Grafana anmelden</a>
(gleiches Passwort wie hier).</p>
{% endblock %}

View File

@@ -188,7 +188,7 @@
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
Kurven in <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a> ansehen.
</p>
{% else %}
<p>Noch nicht durchgerechnet.</p>

View File

@@ -1,4 +1,11 @@
#!/bin/sh
set -e
alembic upgrade head
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
# --proxy-headers + --forwarded-allow-ips='*': hinter dem Reverse Proxy
# (Traefik→Apache/TLS) wertet uvicorn X-Forwarded-Proto/-Host aus, damit
# request.url das öffentliche https/Host statt des pod-internen http sieht.
# '*' ist vertretbar, weil der Pod nur an 127.0.0.1 gebunden ist und nur der
# lokale Traefik ihn erreicht (siehe docs/reverse-proxy.md). Im Direktbetrieb
# ohne Proxy sendet niemand X-Forwarded-*, also bleibt das Verhalten gleich.
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips='*'

View File

@@ -8,7 +8,7 @@
"tags": ["finanzen"],
"time": {
"from": "now-1y",
"to": "now"
"to": "now+19M"
},
"refresh": "",
"panels": [

View File

@@ -1,3 +1,6 @@
from app.config import get_settings
def test_api_requires_key(client):
assert client.get("/api/accounts").status_code == 401
r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"})
@@ -60,3 +63,21 @@ def test_current_password_hash_cache_invalidates_on_file_change(
second = current_password_hash()
assert second == new_hash
assert verify_password("andereswort999", second)
def test_login_cookie_secure_when_enabled(client, monkeypatch):
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", "true")
get_settings.cache_clear()
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert "secure" in r.headers["set-cookie"].lower()
def test_login_cookie_not_secure_by_default(client, monkeypatch):
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
get_settings.cache_clear()
r = client.post("/login", data={"username": "admin", "password": "geheim"},
follow_redirects=False)
assert r.status_code == 303
assert "secure" not in r.headers["set-cookie"].lower()

View File

@@ -1,5 +1,7 @@
from pathlib import Path
import pytest
from app.config import get_settings
@@ -35,3 +37,43 @@ def test_grafana_url_default(monkeypatch):
assert get_settings().grafana_url == "http://localhost:3000"
finally:
get_settings.cache_clear()
def test_grafana_public_url_default(monkeypatch):
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
get_settings.cache_clear()
try:
assert get_settings().grafana_public_url == ""
finally:
get_settings.cache_clear()
def test_grafana_public_url_from_env(monkeypatch):
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
get_settings.cache_clear()
try:
assert get_settings().grafana_public_url == "https://fb.example.de/grafana/"
finally:
get_settings.cache_clear()
def test_session_cookie_secure_default_false(monkeypatch):
monkeypatch.delenv("FB_SESSION_COOKIE_SECURE", raising=False)
get_settings.cache_clear()
try:
assert get_settings().session_cookie_secure is False
finally:
get_settings.cache_clear()
@pytest.mark.parametrize("val,expected", [
("true", True), ("True", True), ("1", True), ("yes", True), ("on", True),
("false", False), ("0", False), ("", False), ("nope", False),
])
def test_session_cookie_secure_parsing(monkeypatch, val, expected):
monkeypatch.setenv("FB_SESSION_COOKIE_SECURE", val)
get_settings.cache_clear()
try:
assert get_settings().session_cookie_secure is expected
finally:
get_settings.cache_clear()

View File

@@ -0,0 +1,9 @@
from pathlib import Path
ENTRYPOINT = Path(__file__).resolve().parent.parent / "entrypoint.sh"
def test_entrypoint_enables_proxy_headers():
text = ENTRYPOINT.read_text()
assert "--proxy-headers" in text
assert "--forwarded-allow-ips" in text

View File

@@ -0,0 +1,51 @@
"""Regressionstests fuer das provisionierte Grafana-Dashboard finanzen.json.
Hintergrund: Kein Panel nutzt $__timeFilter, jedes liefert alle Zeilen und
Grafana beschneidet auf das Dashboard-Zeitfenster. Das Szenario-Vergleich-Panel
zeichnet die Projektion (Zukunft, horizon_days ab heute). Steht 'to' auf 'now',
wird die gesamte Kurve ab morgen abgeschnitten (nur die ~4 Tage <= heute
sichtbar, Y-Achse auf deren schmalen Bereich fixiert). Dieser Test haelt fest,
dass 'to' weit genug in die Zukunft reicht.
"""
import json
import re
from pathlib import Path
from app.config import get_settings
DASHBOARD = Path(__file__).resolve().parent.parent / "grafana" / "dashboards" / "finanzen.json"
_UNIT_DAYS = {"y": 365, "M": 30, "w": 7, "d": 1, "h": 1 / 24}
def _relative_to_days(expr: str) -> float:
"""Grafana-Relativausdruck ('now', 'now+19M', 'now-1y') -> vorzeichen-
behafteter Tages-Offset relativ zu 'now' (Zukunft positiv)."""
expr = expr.strip()
if expr == "now":
return 0.0
m = re.fullmatch(r"now([+-])(\d+)([yMwdh])", expr)
if not m:
raise ValueError(f"Unerwarteter Grafana-Zeitausdruck: {expr!r}")
sign = 1 if m.group(1) == "+" else -1
return sign * int(m.group(2)) * _UNIT_DAYS[m.group(3)]
def _load() -> dict:
return json.loads(DASHBOARD.read_text())
def test_dashboard_has_szenario_panel():
titles = [p.get("title") for p in _load().get("panels", [])]
assert "Szenario-Vergleich" in titles
def test_time_window_covers_projection_horizon():
d = _load()
horizon = get_settings().horizon_days
to_days = _relative_to_days(d["time"]["to"])
assert to_days >= horizon, (
f"time.to reicht nur {to_days} Tage in die Zukunft, deckt aber den "
f"Projektionshorizont von {horizon} Tagen nicht ab -> Projektionskurve "
f"wird abgeschnitten"
)

View File

@@ -1,6 +1,7 @@
from datetime import date, timedelta
from decimal import Decimal
from app.config import get_settings
from app.formats import eur
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem)
@@ -453,3 +454,32 @@ def test_neuer_eintrag_formular_struktur(client, db):
assert 'class="value-label"' in r # dynamisches Wert-Label
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
assert '<hr' in r # Durchrechnen abgesetzt
def test_grafana_links_use_public_url_when_set(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
# iframe + Anmelde-Link nutzen die öffentliche URL, kein :8097 mehr.
assert "https://fb.example.de/grafana/d/finanzen/finanzen" in r.text
assert ":8097" not in r.text
# Nav-Link im base-Template ebenfalls.
assert 'href="https://fb.example.de/grafana"' in r.text
finally:
get_settings.cache_clear()
def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
assert ":8097/d/finanzen/finanzen" in r.text
assert "fb.example.de" not in r.text
finally:
get_settings.cache_clear()

53
stop_finance_pod.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
# Fährt den finance_pod kontrolliert herunter: stoppt den systemd-User-Service
# (der den Pod verwaltet) und danach - falls noch vorhanden - den Pod selbst.
# Gedacht als expliziter Wartungsschritt, z.B. vor dem Editieren der .env
# (Reverse-Proxy-Umzug, siehe docs/reverse-proxy.md) und dem erneuten Ausführen
# von ./create_pod_finance.sh.
#
# KEIN Datenverlust: Postgres-/Grafana-Daten liegen im Bind-Mount
# ~/.local/share/finance_pod und bleiben beim Stoppen erhalten. Der Pod wird
# nur GESTOPPT, nicht entfernt - das Entfernen/Neuerstellen erledigt
# create_pod_finance.sh ohnehin bei jedem Lauf.
set -e
POD_NAME='finance_pod'
SERVICE="pod-${POD_NAME}.service"
# 1. systemd-User-Service stoppen (er startet und verwaltet den Pod). Nur
# stoppen, nicht disablen - create_pod_finance.sh macht am Ende wieder
# `enable --now`. `stop` allein hält den Service bis dahin unten.
if systemctl --user list-units --type=service --all 2>/dev/null | \
grep -q "$SERVICE"; then
echo "Stoppe systemd-User-Service $SERVICE ..."
systemctl --user stop "$SERVICE" || true
else
echo "Service $SERVICE ist nicht registriert - nichts zu stoppen."
fi
# 2. Falls der Pod noch läuft (z.B. unmanaged außerhalb systemd gestartet),
# ebenfalls stoppen. --ignore: kein Fehler, wenn er nicht existiert.
if podman pod exists "$POD_NAME"; then
echo "Stoppe Pod $POD_NAME ..."
podman pod stop --ignore --time 15 "$POD_NAME" || true
else
echo "Pod $POD_NAME existiert nicht."
fi
# 3. Status zur Kontrolle ausgeben.
echo
echo "--- Status nach dem Herunterfahren ---"
printf 'Service aktiv? '
systemctl --user is-active "$SERVICE" || true
echo "Pod/Container:"
podman pod ps --filter "name=$POD_NAME" || true
echo
echo "Fertig heruntergefahren."
# echo "Fertig heruntergefahren. Nächste Schritte:"
# echo " 1. .env editieren: \$EDITOR ~/.local/share/finance_pod/.env"
# echo " (Reverse-Proxy: FB_GRAFANA_PUBLIC_URL + FB_SESSION_COOKIE_SECURE"
# echo " single-quoted setzen - siehe docs/reverse-proxy.md)"
# echo " 2. Neu ausrollen: ./create_pod_finance.sh"