#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" ENV_IN=".env.production" ENV_OUT=".env.prod" APPLY_RUNNING=0 SKIP_DB_ROTATE=0 SKIP_RESTART=0 COMPOSE_OVERRIDE="docker-compose.prod.nginx.yml" NON_INTERACTIVE=0 REQUIRED_CONFIRM_TOKEN="ROTATE-PROD-SECRETS" CONFIRM_TOKEN_INPUT="" usage() { cat <<'EOF' Usage: scripts/ops/rotate_prod_secrets.sh [options] Options: --env-in Source env template (default: .env.production) --env-out Output env file with rotated secrets (default: .env.prod) --compose-override Compose override for production apply (default: docker-compose.prod.nginx.yml) --apply-running Apply generated env to running stack (.env replace + DB password rotate + recreate) --non-interactive Disable prompt confirmation (requires valid --require-confirmation-token) --require-confirmation-token Mandatory token for --apply-running. Expected: ROTATE-PROD-SECRETS --skip-db-rotate With --apply-running: do not run ALTER USER in Postgres --skip-restart With --apply-running: do not recreate stack / migrate / health checks -h, --help Show help Examples: scripts/ops/rotate_prod_secrets.sh scripts/ops/rotate_prod_secrets.sh --apply-running scripts/ops/rotate_prod_secrets.sh --env-in .env --env-out .env.prod --apply-running EOF } while [[ $# -gt 0 ]]; do case "$1" in --env-in) ENV_IN="${2:-}" shift 2 ;; --env-out) ENV_OUT="${2:-}" shift 2 ;; --compose-override) COMPOSE_OVERRIDE="${2:-}" shift 2 ;; --apply-running) APPLY_RUNNING=1 shift ;; --non-interactive) NON_INTERACTIVE=1 shift ;; --require-confirmation-token) CONFIRM_TOKEN_INPUT="${2:-}" shift 2 ;; --skip-db-rotate) SKIP_DB_ROTATE=1 shift ;; --skip-restart) SKIP_RESTART=1 shift ;; -h|--help) usage exit 0 ;; *) echo "[ERROR] Unknown argument: $1" >&2 usage exit 1 ;; esac done if [[ ! -f "$ENV_IN" ]]; then echo "[ERROR] Input env file not found: $ENV_IN" >&2 exit 1 fi if ! command -v openssl >/dev/null 2>&1; then echo "[ERROR] openssl not found (required for secret generation)" >&2 exit 1 fi COMPOSE_ARGS=(-f docker-compose.yml -f "$COMPOSE_OVERRIDE") if [[ "$APPLY_RUNNING" -eq 1 && ! -f "$COMPOSE_OVERRIDE" ]]; then echo "[ERROR] Compose override file not found: $COMPOSE_OVERRIDE" >&2 exit 1 fi rand_alnum() { local length="${1:-64}" local bytes=$(( (length + 1) / 2 )) openssl rand -hex "$bytes" | cut -c1-"$length" } rand_secret() { local length="${1:-64}" local out out="$(openssl rand -base64 "$length" | tr -d '\n' | tr '+/' 'AZ' | tr -dc 'A-Za-z0-9' | cut -c1-"$length")" if [[ "${#out}" -lt "$length" ]]; then out="${out}$(rand_alnum "$((length - ${#out}))")" fi echo "$out" } read_env_value() { local key="$1" local file="$2" local value value="$(grep -E "^${key}=" "$file" | tail -n1 | cut -d= -f2- || true)" echo "$value" } upsert_env_value() { local key="$1" local value="$2" local file="$3" local tmp tmp="$(mktemp)" awk -v k="$key" -v v="$value" ' BEGIN { done = 0 } $0 ~ ("^" k "=") { print k "=" v; done = 1; next } { print } END { if (!done) print k "=" v } ' "$file" > "$tmp" mv "$tmp" "$file" } db_url_with_password() { local current_url="$1" local user="$2" local pass="$3" local db_name="$4" if [[ -n "$current_url" && "$current_url" =~ ^([^:]+://[^:]+:)[^@]*(@.*)$ ]]; then echo "${BASH_REMATCH[1]}${pass}${BASH_REMATCH[2]}" return 0 fi echo "postgresql+psycopg://${user}:${pass}@db:5432/${db_name}" } echo "[1/5] Preparing output env file..." cp "$ENV_IN" "$ENV_OUT" chmod 600 "$ENV_OUT" NEW_ADMIN_JWT_SECRET="$(rand_secret 64)" NEW_PUBLIC_JWT_SECRET="$(rand_secret 64)" NEW_DATA_ENCRYPTION_SECRET="$(rand_secret 64)" NEW_CHAT_ENCRYPTION_SECRET="$(rand_secret 64)" NEW_ENC_KID="k$(date -u +%Y%m%d%H%M)" NEW_INTERNAL_SERVICE_TOKEN="$(rand_secret 64)" NEW_POSTGRES_PASSWORD="$(rand_secret 40)" NEW_MINIO_ROOT_USER="minio_$(rand_alnum 14 | tr '[:upper:]' '[:lower:]')" NEW_MINIO_ROOT_PASSWORD="$(rand_secret 48)" NEW_S3_ACCESS_KEY="$(rand_alnum 20)" NEW_S3_SECRET_KEY="$(rand_secret 48)" NEW_BOOTSTRAP_PASSWORD="$(rand_secret 32)" POSTGRES_USER_VALUE="$(read_env_value "POSTGRES_USER" "$ENV_OUT")" POSTGRES_DB_VALUE="$(read_env_value "POSTGRES_DB" "$ENV_OUT")" DATABASE_URL_VALUE="$(read_env_value "DATABASE_URL" "$ENV_OUT")" if [[ -z "$POSTGRES_USER_VALUE" ]]; then POSTGRES_USER_VALUE="postgres" fi if [[ -z "$POSTGRES_DB_VALUE" ]]; then POSTGRES_DB_VALUE="legal" fi NEW_DATABASE_URL="$(db_url_with_password "$DATABASE_URL_VALUE" "$POSTGRES_USER_VALUE" "$NEW_POSTGRES_PASSWORD" "$POSTGRES_DB_VALUE")" echo "[2/5] Writing rotated internal secrets into $ENV_OUT..." upsert_env_value "APP_ENV" "prod" "$ENV_OUT" upsert_env_value "PRODUCTION_ENFORCE_SECURE_SETTINGS" "true" "$ENV_OUT" upsert_env_value "OTP_DEV_MODE" "false" "$ENV_OUT" upsert_env_value "ADMIN_BOOTSTRAP_ENABLED" "false" "$ENV_OUT" upsert_env_value "PUBLIC_COOKIE_SECURE" "true" "$ENV_OUT" upsert_env_value "S3_USE_SSL" "true" "$ENV_OUT" upsert_env_value "S3_VERIFY_SSL" "true" "$ENV_OUT" upsert_env_value "S3_CA_CERT_PATH" "/etc/ssl/minio/ca.crt" "$ENV_OUT" upsert_env_value "MINIO_TLS_ENABLED" "true" "$ENV_OUT" upsert_env_value "PUBLIC_STRICT_ORIGIN_CHECK" "true" "$ENV_OUT" upsert_env_value "CORS_ALLOW_METHODS" "GET,POST,PUT,PATCH,DELETE,OPTIONS" "$ENV_OUT" upsert_env_value "CORS_ALLOW_HEADERS" "Authorization,Content-Type,X-Requested-With,X-Request-ID" "$ENV_OUT" upsert_env_value "CORS_ALLOW_CREDENTIALS" "true" "$ENV_OUT" upsert_env_value "ADMIN_AUTH_MODE" "password_totp_required" "$ENV_OUT" upsert_env_value "ADMIN_JWT_SECRET" "$NEW_ADMIN_JWT_SECRET" "$ENV_OUT" upsert_env_value "PUBLIC_JWT_SECRET" "$NEW_PUBLIC_JWT_SECRET" "$ENV_OUT" upsert_env_value "DATA_ENCRYPTION_SECRET" "$NEW_DATA_ENCRYPTION_SECRET" "$ENV_OUT" upsert_env_value "CHAT_ENCRYPTION_SECRET" "$NEW_CHAT_ENCRYPTION_SECRET" "$ENV_OUT" upsert_env_value "DATA_ENCRYPTION_ACTIVE_KID" "$NEW_ENC_KID" "$ENV_OUT" upsert_env_value "CHAT_ENCRYPTION_ACTIVE_KID" "$NEW_ENC_KID" "$ENV_OUT" upsert_env_value "DATA_ENCRYPTION_KEYS" "${NEW_ENC_KID}=${NEW_DATA_ENCRYPTION_SECRET}" "$ENV_OUT" upsert_env_value "CHAT_ENCRYPTION_KEYS" "${NEW_ENC_KID}=${NEW_CHAT_ENCRYPTION_SECRET}" "$ENV_OUT" upsert_env_value "INTERNAL_SERVICE_TOKEN" "$NEW_INTERNAL_SERVICE_TOKEN" "$ENV_OUT" upsert_env_value "POSTGRES_PASSWORD" "$NEW_POSTGRES_PASSWORD" "$ENV_OUT" upsert_env_value "DATABASE_URL" "$NEW_DATABASE_URL" "$ENV_OUT" upsert_env_value "MINIO_ROOT_USER" "$NEW_MINIO_ROOT_USER" "$ENV_OUT" upsert_env_value "MINIO_ROOT_PASSWORD" "$NEW_MINIO_ROOT_PASSWORD" "$ENV_OUT" upsert_env_value "S3_ACCESS_KEY" "$NEW_S3_ACCESS_KEY" "$ENV_OUT" upsert_env_value "S3_SECRET_KEY" "$NEW_S3_SECRET_KEY" "$ENV_OUT" upsert_env_value "ADMIN_BOOTSTRAP_PASSWORD" "$NEW_BOOTSTRAP_PASSWORD" "$ENV_OUT" if [[ "$APPLY_RUNNING" -eq 0 ]]; then echo "[3/5] Completed in prepare mode." echo "Generated file: $ENV_OUT" echo "Next step: run with --apply-running to update live stack." exit 0 fi if [[ ! -f ".env" ]]; then echo "[ERROR] .env not found for --apply-running mode" >&2 exit 1 fi if [[ "$CONFIRM_TOKEN_INPUT" != "$REQUIRED_CONFIRM_TOKEN" ]]; then echo "[ERROR] Invalid or missing confirmation token." >&2 echo "Pass: --require-confirmation-token $REQUIRED_CONFIRM_TOKEN" >&2 exit 1 fi if [[ "$NON_INTERACTIVE" -eq 0 ]]; then echo "WARNING: applying rotated secrets to running production stack." echo "This will recreate services and invalidate active auth sessions." read -r -p "Type $REQUIRED_CONFIRM_TOKEN to continue: " typed_token if [[ "$typed_token" != "$REQUIRED_CONFIRM_TOKEN" ]]; then echo "[ABORT] Confirmation token mismatch." >&2 exit 1 fi fi TIMESTAMP="$(date +%Y%m%d-%H%M%S)" BACKUP_FILE=".env.backup.${TIMESTAMP}" echo "[3/5] Backing up and activating new .env..." cp .env "$BACKUP_FILE" chmod 600 "$BACKUP_FILE" cp "$ENV_OUT" .env chmod 600 .env if [[ "$SKIP_DB_ROTATE" -eq 0 ]]; then echo "[4/5] Rotating Postgres user password inside DB..." docker compose "${COMPOSE_ARGS[@]}" up -d db >/dev/null docker compose "${COMPOSE_ARGS[@]}" exec -T db \ psql -U "$POSTGRES_USER_VALUE" -d postgres \ -c "ALTER USER \"$POSTGRES_USER_VALUE\" WITH PASSWORD '$NEW_POSTGRES_PASSWORD';" else echo "[4/5] Skipped DB password ALTER USER (--skip-db-rotate)." fi if [[ "$SKIP_RESTART" -eq 0 ]]; then echo "[5/5] Recreating stack, applying migrations, and checking health..." docker compose "${COMPOSE_ARGS[@]}" up -d --build --force-recreate --remove-orphans docker compose "${COMPOSE_ARGS[@]}" exec -T backend alembic upgrade head curl -fsS http://localhost/health >/dev/null curl -fsS http://localhost/chat-health >/dev/null curl -fsS http://localhost/email-health >/dev/null else echo "[5/5] Skipped stack restart/migrations/health checks (--skip-restart)." fi echo "Rotation completed." echo "Backup file: $BACKUP_FILE" echo "Active env: .env" echo "Generated env snapshot: $ENV_OUT"