Files
gochat/backend/scripts/db_backup.sh
T
Rogeeandrogee 6d6d80dd86 HH-441: harden durable storage and recovery (#92)
* HH-441: harden durable storage and recovery

* HH-441: clear recovery review blockers

* HH-441: enforce offsite backup failure domain

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-22 02:16:02 +08:00

63 lines
2.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# Create one encrypted, off-site copy containing PostgreSQL, attachments, and
# the Connector's online SQLite backup.
set -euo pipefail
umask 077
dsn=${GOCHAT_DATABASE_DSN:?GOCHAT_DATABASE_DSN is required}
storage=${GOCHAT_STORAGE_PATH:?GOCHAT_STORAGE_PATH is required}
connector=${GOCHAT_CONNECTOR_BACKUP_FILE:?GOCHAT_CONNECTOR_BACKUP_FILE is required}
backup_dir=${GOCHAT_BACKUP_DIR:-/var/backups/gochat}
offsite_dir=${GOCHAT_BACKUP_OFFSITE_DIR:?GOCHAT_BACKUP_OFFSITE_DIR is required}
passphrase_file=${GOCHAT_BACKUP_PASSPHRASE_FILE:?GOCHAT_BACKUP_PASSPHRASE_FILE is required}
retention_days=${GOCHAT_BACKUP_RETENTION_DAYS:-30}
version=${GOCHAT_VERSION:-unknown}
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
[[ -d "$storage" ]] || { echo "storage path does not exist: $storage" >&2; exit 1; }
[[ -z "$(find "$storage" -type l -print -quit)" ]] || { echo "storage must not contain symbolic links" >&2; exit 1; }
[[ -f "$connector" ]] || { echo "connector backup does not exist: $connector" >&2; exit 1; }
[[ -r "$passphrase_file" ]] || { echo "backup passphrase file is not readable" >&2; exit 1; }
server_major=$(($(psql "$dsn" -Atqc "SHOW server_version_num") / 10000))
client_major=$(pg_dump --version | awk '{print $NF}' | cut -d. -f1)
[[ "$client_major" == "$server_major" ]] || {
echo "pg_dump major $client_major must match PostgreSQL major $server_major" >&2
exit 1
}
install -d -m 0700 "$backup_dir" "$offsite_dir"
[[ "$(realpath "$backup_dir")" != "$(realpath "$offsite_dir")" ]] || {
echo "off-site directory must use a different path/failure domain" >&2
exit 1
}
work_dir=$(mktemp -d "$backup_dir/.backup.XXXXXX")
trap 'rm -rf "$work_dir"' EXIT
stage=$work_dir/snapshot
mkdir "$stage"
pg_dump --dbname "$dsn" --format=custom --compress=9 --file "$stage/postgres.dump"
pg_restore --list "$stage/postgres.dump" >/dev/null
tar -C "$storage" -cf "$stage/attachments.tar" .
cp "$connector" "$stage/connector.db"
created_at_epoch=$(date -u +%s)
{
echo "created_at=$timestamp"
echo "created_at_epoch=$created_at_epoch"
echo "version=$version"
echo "rpo_target_seconds=86400"
} >"$stage/manifest"
(cd "$stage" && sha256sum postgres.dump attachments.tar connector.db manifest >SHA256SUMS)
bundle=$backup_dir/gochat-$timestamp.tar.enc
tar -C "$stage" -cf - . | openssl enc -aes-256-cbc -pbkdf2 -salt -pass "file:$passphrase_file" -out "$bundle"
(cd "$backup_dir" && sha256sum "$(basename "$bundle")" >"$(basename "$bundle").sha256")
openssl enc -d -aes-256-cbc -pbkdf2 -pass "file:$passphrase_file" -in "$bundle" | tar -tf - >/dev/null
cp "$bundle" "$bundle.sha256" "$offsite_dir/"
find "$backup_dir" "$offsite_dir" -maxdepth 1 -type f -name 'gochat-*.tar.enc*' -mtime "+$retention_days" -delete
echo "backup=$bundle offsite=$offsite_dir/$(basename "$bundle") version=$version created_at=$timestamp"