diff --git a/.gitignore b/.gitignore index d534014..55f6d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /deploy/certs /deploy/certbot/www +/deploy/backups /monero-wallet-rpc/wallet /electrum-daemon/wallet diff --git a/deploy/DEPLOYMENT_GUIDE.md b/deploy/DEPLOYMENT_GUIDE.md index f9eb239..6500b5e 100644 --- a/deploy/DEPLOYMENT_GUIDE.md +++ b/deploy/DEPLOYMENT_GUIDE.md @@ -159,10 +159,18 @@ Tor Browser will then show ".onion available" when visitors open the clearnet si 3. Finish the setup checklist in settings. 4. Connect SimpleX notifications in shop settings. -## 10. Updates +## Updates + +This pulls the latest code and rebuilds the stack (`deploy.sh`). ```bash ./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 +``` diff --git a/deploy/scripts/backup.sh b/deploy/scripts/backup.sh new file mode 100755 index 0000000..5dcedd4 --- /dev/null +++ b/deploy/scripts/backup.sh @@ -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 &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." \ No newline at end of file