#!/usr/bin/env bash set -Eeuo pipefail APP_DIR="${APP_DIR:-/opt/proxpanel}" ENV_FILE="${ENV_FILE:-$APP_DIR/.env.production}" COMPOSE_FILE="${COMPOSE_FILE:-$APP_DIR/infra/deploy/docker-compose.production.yml}" BACKUP_ROOT="${BACKUP_ROOT:-/opt/proxpanel-backups}" usage() { cat <<'EOF' Usage: sudo bash infra/deploy/rotate-production-secrets.sh What this rotates: - JWT_SECRET - JWT_REFRESH_SECRET - POSTGRES_PASSWORD - ADMIN_PASSWORD Safety: - Creates backup in /opt/proxpanel-backups/-secret-rotation/ - Dumps DB before password change - Verifies API health and admin login after rotation EOF } log() { printf '\n[%s] %s\n' "$(date -u +'%Y-%m-%d %H:%M:%S UTC')" "$*" } die() { printf '\n[ERROR] %s\n' "$*" >&2 exit 1 } require_root() { if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then die "Run as root (or with sudo)." fi } require_file() { local file="$1" [[ -f "$file" ]] || die "Missing required file: $file" } require_command() { local cmd="$1" command -v "$cmd" >/dev/null 2>&1 || die "Missing required command: $cmd" } escape_sed_replacement() { local value="$1" value="${value//\\/\\\\}" value="${value//&/\\&}" printf '%s' "$value" } update_env_value() { local key="$1" local value="$2" local escaped escaped="$(escape_sed_replacement "$value")" if grep -q "^${key}=" "$ENV_FILE"; then sed -i "s/^${key}=.*/${key}=${escaped}/" "$ENV_FILE" else printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" fi } random_password() { openssl rand -base64 36 | tr -d '\n' | tr '/+' 'ab' } random_hex() { openssl rand -hex 32 | tr -d '\n' } wait_for_health() { local max_tries=60 local i for ((i=1; i<=max_tries; i++)); do if curl -fsS "http://127.0.0.1:${BACKEND_PORT}/api/health" >/dev/null 2>&1; then return fi sleep 2 done die "Backend health check failed after secret rotation." } main() { if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi require_root require_command docker require_command curl require_command openssl require_command rsync require_file "$ENV_FILE" require_file "$COMPOSE_FILE" # shellcheck disable=SC1090 source "$ENV_FILE" local ts backup_dir compose_args ts="$(date -u +%Y%m%d-%H%M%S)" backup_dir="${BACKUP_ROOT}/${ts}-secret-rotation" compose_args=(--env-file "$ENV_FILE" -f "$COMPOSE_FILE") local old_admin_email old_admin_password old_postgres_user old_postgres_db old_backend_port old_admin_email="${ADMIN_EMAIL:-admin@proxpanel.local}" old_admin_password="${ADMIN_PASSWORD:-}" old_postgres_user="${POSTGRES_USER:-proxpanel}" old_postgres_db="${POSTGRES_DB:-proxpanel}" old_backend_port="${BACKEND_PORT:-8080}" local new_jwt_secret new_jwt_refresh_secret new_postgres_password new_admin_password new_jwt_secret="$(random_password)$(random_password)" new_jwt_refresh_secret="$(random_password)$(random_password)" new_postgres_password="$(random_hex)" new_admin_password="$(random_password)A9!" log "Creating pre-rotation backups in $backup_dir" mkdir -p "$backup_dir" cp "$ENV_FILE" "$backup_dir/.env.production.bak" rsync -a "$APP_DIR/" "$backup_dir/app/" docker exec proxpanel-postgres pg_dump -U "$old_postgres_user" -d "$old_postgres_db" >"$backup_dir/db_pre_rotation.sql" log "Updating env file with rotated secrets" update_env_value "JWT_SECRET" "$new_jwt_secret" update_env_value "JWT_REFRESH_SECRET" "$new_jwt_refresh_secret" update_env_value "POSTGRES_PASSWORD" "$new_postgres_password" update_env_value "ADMIN_PASSWORD" "$new_admin_password" # Re-load env values after edits. # shellcheck disable=SC1090 source "$ENV_FILE" log "Applying Postgres password rotation" docker exec proxpanel-postgres psql -U "$old_postgres_user" -d "$old_postgres_db" -v ON_ERROR_STOP=1 \ -c "ALTER USER \"$old_postgres_user\" WITH PASSWORD '$new_postgres_password';" log "Restarting stack with new secrets" ( cd "$APP_DIR" docker compose "${compose_args[@]}" up -d ) BACKEND_PORT="${BACKEND_PORT:-$old_backend_port}" wait_for_health log "Re-seeding admin credential to match rotated ADMIN_PASSWORD" ( cd "$APP_DIR" docker compose "${compose_args[@]}" exec -T backend npm run prisma:seed ) log "Revoking all active auth sessions after JWT/password rotation" docker exec proxpanel-postgres psql -U "${POSTGRES_USER:-$old_postgres_user}" -d "${POSTGRES_DB:-$old_postgres_db}" \ -v ON_ERROR_STOP=1 -c 'TRUNCATE TABLE "AuthSession" RESTART IDENTITY;' log "Verifying post-rotation login" local login_status login_status="$( curl -sS -o /tmp/proxpanel-rotate-login.json -w "%{http_code}" \ -X POST "http://127.0.0.1:${BACKEND_PORT}/api/auth/login" \ -H "Content-Type: application/json" \ -d "{\"email\":\"${ADMIN_EMAIL}\",\"password\":\"${ADMIN_PASSWORD}\"}" )" [[ "$login_status" == "200" ]] || die "Admin login failed after secret rotation (status $login_status)." local summary_file summary_file="/root/proxpanel-secret-rotation-${ts}.txt" cat >"$summary_file" <