#!/usr/bin/env bash # ── Sovran Hub External Backup Script ──────────────────────────── # Backs up Sovran_SystemsOS data to an external USB hard drive using rsync. # 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. # # The external drive must be formatted as ext4. Files are stored as # directly browsable files under Sovran_SystemsOS_Backup/current/. # Later runs update the same mirror and only transfer changed or new # files, making repeat backups fast. # # PostgreSQL and MariaDB/MySQL databases are NOT included. Bitcoin # blockchain and Electrs index data are NOT included (they live on # the internal second drive). # # 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 RSYNC_WARNINGS=() # Stable rsync mirror sub-path under the target drive. Not timestamped # so later runs update the same destination and only transfer new or changed files. BACKUP_SUBPATH="Sovran_SystemsOS_Backup/current" # ── Logging helpers ────────────────────────────────────────────── log() { local msg 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=$? # Release the concurrency lock file descriptor if it was opened if [[ -n "${LOCK_FD:-}" ]]; then exec {LOCK_FD}>&- 2>/dev/null || true 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" != "ext4" ]]; then fail "Target '$target' must be formatted as ext4 (detected filesystem: $fstype). Manual Backup requires an ext4-formatted external drive for Linux metadata preservation. exFAT, FAT32, and NTFS are not supported." 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" } 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" } # ── Sync one source tree to its backup destination ─────────────── # Usage: sync_tree