diff --git a/backend/tests/test_rc_backup_restore_scripts.py b/backend/tests/test_rc_backup_restore_scripts.py index ae02b3ef..0c543f85 100644 --- a/backend/tests/test_rc_backup_restore_scripts.py +++ b/backend/tests/test_rc_backup_restore_scripts.py @@ -75,3 +75,17 @@ def test_readiness_gate_checks_release_safety_scripts() -> None: "restore_release_backup_smoke.sh", ): assert f"bash -n scripts/{name}" in readiness + + +def test_password_rotation_never_prints_or_persists_generated_secret() -> None: + script = read("rotate_postgres_password.sh") + + assert "openssl rand -hex 32" in script + assert 'echo "$NEW_PASSWORD"' not in script + assert 'printf "%s" "$NEW_PASSWORD"' not in script + assert "GEOINTEL_ROTATED_DATABASE_PASSWORD" in script + assert "NamedTemporaryFile" in script + assert "temporary.replace(path)" in script + assert "ALTER ROLE %s PASSWORD" in script + assert "run-dockerman-container.sh" in script + assert "/health/ready" not in script diff --git a/scripts/README.md b/scripts/README.md index d62a569d..03f97414 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1976,3 +1976,17 @@ bash scripts/restore_release_backup_smoke.sh \ The restore smoke rejects the production database name, compares PostGIS, Alembic and retained table counts, and removes its temporary database. + +After a verified backup/restore, rotate a default production password without +printing or committing the generated secret: + +```bash +bash scripts/rotate_postgres_password.sh \ + --container geointel \ + --env-file /mnt/user/appdata/geointel/.env \ + --restart-all-in-one +``` + +The command atomically updates the operator-owned `.env`, changes the matching +PostgreSQL role and recreates the container. A failed role change restores the +previous environment file. The generated secret is never printed. diff --git a/scripts/rotate_postgres_password.sh b/scripts/rotate_postgres_password.sh new file mode 100644 index 00000000..973e2b0f --- /dev/null +++ b/scripts/rotate_postgres_password.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONTAINER="geointel" +ENV_FILE="$ROOT/.env" +RESTART="false" + +usage() { + cat <<'EOF' +Usage: bash scripts/rotate_postgres_password.sh [options] + +Generates a random 64-character hexadecimal password, atomically updates +GEOINTEL_POSTGRES_PASSWORD in the operator .env file, changes the matching +PostgreSQL role, and never prints the secret. + +Options: + --container NAME + --env-file PATH + --restart-all-in-one Recreate the managed all-in-one container and wait + for fail-closed readiness +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --container) CONTAINER="$2"; shift 2 ;; + --env-file) ENV_FILE="$2"; shift 2 ;; + --restart-all-in-one) RESTART="true"; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +for required in docker openssl python3; do + command -v "$required" >/dev/null 2>&1 || { + echo "Missing required command: $required" >&2 + exit 2 + } +done +if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" != "true" ]; then + echo "Container '$CONTAINER' is not running." >&2 + exit 3 +fi + +ENV_FILE="$(python3 -c 'import pathlib,sys; print(pathlib.Path(sys.argv[1]).expanduser().resolve())' "$ENV_FILE")" +if [ ! -f "$ENV_FILE" ]; then + echo "Environment file does not exist: $ENV_FILE" >&2 + exit 3 +fi +DB_USER="$(docker exec "$CONTAINER" sh -c 'printf %s "${POSTGRES_USER:-${GEOINTEL_POSTGRES_USER:-geointel}}"' )" +if ! [[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_]{0,62}$ ]]; then + echo "Unsafe PostgreSQL role name." >&2 + exit 3 +fi + +NEW_PASSWORD="$(openssl rand -hex 32)" +if [ "${#NEW_PASSWORD}" -ne 64 ]; then + echo "Password generator returned an unexpected result." >&2 + exit 3 +fi +export GEOINTEL_ROTATED_DATABASE_PASSWORD="$NEW_PASSWORD" +BACKUP_ENV="${ENV_FILE}.password-rotation-backup.$$" +cp -- "$ENV_FILE" "$BACKUP_ENV" +chmod 600 "$BACKUP_ENV" + +restore_env_on_failure() { + if [ -f "$BACKUP_ENV" ]; then + mv -f -- "$BACKUP_ENV" "$ENV_FILE" + fi +} +trap restore_env_on_failure EXIT + +python3 - "$ENV_FILE" <<'PY' +import os +import pathlib +import stat +import sys +import tempfile + +path = pathlib.Path(sys.argv[1]) +password = os.environ["GEOINTEL_ROTATED_DATABASE_PASSWORD"] +lines = path.read_text(encoding="utf-8").splitlines() +key = "GEOINTEL_POSTGRES_PASSWORD" +replacement = f"{key}={password}" +updated = False +result = [] +for line in lines: + if line.startswith(f"{key}="): + if not updated: + result.append(replacement) + updated = True + continue + result.append(line) +if not updated: + result.append(replacement) + +mode = stat.S_IMODE(path.stat().st_mode) +with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + delete=False, +) as handle: + handle.write("\n".join(result) + "\n") + temporary = pathlib.Path(handle.name) +temporary.chmod(mode) +temporary.replace(path) +PY + +printf "ALTER ROLE %s PASSWORD '%s';\n" "$DB_USER" "$NEW_PASSWORD" \ + | docker exec -i "$CONTAINER" psql \ + -X -v ON_ERROR_STOP=1 -U "$DB_USER" -d postgres >/dev/null + +rm -f -- "$BACKUP_ENV" +trap - EXIT +unset NEW_PASSWORD GEOINTEL_ROTATED_DATABASE_PASSWORD +echo "PostgreSQL password rotated without exposing the generated secret." + +if [ "$RESTART" = "true" ]; then + ( + cd "$ROOT" + bash deploy/unraid/run-dockerman-container.sh + ) + for attempt in $(seq 1 90); do + health="$( + docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ + "$CONTAINER" 2>/dev/null || true + )" + if [ "$health" = "healthy" ]; then + echo "Recreated container is healthy after attempt $attempt." + exit 0 + fi + if [ "$health" = "unhealthy" ] || [ "$health" = "exited" ] || [ "$health" = "dead" ]; then + echo "Recreated container entered terminal state: $health" >&2 + docker logs --tail 120 "$CONTAINER" >&2 || true + exit 4 + fi + sleep 2 + done + echo "Recreated container did not become healthy." >&2 + docker logs --tail 120 "$CONTAINER" >&2 || true + exit 4 +fi + +echo "Recreate the container before starting new application work." diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index 3587e398..10f22ffc 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -32,6 +32,7 @@ echo "== GeoIntel run readiness check ==" bash -n scripts/backup_release_state.sh bash -n scripts/verify_release_backup.sh bash -n scripts/restore_release_backup_smoke.sh +bash -n scripts/rotate_postgres_password.sh echo "Using Python: ${PYTHON_BIN}" bash scripts/check_repo_structure.sh ${PYTHON_BIN} scripts/smoke_docs.py