Files
homey/scripts/offload-backup.sh
T
2026-10-12 09:09:56 +03:00

164 lines
5.8 KiB
Bash

#!/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