#!/usr/bin/env bash # ── Sovran Hub External Backup Script ──────────────────────────── # Backs up Sovran_SystemsOS data to an external USB hard drive. # Designed for the Hub web UI (no GUI dependencies). # # Your Sovran Pro already backs up your data automatically to its # internal second drive (BTCEcoandBackup at /run/media/Second_Drive). # This script creates an additional copy on an external USB drive — # storing your data in a third location for maximum protection. # # Usage: # BACKUP_TARGET=/run/media// bash sovran-hub-backup.sh # (or run with no env var to auto-detect the first external USB drive) set -euo pipefail BACKUP_LOG="/var/log/sovran-hub-backup.log" BACKUP_STATUS="/var/log/sovran-hub-backup.status" MEDIA_ROOT="/run/media" HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json" ROLE_STATE_NIX="/etc/nixos/role-state.nix" SECOND_DRIVE_MOUNT="/run/media/Second_Drive" SAFETY_MARGIN_BYTES=$((1024 * 1024 * 1024)) # ── Internal drive labels/paths to NEVER use as backup targets ─── INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos") INTERNAL_MOUNTS=("$SECOND_DRIVE_MOUNT" "/boot/efi" "/") FAILED_ALREADY=0 BACKUP_COMPLETE=0 LND_STOPPED=0 LND_UNITS_TO_RESTART=() ARCHIVE_FILES=() ARCHIVE_WARNINGS=() PARTIAL_FILES=() DB_DUMP_FILES=() MANIFEST_EXCLUDES=() LND_BACKUP_NOTES=() # ── Logging helpers ────────────────────────────────────────────── log() { local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*" echo "$msg" | tee -a "$BACKUP_LOG" } set_status() { echo "$1" > "$BACKUP_STATUS" } fail() { FAILED_ALREADY=1 log "ERROR: $*" set_status "FAILED" exit 1 } cleanup() { local rc=$? local restart_failed=0 # Remove any partial archive files or temporary diagnostic files if [[ "${#PARTIAL_FILES[@]}" -gt 0 ]]; then local partial for partial in "${PARTIAL_FILES[@]}"; do [[ -f "$partial" ]] && rm -f "$partial" || true done fi # Release the concurrency lock file descriptor if it was opened [[ -n "${LOCK_FD:-}" ]] && exec {LOCK_FD}>&- 2>/dev/null || true if [[ "$LND_STOPPED" -eq 1 ]]; then log "Restarting previously active LND-related services…" for (( idx=${#LND_UNITS_TO_RESTART[@]}-1 ; idx>=0 ; idx-- )); do local unit="${LND_UNITS_TO_RESTART[$idx]}" if systemctl start "$unit"; then log "Started $unit" else log "ERROR: Failed to start $unit" restart_failed=1 fi done LND_STOPPED=0 fi if [[ "$restart_failed" -eq 1 ]]; then rc=1 FAILED_ALREADY=1 set_status "FAILED" fi if [[ "$BACKUP_COMPLETE" -eq 1 && "$rc" -eq 0 ]]; then return fi if [[ "$FAILED_ALREADY" -eq 0 ]]; then log "ERROR: Backup terminated unexpectedly (exit code $rc)." set_status "FAILED" fi # Mark the backup directory as incomplete so failed runs are identifiable if [[ -n "${BACKUP_DIR:-}" && -d "${BACKUP_DIR:-}" && ! -f "${BACKUP_DIR:-}/BACKUP_COMPLETE" ]]; then touch "${BACKUP_DIR}/INCOMPLETE" 2>/dev/null || true fi } trap cleanup EXIT trap 'exit 1' INT TERM require_cmd() { local cmd="$1" command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd" } # ── Check whether a mount point is an internal drive ──────────── is_internal() { local mnt="$1" for internal in "${INTERNAL_MOUNTS[@]}"; do if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then return 0 fi done return 1 } # ── Use lsblk to find the first genuine external USB drive ─────── find_external_drive() { local target="" while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do [[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue [[ "$hotplug" == "1" || "$removable" == "1" ]] || continue [[ -n "$mountpoint" ]] || continue local skip=0 for lbl in "${INTERNAL_LABELS[@]}"; do [[ "$label" == "$lbl" ]] && skip=1 && break done [[ "$skip" -eq 1 ]] && continue is_internal "$mountpoint" && continue if mountpoint -q "$mountpoint" 2>/dev/null; then target="$mountpoint" break fi done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \ python3 -c " import sys, json def flatten(devs): for d in devs: yield d for c in d.get('children', []): yield from flatten([c]) data = json.load(sys.stdin) for d in flatten(data.get('blockdevices', [])): print('\\t'.join([ d.get('type') or '', str(d.get('hotplug') or '0'), str(d.get('rm') or '0'), d.get('label') or '', d.get('mountpoint') or '', ])) " 2>/dev/null || true) if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then while IFS= read -r -d '' mnt; do is_internal "$mnt" && continue if mountpoint -q "$mnt" 2>/dev/null; then target="$mnt" break fi done < <(find "$MEDIA_ROOT" -mindepth 2 -maxdepth 2 -type d -print0 2>/dev/null) fi echo "$target" } # ── Detect the configured system role ─────────────────────────── detect_role() { local role="server_plus_desktop" if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then local r r=$(python3 -c \ "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('role',''))" \ "$HUB_CONFIG_JSON" 2>/dev/null || true) if [[ -n "$r" ]]; then echo "$r" return fi fi if [[ -f "$ROLE_STATE_NIX" ]]; then if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then role="desktop" elif grep -q 'roles\.node = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then role="node" fi fi echo "$role" } validate_target_mount() { local target="$1" [[ "$target" == "${MEDIA_ROOT}/"* ]] || fail "Target '$target' must be mounted under $MEDIA_ROOT." [[ -d "$target" ]] || fail "Target path '$target' does not exist." mountpoint -q "$target" || fail "Target path '$target' is not a mount point." local fstype="" fstype=$(findmnt -n -o FSTYPE -T "$target" 2>/dev/null || true) [[ -n "$fstype" ]] || fail "Could not determine filesystem type for '$target'." if [[ "$fstype" != "exfat" && "$fstype" != "fuseblk" ]]; then fail "Target '$target' must be exFAT (detected filesystem: $fstype)." fi if [[ "$fstype" == "fuseblk" ]]; then local src_dev blk_type src_dev=$(findmnt -n -o SOURCE -T "$target" 2>/dev/null || true) blk_type="" if [[ -n "$src_dev" ]]; then blk_type=$(lsblk -no FSTYPE "$src_dev" 2>/dev/null || true) [[ -z "$blk_type" ]] && blk_type=$(blkid -o value -s TYPE "$src_dev" 2>/dev/null || true) fi if [[ "$blk_type" != "exfat" && "$blk_type" != "fuseblk" ]]; then fail "Target '$target' is fuseblk but not identified as exFAT-compatible." fi fi local write_test write_test="$target/.sovran-write-test-$$" if ! ( : > "$write_test" && echo "ok" >> "$write_test" && rm -f "$write_test" ); then fail "Target '$target' is not writable." fi log "Verified backup target filesystem: $fstype" } has_unit() { systemctl cat "$1" >/dev/null 2>&1 } is_unit_active() { systemctl is-active --quiet "$1" } estimate_path_bytes() { local path="$1" shift || true [[ -e "$path" ]] || { echo 0 return } local size size=$(du -s -B1 -x "$@" "$path" 2>/dev/null | awk '{print $1}' || true) [[ -n "$size" ]] || size=0 echo "$size" } # ── Initialise log file ────────────────────────────────────────── : > "$BACKUP_LOG" set_status "RUNNING" log "=== Sovran_SystemsOS External Hub Backup ===" log "Starting backup process…" # ── Acquire exclusive run lock ──────────────────────────────────── # Prevents two simultaneous backup runs (e.g. from double-click or # stale RUNNING status after a Hub restart). LOCK_FILE="/var/lock/sovran-hub-backup.lock" # Note: exec {LOCK_FD}>>file requires bash 4.1+ (NixOS provides bash 5.x). exec {LOCK_FD}>>"$LOCK_FILE" 2>/dev/null || \ fail "Cannot open lock file: $LOCK_FILE. Ensure /var/lock is writable." flock --nonblock "$LOCK_FD" 2>/dev/null || \ fail "Another backup is already running. Wait for it to complete or check $BACKUP_STATUS." require_cmd tar require_cmd sha256sum require_cmd findmnt require_cmd lsblk require_cmd mountpoint require_cmd df require_cmd du require_cmd awk require_cmd sort require_cmd find require_cmd systemctl require_cmd hostname require_cmd date require_cmd python3 require_cmd runuser require_cmd mktemp require_cmd flock # ── Detect system role ─────────────────────────────────────────── ROLE="$(detect_role)" case "$ROLE" in desktop) ROLE_LABEL="Desktop Only" ;; node) ROLE_LABEL="Node (Bitcoin-only)" ;; server_plus_desktop) ROLE_LABEL="Server + Desktop" ;; *) ROLE_LABEL="$ROLE" ;; esac log "Detected role: $ROLE_LABEL" # ── Detect target drive ────────────────────────────────────────── if [[ -n "${BACKUP_TARGET:-}" ]]; then TARGET="$BACKUP_TARGET" if is_internal "$TARGET"; then fail "Target '$TARGET' is an internal system drive and cannot be used for external backup." fi log "Using specified backup target: $TARGET" else log "Auto-detecting external USB drives…" TARGET="$(find_external_drive)" if [[ -z "$TARGET" ]]; then fail "No external USB drive detected. Please plug in an exFAT-formatted USB drive and try again." fi log "Detected external drive: $TARGET" fi validate_target_mount "$TARGET" # ── Plan role-aware source scope and exclusions ───────────────── LND_AVAILABLE=0 if [[ "$ROLE" != "desktop" ]] && [[ -d /var/lib/lnd ]] && has_unit "lnd.service"; then LND_AVAILABLE=1 fi if [[ "$ROLE" == "desktop" ]]; then MANIFEST_EXCLUDES+=("/etc/nix-bitcoin-secrets (not applicable for Desktop Only role)") else MANIFEST_EXCLUDES+=("/etc/nix-bitcoin-secrets skipped when path absent") fi MANIFEST_EXCLUDES+=( "/run/media/Second_Drive (never traversed)" "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node (excluded; internal second-drive data)" "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data (excluded; internal second-drive data)" "/var/lib/bitcoind (excluded from manual backup)" "/var/lib/electrs (excluded from manual backup)" "/var/lib/*/log and /var/lib/*/logs" "/var/lib/*/cache and /var/lib/*/tmp" "/home/*/.cache (system and application disk caches)" "/home/*/.local/share/Trash and /home/*/Trash (trash directories)" "/home/*/.mozilla/firefox/*/cache2 and */startupCache (Firefox volatile cache — profile data is kept)" "/home/*/.config/google-chrome/*/Cache (Chrome disk cache — profile data is kept)" "/home/*/.config/chromium/*/Cache (Chromium disk cache — profile data is kept)" "/home/*/.config/BraveSoftware/Brave-Browser/*/Cache (Brave disk cache — profile data is kept)" "/home/*/.local/share/baloo (KDE file indexer — rebuilt automatically)" "/home/*/.thumbnails (thumbnail cache — rebuilt automatically)" "/home/*/.xsession-errors and .xsession-errors.old (X session error logs)" ) if [[ "$ROLE" == "desktop" || "$LND_AVAILABLE" -eq 1 ]]; then MANIFEST_EXCLUDES+=("/var/lib/lnd from general /var/lib archive") fi # ── Estimate required free space ───────────────────────────────── ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos) HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash') SECRETS_BYTES=0 if [[ "$ROLE" != "desktop" ]]; then SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets) fi VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \ --exclude='bitcoind' \ --exclude='electrs' \ --exclude='lnd' \ --exclude='*/log' \ --exclude='*/logs' \ --exclude='*/cache' \ --exclude='*/tmp') LND_BYTES=0 if [[ "$LND_AVAILABLE" -eq 1 ]]; then LND_BYTES=$(estimate_path_bytes /var/lib/lnd) fi ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES + LND_BYTES )) # Require 20% growth headroom plus an additional fixed 1 GiB safety margin. REQUIRED_BYTES=$(( ESTIMATED_BYTES + (ESTIMATED_BYTES / 5) + SAFETY_MARGIN_BYTES )) FREE_BYTES=$(df -B1 --output=avail "$TARGET" | tail -1 | tr -d ' ') FREE_GB=$(( FREE_BYTES / 1024 / 1024 / 1024 )) REQUIRED_GB=$(( REQUIRED_BYTES / 1024 / 1024 / 1024 )) log "Estimated backup size: $(( ESTIMATED_BYTES / 1024 / 1024 / 1024 )) GB" log "Required free space (with safety margin): ${REQUIRED_GB} GB" log "Free space on drive: ${FREE_GB} GB" (( FREE_BYTES >= REQUIRED_BYTES )) || \ fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)." # ── Create timestamped backup directory ───────────────────────── TIMESTAMP="$(date '+%Y%m%d_%H%M%S')" BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}" DB_DUMP_DIR="$BACKUP_DIR/database-dumps" mkdir -p "$BACKUP_DIR" "$DB_DUMP_DIR" # Write an INCOMPLETE marker immediately; it is removed only on successful completion. # Failed runs keep this marker so they are easily identifiable and not confused with # complete backups during restore selection. touch "$BACKUP_DIR/INCOMPLETE" log "Backup destination: $BACKUP_DIR" create_tar_archive() { _create_archive_impl STRICT "$@" } # ── Helper: classify a GNU tar (LC_ALL=C) diagnostic line ──────── # Returns 0 (true) if the message is an allowlisted transient condition # that is safe to ignore for /home (live desktop file changes). # Only two verified GNU tar messages qualify; all others are fatal. _is_home_warning_allowlisted() { local msg="$1" case "$msg" in *"file changed as we read it"*) return 0 ;; *"file removed before we read it"*) return 0 ;; *) return 1 ;; esac } # ── Internal archive builder ────────────────────────────────────── # mode: STRICT — any tar error fails. # HOME — tar exit 1 is accepted when every diagnostic is an # allowlisted transient condition (live file changes). _create_archive_impl() { local mode="$1"; shift local archive_name="$1"; shift local archive_path="$BACKUP_DIR/$archive_name" local partial_path="${archive_path}.partial" local diag_tmp # mktemp creates files with mode 0600 (owner-only) by default, so tar # diagnostics (which may include file paths) are not readable by other users. diag_tmp="$(mktemp /tmp/sovran-tar-diag.XXXXXX)" PARTIAL_FILES+=("$partial_path" "$diag_tmp") log "Creating $archive_name …" local tar_rc=0 LC_ALL=C tar \ --create \ --file "$partial_path" \ --numeric-owner \ --acls \ --xattrs \ --sparse \ --one-file-system \ "$@" 2>"$diag_tmp" || tar_rc=$? # Log every tar diagnostic to the backup log so it appears in the Hub UI local has_fatal_diag=0 if [[ -s "$diag_tmp" ]]; then while IFS= read -r diag_line; do [[ -n "$diag_line" ]] || continue log "tar: $diag_line" if [[ "$mode" == "HOME" ]] && ! _is_home_warning_allowlisted "$diag_line"; then has_fatal_diag=1 fi done < "$diag_tmp" fi local accept=0 if [[ "$tar_rc" -eq 0 ]]; then accept=1 elif [[ "$mode" == "HOME" && "$tar_rc" -eq 1 && "$has_fatal_diag" -eq 0 ]]; then accept=1 log "NOTE: $archive_name completed with nonfatal warnings (live files changed during backup -- this is normal on an active desktop and does not affect the safety of your backup)." ARCHIVE_WARNINGS+=("$archive_name: nonfatal warnings -- live files changed during backup (normal on an active desktop)") fi if [[ "$accept" -eq 0 ]]; then rm -f "$partial_path" "$diag_tmp" if [[ "$tar_rc" -gt 1 ]]; then fail "tar exited with fatal code $tar_rc while creating $archive_name." elif [[ "$mode" == "HOME" ]]; then fail "tar exited with code $tar_rc and unrecognized diagnostics while creating $archive_name." else fail "tar exited with code $tar_rc while creating $archive_name." fi fi # Verify the partial archive is non-empty and readable (spot-check first entry) if [[ ! -s "$partial_path" ]]; then rm -f "$partial_path" "$diag_tmp" fail "Archive $archive_name is empty after creation." fi # Fast readability check: read only the first tar entry header (~512 bytes). # head -1 closes the pipe after one line, sending SIGPIPE to tar which then # exits early — so this is O(1) regardless of archive size. local spot_entry spot_entry="$(LC_ALL=C tar --list --file "$partial_path" 2>/dev/null | head -1 || true)" if [[ -z "$spot_entry" ]]; then rm -f "$partial_path" "$diag_tmp" fail "Archive $archive_name failed readability check." fi # Atomic publish: rename partial to final path only after acceptance mv "$partial_path" "$archive_path" rm -f "$diag_tmp" ARCHIVE_FILES+=("$archive_name") log "Created archive: $archive_name" } # ── Home archive: tolerates allowlisted live-file warnings ─────── create_home_tar_archive() { _create_archive_impl HOME "$@" } export_postgresql_dumps() { if ! command -v pg_dump >/dev/null 2>&1 || ! has_unit "postgresql.service"; then log "PostgreSQL tools/service not available — skipping PostgreSQL exports." return fi if ! is_unit_active "postgresql.service"; then log "PostgreSQL service is not active — skipping PostgreSQL exports." return fi log "Exporting PostgreSQL globals and databases…" local globals_file="$DB_DUMP_DIR/postgresql_globals.sql" runuser -u postgres -- pg_dumpall --globals-only > "$globals_file" || \ fail "Failed to export PostgreSQL globals." DB_DUMP_FILES+=("database-dumps/postgresql_globals.sql") local dbs dbs=$(runuser -u postgres -- psql -Atqc "SELECT datname FROM pg_database WHERE datistemplate = false AND datallowconn AND datname <> 'postgres';" 2>/dev/null || true) if [[ -z "$dbs" ]]; then log "No non-template PostgreSQL application databases found." return fi while IFS= read -r db; do [[ -n "$db" ]] || continue local safe_db safe_db="$(echo "$db" | tr -c '[:alnum:]_.-' '_')" local out_file="$DB_DUMP_DIR/postgresql_${safe_db}.dump" runuser -u postgres -- pg_dump --format=custom --file "$out_file" "$db" || \ fail "Failed to export PostgreSQL database '$db'." DB_DUMP_FILES+=("database-dumps/postgresql_${safe_db}.dump") done <<< "$dbs" } export_mariadb_dumps() { local dump_cmd="" local query_cmd="" local mariadb_unit="" if command -v mariadb-dump >/dev/null 2>&1; then dump_cmd="mariadb-dump" elif command -v mysqldump >/dev/null 2>&1; then dump_cmd="mysqldump" fi if command -v mariadb >/dev/null 2>&1; then query_cmd="mariadb" elif command -v mysql >/dev/null 2>&1; then query_cmd="mysql" fi if [[ -z "$dump_cmd" || -z "$query_cmd" ]]; then log "MariaDB dump/query tools not available — skipping MariaDB exports." return fi if has_unit "mariadb.service" && is_unit_active "mariadb.service"; then mariadb_unit="mariadb.service" elif has_unit "mysql.service" && is_unit_active "mysql.service"; then mariadb_unit="mysql.service" else log "MariaDB service is not active — skipping MariaDB exports." return fi log "Exporting MariaDB databases from ${mariadb_unit}…" local dbs dbs=$($query_cmd -N -e "SHOW DATABASES" 2>/dev/null || true) if [[ -z "$dbs" ]]; then log "No MariaDB databases found." return fi while IFS= read -r db; do [[ -n "$db" ]] || continue case "$db" in information_schema|performance_schema|mysql|sys) continue ;; esac local safe_db out_file safe_db="$(echo "$db" | tr -c '[:alnum:]_.-' '_')" out_file="$DB_DUMP_DIR/mariadb_${safe_db}.sql" $dump_cmd --single-transaction --quick --routines --events --triggers "$db" > "$out_file" || \ fail "Failed to export MariaDB database '$db'." DB_DUMP_FILES+=("database-dumps/mariadb_${safe_db}.sql") done <<< "$dbs" } export_lnd_scb_if_possible() { [[ "$LND_AVAILABLE" -eq 1 ]] || return local scb_file="$BACKUP_DIR/lnd-static-channel-backup.scb" local attempts=( "lncli exportchanbackup --all --output_file $scb_file" "lncli -n mainnet exportchanbackup --all --output_file $scb_file" "runuser -u lnd -- lncli exportchanbackup --all --output_file $scb_file" "runuser -u lnd -- lncli -n mainnet exportchanbackup --all --output_file $scb_file" ) if ! command -v lncli >/dev/null 2>&1; then log "lncli not available — skipping Static Channel Backup export." LND_BACKUP_NOTES+=("Static Channel Backup skipped (lncli unavailable)") return fi if ! is_unit_active "lnd.service"; then log "LND service is not active — skipping Static Channel Backup export." LND_BACKUP_NOTES+=("Static Channel Backup skipped (lnd.service inactive)") return fi log "Exporting LND Static Channel Backup…" local attempt for attempt in "${attempts[@]}"; do if eval "$attempt" >/dev/null 2>&1; then DB_DUMP_FILES+=("lnd-static-channel-backup.scb") LND_BACKUP_NOTES+=("Static Channel Backup exported via lncli") log "LND Static Channel Backup exported." return fi done log "WARNING: Unable to export LND Static Channel Backup with available lncli invocations." LND_BACKUP_NOTES+=("Static Channel Backup export failed (no compatible lncli invocation succeeded)") } capture_active_lnd_dependents() { [[ "$LND_AVAILABLE" -eq 1 ]] || return LND_UNITS_TO_RESTART=() local raw_units="" raw_units=$(systemctl show lnd.service -p RequiredBy -p WantedBy --value 2>/dev/null | tr ' ' '\n' | grep '\.service$' | sort -u || true) while IFS= read -r unit; do [[ -n "$unit" ]] || continue if is_unit_active "$unit"; then LND_UNITS_TO_RESTART+=("$unit") fi done <<< "$raw_units" if is_unit_active "lnd.service"; then LND_UNITS_TO_RESTART+=("lnd.service") fi } stop_lnd_stack_if_needed() { [[ "$LND_AVAILABLE" -eq 1 ]] || return capture_active_lnd_dependents if [[ "${#LND_UNITS_TO_RESTART[@]}" -eq 0 ]]; then log "No active LND-related services needed stopping." return fi log "Stopping active services that depend on LND for clean /var/lib/lnd archive…" local unit for unit in "${LND_UNITS_TO_RESTART[@]}"; do if [[ "$unit" == "lnd.service" ]]; then continue fi systemctl stop "$unit" || fail "Failed to stop dependent service: $unit" log "Stopped $unit" done if printf '%s\n' "${LND_UNITS_TO_RESTART[@]}" | grep -qx 'lnd.service'; then systemctl stop lnd.service || fail "Failed to stop lnd.service" log "Stopped lnd.service" fi LND_STOPPED=1 } # ── Stage 1/5: NixOS configuration ────────────────────────────── log "" log "── Stage 1/5: NixOS configuration (/etc/nixos) ──────────────" if [[ -d /etc/nixos ]]; then create_tar_archive "etc-nixos.tar" -C / etc/nixos log "Stage 1 complete." else log "WARNING: /etc/nixos not found — skipping." fi # ── Stage 2/5: Secrets ────────────────────────────────────────── log "" log "── Stage 2/5: Secrets (/etc/nix-bitcoin-secrets) ───────────" if [[ "$ROLE" == "desktop" ]]; then log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role." elif [[ -e /etc/nix-bitcoin-secrets ]]; then create_tar_archive "etc-nix-bitcoin-secrets.tar" -C / etc/nix-bitcoin-secrets else log "(not found: /etc/nix-bitcoin-secrets — skipping)" fi log "Stage 2 complete." # ── Stage 3/5: Home directory ─────────────────────────────────── log "" log "── Stage 3/5: Home directory (/home) ───────────────────────" if [[ -d /home ]]; then create_home_tar_archive "home.tar" \ -C / \ --exclude='home/*/.cache' \ --exclude='home/*/.local/share/Trash' \ --exclude='home/*/Trash' \ --exclude='home/*/.mozilla/firefox/*/cache2' \ --exclude='home/*/.mozilla/firefox/*/startupCache' \ --exclude='home/*/.mozilla/firefox/*/thumbnails' \ --exclude='home/*/.config/google-chrome/*/Cache' \ --exclude='home/*/.config/google-chrome/*/Code Cache' \ --exclude='home/*/.config/chromium/*/Cache' \ --exclude='home/*/.config/chromium/*/Code Cache' \ --exclude='home/*/.config/BraveSoftware/Brave-Browser/*/Cache' \ --exclude='home/*/.config/BraveSoftware/Brave-Browser/*/Code Cache' \ --exclude='home/*/.local/share/baloo' \ --exclude='home/*/.thumbnails' \ --exclude='home/*/.xsession-errors' \ --exclude='home/*/.xsession-errors.old' \ home log "Stage 3 complete." else log "WARNING: /home not found — skipping." fi # ── Stage 4/5: Database exports + LND artifacts ──────────────── log "" log "── Stage 4/5: Database and LND consistency exports ─────────" export_postgresql_dumps export_mariadb_dumps export_lnd_scb_if_possible if [[ "$LND_AVAILABLE" -eq 1 ]]; then stop_lnd_stack_if_needed create_tar_archive "var-lib-lnd-clean.tar" -C / var/lib/lnd LND_BACKUP_NOTES+=("Created clean raw /var/lib/lnd archive after controlled service stop") fi log "Stage 4 complete." # ── Stage 5/5: System data ────────────────────────────────────── log "" log "── Stage 5/5: System data (/var/lib) ───────────────────────" if [[ -d /var/lib ]]; then VAR_LIB_EXCLUDES=( --exclude='var/lib/bitcoind' --exclude='var/lib/electrs' --exclude='var/lib/*/log' --exclude='var/lib/*/logs' --exclude='var/lib/*/cache' --exclude='var/lib/*/tmp' ) if [[ "$ROLE" == "desktop" || "$LND_AVAILABLE" -eq 1 ]]; then VAR_LIB_EXCLUDES+=(--exclude='var/lib/lnd') fi create_tar_archive "var-lib.tar" -C / "${VAR_LIB_EXCLUDES[@]}" var/lib log "Stage 5 complete." else log "WARNING: /var/lib not found — skipping." fi # ── Generate manifest ──────────────────────────────────────────── log "" log "Generating BACKUP_MANIFEST.txt …" MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt" CHECKSUM_FILE="$BACKUP_DIR/SHA256SUMS.txt" { echo "Sovran_SystemsOS Backup Manifest" echo "Generated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" echo "Timestamp: $TIMESTAMP" echo "Hostname: $(hostname)" echo "Role: $ROLE_LABEL" echo "Target: $TARGET" echo "" echo "Source paths included:" echo "- /etc/nixos" echo "- /home" if [[ "$ROLE" != "desktop" ]]; then echo "- /etc/nix-bitcoin-secrets (when present)" fi echo "- /var/lib" echo "" echo "Exclusions:" for ex in "${MANIFEST_EXCLUDES[@]}"; do echo "- $ex" done echo "" echo "Archives:" for archive in "${ARCHIVE_FILES[@]}"; do echo "- $archive" done echo "" echo "Database and LND exports:" if [[ "${#DB_DUMP_FILES[@]}" -eq 0 && "${#LND_BACKUP_NOTES[@]}" -eq 0 ]]; then echo "- none" else for dump in "${DB_DUMP_FILES[@]}"; do echo "- $dump" done for note in "${LND_BACKUP_NOTES[@]}"; do echo "- $note" done fi echo "" echo "Restore guidance:" echo "- Verify artifacts: cd && sha256sum -c SHA256SUMS.txt" echo "- Extract a tar archive: sudo tar --acls --xattrs --numeric-owner -xpf .tar -C /" echo "- PostgreSQL globals: sudo -u postgres psql -f database-dumps/postgresql_globals.sql" echo "- PostgreSQL DB dump: sudo -u postgres pg_restore --create --clean --if-exists -d postgres database-dumps/postgresql_.dump" echo "- MariaDB DB dump: mariadb < database-dumps/mariadb_.sql" echo "- LND SCB: keep lnd-static-channel-backup.scb with wallet seed for channel recovery procedures" echo "- Note: when restoring /var/lib, exclude raw DB directories (var/lib/postgresql, var/lib/mysql)" echo " and restore from native dumps instead for PostgreSQL and MariaDB" echo "" echo "Nonfatal warnings:" if [[ "${#ARCHIVE_WARNINGS[@]}" -eq 0 ]]; then echo "- none" else for warning in "${ARCHIVE_WARNINGS[@]}"; do echo "- $warning" done fi echo "" echo "Important note: Bitcoin blockchain and Electrs index data are intentionally excluded" echo "from manual external backup because they already live on the internal second drive" echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data." echo "" echo "Artifact listing:" # INCOMPLETE exists at this point (created at backup start); BACKUP_COMPLETE does not # exist yet (written after checksums). Excluding both marker files here is intentional: # INCOMPLETE is excluded so it doesn't appear as a data artifact, and BACKUP_COMPLETE # is excluded defensively for consistency should the ordering ever change. find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 -type f \ ! -name 'INCOMPLETE' ! -name 'BACKUP_COMPLETE' -print0 | sort -z | tr '\0' '\n' } > "$MANIFEST_FILE" # ── Generate checksums for all backup artifacts ───────────────── log "Generating SHA-256 checksums …" ( cd "$BACKUP_DIR" while IFS= read -r -d '' file; do sha256sum "$file" done < <(find . -mindepth 1 -maxdepth 2 -type f ! -name 'SHA256SUMS.txt' ! -name 'INCOMPLETE' ! -name 'BACKUP_COMPLETE' -print0 | sort -z) ) > "$CHECKSUM_FILE" log "Manifest written to $MANIFEST_FILE" log "Checksums written to $CHECKSUM_FILE" # ── Done ───────────────────────────────────────────────────────── log "" if [[ "${#ARCHIVE_WARNINGS[@]}" -gt 0 ]]; then log "Backup completed with nonfatal warnings:" for warning in "${ARCHIVE_WARNINGS[@]}"; do log " WARNING: $warning" done log "Your important data is backed up. The warnings above indicate files that changed" log "during backup, which is normal on an active desktop and does not affect your backup." log "" fi log "All Finished! Your data is now backed up to a third location." log "Please eject the drive safely before removing it from your Sovran Pro." # Remove incomplete marker and write completion marker only after all work succeeds rm -f "$BACKUP_DIR/INCOMPLETE" echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$BACKUP_DIR/BACKUP_COMPLETE" BACKUP_COMPLETE=1 set_status "SUCCESS"