ops: add production hardening automation for secrets, backups, and rollback

This commit is contained in:
Austin A
2026-04-18 09:13:13 +01:00
parent 731a833075
commit 95633a6722
6 changed files with 738 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
#!/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/<timestamp>-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" <<EOF
ProxPanel secret rotation completed at $(date -u +'%Y-%m-%d %H:%M:%S UTC')
Backup directory:
$backup_dir
Rotated secrets:
JWT_SECRET
JWT_REFRESH_SECRET
POSTGRES_PASSWORD
ADMIN_PASSWORD
Admin credentials:
ADMIN_EMAIL=${ADMIN_EMAIL}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
Post-rotation checks:
curl -fsS http://127.0.0.1:${BACKEND_PORT}/api/health
curl -X POST http://127.0.0.1:${BACKEND_PORT}/api/auth/login ...
Important:
Change ADMIN_PASSWORD again from Profile page after login.
EOF
chmod 600 "$summary_file"
log "Rotation complete."
printf '\nNew admin email: %s\n' "${ADMIN_EMAIL}"
printf 'New admin password: %s\n' "${ADMIN_PASSWORD}"
printf 'Summary: %s\n' "$summary_file"
}
main "$@"