Files
bin/docs/superpowers/plans/2026-07-20-ausbaustufe-10.md
2026-07-21 07:31:27 +02:00

777 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.