#!/usr/bin/env bash # Restore an encrypted GoChat backup into an empty database/storage target and # print auditable RPO/RTO and business-integrity evidence. set -euo pipefail umask 077 script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) "$script_dir/database_client_entrypoint.sh" --check bundle=${1:?usage: db_restore.sh /path/to/gochat-*.tar.enc} dsn=${GOCHAT_DATABASE_DSN:?GOCHAT_DATABASE_DSN is required} storage=${GOCHAT_STORAGE_PATH:?GOCHAT_STORAGE_PATH is required} connector=${GOCHAT_CONNECTOR_DB_PATH:?GOCHAT_CONNECTOR_DB_PATH is required} passphrase_file=${GOCHAT_BACKUP_PASSPHRASE_FILE:?GOCHAT_BACKUP_PASSPHRASE_FILE is required} started_at=$(date -u +%s) [[ -f "$bundle" ]] || { echo "backup bundle does not exist: $bundle" >&2; exit 1; } [[ -f "$bundle.sha256" ]] || { echo "backup checksum does not exist: $bundle.sha256" >&2; exit 1; } (cd "$(dirname "$bundle")" && sha256sum -c "$(basename "$bundle").sha256") server_major=$(($(psql "$dsn" -Atqc "SHOW server_version_num") / 10000)) client_major=$(pg_restore --version | awk '{print $NF}' | cut -d. -f1) [[ "$client_major" == "$server_major" ]] || { echo "pg_restore major $client_major must match PostgreSQL major $server_major" >&2 exit 1 } table_count=$(psql "$dsn" -Atqc "SELECT count(*) FROM pg_tables WHERE schemaname = 'public'") [[ "$table_count" == "0" ]] || { echo "target database is not empty" >&2; exit 1; } install -d -m 0700 "$storage" "$(dirname "$connector")" [[ -z "$(find "$storage" -mindepth 1 -maxdepth 1 -print -quit)" ]] || { echo "target storage is not empty" >&2; exit 1; } [[ ! -e "$connector" ]] || { echo "target connector database already exists" >&2; exit 1; } work_dir=$(mktemp -d "$(dirname "$bundle")/.restore.XXXXXX") trap 'rm -rf "$work_dir"' EXIT archive=$work_dir/snapshot.tar openssl enc -d -aes-256-cbc -pbkdf2 -pass "file:$passphrase_file" -in "$bundle" -out "$archive" while IFS= read -r path; do case "$path" in /*|../*|*/../*|*/..) echo "unsafe archive path: $path" >&2; exit 1 ;; esac done < <(tar -tf "$archive") if tar -tvf "$archive" | awk '{type = substr($1, 1, 1); if (type != "-" && type != "d") found = 1} END {exit(found ? 0 : 1)}'; then echo "backup archive contains unsupported entry types" >&2 exit 1 fi tar -C "$work_dir" -xf "$archive" rm "$archive" (cd "$work_dir" && sha256sum -c SHA256SUMS) pg_restore --list "$work_dir/postgres.dump" >/dev/null pg_restore --exit-on-error --no-owner --no-privileges --dbname "$dsn" "$work_dir/postgres.dump" while IFS= read -r path; do case "$path" in /*|../*|*/../*|*/..) echo "unsafe attachment path: $path" >&2; exit 1 ;; esac done < <(tar -tf "$work_dir/attachments.tar") if tar -tvf "$work_dir/attachments.tar" | awk '{type = substr($1, 1, 1); if (type != "-" && type != "d") found = 1} END {exit(found ? 0 : 1)}'; then echo "attachment archive contains unsupported entry types" >&2 exit 1 fi tar -C "$storage" -xf "$work_dir/attachments.tar" install -m 0600 "$work_dir/connector.db" "$connector" migration_version=$(psql "$dsn" -Atqc "SELECT COALESCE(MAX(version), 0) FROM schema_migrations WHERE NOT dirty") accounts=$(psql "$dsn" -Atqc "SELECT count(*) FROM accounts WHERE deleted_at IS NULL") attachments=$(psql "$dsn" -Atqc "SELECT count(*) FROM attachments WHERE deleted_at IS NULL") backup_epoch=$(awk -F= '$1 == "created_at_epoch" {print $2}' "$work_dir/manifest") version=$(awk -F= '$1 == "version" {print substr($0, index($0, "=") + 1)}' "$work_dir/manifest") finished_at=$(date -u +%s) echo "restore=ok version=$version migration=$migration_version accounts=$accounts attachments=$attachments rpo_seconds=$((started_at-backup_epoch)) rto_seconds=$((finished_at-started_at))"