From b9ce853059671777f9e93301ca349f2b2d1a6732 Mon Sep 17 00:00:00 2001 From: elpatron Date: Mon, 1 Jun 2026 15:09:15 +0200 Subject: [PATCH] feat(ops): script to rotate PostgreSQL password safely Add rotate-postgres-password.sh with optional app role, document the procedure, and stop defaulting production POSTGRES_PASSWORD to postgres. Co-authored-by: Cursor --- .cursor/skills/merge/SKILL.md | 182 -------------------------- .env.example | 2 + README.md | 1 + docker-compose.yml | 3 +- docs/deployment/npm-security.md | 2 +- docs/deployment/postgres-password.md | 42 ++++++ scripts/rotate-postgres-password.sh | 183 +++++++++++++++++++++++++++ scripts/server-patch-env-sprint1.sh | 7 +- 8 files changed, 236 insertions(+), 186 deletions(-) delete mode 100644 .cursor/skills/merge/SKILL.md create mode 100644 docs/deployment/postgres-password.md create mode 100755 scripts/rotate-postgres-password.sh diff --git a/.cursor/skills/merge/SKILL.md b/.cursor/skills/merge/SKILL.md deleted file mode 100644 index 3a700b0..0000000 --- a/.cursor/skills/merge/SKILL.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -name: merge -description: >- - Merge Git branches safely — fetch latest, merge or rebase onto master, resolve - conflicts intelligently, and verify the result. Use when the user asks to merge - branches, sync with master, resolve merge conflicts, or bring a feature branch - up to date. ---- - -# Git Merge - -Führe Branch-Merges sicher und nachvollziehbar aus. Für PR-Review, CI und -Comment-Triage siehe den **babysit**-Skill — dieser Skill deckt die Git-Merge- -Operation selbst ab. - -## Projekt-Kontext - -- **Basis-Branch:** `master` (nicht `main`) -- **Monorepo:** `client/` (React PWA) und `server/` (Express API) — Konflikte - können in beiden liegen - -## Sicherheitsregeln (immer einhalten) - -- **Niemals** `git config` ändern -- **Niemals** `--no-verify`, `--no-gpg-sign` o.ä. ohne explizite Anfrage -- **Niemals** `push --force` auf `master` — bei Bedarf warnen und abbrechen -- **Niemals** destruktive Befehle (`reset --hard`, `clean -fd`) ohne explizite Anfrage -- **Niemals** interaktive Git-Befehle (`-i`-Flags) — nicht unterstützt -- **Kein Commit** ohne explizite Anfrage des Users -- **Kein Push** ohne explizite Anfrage des Users - -## Workflow - -### 1. Ausgangslage klären - -Parallel ausführen: - -```bash -git status -git branch -vv -git log --oneline -5 -``` - -Ermittle: - -- Aktueller Branch -- Ziel-Branch (Standard: `master`) -- Ob uncommittete Änderungen vorliegen -- Ob der Branch einen Remote-Tracking-Branch hat - -**Bei uncommitteten Änderungen:** Stashen (`git stash push -m "pre-merge"`) nur -mit Zustimmung oder wenn der User es verlangt hat. Sonst stoppen und melden. - -### 2. Merge-Strategie wählen - -| Situation | Empfehlung | -|-----------|------------| -| Feature-Branch aktuell halten | `git merge origin/master` (Merge-Commit) | -| Linearer Verlauf gewünscht | `git rebase origin/master` (nur wenn User Rebase verlangt) | -| Zwei Feature-Branches zusammenführen | `git merge ` auf Ziel-Branch | - -**Standard:** Merge (nicht Rebase), es sei denn der User verlangt Rebase. - -### 3. Remote aktualisieren - -```bash -git fetch origin -``` - -Vor dem Merge prüfen, wie weit der Branch hinter `origin/master` liegt: - -```bash -git log --oneline HEAD..origin/master -git log --oneline origin/master..HEAD -``` - -### 4. Merge ausführen - -**Feature-Branch mit master synchronisieren** (häuigster Fall): - -```bash -git checkout -git merge origin/master -``` - -**Branch in master mergen** (nur wenn User das ausdrücklich will — normalerweise -passiert das via PR): - -```bash -git checkout master -git pull origin master -git merge -``` - -Merge-Commit-Nachricht kurz und sachlich halten, z.B.: -`Merge branch 'master' into feature/push-notifications-owner` - -### 5. Konflikte lösen - -Konfliktdateien finden: - -```bash -git diff --name-only --diff-filter=U -``` - -**Pro Konfliktdatei:** - -1. Datei lesen und beide Seiten verstehen (HEAD = eigener Branch, incoming = gemergter Branch) -2. Intent beider Änderungen erhalten — nicht blind eine Seite wählen -3. Konfliktmarker entfernen (`<<<<<<<`, `=======`, `>>>>>>>`) -4. Bei widersprüchlicher Intent: Merge abbrechen und User fragen - -```bash -git merge --abort # oder: git rebase --abort -``` - -**Typische Konflikt-Muster in diesem Projekt:** - -| Bereich | Hinweis | -|---------|---------| -| `package-lock.json` | Nach manueller Lösung `npm install` im betroffenen Paket (`client/` oder `server/`) ausführen | -| i18n (`client/src/i18n/`) | Beide Sprachkeys (DE + EN) behalten, keine Keys verlieren | -| Prisma/Schema | Migrationen beider Seiten zusammenführen, nicht überschreiben | -| Verschlüsselung/Auth | Vorsichtig — keine Sicherheitslogik stillschweigend vereinfachen | - -Nach jeder gelösten Datei: - -```bash -git add -``` - -Merge abschließen (nur wenn User Commit verlangt hat): - -```bash -git commit -m "$(cat <<'EOF' -Merge branch 'master' into - -EOF -)" -``` - -### 6. Verifizieren - -Nach erfolgreichem Merge: - -```bash -git status -git log --oneline -5 -``` - -Relevante Checks je nach betroffenen Bereichen: - -```bash -# Client -cd client && npm run build - -# Server -cd server && npm run build -``` - -Bei Lockfile-Konflikten oder Dependency-Änderungen: Build in beiden Paketen prüfen. - -### 7. Abschluss - -- Ergebnis dem User mitteilen: welche Branches, wie viele Konflikte, was gelöst wurde -- Bei `git stash`: erinnern, Stash wieder anzuwenden (`git stash pop`) -- Push nur auf explizite Anfrage: `git push origin ` - -## Wann abbrechen und fragen - -- Widersprüchliche fachliche Intent (z.B. beide Seiten ändern dieselbe Logik unterschiedlich) -- Konflikte in Krypto-, Auth- oder Sync-Kernlogik ohne klares „richtig“ -- Merge würde `.env`, Credentials oder Secrets einschließen -- User wollte nur Status prüfen, nicht tatsächlich mergen - -## Abgrenzung zu anderen Skills - -| Skill | Wann | -|-------|------| -| **merge** (dieser) | Git merge/rebase, Konflikte, Branch sync | -| **babysit** | PR merge-ready: Comments, CI, PR-Konflikte im PR-Kontext | -| **creating-pull-requests** | PR erstellen und pushen | diff --git a/.env.example b/.env.example index 5ea1754..bc0fd3a 100755 --- a/.env.example +++ b/.env.example @@ -18,6 +18,8 @@ ORIGIN=http://localhost:5173 # TRUST_PROXY=1 # Docker Compose database (required for production deploy) +# Generate: openssl rand -hex 24 +# Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md) # POSTGRES_USER=postgres # POSTGRES_PASSWORD= # POSTGRES_DB=daagbox diff --git a/README.md b/README.md index c3a2b4b..6594228 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen | Dokument | Inhalt | |----------|--------| | [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header | +| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle | | [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics | | [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan | | [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan | diff --git a/docker-compose.yml b/docker-compose.yml index a6d6501..3baef24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,8 @@ services: volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d daagbox"] + test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""] + # Not published to the host — reachable only on the Compose network (do not add ports: here) interval: 5s timeout: 5s retries: 5 diff --git a/docs/deployment/npm-security.md b/docs/deployment/npm-security.md index f0c4fb3..9857d92 100644 --- a/docs/deployment/npm-security.md +++ b/docs/deployment/npm-security.md @@ -57,4 +57,4 @@ Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausib ## Docker Compose -Keine Default-Passwörter in Produktion: `POSTGRES_PASSWORD` und `SESSION_SECRET` in `.env` setzen (siehe [`.env.example`](../../.env.example)). +Keine Default-Passwörter in Produktion: starkes `POSTGRES_PASSWORD` (siehe [postgres-password.md](postgres-password.md)) und `SESSION_SECRET` in `.env` setzen (siehe [`.env.example`](../../.env.example)). diff --git a/docs/deployment/postgres-password.md b/docs/deployment/postgres-password.md new file mode 100644 index 0000000..aea2468 --- /dev/null +++ b/docs/deployment/postgres-password.md @@ -0,0 +1,42 @@ +# PostgreSQL absichern (Produktion) + +## Ist-Zustand + +- Die Datenbank läuft im Container `daagbox-prod-db` **ohne** Host-Port (nur Docker-Netz `db:5432`) — gut. +- Das Passwort wird beim **ersten** Start des Volumes gesetzt; ein späteres Ändern nur von `POSTGRES_PASSWORD` in `.env` **ändert nicht** das laufende Passwort. +- Nach Sprint 1 war auf dem Server noch das Legacy-Passwort `postgres` möglich → per Skript rotieren. + +## Empfohlene Schritte + +1. **Backup/Snapshot** (hast du laut Vorgabe). +2. Auf dem Server im Repo: + ```bash + cd /opt/kapteins-daagbok + git pull + chmod +x scripts/rotate-postgres-password.sh + ./scripts/rotate-postgres-password.sh + ``` +3. Inhalt von `.postgres-credentials.` in den Passwort-Manager übernehmen, Datei auf dem Server löschen: + ```bash + shred -u .postgres-credentials.* # oder rm nach manuellem Notieren + ``` + +### Optional: eigener App-Benutzer (statt `postgres` für Prisma) + +```bash +./scripts/rotate-postgres-password.sh --app-user daagbok +``` + +- **`daagbok`**: Login für Backend/Prisma (kein Superuser) +- **`postgres`**: nur noch Admin (Passwort in `POSTGRES_ADMIN_PASSWORD` in `.env`) + +## Lokale Entwicklung + +`scripts/start-dev.sh` nutzt weiterhin `postgres/postgres` auf localhost — nur für Dev. Produktion nie dieses Passwort wiederverwenden. + +## Verifikation + +```bash +docker exec daagbox-prod-backend wget -qO- http://127.0.0.1:5000/api/health +curl -sf https://kapteins-daagbok.eu/api/health +``` diff --git a/scripts/rotate-postgres-password.sh b/scripts/rotate-postgres-password.sh new file mode 100755 index 0000000..c4f56af --- /dev/null +++ b/scripts/rotate-postgres-password.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Rotate PostgreSQL password on a running Docker Compose stack (existing volume safe). +# +# The Postgres image only applies POSTGRES_PASSWORD on first init; for existing data +# you must ALTER USER inside the running database, then update .env and restart backend. +# +# Usage (on server in repo root, with backup/snapshot taken): +# ./scripts/rotate-postgres-password.sh +# ./scripts/rotate-postgres-password.sh --app-user daagbok # optional: dedicated app role +# +# Writes the new credentials once to .postgres-credentials. (mode 600). +set -euo pipefail + +ENV_FILE="${ENV_FILE:-.env}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" +DB_CONTAINER="${DB_CONTAINER:-daagbox-prod-db}" +BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}" +CREATE_APP_USER="" +APP_USER_NAME="daagbok" + +while [ $# -gt 0 ]; do + case "$1" in + --app-user) + CREATE_APP_USER=1 + APP_USER_NAME="${2:-daagbok}" + shift 2 + ;; + -h|--help) + sed -n '2,12p' "$0" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: $ENV_FILE not found" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +set -a +source "$ENV_FILE" +set +a + +POSTGRES_USER="${POSTGRES_USER:-postgres}" +POSTGRES_DB="${POSTGRES_DB:-daagbox}" +OLD_PASSWORD="${POSTGRES_PASSWORD:-}" + +if [ -z "$OLD_PASSWORD" ]; then + echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2 + exit 1 +fi + +NEW_PASSWORD="$(openssl rand -hex 24)" +NEW_APP_PASSWORD="" +if [ -n "$CREATE_APP_USER" ]; then + NEW_APP_PASSWORD="$(openssl rand -hex 24)" +fi + +BACKUP_ENV="${ENV_FILE}.bak.pg-rotate.$(date +%Y%m%d-%H%M%S)" +cp "$ENV_FILE" "$BACKUP_ENV" +echo "Backed up $ENV_FILE → $BACKUP_ENV" + +echo "Rotating password for PostgreSQL role: $POSTGRES_USER (database: $POSTGRES_DB)" + +# Escape single quotes for SQL string literals +sql_escape() { + printf "%s" "$1" | sed "s/'/''/g" +} +NEW_PW_SQL="$(sql_escape "$NEW_PASSWORD")" + +export PGPASSWORD="$OLD_PASSWORD" +if ! docker exec -e PGPASSWORD="$OLD_PASSWORD" "$DB_CONTAINER" \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 \ + -c "ALTER USER \"${POSTGRES_USER}\" WITH PASSWORD '${NEW_PW_SQL}';" >/dev/null; then + echo "Error: ALTER USER failed. Is POSTGRES_PASSWORD in .env still correct?" >&2 + exit 1 +fi +unset PGPASSWORD + +TARGET_USER="$POSTGRES_USER" +TARGET_PASSWORD="$NEW_PASSWORD" + +if [ -n "$CREATE_APP_USER" ]; then + APP_PW_SQL="$(sql_escape "$NEW_APP_PASSWORD")" + export PGPASSWORD="$NEW_PASSWORD" + docker exec -e PGPASSWORD="$NEW_PASSWORD" "$DB_CONTAINER" psql -U postgres -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 < str: + pattern = rf"^{re.escape(name)}=.*$" + line = f"{name}={value}" + if re.search(pattern, content, flags=re.M): + return re.sub(pattern, line, content, count=1, flags=re.M) + return content.rstrip() + "\n" + line + "\n" + +text = set_var("POSTGRES_USER", target_user, text) +text = set_var("POSTGRES_PASSWORD", target_password, text) +text = set_var("POSTGRES_DB", "daagbox", text) if "POSTGRES_DB=" not in text else text +if use_app_user: + text = set_var("POSTGRES_ADMIN_PASSWORD", postgres_password, text) + +path.write_text(text, encoding="utf-8") +PY + +CREDS_FILE=".postgres-credentials.$(date +%Y%m%d-%H%M%S)" +umask 077 +{ + echo "# Generated $(date -Iseconds) — store in password manager, then delete this file." + echo "POSTGRES_USER=$TARGET_USER" + echo "POSTGRES_PASSWORD=$TARGET_PASSWORD" + echo "POSTGRES_DB=$POSTGRES_DB" + if [ -n "$CREATE_APP_USER" ]; then + echo "POSTGRES_ADMIN_USER=postgres" + echo "POSTGRES_ADMIN_PASSWORD=$NEW_PASSWORD" + fi +} > "$CREDS_FILE" +chmod 600 "$CREDS_FILE" +echo "Credentials written to $CREDS_FILE (chmod 600)" + +echo "Restarting backend to pick up DATABASE_URL..." +docker compose -f "$COMPOSE_FILE" up -d backend + +echo "Waiting for backend health..." +for _ in $(seq 1 45); do + status="$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null || echo missing)" + if [ "$status" = healthy ]; then + break + fi + sleep 1 +done + +export PGPASSWORD="$TARGET_PASSWORD" +docker exec -e PGPASSWORD="$TARGET_PASSWORD" "$DB_CONTAINER" \ + psql -U "$TARGET_USER" -d "$POSTGRES_DB" -tAc 'SELECT count(*) FROM "User";' >/dev/null +unset PGPASSWORD + +if curl -sf http://127.0.0.1/api/health | grep -q '"status":"ok"'; then + echo "OK: /api/health and DB connection verified." +else + echo "Warning: health check failed — see: docker compose logs backend" >&2 + exit 1 +fi + +echo "Done. Remove $CREDS_FILE after saving credentials securely." diff --git a/scripts/server-patch-env-sprint1.sh b/scripts/server-patch-env-sprint1.sh index 4d1d52f..ff34c53 100755 --- a/scripts/server-patch-env-sprint1.sh +++ b/scripts/server-patch-env-sprint1.sh @@ -29,8 +29,11 @@ echo "Patching $ENV_FILE for Sprint 1..." # Match running container (docker exec daagbox-prod-db: USER=postgres DB=daagbox) ensure_var POSTGRES_USER "postgres" ensure_var POSTGRES_DB "daagbox" -# Default from legacy docker-compose.yml; change only if you use a different DB password -ensure_var POSTGRES_PASSWORD "postgres" +if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$" "$ENV_FILE"; then + echo " skip POSTGRES_PASSWORD (set manually or run scripts/rotate-postgres-password.sh)" +else + echo " keep POSTGRES_PASSWORD (already set)" +fi # NPM on 172.16.10.10 → app on this host ensure_var TRUST_PROXY "172.16.10.10"