From 7a8e63dc76c5b6ffd81d67b9fad77b3f41c4724dde9b9cbaf4ddf367f8a0011a Mon Sep 17 00:00:00 2001 From: wlfb Date: Mon, 20 Jul 2026 22:54:59 +0200 Subject: [PATCH] docs: Plan Ausbaustufe 10 (Reverse-Proxy-Tauglichkeit) --- .../plans/2026-07-20-ausbaustufe-10.md | 769 ++++++++++++++++++ 1 file changed, 769 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-ausbaustufe-10.md diff --git a/docs/superpowers/plans/2026-07-20-ausbaustufe-10.md b/docs/superpowers/plans/2026-07-20-ausbaustufe-10.md new file mode 100644 index 0000000..2d96791 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-ausbaustufe-10.md @@ -0,0 +1,769 @@ +# 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 `: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 ins Repo oder Image.** Proxy-Werte nur via + `.env` (`FB_GRAFANA_PUBLIC_URL`, `FB_SESSION_COOKIE_SECURE`). In Tests/Doku + nur Beispiel-Domains (`fb.example.de`), niemals die echte Domain als + Default/Fixwert im Code. +- **`.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). + +- [ ] **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() +``` + +- [ ] **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: 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://: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** + +```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). + +- [ ] **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() +``` + +- [ ] **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: + +```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 +``` + +- [ ] **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** + +```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://: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: + +```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() +``` + +- [ ] **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 `:8097`, `:8097` ist im Text). + +- [ ] **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.wolfundlaemmlein.de/grafana/'), wird sie ohne abschließenden + Slash zurückgegeben; sonst der lokale Fallback http://: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: + +```html + Grafana +``` + +`finance/app/templates/index.html` Zeilen 80 und 82: + +```html + +``` +```html + Grafana anmelden +``` + +`finance/app/templates/szenarien.html` Zeile 191: + +```html + Kurven in Grafana 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** + +```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). + +- [ ] **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 +``` + +- [ ] **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: 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='*' +``` + +- [ ] **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** + +```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. + +- [ ] **Step 1: Sub-Pfad-Berechnung einfügen** — in `create_pod_finance.sh` + VOR dem Grafana-`podman run` (also vor der Zeile + `podman run -d --name "$GRAFANA_CTR_NAME" ...`) diesen Block einfügen: + +```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.wolfundlaemmlein.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 \`): + +```bash + "${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: + +```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.) + +- [ ] **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" +``` + +- [ ] **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] + -> [] +``` + +- [ ] **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. + +- [ ] **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 `: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 + + 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/ + +``` + +## 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.: + +```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. +``` + +- [ ] **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 1–5 +ü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. + +- [ ] **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** + +```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`. + +- [ ] **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. + +```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 ⇒ `: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.