Add interactive prod backup script for disaster-recovery archives. #10

Merged
nobswebdev merged 1 commits from slave/backups into master 2026-11-14 23:41:44 +00:00
3 changed files with 346 additions and 2 deletions
Showing only changes of commit f60fb7bbaf - Show all commits
+1
View File
@@ -3,6 +3,7 @@
/deploy/certs /deploy/certs
/deploy/certbot/www /deploy/certbot/www
/deploy/backups
/monero-wallet-rpc/wallet /monero-wallet-rpc/wallet
/electrum-daemon/wallet /electrum-daemon/wallet
+10 -2
View File
@@ -159,10 +159,18 @@ Tor Browser will then show ".onion available" when visitors open the clearnet si
3. Finish the setup checklist in settings. 3. Finish the setup checklist in settings.
4. Connect SimpleX notifications in shop settings. 4. Connect SimpleX notifications in shop settings.
## 10. Updates ## Updates
This pulls the latest code and rebuilds the stack (`deploy.sh`).
```bash ```bash
./deploy/scripts/update.sh ./deploy/scripts/update.sh
``` ```
This pulls the latest code and rebuilds the stack (`deploy.sh`). ## Backups
This creates a full disaster-recovery archive.
```bash
./deploy/scripts/backup.sh
```
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENV_FILE="${ROOT_DIR}/.env.prod"
COMPOSE_FILE="${ROOT_DIR}/docker-compose.prod.yml"
BACKUP_DIR="${ROOT_DIR}/deploy/backups"
cd "$ROOT_DIR"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing ${ENV_FILE}. Copy .env.example to .env.prod and configure it." >&2
exit 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
if [[ -z "${COMPOSE_PROJECT_NAME:-}" ]]; then
echo "COMPOSE_PROJECT_NAME is not set in .env.prod" >&2
exit 1
fi
if [[ -z "${POSTGRES_USER:-}" || -z "${POSTGRES_DB:-}" ]]; then
echo "POSTGRES_USER and POSTGRES_DB must be set in .env.prod" >&2
exit 1
fi
if [[ -z "${MONERO_WALLET_DIR:-}" ]]; then
echo "MONERO_WALLET_DIR is not set in .env.prod" >&2
exit 1
fi
if [[ -z "${ELECTRUM_WALLET_DIR:-}" ]]; then
echo "ELECTRUM_WALLET_DIR is not set in .env.prod" >&2
exit 1
fi
compose() {
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
}
resolve_host_path() {
local rel="${1:-}"
local label="$2"
if [[ -z "$rel" ]]; then
echo "Cannot resolve empty path for ${label}" >&2
exit 1
fi
if [[ "$rel" == /* ]]; then
echo "$rel"
else
echo "${ROOT_DIR}/${rel#./}"
fi
}
build_compose_volume_name() {
local vol_key="$1"
echo "${COMPOSE_PROJECT_NAME}_${vol_key}"
}
require_min_bytes() {
local file="$1"
local min_bytes="$2"
local label="$3"
if [[ ! -f "$file" ]] || (( $(stat -c%s "$file") < min_bytes )); then
echo "Backup failed: ${label} is missing or empty." >&2
exit 1
fi
}
require_tar_entries() {
local file="$1"
local label="$2"
local entry_count
if [[ ! -f "$file" ]]; then
echo "Backup failed: ${label} is missing or has no archive entries." >&2
exit 1
fi
entry_count="$(tar tf "$file" 2>/dev/null | wc -l)"
if (( entry_count < 1 )); then
echo "Backup failed: ${label} is missing or has no archive entries." >&2
exit 1
fi
}
tar_volume() {
local vol_key="$1"
local archive_name="$2"
local vol_name
vol_name="$(build_compose_volume_name "$vol_key")"
if ! docker volume inspect "$vol_name" >/dev/null 2>&1; then
echo "Backup failed: Docker volume ${vol_name} not found. Is the stack deployed?" >&2
exit 1
fi
docker run --rm \
-v "${vol_name}:/data:ro" \
-v "${STAGING_TMP}:/backup" \
alpine tar -cf "/backup/${archive_name}" -C /data .
}
SHOULD_STOP=false
STOPPED=false
STAGING_TMP=""
ARCHIVE_TMP=""
on_exit() {
if [[ -n "$STAGING_TMP" && -d "$STAGING_TMP" ]]; then
rm -rf "$STAGING_TMP"
fi
if [[ -n "$ARCHIVE_TMP" && -f "$ARCHIVE_TMP" ]]; then
rm -f "$ARCHIVE_TMP"
fi
if [[ "$STOPPED" == true ]]; then
echo "Restarting NullCart..."
compose start
fi
}
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
echo "Checking if PostgreSQL is running and accepting connections..."
cid="$(compose ps --status running -q postgres 2>/dev/null | head -n1)"
if [[ -z "$cid" ]]; then
echo "Backup failed: PostgreSQL is not running. Start the stack (for example: docker compose --env-file \"${ENV_FILE}\" -f \"${COMPOSE_FILE}\" up -d postgres)." >&2
exit 1
fi
if ! compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then
echo "Backup failed: PostgreSQL is not accepting connections. Check container logs or wait until the database is healthy." >&2
exit 1
fi
# Check if the script is running from an interactive terminal
if [[ ! -r /dev/tty ]]; then
echo "Run this script from an interactive terminal (for example: ssh -t)." >&2
exit 1
fi
printf 'Would you like to stop NullCart before doing backup? Doing backup while NullCart is running may lead to backup data corruption [y/n] (recommended y): '
read -n 1 -r STOP_ANSWER </dev/tty || {
echo "Backup failed: could not read answer from the terminal." >&2
exit 1
}
printf '\n'
case "$STOP_ANSWER" in
y)
SHOULD_STOP=true
;;
n)
echo "Continuing backup with NullCart running..."
;;
*)
echo "Invalid answer. Use y or n." >&2
exit 1
;;
esac
STAGING_TMP="$(mktemp -d)"
mkdir -p "$BACKUP_DIR"
TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)"
ARCHIVE_PATH="${BACKUP_DIR}/nullcart-backup-${TIMESTAMP}.tar.gz"
ARCHIVE_TMP="${ARCHIVE_PATH}.tmp"
if [[ "$SHOULD_STOP" == true ]]; then
stop_services=()
compose_services="$(compose config --services)"
while IFS= read -r svc; do
if [[ -z "$svc" || "$svc" == "postgres" ]]; then
continue
fi
stop_services+=("$svc")
done <<<"$compose_services"
if ((${#stop_services[@]} == 0)); then
echo "Backup failed: no application services found to stop in ${COMPOSE_FILE}." >&2
exit 1
fi
echo "Stopping NullCart application services (PostgreSQL stays up for dump)..."
compose stop "${stop_services[@]}"
STOPPED=true
fi
echo "Backing up PostgreSQL..."
compose exec -T postgres pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc > "${STAGING_TMP}/postgres.dump"
require_min_bytes "${STAGING_TMP}/postgres.dump" 64 "postgres.dump"
echo "Backing up environment file..."
cp "$ENV_FILE" "${STAGING_TMP}/env.prod"
require_min_bytes "${STAGING_TMP}/env.prod" 1 "env.prod"
echo "Backing up uploads..."
UPLOADS_DIR="${ROOT_DIR}/backend/uploads"
if [[ ! -d "$UPLOADS_DIR" ]]; then
echo "Backup failed: uploads directory not found at ${UPLOADS_DIR}" >&2
exit 1
fi
tar -cf "${STAGING_TMP}/uploads.tar" -C "${ROOT_DIR}/backend" uploads
require_tar_entries "${STAGING_TMP}/uploads.tar" "uploads.tar"
echo "Backing up Monero wallet..."
MONERO_WALLET_PATH="$(resolve_host_path "$MONERO_WALLET_DIR" "Monero wallet")"
if [[ ! -d "$MONERO_WALLET_PATH" ]]; then
echo "Backup failed: Monero wallet directory not found at ${MONERO_WALLET_PATH}" >&2
exit 1
fi
if [[ -z "$(ls -A "$MONERO_WALLET_PATH" 2>/dev/null || true)" ]]; then
echo "Backup failed: Monero wallet directory is empty at ${MONERO_WALLET_PATH}" >&2
exit 1
fi
tar -cf "${STAGING_TMP}/monero-wallet.tar" -C "$(dirname "$MONERO_WALLET_PATH")" "$(basename "$MONERO_WALLET_PATH")"
require_tar_entries "${STAGING_TMP}/monero-wallet.tar" "monero-wallet.tar"
echo "Backing up Electrum wallet..."
ELECTRUM_WALLET_PATH="$(resolve_host_path "$ELECTRUM_WALLET_DIR" "Electrum wallet")"
if [[ ! -d "$ELECTRUM_WALLET_PATH" ]]; then
echo "Backup failed: Electrum wallet directory not found at ${ELECTRUM_WALLET_PATH}" >&2
exit 1
fi
if [[ -z "$(ls -A "$ELECTRUM_WALLET_PATH" 2>/dev/null || true)" ]]; then
echo "Backup failed: Electrum wallet directory is empty at ${ELECTRUM_WALLET_PATH}" >&2
exit 1
fi
tar -cf "${STAGING_TMP}/electrum-wallet.tar" -C "$(dirname "$ELECTRUM_WALLET_PATH")" "$(basename "$ELECTRUM_WALLET_PATH")"
require_tar_entries "${STAGING_TMP}/electrum-wallet.tar" "electrum-wallet.tar"
echo "Backing up Docker volumes..."
echo "Backing up Tor hidden service volume..."
tar_volume "nullcart.tor.prod.data" "volume-tor.tar"
require_tar_entries "${STAGING_TMP}/volume-tor.tar" "volume-tor.tar"
echo "Backing up SimpleX volume..."
tar_volume "nullcart.simplex.prod.data" "volume-simplex.tar"
require_tar_entries "${STAGING_TMP}/volume-simplex.tar" "volume-simplex.tar"
echo "Backing up pgAdmin volume..."
tar_volume "nullcart.pgadmin.prod.data" "volume-pgadmin.tar"
require_tar_entries "${STAGING_TMP}/volume-pgadmin.tar" "volume-pgadmin.tar"
echo "Backing up TLS certs directory..."
CERTS_DIR="${ROOT_DIR}/deploy/certs"
if [[ ! -d "$CERTS_DIR" ]]; then
echo "Backup failed: TLS certs directory not found at ${CERTS_DIR}" >&2
exit 1
fi
tar -cf "${STAGING_TMP}/certs.tar" -C "${ROOT_DIR}/deploy" certs
require_tar_entries "${STAGING_TMP}/certs.tar" "certs.tar"
echo "Backing up Git commit..."
GIT_REV=""
if git -C "$ROOT_DIR" rev-parse HEAD >/dev/null 2>&1; then
GIT_REV="$(git -C "$ROOT_DIR" rev-parse HEAD)"
fi
{
echo "NullCart backup"
echo "Created (UTC): $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "Compose project: ${COMPOSE_PROJECT_NAME}"
echo "Stopped application services before pg_dump: $([[ "$STOPPED" == true ]] && echo yes || echo no)"
if [[ -n "$GIT_REV" ]]; then
echo "Git commit: ${GIT_REV}"
fi
echo ""
echo "Contents:"
echo " postgres.dump PostgreSQL custom-format dump (pg_restore)"
echo " env.prod Production environment file"
echo " uploads.tar backend/uploads"
echo " monero-wallet.tar Monero wallet directory"
echo " electrum-wallet.tar Electrum wallet directory"
echo " volume-tor.tar Tor hidden service keys"
echo " volume-simplex.tar SimpleX CLI data"
echo " volume-pgadmin.tar pgAdmin data"
echo " certs.tar deploy/certs (TLS)"
} > "${STAGING_TMP}/MANIFEST.txt"
require_min_bytes "${STAGING_TMP}/MANIFEST.txt" 1 "MANIFEST.txt"
rm -f "$ARCHIVE_TMP"
tar czf "$ARCHIVE_TMP" -C "$STAGING_TMP" .
tar tzf "$ARCHIVE_TMP" >/dev/null
chmod 600 "$ARCHIVE_TMP"
mv -f "$ARCHIVE_TMP" "$ARCHIVE_PATH"
echo "Backup complete: ${ARCHIVE_PATH}"
echo "This archive contains secrets. It is recommended to store it encrypted on an external storage."