#!/usr/bin/env bash # offload-backup.sh — back up /mnt/data directly to a USB drive using restic. # # This is a Nix template, not a directly runnable script: the BACKUP_PATHS # and EXCLUDE_ARGS arrays below are filled in by modules/backup.nix from # the same homey.backup.extraPaths/extraExcludePaths every service module # already contributes to the nightly S3 backup, so this list can't silently # drift out of sync the way a hand-maintained copy did before. The built # package is installed on the Pi as homey-offload-backup via # environment.systemPackages — deploy with nixos-rebuild, then run it from # the dev shell (homey-offload-backup, which just ssh's in and invokes the # installed command). # # Scans for plugged-in USB partitions, lets you pick one, mounts it, # initialises a restic repo on it, and runs a backup of all service data dirs. # # The restic password is read from the sops-managed secret at runtime; # no S3 credentials are needed — this is a direct local backup. set -euo pipefail # A dropped SSH connection must not kill this mid-backup and skip the # cleanup trap below — writing to the now-closed stdout pipe would # otherwise deliver SIGPIPE and terminate the script immediately. trap '' PIPE REPO_NAME="homey-backup" DATA_DIR="/mnt/data" PASSWORD_FILE="/run/secrets/restic/password" BACKUP_PATHS=( @backupPaths@ ) EXCLUDE_ARGS=( --exclude "$DATA_DIR/restic-cache" @excludeArgs@ ) # --------------------------------------------------------------------------- # Find USB partitions # # TRAN (transport) is only reported on the parent disk, never on its # partitions, so partitions are matched up to their disk via PKNAME. Already- # mounted partitions are excluded so the main data drive (/mnt/data) can # never be selected by accident. # --------------------------------------------------------------------------- echo "Scanning for USB drives..." mapfile -t LSBLK_LINES < <(lsblk -Ppo NAME,TYPE,TRAN,SIZE,LABEL,MOUNTPOINT,PKNAME) # Extracts KEY="value" from one lsblk -P line without eval, so a maliciously # crafted filesystem label can't inject shell commands. lsblk_field() { local line="$1" key="$2" [[ "$line" =~ ${key}=\"([^\"]*)\" ]] && printf '%s' "${BASH_REMATCH[1]}" } declare -A DISK_TRAN for line in "${LSBLK_LINES[@]}"; do name=$(lsblk_field "$line" NAME) type=$(lsblk_field "$line" TYPE) tran=$(lsblk_field "$line" TRAN) [ "$type" = "disk" ] && DISK_TRAN["$name"]="$tran" done USB_PARTS=() for line in "${LSBLK_LINES[@]}"; do name=$(lsblk_field "$line" NAME) type=$(lsblk_field "$line" TYPE) size=$(lsblk_field "$line" SIZE) label=$(lsblk_field "$line" LABEL) mountpoint=$(lsblk_field "$line" MOUNTPOINT) pkname=$(lsblk_field "$line" PKNAME) if [ "$type" = "part" ] \ && [ "${DISK_TRAN[$pkname]:-}" = "usb" ] \ && [ -z "$mountpoint" ]; then USB_PARTS+=("$name|$size|$label") fi done if [ "${#USB_PARTS[@]}" -eq 0 ]; then echo "No eligible USB partitions found (must be on a USB-attached disk and not already mounted)." >&2 exit 1 fi echo "" echo "Available USB partitions (already-mounted drives are hidden):" for i in "${!USB_PARTS[@]}"; do IFS='|' read -r dev size label <<< "${USB_PARTS[$i]}" label="${label:-(no label)}" printf " [%d] %s %s label=%s\n" "$((i + 1))" "$dev" "$size" "$label" done echo "" read -rp "Select a partition [1-${#USB_PARTS[@]}]: " CHOICE if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] \ || [ "$CHOICE" -lt 1 ] \ || [ "$CHOICE" -gt "${#USB_PARTS[@]}" ]; then echo "Invalid selection." >&2 exit 1 fi IFS='|' read -r SELECTED_DEV _ SELECTED_LABEL <<< "${USB_PARTS[$((CHOICE - 1))]}" # Kernel device names (sdX) aren't stable across reboots/replugs — prefer the # udev by-label symlink so the mount target doesn't depend on enumeration # order, falling back to the kernel name if the drive has no label. if [ -n "$SELECTED_LABEL" ] && [ -e "/dev/disk/by-label/$SELECTED_LABEL" ]; then SELECTED_DEV="/dev/disk/by-label/$SELECTED_LABEL" fi # --------------------------------------------------------------------------- # Mount (selection above guarantees this partition isn't already mounted) # --------------------------------------------------------------------------- MOUNT_DIR=$(mktemp -d) echo "Mounting $SELECTED_DEV at $MOUNT_DIR..." mount "$SELECTED_DEV" "$MOUNT_DIR" # Guarded against running twice: trapping both a signal (e.g. INT) and EXIT # with the same handler means a signal-triggered cleanup runs again via the # subsequent EXIT trap once set -e notices the interrupted command failed. CLEANED_UP=false cleanup() { $CLEANED_UP && return CLEANED_UP=true echo "Unmounting $MOUNT_DIR..." umount "$MOUNT_DIR" rmdir "$MOUNT_DIR" } trap cleanup EXIT HUP INT TERM # --------------------------------------------------------------------------- # Initialise restic repo if this is the first run # --------------------------------------------------------------------------- REPO="$MOUNT_DIR/$REPO_NAME" if ! restic -r "$REPO" --password-file "$PASSWORD_FILE" snapshots &>/dev/null 2>&1; then echo "Initialising restic repository at $REPO..." restic -r "$REPO" --password-file "$PASSWORD_FILE" init fi # --------------------------------------------------------------------------- # Run the backup # --------------------------------------------------------------------------- echo "" echo "Backing up to $REPO..." # --parent latest: restic only auto-picks a parent snapshot when this run's # path list exactly matches a previous snapshot's. BACKUP_PATHS grows as # service modules are added/removed, so without this every such change # would silently force a full unoptimized re-read of every path, not just # the new ones. restic -r "$REPO" \ --password-file "$PASSWORD_FILE" \ --cache-dir "$DATA_DIR/restic-cache" \ backup \ --parent latest \ "${BACKUP_PATHS[@]}" \ "${EXCLUDE_ARGS[@]}" echo "" echo "Snapshots on this drive:" restic -r "$REPO" --password-file "$PASSWORD_FILE" snapshots