#!/usr/bin/env bash set -euo pipefail # POS: system backup — Encrypted (AES-256) folder snapshots (tar + gpg) # POS_FLAGS: --service --no-encrypt # POS_SUBCMDS: menu # POS_CONFIG: notify | notify.env | NOTIFY_PLATFORM=:Comma-separated notify platforms (default telegram) — shared by backup, firewall, share nfs client/server # POS_DEPS: tar source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh" source "$(dirname "$0")/../lib/usb-lib.sh" 2>/dev/null || source "$(dirname "$0")/usb-lib.sh" source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh" load_system_env EFF_ROOTS="${BACKUP_SERVICE_ROOTS:-/srv $HOME/srv}" trap 'notify_send "Backup FAILED: ${FOLDER:-unknown}"' ERR usage() { cat < pos system backup --no-encrypt pos system backup --service Create a gpg-encrypted (AES-256) tar.gz snapshot of a folder and verify it. The archive password is prompted twice and never stored. With --no-encrypt (or BACKUP_ENCRYPT=0) the backup is kept as a plain .tar.gz — no password, headless/cron safe. Modes: Back up that folder directly. --no-encrypt Skip encryption (no password prompt, artifact stays .tar.gz). --service List folders under /srv and ~/srv, pick one, back it up. Bare \`pos system backup\` on a terminal (or \`pos system backup menu\`) opens an interactive menu wrapping these modes; arguments stay scriptable. The final artifact _.tar.gz[.gpg] is written to the current directory. After it verifies, connected USB storage is offered: the copy lands in /backups/ and is sha256-verified 100% before it is announced. A stick that is plugged in but not mounted is offered a mount first (sudo, mirrors the usb-automount scheme) before the copy. Environment: BACKUP_ENCRYPT Set to 0 to skip encryption (same as --no-encrypt) (default: 1) BACKUP_SERVICE_ROOTS Space-separated roots for --service (effective: ${EFF_ROOTS}) BACKUP_USB_ROOT USB root to copy finished backups to (default: auto-detect mounted USB storage) BACKUP_MOUNT_BASE Where to mount an unmounted USB stick offered during the copy (default: /media; alias of USB_MOUNT_BASE) BACKUP_USB_BYID by-id dir used to corroborate USB detection (default: /dev/disk/by-id; alias of USB_BYID) (loaded from ~/.config/linux_post_install/system.env unless exported) EOF exit 0 } command -v tar &>/dev/null || err "tar not found" # ── USB copy (optional post-backup step) ───────────────────────── # Detection runs AFTER the backup finished, so a stick plugged in while # the archive was being made is found. The copy lands in /backups/ # and the transfer is proven 100% (sha256 source vs copy) before any # success is announced. BACKUP_USB_ROOT pins the root and skips # detection; otherwise usb_pick_root (lib/usb-lib.sh) auto-detects, # offers to mount unmounted sticks, and picks the target. usb_copy_offer() { local archive="$1" root="" dest_dir="" dest="" src_sum="" dst_sum="" command -v lsblk &>/dev/null || { warn "lsblk not found — USB copy skipped"; return 0; } command -v jq &>/dev/null || { warn "jq not found — USB copy skipped"; return 0; } section "USB copy" if [ -n "${BACKUP_USB_ROOT:-}" ]; then root="$BACKUP_USB_ROOT" if ! confirm "Copy backup to ${root%/}/backups/?" n; then log "Skipped — backup stays local: $archive" return 0 fi else usb_pick_root "Copy backup to" "backups" "backup stays local: $archive" || { log "Skipped — backup stays local: $archive" return 0 } root="$USB_ROOT" fi dest_dir="${root%/}/backups" dest="$dest_dir/$(basename "$archive")" mkdir -p "$dest_dir" log "Copying to $dest ..." cp "$archive" "$dest_dir/" chmod 600 "$dest" 2>/dev/null \ || warn "Could not chmod 600 the USB copy (vfat filesystem?)" log "Verifying transfer (sha256)..." src_sum="$(sha256sum "$archive" | cut -d' ' -f1)" dst_sum="$(sha256sum "$dest" | cut -d' ' -f1)" if [ "$src_sum" != "$dst_sum" ]; then warn "USB copy FAILED verification — checksum mismatch:" warn " source: $src_sum $archive" warn " copy : $dst_sum $dest" notify_send "USB copy FAILED for $archive — checksum mismatch on $dest" exit 1 fi ok "Transfer verified 100% (sha256 match): $dest" notify_send "Backup copied to USB: $dest (sha256 verified)" } # ── USB detection / mount offer / pick flow ───────────────────── # Shared with pos-media-sync: lib/usb-lib.sh (usb_detect / # usb_related_present / usb_mount_offer / usb_pick_root). SERVICE=0 ENCRYPT=1 [ "${BACKUP_ENCRYPT:-1}" = "0" ] && ENCRYPT=0 # ── Folder picker over the --service roots ────────────────────── # Sets $FOLDER; errors out when nothing can be offered (same as --service). pick_service_folder() { if [ -n "${BACKUP_SERVICE_ROOTS:-}" ]; then read -r -a roots <<< "$BACKUP_SERVICE_ROOTS" else roots=(/srv "$HOME/srv") fi idx=0 for root in "${roots[@]}"; do [ -d "$root" ] || { warn "Root not found: $root"; continue; } dirs=() while IFS= read -r d; do dirs+=("$d") done < <(find "$root" -maxdepth 1 -mindepth 1 -type d | sort) [ ${#dirs[@]} -gt 0 ] || { warn "No folders in $root"; continue; } echo echo "${root}:" for d in "${dirs[@]}"; do idx=$((idx + 1)) names[$idx]="$d" printf "%2d) %s\n" "$idx" "$d" done done [ "$idx" -gt 0 ] || err "No folders found under: ${roots[*]}" read -rp "Select folder number: " choice if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > idx )); then err "Invalid selection: $choice" fi FOLDER="${names[$choice]}" } # ── Backup flow ($FOLDER → timestamped archive, then USB copy offer) ── run_backup() { [ -d "$FOLDER" ] || err "Folder not found: $FOLDER" NAME="$(basename "$FOLDER")" DATE="$(date +%Y-%m-%d_%H-%M-%S)" ARCHIVE="${NAME}_${DATE}.tar.gz" echo log "Creating backup..." echo "Source : $FOLDER" echo "Output : $ARCHIVE" sudo tar -czvf "$ARCHIVE" -C "$(dirname "$FOLDER")" "$NAME" log "Verifying archive..." tar -tzf "$ARCHIVE" > /dev/null log "Archive verified" if [ "$ENCRYPT" -eq 1 ]; then command -v gpg &>/dev/null || err "gpg not found (install gnupg)" while true; do read -s -rp "Enter backup password: " PASS echo read -s -rp "Confirm backup password: " CONFIRM echo if [ -n "$PASS" ] && [ "$PASS" = "$CONFIRM" ]; then break fi warn "Passwords are empty or do not match — try again" done unset CONFIRM log "Encrypting backup..." gpg --batch --yes --passphrase "$PASS" --symmetric --cipher-algo AES256 "$ARCHIVE" rm -f "$ARCHIVE" ARCHIVE="${ARCHIVE}.gpg" chmod 600 "$ARCHIVE" log "Verifying encrypted backup..." gpg --batch --quiet --passphrase "$PASS" --decrypt "$ARCHIVE" | tar -tzf - > /dev/null unset PASS else chmod 600 "$ARCHIVE" log "No encryption requested — keeping $ARCHIVE" fi echo log "Backup completed: $ARCHIVE" notify_send "Backup completed: $ARCHIVE" # Optional: detect a USB stick connected after the backup finished, offer to # copy the archive to /backups/, and prove the transfer 100%. From here # on a failure is a USB-copy problem, not a backup problem. trap 'notify_send "USB copy FAILED: ${ARCHIVE:-unknown}"' ERR usb_copy_offer "$ARCHIVE" } # ── Interactive menu (opt-in front door, Pattern B via lib/menu-lib.sh) ── menu_backup_folder() { local enc="$1" val prompt val="$(menu_ask_value "Folder to back up")" || return 0 if [ ! -d "$val" ]; then warn "Not a folder: $val" return 0 fi FOLDER="$val" if [ "$enc" -eq 1 ]; then prompt="Create ENCRYPTED backup of $FOLDER?" else prompt="Create UNENCRYPTED backup of $FOLDER (plain .tar.gz, no password)?" fi confirm "$prompt" n || { log "Cancelled"; return 0; } ENCRYPT="$enc" run_backup } menu_backup_service() { if ! pick_service_folder; then warn "Cancelled — no folder selected" return 0 fi confirm "Create ENCRYPTED backup of $FOLDER?" n || { log "Cancelled"; return 0; } ENCRYPT=1 run_backup } run_menu() { menu_guard || exit 1 while true; do local choice choice="$(menu_run "System backup" \ "New encrypted backup (type/paste folder)" \ "New encrypted backup — pick from ${EFF_ROOTS}" \ "New backup WITHOUT encryption (type/paste folder)")" || return 0 case "$choice" in 1) menu_backup_folder 1 ;; 2) menu_backup_service ;; 3) menu_backup_folder 0 ;; esac done } # Menu door: explicit verb, or zero args on a terminal. Everything below — # including zero args without a terminal — stays byte-compatible with the # pre-menu CLI. if [ "${1:-}" = "menu" ]; then run_menu exit 0 fi for arg in "$@"; do case "$arg" in -h|--help) usage ;; --service) SERVICE=1 ;; --no-encrypt) ENCRYPT=0 ;; *) FOLDER="$arg" ;; esac done if [ $# -eq 0 ] && [ -t 0 ]; then run_menu exit 0 fi { [ "$SERVICE" -eq 1 ] || [ -n "${FOLDER:-}" ]; } || err "Missing folder path (or use --service)" if [ "$SERVICE" -eq 1 ]; then pick_service_folder fi run_backup