Compare commits
13 Commits
8eee62ee19
...
dec1cb9075
| Author | SHA256 | Date | |
|---|---|---|---|
| dec1cb9075 | |||
| 81a0ebaf9d | |||
| 6ba6ffa9df | |||
| 51d85c8de6 | |||
| 208c0a4df4 | |||
| fa45527faf | |||
| 2610b71b06 | |||
| df5b29d8ff | |||
| a575c57d5b | |||
| 52f527a375 | |||
| fa2d32aa8f | |||
| e061796997 | |||
| 7a8e63dc76 |
10
CLAUDE.md
10
CLAUDE.md
@@ -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
|
`truncate` — siehe `services/admin.py::_rewrite_env_file`), niemals über
|
||||||
Temp-Datei+`rename` (neuer Inode, vom laufenden Mount nicht mehr gesehen).
|
Temp-Datei+`rename` (neuer Inode, vom laufenden Mount nicht mehr gesehen).
|
||||||
Host-seitige `sed`-Edits in `create_pod_finance.sh` selbst sind unkritisch,
|
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
|
**Disaster Recovery = `BIND_DIR`-Backup + Repo + Skript.** Für vollständige
|
||||||
Wiederherstellung werden **beide** gebraucht: ein Backup von
|
Wiederherstellung werden **beide** gebraucht: ein Backup von
|
||||||
|
|||||||
@@ -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;"
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO finance_read;"
|
||||||
echo "Role 'finance_read' is ready."
|
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)
|
# 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
|
# 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,
|
# 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_GUI_PASSWORD_HASH \
|
||||||
-e FB_INBOX_DIR=/data/inbox \
|
-e FB_INBOX_DIR=/data/inbox \
|
||||||
-e FB_UPLOADS_DIR=/data/uploads \
|
-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 "$DATA_DIR:/data:Z" \
|
||||||
-v "$ENV_FILE:/data/.env:Z" \
|
-v "$ENV_FILE:/data/.env:Z" \
|
||||||
"$API_IMAGE"
|
"$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_ADMIN_PASSWORD="$FB_PASSWORD" \
|
||||||
-e GF_SECURITY_ALLOW_EMBEDDING=true \
|
-e GF_SECURITY_ALLOW_EMBEDDING=true \
|
||||||
-e GF_SECURITY_COOKIE_SAMESITE=lax \
|
-e GF_SECURITY_COOKIE_SAMESITE=lax \
|
||||||
|
"${GRAFANA_SUBPATH_ARGS[@]}" \
|
||||||
-e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \
|
-e GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/finanzen.json \
|
||||||
-e FINANCE_READ_PASSWORD \
|
-e FINANCE_READ_PASSWORD \
|
||||||
-v "$GRAFANA_PROVISIONING_DIR:/etc/grafana/provisioning:Z,ro" \
|
-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
|
# Wait for API and Grafana readiness
|
||||||
CHECK_URL_API="http://$HOST_LOCAL_IP:$API_HOST_PORT/login"
|
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
|
for attempt in $(seq 1 30); do
|
||||||
API_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_API" || true)
|
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)
|
GRAFANA_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$CHECK_URL_GRAFANA" || true)
|
||||||
|
|||||||
@@ -196,6 +196,11 @@ sequenceDiagram
|
|||||||
|
|
||||||
## 3. Deployment-Diagramm
|
## 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
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph HOST["Host wlfb (rootless Podman)"]
|
subgraph HOST["Host wlfb (rootless Podman)"]
|
||||||
|
|||||||
258
docs/reverse-proxy.md
Normal file
258
docs/reverse-proxy.md
Normal 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.
|
||||||
776
docs/superpowers/plans/2026-07-20-ausbaustufe-10.md
Normal file
776
docs/superpowers/plans/2026-07-20-ausbaustufe-10.md
Normal 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 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.
|
||||||
|
|
||||||
|
- [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.
|
||||||
@@ -1 +1 @@
|
|||||||
0.9.0
|
0.10.0
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ from functools import lru_cache
|
|||||||
from pathlib import Path
|
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)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
database_url: str
|
database_url: str
|
||||||
@@ -18,6 +24,8 @@ class Settings:
|
|||||||
horizon_days: int
|
horizon_days: int
|
||||||
env_file: Path
|
env_file: Path
|
||||||
grafana_url: str
|
grafana_url: str
|
||||||
|
grafana_public_url: str
|
||||||
|
session_cookie_secure: bool
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
@@ -38,4 +46,12 @@ def get_settings() -> Settings:
|
|||||||
# 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env".
|
# 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env".
|
||||||
env_file=Path(e("FB_ENV_FILE", "/data/.env")),
|
env_file=Path(e("FB_ENV_FILE", "/data/.env")),
|
||||||
grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"),
|
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", "")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ def login(username: str = Form(...), password: str = Form(...)):
|
|||||||
return HTMLResponse("Login fehlgeschlagen", status_code=401)
|
return HTMLResponse("Login fehlgeschlagen", status_code=401)
|
||||||
resp = RedirectResponse("/", status_code=303)
|
resp = RedirectResponse("/", status_code=303)
|
||||||
resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True,
|
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
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import COOKIE, session_valid
|
from app.auth import COOKIE, session_valid
|
||||||
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.engine.loans import add_months
|
from app.engine.loans import add_months
|
||||||
from app.engine.recurrence import occurrences
|
from app.engine.recurrence import occurrences
|
||||||
@@ -30,6 +31,22 @@ PAGE_SIZE = 50
|
|||||||
|
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
templates.env.globals["app_version"] = get_version()
|
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["eur"] = eur
|
||||||
templates.env.filters["de_label"] = de_label
|
templates.env.filters["de_label"] = de_label
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<a href="/szenarien">Szenarien</a>
|
<a href="/szenarien">Szenarien</a>
|
||||||
<a href="/admin">Admin</a>
|
<a href="/admin">Admin</a>
|
||||||
<a href="/hilfe">Hilfe</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">
|
<form method="post" action="/logout">
|
||||||
<button type="submit">Logout</button>
|
<button type="submit">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -77,8 +77,8 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2>Grafana-Dashboard</h2>
|
<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
|
<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>
|
(gleiches Passwort wie hier).</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@
|
|||||||
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
|
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_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 %}
|
{% 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>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>Noch nicht durchgerechnet.</p>
|
<p>Noch nicht durchgerechnet.</p>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
alembic upgrade head
|
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='*'
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
def test_api_requires_key(client):
|
def test_api_requires_key(client):
|
||||||
assert client.get("/api/accounts").status_code == 401
|
assert client.get("/api/accounts").status_code == 401
|
||||||
r = client.get("/api/accounts", headers={"Authorization": "Bearer test-key"})
|
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()
|
second = current_password_hash()
|
||||||
assert second == new_hash
|
assert second == new_hash
|
||||||
assert verify_password("andereswort999", second)
|
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()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -35,3 +37,43 @@ def test_grafana_url_default(monkeypatch):
|
|||||||
assert get_settings().grafana_url == "http://localhost:3000"
|
assert get_settings().grafana_url == "http://localhost:3000"
|
||||||
finally:
|
finally:
|
||||||
get_settings.cache_clear()
|
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()
|
||||||
|
|||||||
9
finance/tests/test_entrypoint.py
Normal file
9
finance/tests/test_entrypoint.py
Normal 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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.formats import eur
|
from app.formats import eur
|
||||||
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
|
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
|
||||||
Scenario, ScenarioModifier, ScenarioPlannedItem)
|
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 'class="value-label"' in r # dynamisches Wert-Label
|
||||||
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
|
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
|
||||||
assert '<hr' in r # Durchrechnen abgesetzt
|
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
53
stop_finance_pod.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user