#!/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 echo "PostgreSQL is running and accepting connections." # 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 &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."