Files
bin/docs/superpowers/plans/2026-07-20-ausbaustufe-10.md

29 KiB
Raw Blame History

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.

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).

  • Step 1: Failing Tests schreiben — an finance/tests/test_config.py anhängen (oben import pytest ergänzen, falls nicht vorhanden):

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()
  • 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).

  • Step 3: Implementierenfinance/app/config.py. Bool-Helfer vor get_settings einfügen, zwei Felder ins @dataclass Settings und zwei Zeilen in den Settings(...)-Konstruktor:
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:

    grafana_public_url: str
    session_cookie_secure: bool

In get_settings() in den return Settings(...)-Aufruf (nach grafana_url=...) ergänzen:

        # Ö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", "")),
  • Step 4: Tests grün

Run: cd finance && .venv/bin/python -m pytest tests/test_config.py -q Expected: PASS.

  • Step 5: Volle Suite

Run: cd finance && .venv/bin/python -m pytest -q Expected: PASS (> 199).

  • Step 6: Fable-Gate + Commit
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"

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).

  • 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):

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()
  • 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).

  • Step 3: Implementieren — in finance/app/main.py den set_cookie-Aufruf im Login um secure= ergänzen:
@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
  • Step 4: Tests grün

Run: cd finance && .venv/bin/python -m pytest tests/test_auth.py -q Expected: PASS.

  • Step 5: Volle Suite

Run: cd finance && .venv/bin/python -m pytest -q Expected: PASS.

  • Step 6: Fable-Gate + Commit
git add finance/app/main.py finance/tests/test_auth.py
git commit -m "feat: Session-Cookie secure-Flag per FB_SESSION_COOKIE_SECURE"

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/....

  • Step 1: Failing Tests — an finance/tests/test_gui.py anhängen. Oben from app.config import get_settings ergänzen, falls nicht vorhanden:

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()
  • 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).

  • Step 3a: Jinja-Global implementierenfinance/app/routers/gui.py. Import ergänzen (bei den from app...-Imports):
from app.config import get_settings

Nach dem Block, der templates konfiguriert (templates.env.globals[...]), einfügen:

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
  • Step 3b: Templates umstellen.

finance/app/templates/base.html Zeile 20:

    <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a>

finance/app/templates/index.html Zeilen 80 und 82:

<iframe class="grafana" src="{{ grafana_public_base(request) }}/d/finanzen/finanzen?orgId=1&kiosk"></iframe>
  <a href="{{ grafana_public_base(request) }}" target="_blank">Grafana anmelden</a>

finance/app/templates/szenarien.html Zeile 191:

      Kurven in <a href="{{ grafana_public_base(request) }}" target="_blank" rel="noopener">Grafana</a> ansehen.
  • Step 4: Tests grün

Run: cd finance && .venv/bin/python -m pytest tests/test_gui.py -q Expected: PASS.

  • Step 5: Kontrollgriff — keine hartkodierten :8097 mehr in Templates

Run: grep -rn ":8097" finance/app/templates Expected: keine Treffer.

  • Step 6: Volle Suite

Run: cd finance && .venv/bin/python -m pytest -q Expected: PASS.

  • Step 7: Fable-Gate + Commit
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).

  • Step 1: Failing Testfinance/tests/test_entrypoint.py anlegen:

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
  • Step 2: Fehlschlag prüfen

Run: cd finance && .venv/bin/python -m pytest tests/test_entrypoint.py -q Expected: FAIL (Flags fehlen noch).

  • Step 3: Implementierenfinance/entrypoint.sh vollständig:
#!/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='*'
  • 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.

  • Step 5: Volle Suite

Run: cd finance && .venv/bin/python -m pytest -q Expected: PASS.

  • Step 6: Fable-Gate + Commit
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.

  • 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:

# --- 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
  • 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 \):
  "${GRAFANA_SUBPATH_ARGS[@]}" \

(Bei leerem Array expandiert das unter bash zu nichts — Lokalbetrieb unverändert.)

  • 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:
  -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.)

  • Step 4: Grafana-Health-Check-URL sub-path-bewusst machen — die Zeile
CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT/api/health"

ersetzen durch:

CHECK_URL_GRAFANA="http://$HOST_LOCAL_IP:$GRAFANA_HOST_PORT${GF_SUBPATH}/api/health"
  • 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):

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]
 -> []
  • 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.

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.

  • Step 1: docs/reverse-proxy.md anlegen mit folgendem Inhalt:

# 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.
  • Step 2: Verweis in docs/ARCHITEKTUR.md — im Deployment-Abschnitt (Abschnitt 3) einen Hinweis auf die neue Doku ergänzen, z.B.:
> 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.
  • Step 3: CLAUDE.md — im Abschnitt zur .env die zwei neuen Variablen kurz erwähnen (ein Satz), inkl. Verweis auf docs/reverse-proxy.md.

  • 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).

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.

  • Step 1: Version hochzählen

finance/VERSION:

0.10.0
  • Step 2: Volle Suite

Run: cd finance && .venv/bin/python -m pytest -q Expected: PASS (> 199).

  • 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).

  • Step 4: Live-Check Lokalbetrieb unverändert
# 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.

  • 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).

  • 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.

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.