ops: add production hardening automation for secrets, backups, and rollback
This commit is contained in:
68
infra/deploy/SECRET_ROTATION_CHECKLIST.md
Normal file
68
infra/deploy/SECRET_ROTATION_CHECKLIST.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Secret Rotation Checklist (Production)
|
||||||
|
|
||||||
|
Target: `my.votcloud.com`
|
||||||
|
Host: `102.69.243.167`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Rotate the following regularly (monthly/quarterly or after any incident):
|
||||||
|
|
||||||
|
1. `JWT_SECRET`
|
||||||
|
2. `JWT_REFRESH_SECRET`
|
||||||
|
3. `POSTGRES_PASSWORD`
|
||||||
|
4. `ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
1. `BACKUP_ENCRYPTION_KEY` (with controlled key migration plan)
|
||||||
|
2. Proxmox API token secret
|
||||||
|
3. Payment/webhook secrets
|
||||||
|
|
||||||
|
## Runbook (Safe Order)
|
||||||
|
|
||||||
|
1. Create timestamped app/env/db backup.
|
||||||
|
2. Rotate env secrets in `.env.production`.
|
||||||
|
3. Apply DB password rotation (`ALTER USER ... WITH PASSWORD ...`).
|
||||||
|
4. Restart app stack with new env.
|
||||||
|
5. Re-seed admin (`npm run prisma:seed`) to sync rotated admin password.
|
||||||
|
6. Revoke all active sessions (`AuthSession`) to invalidate old sessions.
|
||||||
|
7. Verify:
|
||||||
|
- `GET /api/health`
|
||||||
|
- Admin login
|
||||||
|
- Core pages (`/rbac`, `/profile`, `/system`, `/audit-logs`)
|
||||||
|
8. Save secure summary with new admin credentials under `/root/`.
|
||||||
|
|
||||||
|
## Automation Script
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/proxpanel/infra/deploy/rotate-production-secrets.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Script guarantees:
|
||||||
|
|
||||||
|
1. Backup directory created in `/opt/proxpanel-backups/<timestamp>-secret-rotation/`
|
||||||
|
2. DB pre-rotation dump created
|
||||||
|
3. Post-rotation health + login verified
|
||||||
|
4. Summary written to `/root/proxpanel-secret-rotation-<timestamp>.txt`
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If post-rotation checks fail:
|
||||||
|
|
||||||
|
1. Restore `.env.production` from backup.
|
||||||
|
2. Restore previous app files if needed.
|
||||||
|
3. Restore DB dump if schema/state corruption occurred.
|
||||||
|
4. Recreate containers:
|
||||||
|
- `docker compose --env-file .env.production -f infra/deploy/docker-compose.production.yml up -d --build`
|
||||||
|
|
||||||
|
## Audit Trail
|
||||||
|
|
||||||
|
Store:
|
||||||
|
|
||||||
|
1. Rotation timestamp
|
||||||
|
2. Operator identity
|
||||||
|
3. Backup directory used
|
||||||
|
4. Health verification evidence
|
||||||
|
5. Any rollback events
|
||||||
87
infra/deploy/configure-db-backup-cron.sh
Executable file
87
infra/deploy/configure-db-backup-cron.sh
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/opt/proxpanel}"
|
||||||
|
SECRET_FILE="${SECRET_FILE:-$APP_DIR/.backup.env}"
|
||||||
|
CRON_FILE="${CRON_FILE:-/etc/cron.d/proxpanel-db-backup}"
|
||||||
|
BACKUP_LOG="${BACKUP_LOG:-/var/log/proxpanel-db-backup.log}"
|
||||||
|
RESTORE_LOG="${RESTORE_LOG:-/var/log/proxpanel-db-restore-test.log}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
sudo bash infra/deploy/configure-db-backup-cron.sh [--run-now]
|
||||||
|
|
||||||
|
Default schedule (UTC):
|
||||||
|
- 02:15 daily: encrypted DB backup
|
||||||
|
- 02:45 daily: restore test against latest encrypted backup
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[%s] %s\n' "$(date -u +'%Y-%m-%d %H:%M:%S UTC')" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf '[ERROR] %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
random_key() {
|
||||||
|
openssl rand -hex 64 | tr -d '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local run_now="false"
|
||||||
|
if [[ "${1:-}" == "--run-now" ]]; then
|
||||||
|
run_now="true"
|
||||||
|
elif [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
elif [[ -n "${1:-}" ]]; then
|
||||||
|
die "Unknown argument: $1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Run as root (or with sudo)."
|
||||||
|
[[ -d "$APP_DIR" ]] || die "Missing app dir: $APP_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f "$SECRET_FILE" ]]; then
|
||||||
|
log "Creating $SECRET_FILE"
|
||||||
|
umask 077
|
||||||
|
cat >"$SECRET_FILE" <<EOF
|
||||||
|
BACKUP_ENCRYPTION_KEY=$(random_key)
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
chmod 600 "$SECRET_FILE"
|
||||||
|
|
||||||
|
log "Making scripts executable"
|
||||||
|
chmod +x \
|
||||||
|
"$APP_DIR/infra/deploy/db-backup-encrypted.sh" \
|
||||||
|
"$APP_DIR/infra/deploy/db-restore-test.sh"
|
||||||
|
|
||||||
|
log "Installing cron schedule at $CRON_FILE"
|
||||||
|
cat >"$CRON_FILE" <<'EOF'
|
||||||
|
SHELL=/bin/bash
|
||||||
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
|
# Encrypted PostgreSQL backup every day at 02:15 UTC
|
||||||
|
15 2 * * * root APP_DIR=/opt/proxpanel /opt/proxpanel/infra/deploy/db-backup-encrypted.sh >> /var/log/proxpanel-db-backup.log 2>&1
|
||||||
|
|
||||||
|
# Restore test every day at 02:45 UTC
|
||||||
|
45 2 * * * root APP_DIR=/opt/proxpanel /opt/proxpanel/infra/deploy/db-restore-test.sh >> /var/log/proxpanel-db-restore-test.log 2>&1
|
||||||
|
EOF
|
||||||
|
chmod 644 "$CRON_FILE"
|
||||||
|
|
||||||
|
touch "$BACKUP_LOG" "$RESTORE_LOG"
|
||||||
|
chmod 640 "$BACKUP_LOG" "$RESTORE_LOG"
|
||||||
|
|
||||||
|
if [[ "$run_now" == "true" ]]; then
|
||||||
|
log "Running immediate backup + restore test"
|
||||||
|
APP_DIR="$APP_DIR" "$APP_DIR/infra/deploy/db-backup-encrypted.sh"
|
||||||
|
APP_DIR="$APP_DIR" "$APP_DIR/infra/deploy/db-restore-test.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "DB backup/restore cron configured successfully."
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
74
infra/deploy/db-backup-encrypted.sh
Executable file
74
infra/deploy/db-backup-encrypted.sh
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/opt/proxpanel}"
|
||||||
|
ENV_FILE="${ENV_FILE:-$APP_DIR/.env.production}"
|
||||||
|
SECRET_FILE="${SECRET_FILE:-$APP_DIR/.backup.env}"
|
||||||
|
BACKUP_ROOT="${BACKUP_ROOT:-/opt/proxpanel-backups/daily}"
|
||||||
|
RETENTION_DAYS="${RETENTION_DAYS:-14}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[%s] %s\n' "$(date -u +'%Y-%m-%d %H:%M:%S UTC')" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf '[ERROR] %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
[[ -f "$1" ]] || die "Missing required file: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
require_command docker
|
||||||
|
require_command openssl
|
||||||
|
require_command sha256sum
|
||||||
|
require_file "$ENV_FILE"
|
||||||
|
require_file "$SECRET_FILE"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$SECRET_FILE"
|
||||||
|
|
||||||
|
[[ -n "${BACKUP_ENCRYPTION_KEY:-}" ]] || die "BACKUP_ENCRYPTION_KEY is empty in $SECRET_FILE"
|
||||||
|
export BACKUP_ENCRYPTION_KEY
|
||||||
|
[[ -n "${POSTGRES_USER:-}" ]] || die "POSTGRES_USER missing in $ENV_FILE"
|
||||||
|
[[ -n "${POSTGRES_DB:-}" ]] || die "POSTGRES_DB missing in $ENV_FILE"
|
||||||
|
|
||||||
|
local ts backup_dir plain_sql encrypted_sql
|
||||||
|
ts="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
backup_dir="${BACKUP_ROOT}/${ts}"
|
||||||
|
plain_sql="${backup_dir}/proxpanel.sql"
|
||||||
|
encrypted_sql="${plain_sql}.enc"
|
||||||
|
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
chmod 700 "$backup_dir"
|
||||||
|
|
||||||
|
log "Creating plaintext dump"
|
||||||
|
docker exec proxpanel-postgres pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists >"$plain_sql"
|
||||||
|
|
||||||
|
log "Encrypting dump"
|
||||||
|
openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \
|
||||||
|
-in "$plain_sql" \
|
||||||
|
-out "$encrypted_sql" \
|
||||||
|
-pass env:BACKUP_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
log "Writing checksum"
|
||||||
|
sha256sum "$encrypted_sql" >"${encrypted_sql}.sha256"
|
||||||
|
|
||||||
|
rm -f "$plain_sql"
|
||||||
|
chmod 600 "$encrypted_sql" "${encrypted_sql}.sha256"
|
||||||
|
|
||||||
|
log "Applying retention policy (${RETENTION_DAYS} days)"
|
||||||
|
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" -exec rm -rf {} +
|
||||||
|
|
||||||
|
log "Encrypted backup created: $encrypted_sql"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
114
infra/deploy/db-restore-test.sh
Executable file
114
infra/deploy/db-restore-test.sh
Executable file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/opt/proxpanel}"
|
||||||
|
SECRET_FILE="${SECRET_FILE:-$APP_DIR/.backup.env}"
|
||||||
|
BACKUP_ROOT="${BACKUP_ROOT:-/opt/proxpanel-backups/daily}"
|
||||||
|
TMP_ROOT="${TMP_ROOT:-/tmp/proxpanel-restore-test}"
|
||||||
|
TEST_CONTAINER="${TEST_CONTAINER:-proxpanel-restore-test}"
|
||||||
|
PG_IMAGE="${PG_IMAGE:-postgres:16-alpine}"
|
||||||
|
PG_USER="${PG_USER:-proxpanel}"
|
||||||
|
PG_PASSWORD="${PG_PASSWORD:-restoretestpass}"
|
||||||
|
PG_DB="${PG_DB:-proxpanel_restore}"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "$TEST_CONTAINER" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$TMP_ROOT"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[%s] %s\n' "$(date -u +'%Y-%m-%d %H:%M:%S UTC')" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf '[ERROR] %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
[[ -f "$1" ]] || die "Missing required file: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
find_latest_encrypted_backup() {
|
||||||
|
find "$BACKUP_ROOT" -mindepth 2 -maxdepth 2 -type f -name 'proxpanel.sql.enc' | sort | tail -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_pg_ready() {
|
||||||
|
local tries=60
|
||||||
|
local i
|
||||||
|
for ((i=1; i<=tries; i++)); do
|
||||||
|
if docker exec "$TEST_CONTAINER" pg_isready -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
die "Restore test postgres did not become ready."
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
require_command docker
|
||||||
|
require_command openssl
|
||||||
|
require_command sha256sum
|
||||||
|
require_file "$SECRET_FILE"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$SECRET_FILE"
|
||||||
|
[[ -n "${BACKUP_ENCRYPTION_KEY:-}" ]] || die "BACKUP_ENCRYPTION_KEY is empty in $SECRET_FILE"
|
||||||
|
export BACKUP_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
local encrypted_backup checksum_file latest_dir decrypted_sql
|
||||||
|
encrypted_backup="$(find_latest_encrypted_backup)"
|
||||||
|
[[ -n "$encrypted_backup" ]] || die "No encrypted backup found in $BACKUP_ROOT"
|
||||||
|
checksum_file="${encrypted_backup}.sha256"
|
||||||
|
require_file "$checksum_file"
|
||||||
|
|
||||||
|
latest_dir="$(dirname "$encrypted_backup")"
|
||||||
|
mkdir -p "$TMP_ROOT"
|
||||||
|
chmod 700 "$TMP_ROOT"
|
||||||
|
decrypted_sql="${TMP_ROOT}/restore.sql"
|
||||||
|
|
||||||
|
log "Verifying checksum for $encrypted_backup"
|
||||||
|
(cd "$latest_dir" && sha256sum -c "$(basename "$checksum_file")")
|
||||||
|
|
||||||
|
log "Decrypting latest backup"
|
||||||
|
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
|
||||||
|
-in "$encrypted_backup" \
|
||||||
|
-out "$decrypted_sql" \
|
||||||
|
-pass env:BACKUP_ENCRYPTION_KEY
|
||||||
|
|
||||||
|
log "Starting isolated restore-test postgres container"
|
||||||
|
docker rm -f "$TEST_CONTAINER" >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name "$TEST_CONTAINER" \
|
||||||
|
-e POSTGRES_USER="$PG_USER" \
|
||||||
|
-e POSTGRES_PASSWORD="$PG_PASSWORD" \
|
||||||
|
-e POSTGRES_DB="$PG_DB" \
|
||||||
|
"$PG_IMAGE" >/dev/null
|
||||||
|
|
||||||
|
wait_pg_ready
|
||||||
|
|
||||||
|
log "Applying restored SQL into test DB"
|
||||||
|
cat "$decrypted_sql" | docker exec -i "$TEST_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" >/dev/null
|
||||||
|
|
||||||
|
log "Running restore sanity checks"
|
||||||
|
local table_count required_table_count
|
||||||
|
table_count="$(
|
||||||
|
docker exec "$TEST_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -Atc \
|
||||||
|
"select count(*) from information_schema.tables where table_schema='public';"
|
||||||
|
)"
|
||||||
|
required_table_count="$(
|
||||||
|
docker exec "$TEST_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -Atc \
|
||||||
|
"select count(*) from information_schema.tables where table_schema='public' and table_name in ('User','Tenant','AuditLog');"
|
||||||
|
)"
|
||||||
|
|
||||||
|
[[ "${table_count:-0}" -ge 10 ]] || die "Restore sanity check failed (unexpected table count: $table_count)"
|
||||||
|
[[ "${required_table_count:-0}" -eq 3 ]] || die "Restore sanity check failed (required tables missing)"
|
||||||
|
|
||||||
|
log "Restore test passed (tables=$table_count)"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
186
infra/deploy/rollback-blue-green.sh
Executable file
186
infra/deploy/rollback-blue-green.sh
Executable file
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/opt/proxpanel}"
|
||||||
|
BACKUP_ROOT="${BACKUP_ROOT:-/opt/proxpanel-backups}"
|
||||||
|
GREEN_DIR="${GREEN_DIR:-/opt/proxpanel-green}"
|
||||||
|
GREEN_PROJECT="${GREEN_PROJECT:-proxpanel_green}"
|
||||||
|
GREEN_FRONTEND_PORT="${GREEN_FRONTEND_PORT:-18081}"
|
||||||
|
GREEN_BACKEND_PORT="${GREEN_BACKEND_PORT:-18080}"
|
||||||
|
NGINX_SITE="${NGINX_SITE:-/etc/nginx/sites-available/proxpanel.conf}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
sudo bash infra/deploy/rollback-blue-green.sh --backup-dir /opt/proxpanel-backups/<timestamp> [--cutover]
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
1) Stages backup app/env into /opt/proxpanel-green
|
||||||
|
2) Boots green stack on ports 18081/18080
|
||||||
|
3) Restores DB dump from selected backup into green postgres
|
||||||
|
4) Verifies green API health
|
||||||
|
5) If --cutover is provided, switches Nginx upstream to green frontend port
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This script requires backup directory format created by deployment steps:
|
||||||
|
<backup-dir>/app/
|
||||||
|
<backup-dir>/env.production.bak (or .env.production.bak)
|
||||||
|
<backup-dir>/db_pre_*.sql
|
||||||
|
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() {
|
||||||
|
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Run as root (or with sudo)."
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
BACKUP_DIR=""
|
||||||
|
CUTOVER="false"
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--backup-dir)
|
||||||
|
BACKUP_DIR="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--cutover)
|
||||||
|
CUTOVER="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$BACKUP_DIR" ]] || die "--backup-dir is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
pick_db_dump() {
|
||||||
|
find "$BACKUP_DIR" -maxdepth 1 -type f -name 'db_pre*.sql' | sort | tail -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pick_env_backup() {
|
||||||
|
if [[ -f "$BACKUP_DIR/env.production.bak" ]]; then
|
||||||
|
printf '%s' "$BACKUP_DIR/env.production.bak"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -f "$BACKUP_DIR/.env.production.bak" ]]; then
|
||||||
|
printf '%s' "$BACKUP_DIR/.env.production.bak"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
printf ''
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_green_health() {
|
||||||
|
local i
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
if curl -fsS "http://127.0.0.1:${GREEN_BACKEND_PORT}/api/health" >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
die "Green backend health check failed."
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
require_root
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
[[ -d "$BACKUP_DIR/app" ]] || die "Missing backup app directory: $BACKUP_DIR/app"
|
||||||
|
local env_backup
|
||||||
|
env_backup="$(pick_env_backup)"
|
||||||
|
[[ -n "$env_backup" ]] || die "Missing env backup in $BACKUP_DIR (.env.production.bak or env.production.bak)"
|
||||||
|
local db_dump
|
||||||
|
db_dump="$(pick_db_dump)"
|
||||||
|
[[ -n "$db_dump" ]] || die "Missing DB dump in backup dir: $BACKUP_DIR"
|
||||||
|
|
||||||
|
log "Preparing green staging directory at $GREEN_DIR"
|
||||||
|
rm -rf "$GREEN_DIR"
|
||||||
|
mkdir -p "$GREEN_DIR"
|
||||||
|
rsync -a "$BACKUP_DIR/app/" "$GREEN_DIR/"
|
||||||
|
cp "$env_backup" "$GREEN_DIR/.env.production"
|
||||||
|
|
||||||
|
# Override ports for green stack and keep CORS aligned to production hostname.
|
||||||
|
if grep -q '^FRONTEND_PORT=' "$GREEN_DIR/.env.production"; then
|
||||||
|
sed -i "s/^FRONTEND_PORT=.*/FRONTEND_PORT=${GREEN_FRONTEND_PORT}/" "$GREEN_DIR/.env.production"
|
||||||
|
else
|
||||||
|
echo "FRONTEND_PORT=${GREEN_FRONTEND_PORT}" >> "$GREEN_DIR/.env.production"
|
||||||
|
fi
|
||||||
|
if grep -q '^BACKEND_PORT=' "$GREEN_DIR/.env.production"; then
|
||||||
|
sed -i "s/^BACKEND_PORT=.*/BACKEND_PORT=${GREEN_BACKEND_PORT}/" "$GREEN_DIR/.env.production"
|
||||||
|
else
|
||||||
|
echo "BACKEND_PORT=${GREEN_BACKEND_PORT}" >> "$GREEN_DIR/.env.production"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove fixed container_name lines for project-isolated blue/green operation.
|
||||||
|
local compose_green
|
||||||
|
compose_green="$GREEN_DIR/infra/deploy/docker-compose.green.yml"
|
||||||
|
awk '!/^[[:space:]]*container_name:/' "$GREEN_DIR/infra/deploy/docker-compose.production.yml" > "$compose_green"
|
||||||
|
|
||||||
|
log "Stopping previous green project (if any)"
|
||||||
|
(
|
||||||
|
cd "$GREEN_DIR"
|
||||||
|
docker compose -p "$GREEN_PROJECT" --env-file .env.production -f "$compose_green" down -v || true
|
||||||
|
)
|
||||||
|
|
||||||
|
log "Starting green stack on ${GREEN_FRONTEND_PORT}/${GREEN_BACKEND_PORT}"
|
||||||
|
(
|
||||||
|
cd "$GREEN_DIR"
|
||||||
|
docker compose -p "$GREEN_PROJECT" --env-file .env.production -f "$compose_green" up -d --build
|
||||||
|
)
|
||||||
|
|
||||||
|
log "Restoring backup DB into green postgres"
|
||||||
|
local green_postgres_id green_pg_user green_pg_db
|
||||||
|
green_postgres_id="$(
|
||||||
|
cd "$GREEN_DIR" && docker compose -p "$GREEN_PROJECT" --env-file .env.production -f "$compose_green" ps -q postgres
|
||||||
|
)"
|
||||||
|
[[ -n "$green_postgres_id" ]] || die "Could not locate green postgres container."
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$GREEN_DIR/.env.production"
|
||||||
|
green_pg_user="${POSTGRES_USER:-proxpanel}"
|
||||||
|
green_pg_db="${POSTGRES_DB:-proxpanel}"
|
||||||
|
|
||||||
|
docker exec "$green_postgres_id" psql -U "$green_pg_user" -d "$green_pg_db" -v ON_ERROR_STOP=1 \
|
||||||
|
-c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
||||||
|
|
||||||
|
cat "$db_dump" | docker exec -i "$green_postgres_id" psql -U "$green_pg_user" -d "$green_pg_db" -v ON_ERROR_STOP=1 >/dev/null
|
||||||
|
|
||||||
|
log "Validating green health"
|
||||||
|
wait_for_green_health
|
||||||
|
curl -fsS "http://127.0.0.1:${GREEN_BACKEND_PORT}/api/health" >/dev/null
|
||||||
|
|
||||||
|
if [[ "$CUTOVER" == "true" ]]; then
|
||||||
|
log "Cutting over Nginx upstream to green frontend port ${GREEN_FRONTEND_PORT}"
|
||||||
|
[[ -f "$NGINX_SITE" ]] || die "Missing nginx site config: $NGINX_SITE"
|
||||||
|
cp "$NGINX_SITE" "${NGINX_SITE}.pre-green.$(date -u +%Y%m%d-%H%M%S).bak"
|
||||||
|
sed -i -E "s#proxy_pass http://127\\.0\\.0\\.1:[0-9]+;#proxy_pass http://127.0.0.1:${GREEN_FRONTEND_PORT};#g" "$NGINX_SITE"
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
log "Nginx cutover complete."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Blue/green rollback prep complete."
|
||||||
|
printf '\nGreen frontend: http://127.0.0.1:%s\n' "$GREEN_FRONTEND_PORT"
|
||||||
|
printf 'Green backend : http://127.0.0.1:%s/api/health\n' "$GREEN_BACKEND_PORT"
|
||||||
|
if [[ "$CUTOVER" == "true" ]]; then
|
||||||
|
printf 'Traffic switched to green.\n'
|
||||||
|
else
|
||||||
|
printf 'Traffic still on blue. Re-run with --cutover when ready.\n'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
209
infra/deploy/rotate-production-secrets.sh
Executable file
209
infra/deploy/rotate-production-secrets.sh
Executable 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 "$@"
|
||||||
Reference in New Issue
Block a user