Add interactive prod backup script for disaster-recovery archives. #10
@@ -3,6 +3,7 @@
|
||||
|
||||
/deploy/certs
|
||||
/deploy/certbot/www
|
||||
/deploy/backups
|
||||
|
||||
/monero-wallet-rpc/wallet
|
||||
/electrum-daemon/wallet
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Executable
+335
@@ -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."
|
||||
Reference in New Issue
Block a user