From ae71395956b07cbe085228c3dc5649bc69f76578 Mon Sep 17 00:00:00 2001 From: Aner Zakobar Date: Mon, 12 Oct 2026 09:09:56 +0300 Subject: [PATCH] Backup script fixes --- modules/backup.nix | 22 +++++++ scripts/offload-backup.sh | 131 +++++++++++++++++++++++++------------- shells/defaultShell.nix | 8 ++- 3 files changed, 115 insertions(+), 46 deletions(-) diff --git a/modules/backup.nix b/modules/backup.nix index 3b904a2..aff78d6 100644 --- a/modules/backup.nix +++ b/modules/backup.nix @@ -37,6 +37,20 @@ let cfg = config.homey.backup; dataDir = config.homey.storage.mountPoint; + + # The manual offload script backs up a mirror of the external HD onto a + # USB drive, so only extraPaths/extraExcludePaths actually under dataDir + # apply (e.g. eurovote's /var/lib/eurovote is OS state, out of scope here). + offloadDataPaths = lib.filter (lib.hasPrefix "${dataDir}/") cfg.extraPaths; + offloadExcludeDataPaths = lib.filter (lib.hasPrefix "${dataDir}/") cfg.extraExcludePaths; + + offloadBackupScript = pkgs.replaceVars ../scripts/offload-backup.sh { + backupPaths = lib.concatMapStringsSep "\n " (p: ''"${p}"'') offloadDataPaths; + excludeArgs = lib.concatMapStringsSep "\n " (p: ''--exclude "${p}"'') offloadExcludeDataPaths; + }; + + offloadBackupPackage = pkgs.writeShellScriptBin "homey-offload-backup" + (builtins.readFile offloadBackupScript); in { options.homey.backup = { @@ -83,6 +97,14 @@ in }; config = lib.mkIf cfg.enable { + # ----------------------------------------------------------------------- + # Manual offload script — installed system-wide so its path list stays + # in sync with extraPaths/extraExcludePaths on every deploy, instead of + # being hand-copied and left to drift. Run via `homey-offload-backup` + # in the dev shell. + # ----------------------------------------------------------------------- + environment.systemPackages = [ offloadBackupPackage ]; + # ----------------------------------------------------------------------- # Secrets # ----------------------------------------------------------------------- diff --git a/scripts/offload-backup.sh b/scripts/offload-backup.sh index 63450a7..cc1639c 100644 --- a/scripts/offload-backup.sh +++ b/scripts/offload-backup.sh @@ -1,57 +1,95 @@ #!/usr/bin/env bash # offload-backup.sh — back up /mnt/data directly to a USB drive using restic. # -# Run on the Pi (see homey-offload-backup in the dev shell). -# Scans for plugged-in USB partitions, lets you pick one, mounts it if needed, +# 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. -# -# Usage: sudo bash offload-backup.sh 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=( - "$DATA_DIR/openldap" - "$DATA_DIR/authelia" - "$DATA_DIR/gitea" - "$DATA_DIR/nextcloud" - "$DATA_DIR/jellyfin" - "$DATA_DIR/transmission" + @backupPaths@ ) EXCLUDE_ARGS=( - --exclude "$DATA_DIR/nextcloud/db" --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 USB_PARTS < <( - lsblk -o NAME,SIZE,TRAN,LABEL,MOUNTPOINT -rn \ - | awk '$3 == "usb" && $2 != "" {print $1, $2, $4, $5}' -) + +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 USB partitions found. Plug in a USB drive and try again." >&2 + 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:" +echo "Available USB partitions (already-mounted drives are hidden):" for i in "${!USB_PARTS[@]}"; do - read -r dev size label mount <<< "${USB_PARTS[$i]}" + IFS='|' read -r dev size label <<< "${USB_PARTS[$i]}" label="${label:-(no label)}" - mount="${mount:-(not mounted)}" - printf " [%d] /dev/%s %s label=%s mount=%s\n" \ - "$((i + 1))" "$dev" "$size" "$label" "$mount" + printf " [%d] %s %s label=%s\n" "$((i + 1))" "$dev" "$size" "$label" done echo "" read -rp "Select a partition [1-${#USB_PARTS[@]}]: " CHOICE @@ -63,33 +101,34 @@ if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] \ exit 1 fi -read -r SELECTED_DEV _ _ EXISTING_MOUNT <<< "${USB_PARTS[$((CHOICE - 1))]}" -SELECTED_DEV="/dev/$SELECTED_DEV" +IFS='|' read -r SELECTED_DEV _ SELECTED_LABEL <<< "${USB_PARTS[$((CHOICE - 1))]}" -# --------------------------------------------------------------------------- -# Mount if needed -# --------------------------------------------------------------------------- -MOUNTED_HERE=false -MOUNT_DIR="" - -if [ -n "$EXISTING_MOUNT" ]; then - MOUNT_DIR="$EXISTING_MOUNT" - echo "Using existing mount at $MOUNT_DIR" -else - MOUNT_DIR=$(mktemp -d) - echo "Mounting $SELECTED_DEV at $MOUNT_DIR..." - mount "$SELECTED_DEV" "$MOUNT_DIR" - MOUNTED_HERE=true +# 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() { - if [ "$MOUNTED_HERE" = true ] && [ -n "$MOUNT_DIR" ]; then - echo "Unmounting $MOUNT_DIR..." - umount "$MOUNT_DIR" - rmdir "$MOUNT_DIR" - fi + $CLEANED_UP && return + CLEANED_UP=true + echo "Unmounting $MOUNT_DIR..." + umount "$MOUNT_DIR" + rmdir "$MOUNT_DIR" } -trap cleanup EXIT +trap cleanup EXIT HUP INT TERM # --------------------------------------------------------------------------- # Initialise restic repo if this is the first run @@ -106,10 +145,16 @@ fi # --------------------------------------------------------------------------- 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[@]}" diff --git a/shells/defaultShell.nix b/shells/defaultShell.nix index 461ee04..a3ddc4a 100644 --- a/shells/defaultShell.nix +++ b/shells/defaultShell.nix @@ -16,9 +16,11 @@ pkgs.mkShell { --flake .#pi-main '') (pkgs.writeShellScriptBin "homey-offload-backup" '' - set -euo pipefail - scp scripts/offload-backup.sh admin@192.168.1.100:/tmp/homey-offload-backup.sh - ssh -t admin@192.168.1.100 'sudo bash /tmp/homey-offload-backup.sh; rm /tmp/homey-offload-backup.sh' + # The script itself is built from modules/backup.nix and installed on + # the Pi via nixos-rebuild (see homey-deploy-rpi-main) — this just + # invokes it. Redeploy first if you've changed which services + # contribute to homey.backup.extraPaths. + ssh -t admin@192.168.1.100 'sudo homey-offload-backup' '') (pkgs.writeShellScriptBin "homey-backup-status" '' ssh admin@192.168.1.100 bash -s <<'ENDSSH'