210 lines
5.7 KiB
Bash
Executable File
210 lines
5.7 KiB
Bash
Executable File
#!/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 "$@"
|