Add safe Postgres credential rotation
This commit is contained in:
@@ -75,3 +75,17 @@ def test_readiness_gate_checks_release_safety_scripts() -> None:
|
|||||||
"restore_release_backup_smoke.sh",
|
"restore_release_backup_smoke.sh",
|
||||||
):
|
):
|
||||||
assert f"bash -n scripts/{name}" in readiness
|
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
|
||||||
|
|||||||
@@ -1976,3 +1976,17 @@ bash scripts/restore_release_backup_smoke.sh \
|
|||||||
|
|
||||||
The restore smoke rejects the production database name, compares PostGIS,
|
The restore smoke rejects the production database name, compares PostGIS,
|
||||||
Alembic and retained table counts, and removes its temporary database.
|
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.
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -32,6 +32,7 @@ echo "== GeoIntel run readiness check =="
|
|||||||
bash -n scripts/backup_release_state.sh
|
bash -n scripts/backup_release_state.sh
|
||||||
bash -n scripts/verify_release_backup.sh
|
bash -n scripts/verify_release_backup.sh
|
||||||
bash -n scripts/restore_release_backup_smoke.sh
|
bash -n scripts/restore_release_backup_smoke.sh
|
||||||
|
bash -n scripts/rotate_postgres_password.sh
|
||||||
echo "Using Python: ${PYTHON_BIN}"
|
echo "Using Python: ${PYTHON_BIN}"
|
||||||
bash scripts/check_repo_structure.sh
|
bash scripts/check_repo_structure.sh
|
||||||
${PYTHON_BIN} scripts/smoke_docs.py
|
${PYTHON_BIN} scripts/smoke_docs.py
|
||||||
|
|||||||
Reference in New Issue
Block a user