#!/bin/bash # ========================================================== # ProxMenux - Host Config Backup / Restore # ========================================================== # Author : MacRimi # Copyright : (c) 2024 MacRimi # License : GPL-3.0 # Version : 1.0 # Last Updated: 08/04/2026 # ========================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)" LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts" LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT" BASE_DIR="/usr/local/share/proxmenux" UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL" UTILS_FILE="$LOCAL_SCRIPTS/utils.sh" elif [[ ! -f "$UTILS_FILE" ]]; then UTILS_FILE="$BASE_DIR/utils.sh" fi if [[ -f "$UTILS_FILE" ]]; then # shellcheck source=/dev/null source "$UTILS_FILE" else echo "ERROR: utils.sh not found. Cannot continue." >&2 exit 1 fi # Source shared library LIB_FILE="$SCRIPT_DIR/lib_host_backup_common.sh" [[ ! -f "$LIB_FILE" ]] && LIB_FILE="$LOCAL_SCRIPTS_DEFAULT/backup_restore/lib_host_backup_common.sh" if [[ -f "$LIB_FILE" ]]; then # shellcheck source=/dev/null source "$LIB_FILE" else msg_error "$(translate "Cannot load backup library: lib_host_backup_common.sh")" exit 1 fi load_language initialize_cache if ! command -v pveversion >/dev/null 2>&1; then dialog --backtitle "ProxMenux" --title "$(translate "Error")" \ --msgbox "$(translate "This script must be run on a Proxmox host.")" 8 60 exit 1 fi if [[ $EUID -ne 0 ]]; then dialog --backtitle "ProxMenux" --title "$(translate "Error")" \ --msgbox "$(translate "This script must be run as root.")" 8 60 exit 1 fi # ========================================================== # BACKUP — PBS # ========================================================== _bk_pbs() { local profile_mode="$1" local -a paths=() local backup_id epoch log_file staging_root t_start elapsed staged_size hb_select_pbs_repository || return 1 # Return 1 propagates operator cancel from the encryption / passphrase # dialogs so the outer menu shows the source picker again instead of # proceeding with a half-configured backup. hb_ask_pbs_encryption || return 1 hb_select_profile_paths "$profile_mode" paths || return 1 backup_id="hostcfg-$(hostname)" backup_id=$(dialog --backtitle "ProxMenux" --title "PBS" \ --inputbox "$(hb_translate "Backup ID (group name in PBS):")" \ "$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$backup_id" 3>&1 1>&2 2>&3) || return 1 [[ -z "$backup_id" ]] && return 1 # Sanitize: only alphanumeric, dash, underscore backup_id=$(echo "$backup_id" | tr -cs '[:alnum:]_-' '-' | sed 's/-*$//') log_file="/tmp/proxmenux-pbs-backup-$(date +%Y%m%d_%H%M%S).log" staging_root=$(mktemp -d /tmp/proxmenux-pbs-stage.XXXXXX) # shellcheck disable=SC2064 trap "rm -rf '$staging_root'" RETURN show_proxmenux_logo msg_title "$(translate "Host Backup → PBS")" echo -e "" local _pbs_enc_label if [[ -n "$HB_PBS_KEYFILE_OPT" ]]; then _pbs_enc_label=$(hb_translate "Enabled"); else _pbs_enc_label=$(hb_translate "Disabled"); fi echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${HB_PBS_REPOSITORY}${CL}" echo -e "${TAB}${BGN}$(translate "Backup ID:")${CL} ${BL}${backup_id}${CL}" echo -e "${TAB}${BGN}$(translate "Encryption:")${CL} ${BL}${_pbs_enc_label}${CL}" echo -e "${TAB}${BGN}$(translate "Paths:")${CL}" local p; for p in "${paths[@]}"; do echo -e "${TAB} ${BL}•${CL} $p"; done echo -e "" msg_info "$(translate "Preparing files for backup...")" hb_prepare_staging "$staging_root" "${paths[@]}" staged_size=$(hb_file_size "$staging_root/rootfs") msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size" # Notify host_backup_start. Per-channel/per-event toggles in # Settings decide whether the user actually receives it; same # template the scheduled runner uses. export HB_NOTIFY_JOB_ID="manual-pbs-${backup_id}" export HB_NOTIFY_BACKEND="pbs" export HB_NOTIFY_DESTINATION="$HB_PBS_REPOSITORY" export HB_NOTIFY_PROFILE_MODE="$profile_mode" export HB_NOTIFY_LOG_FILE="$log_file" export HB_NOTIFY_DATA_SIZE="$staged_size" hb_notify_lifecycle "start" echo -e "" msg_info "$(translate "Connecting to PBS and starting backup...")" stop_spinner epoch=$(date +%s) t_start=$SECONDS # We back up the WHOLE staging_root (rootfs/ + metadata/) into # the .pxar — earlier versions used `$staging_root/rootfs` as # the source, which left metadata/ (hostname, pveversion, # selected paths, etc.) out of the archive. The compat check # in restore then had nothing to read and degraded to # cross-host warnings even on same-host restores. Old PBS # snapshots created with the rootfs-only source still restore # correctly via case 3 in _rs_check_layout (which wraps a flat # etc/var/root/usr layout into rootfs/ and creates an empty # metadata/), so this change is backward-compatible. local -a cmd=( proxmox-backup-client backup "hostcfg.pxar:$staging_root" --repository "$HB_PBS_REPOSITORY" --backup-type host --backup-id "$backup_id" --backup-time "$epoch" ) # shellcheck disable=SC2086 # intentional word-split: HB_PBS_KEYFILE_OPT="--keyfile /path" [[ -n "$HB_PBS_KEYFILE_OPT" ]] && cmd+=($HB_PBS_KEYFILE_OPT) : > "$log_file" if env \ PBS_PASSWORD="$HB_PBS_SECRET" \ PBS_ENCRYPTION_PASSWORD="${HB_PBS_ENC_PASS:-}" \ PBS_FINGERPRINT="${HB_PBS_FINGERPRINT:-}" \ "${cmd[@]}" 2>&1 | tee -a "$log_file"; then # Main backup OK — also upload the keyfile recovery blob if # one was configured AND this backup actually used the # keyfile. Gating on HB_PBS_KEYFILE_OPT (rather than only on # the file existing on disk) stops the upload when the # operator declined encryption for this run but a recovery # blob is still cached from a prior encrypted backup: in # that case the blob does not describe this snapshot and # uploading it as a paired recovery would be misleading. # This runs as a SEPARATE backup group # (`host/hostcfg--keyrecovery`) with NO --keyfile, # so PBS stores it as a plain (non-PBS-encrypted) blob that # can be retrieved during fresh-install recovery. The blob # is still passphrase-protected by openssl. if [[ -n "$HB_PBS_KEYFILE_OPT" && -f "$HB_STATE_DIR/pbs-key.recovery.enc" ]]; then hb_pbs_upload_recovery_blob "$epoch" \ || msg_warn "$(translate "Recovery blob upload failed — main backup is OK, but keyfile recovery from PBS will not be available for this backup.")" fi elapsed=$((SECONDS - t_start)) local snap_time snap_time=$(date -d "@$epoch" '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || date -r "$epoch" '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "$epoch") echo -e "" echo -e "${TAB}${BOLD}$(translate "Backup completed:")${CL}" echo -e "${TAB}${BGN}$(translate "Method:")${CL} ${BL}Proxmox Backup Server (PBS)${CL}" echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${HB_PBS_REPOSITORY}${CL}" echo -e "${TAB}${BGN}$(translate "Backup ID:")${CL} ${BL}${backup_id}${CL}" echo -e "${TAB}${BGN}$(translate "Backup path:")${CL} ${BL}host/${backup_id}/${snap_time}${CL}" echo -e "${TAB}${BGN}$(translate "Data size:")${CL} ${BL}${staged_size}${CL}" echo -e "${TAB}${BGN}$(translate "Duration:")${CL} ${BL}$(hb_human_elapsed "$elapsed")${CL}" echo -e "${TAB}${BGN}$(translate "Encryption:")${CL} ${BL}${_pbs_enc_label}${CL}" # Only point at the log if it actually has output. On a clean # success the underlying tool is silent and surfacing an empty # file path just confuses the operator into thinking they need # to look at it. [[ -s "$log_file" ]] && echo -e "${TAB}${BGN}$(translate "Log:")${CL} ${BL}${log_file}${CL}" echo -e "" msg_ok "$(translate "Backup completed successfully.")" export HB_NOTIFY_ARCHIVE_SIZE="-" export HB_NOTIFY_DURATION="$(hb_human_elapsed "$elapsed" 2>/dev/null || echo "${elapsed}s")" hb_notify_lifecycle "complete" else echo -e "" msg_error "$(translate "PBS backup failed.")" local _hb_reason _hb_reason=$(grep -iE 'error|fail|fatal|abort' "$log_file" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//') [[ -z "$_hb_reason" ]] && _hb_reason="proxmox-backup-client returned non-zero" export HB_NOTIFY_ARCHIVE_SIZE="-" export HB_NOTIFY_DURATION="$(hb_human_elapsed "$((SECONDS - t_start))" 2>/dev/null || echo "")" export HB_NOTIFY_REASON="$_hb_reason" hb_notify_lifecycle "fail" hb_show_log "$log_file" "$(translate "PBS backup error log")" echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r return 1 fi echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r } # ========================================================== # BACKUP — BORG # ========================================================== _bk_borg() { local profile_mode="$1" local -a paths=() local borg_bin repo staging_root log_file t_start elapsed staged_size archive_name borg_bin=$(hb_ensure_borg) || return 1 hb_select_borg_repo repo || return 1 hb_prepare_borg_passphrase || return 1 hb_select_profile_paths "$profile_mode" paths || return 1 archive_name="hostcfg-$(hostname)-$(date +%Y%m%d_%H%M%S)" log_file="/tmp/proxmenux-borg-backup-$(date +%Y%m%d_%H%M%S).log" staging_root=$(mktemp -d /tmp/proxmenux-borg-stage.XXXXXX) # shellcheck disable=SC2064 trap "rm -rf '$staging_root'" RETURN show_proxmenux_logo msg_title "$(translate "Host Backup → Borg")" echo -e "" local _borg_enc_label if [[ "${BORG_ENCRYPT_MODE:-none}" == "repokey" ]]; then _borg_enc_label=$(hb_translate "Enabled (repokey)"); else _borg_enc_label=$(hb_translate "Disabled"); fi echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${repo}${CL}" echo -e "${TAB}${BGN}$(translate "Archive:")${CL} ${BL}${archive_name}${CL}" echo -e "${TAB}${BGN}$(translate "Encryption:")${CL} ${BL}${_borg_enc_label}${CL}" echo -e "${TAB}${BGN}$(translate "Paths:")${CL}" local p; for p in "${paths[@]}"; do echo -e "${TAB} ${BL}•${CL} $p"; done echo -e "" msg_info "$(translate "Preparing files for backup...")" hb_prepare_staging "$staging_root" "${paths[@]}" staged_size=$(hb_file_size "$staging_root/rootfs") msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size" export HB_NOTIFY_JOB_ID="manual-borg-${archive_name}" export HB_NOTIFY_BACKEND="borg" export HB_NOTIFY_DESTINATION="$repo" export HB_NOTIFY_PROFILE_MODE="$profile_mode" export HB_NOTIFY_LOG_FILE="$log_file" export HB_NOTIFY_DATA_SIZE="$staged_size" hb_notify_lifecycle "start" msg_info "$(translate "Initializing Borg repository if needed...")" if ! hb_borg_init_if_needed "$borg_bin" "$repo" "${BORG_ENCRYPT_MODE:-none}" >/dev/null 2>&1; then msg_error "$(translate "Failed to initialize Borg repository at:") $repo" return 1 fi msg_ok "$(translate "Repository ready.")" echo -e "" msg_info "$(translate "Starting Borg backup...")" stop_spinner t_start=$SECONDS : > "$log_file" # Include manifest.json (top-level) when present — without it, # parse_manifest.sh can't read the schema'd manifest on restore. local -a _bk_borg_paths=(rootfs metadata) [[ -f "$staging_root/manifest.json" ]] && _bk_borg_paths+=(manifest.json) if (cd "$staging_root" && "$borg_bin" create --stats --progress \ "$repo::$archive_name" "${_bk_borg_paths[@]}") 2>&1 | tee -a "$log_file"; then elapsed=$((SECONDS - t_start)) # Extract compressed size from borg stats if available local borg_compressed borg_compressed=$(grep -i "this archive" "$log_file" | awk '{print $4, $5}' | tail -1) [[ -z "$borg_compressed" ]] && borg_compressed="$staged_size" echo -e "" echo -e "${TAB}${BOLD}$(translate "Backup completed:")${CL}" echo -e "${TAB}${BGN}$(translate "Method:")${CL} ${BL}BorgBackup${CL}" echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${repo}${CL}" echo -e "${TAB}${BGN}$(translate "Archive:")${CL} ${BL}${archive_name}${CL}" echo -e "${TAB}${BGN}$(translate "Data size:")${CL} ${BL}${staged_size}${CL}" echo -e "${TAB}${BGN}$(translate "Compressed size:")${CL} ${BL}${borg_compressed}${CL}" echo -e "${TAB}${BGN}$(translate "Duration:")${CL} ${BL}$(hb_human_elapsed "$elapsed")${CL}" echo -e "${TAB}${BGN}$(translate "Encryption:")${CL} ${BL}${_borg_enc_label}${CL}" [[ -s "$log_file" ]] && echo -e "${TAB}${BGN}$(translate "Log:")${CL} ${BL}${log_file}${CL}" echo -e "" msg_ok "$(translate "Backup completed successfully.")" export HB_NOTIFY_ARCHIVE_SIZE="$borg_compressed" export HB_NOTIFY_DURATION="$(hb_human_elapsed "$elapsed" 2>/dev/null || echo "${elapsed}s")" hb_notify_lifecycle "complete" else echo -e "" msg_error "$(translate "Borg backup failed.")" local _hb_reason _hb_reason=$(grep -iE 'error|fail|fatal|abort' "$log_file" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//') [[ -z "$_hb_reason" ]] && _hb_reason="borg create returned non-zero" export HB_NOTIFY_ARCHIVE_SIZE="-" export HB_NOTIFY_DURATION="$(hb_human_elapsed "$((SECONDS - t_start))" 2>/dev/null || echo "")" export HB_NOTIFY_REASON="$_hb_reason" hb_notify_lifecycle "fail" hb_show_log "$log_file" "$(translate "Borg backup error log")" echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r return 1 fi echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r } # ========================================================== # BACKUP — LOCAL tar # ========================================================== _bk_local() { local profile_mode="$1" local -a paths=() local dest_dir staging_root archive log_file t_start elapsed staged_size archive_size hb_require_cmd rsync rsync || return 1 dest_dir=$(hb_select_local_target) || return 1 hb_select_profile_paths "$profile_mode" paths || return 1 # Safety check: if the destination directory is INSIDE any selected # backup path, creating the archive would copy the backup into # itself — recursion → corrupted archive or unbounded growth that # fills the disk. Common footgun when an operator adds a custom # path like /var/lib/vz and then picks /var/lib/vz/dump as # destination, or the default profile's /root and a destination # under /root/. local dest_real conflict="" dest_real=$(readlink -m "$dest_dir" 2>/dev/null || echo "$dest_dir") local p_real p for p in "${paths[@]}"; do p_real=$(readlink -m "$p" 2>/dev/null || echo "$p") if [[ "$dest_real" == "$p_real" || "$dest_real" == "$p_real"/* ]]; then conflict="$p" break fi done if [[ -n "$conflict" ]]; then local body body="$(translate "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.")"$'\n\n' body+="\Zb$(translate "Destination:")\ZB \Z4${dest_dir}\Zn"$'\n' body+="\Zb$(translate "Conflicting path included in backup:")\ZB \Z1${conflict}\Zn"$'\n\n' body+="$(translate "To fix this, do ONE of the following:")"$'\n' body+=" • $(translate "Choose a destination directory OUTSIDE of") ${conflict}"$'\n' body+=" • $(translate "Go to \"Manage custom paths\" and remove your custom entry that includes the destination")"$'\n' body+=" • $(translate "Use Custom backup and uncheck the conflicting path from the list")" dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Backup destination is inside the backup")" \ --msgbox "$body" 20 88 return 1 fi archive="$dest_dir/hostcfg-$(hostname)-$(date +%Y%m%d_%H%M%S).tar.zst" log_file="/tmp/proxmenux-local-backup-$(date +%Y%m%d_%H%M%S).log" staging_root=$(mktemp -d /tmp/proxmenux-local-stage.XXXXXX) # shellcheck disable=SC2064 trap "rm -rf '$staging_root'" RETURN show_proxmenux_logo msg_title "$(translate "Host Backup → Local archive")" echo -e "" echo -e "${TAB}${BGN}$(translate "Destination:")${CL} ${BL}${archive}${CL}" echo -e "${TAB}${BGN}$(translate "Paths:")${CL}" local p; for p in "${paths[@]}"; do echo -e "${TAB} ${BL}•${CL} $p"; done echo -e "" msg_info "$(translate "Preparing files for backup...")" hb_prepare_staging "$staging_root" "${paths[@]}" staged_size=$(hb_file_size "$staging_root/rootfs") msg_ok "$(translate "Staging ready.") $(translate "Data size:") $staged_size" export HB_NOTIFY_JOB_ID="manual-local-$(basename "$archive" .tar.zst)" export HB_NOTIFY_BACKEND="local" export HB_NOTIFY_DESTINATION="$archive" export HB_NOTIFY_PROFILE_MODE="$profile_mode" export HB_NOTIFY_LOG_FILE="$log_file" export HB_NOTIFY_DATA_SIZE="$staged_size" hb_notify_lifecycle "start" echo -e "" msg_info "$(translate "Creating compressed archive...")" stop_spinner t_start=$SECONDS : > "$log_file" local tar_ok=0 if command -v zstd >/dev/null 2>&1; then if tar --zstd -cf "$archive" -C "$staging_root" . >>"$log_file" 2>&1; then tar_ok=1 fi else # Fallback: gzip (rename archive) archive="${archive%.zst}" archive="${archive%.tar}.tar.gz" if hb_ensure_pv; then local stage_bytes local pipefail_state stage_bytes=$(du -sb "$staging_root" 2>/dev/null | awk '{print $1}') pipefail_state=$(set -o | awk '$1=="pipefail" {print $2}') set -o pipefail if tar -cf - -C "$staging_root" . 2>>"$log_file" \ | pv -s "$stage_bytes" | gzip > "$archive" 2>>"$log_file"; then tar_ok=1 fi [[ "$pipefail_state" == "off" ]] && set +o pipefail else if tar -czf "$archive" -C "$staging_root" . >>"$log_file" 2>&1; then tar_ok=1 fi fi fi elapsed=$((SECONDS - t_start)) if [[ $tar_ok -eq 1 && -f "$archive" ]]; then # Drop a sidecar JSON next to the archive so the Monitor # (and any future tooling) can identify this as a # ProxMenux host backup regardless of any future rename. hb_write_archive_sidecar "$archive" "manual" "" "$profile_mode" || true archive_size=$(hb_file_size "$archive") echo -e "" echo -e "${TAB}${BOLD}$(translate "Backup completed:")${CL}" echo -e "${TAB}${BGN}$(translate "Method:")${CL} ${BL}Local archive (tar)${CL}" echo -e "${TAB}${BGN}$(translate "Archive:")${CL} ${BL}${archive}${CL}" echo -e "${TAB}${BGN}$(translate "Data size:")${CL} ${BL}${staged_size}${CL}" echo -e "${TAB}${BGN}$(translate "Archive size:")${CL} ${BL}${archive_size}${CL}" echo -e "${TAB}${BGN}$(translate "Duration:")${CL} ${BL}$(hb_human_elapsed "$elapsed")${CL}" [[ -s "$log_file" ]] && echo -e "${TAB}${BGN}$(translate "Log:")${CL} ${BL}${log_file}${CL}" echo -e "" msg_ok "$(translate "Backup completed successfully.")" export HB_NOTIFY_ARCHIVE_SIZE="$archive_size" export HB_NOTIFY_DURATION="$(hb_human_elapsed "$elapsed" 2>/dev/null || echo "${elapsed}s")" hb_notify_lifecycle "complete" else echo -e "" msg_error "$(translate "Local backup failed.")" local _hb_reason _hb_reason=$(grep -iE 'error|fail|fatal|abort' "$log_file" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//') [[ -z "$_hb_reason" ]] && _hb_reason="tar/zstd returned non-zero" export HB_NOTIFY_ARCHIVE_SIZE="-" export HB_NOTIFY_DURATION="$(hb_human_elapsed "$elapsed" 2>/dev/null || echo "${elapsed}s")" export HB_NOTIFY_REASON="$_hb_reason" hb_notify_lifecycle "fail" hb_show_log "$log_file" "$(translate "Local backup error log")" echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r return 1 fi echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r } # ========================================================== # BACKUP MENU # ========================================================== _bk_scheduler() { local scheduler="$LOCAL_SCRIPTS/backup_restore/backup_scheduler.sh" [[ ! -f "$scheduler" ]] && scheduler="$SCRIPT_DIR/backup_scheduler.sh" if [[ ! -f "$scheduler" ]]; then show_proxmenux_logo msg_error "$(translate "Scheduler script not found:") $scheduler" echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r return 1 fi bash "$scheduler" } _bk_manage_local_destinations() { while true; do # Single configured target (or none → caller falls back to default) local current="" current=$(hb_get_local_target 2>/dev/null) || current="" local body="" if [[ -n "$current" ]]; then body+="\Zb$(translate "Currently configured target:")\ZB"$'\n' body+=" \Z4${current}\Zn" else body+="$(translate "No target configured.")"$'\n' body+="$(translate "Default will be used:") \Z4${HB_LOCAL_TARGET_DEFAULT}\Zn" fi local -a menu_args=() menu_args+=("default" "1 $(translate "Use default") (${HB_LOCAL_TARGET_DEFAULT})") menu_args+=("custom" "2 $(translate "Use a custom path")") menu_args+=("usb" "3 $(translate "Use a USB disk")") if [[ -n "$current" ]]; then menu_args+=("clear" "C $(translate "Clear configured target")") fi menu_args+=("back" "$(translate "← Return")") local choice choice=$(dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Manage local backup target")" \ --menu "\n${body}\n" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu_args[@]}" \ 3>&1 1>&2 2>&3) || break case "$choice" in default) hb_set_local_target "$HB_LOCAL_TARGET_DEFAULT" ;; custom) local new_path new_path=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Custom path")" \ --inputbox "$(translate "Absolute directory path to use as backup target:")" \ "$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup" \ 3>&1 1>&2 2>&3) || continue new_path=$(hb_trim_dialog_value "$new_path") [[ -z "$new_path" ]] && continue mkdir -p "$new_path" 2>/dev/null || { dialog --backtitle "ProxMenux" --msgbox \ "$(translate "Cannot create:") $new_path" 8 60 continue } hb_set_local_target "$new_path" ;; usb) _bk_local_target_usb_submenu ;; clear) hb_clear_local_target ;; back) break ;; esac done } # Submenu reached when the user picks "Use a USB disk" in the local # target manager. Lists mounted USB partitions so the operator can pick # one as the target; mount / unmount are auxiliary actions for preparing # a fresh disk or releasing one. Picking a USB sets it as the configured # target and returns to the parent menu. _bk_local_target_usb_submenu() { while true; do local -a usb_mp=() local -a usb_desc=() local state path_or_dev label size fstype uuid while IFS=$'\t' read -r state path_or_dev label size fstype uuid; do [[ "$state" != "mounted" ]] && continue usb_mp+=("$path_or_dev") usb_desc+=("${label:-?} [${fstype}] $size → $path_or_dev") done < <(hb_list_usb_partitions) local body="" if (( ${#usb_mp[@]} > 0 )); then body+="\Zb$(translate "USB drives mounted now:")\ZB"$'\n' local d for d in "${usb_desc[@]}"; do body+=" • ${d}"$'\n' done else body+="$(translate "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.")" fi local -a menu_args=() if (( ${#usb_mp[@]} > 0 )); then menu_args+=("pick" "$(translate "Pick a mounted USB as target")") fi menu_args+=("mount" "⊕ $(translate "Mount a USB drive now")") if (( ${#usb_mp[@]} > 0 )); then menu_args+=("unmount" "⊖ $(translate "Unmount a USB drive")") fi menu_args+=("back" "$(translate "← Return")") local choice choice=$(dialog --backtitle "ProxMenux" --colors \ --title "$(translate "USB disk target")" \ --menu "\n${body}\n" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu_args[@]}" \ 3>&1 1>&2 2>&3) || break case "$choice" in pick) local pick_menu=() i=1 idx for idx in "${!usb_mp[@]}"; do pick_menu+=("$i" "${usb_desc[$idx]}"); ((i++)) done local pick pick=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Pick USB target")" \ --menu "\n$(translate "Select the mounted USB to use as backup target:")" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${pick_menu[@]}" \ 3>&1 1>&2 2>&3) || continue hb_set_local_target "${usb_mp[$((pick-1))]}" return 0 ;; mount) # Auxiliary: prepare a USB so it can be picked next pass. hb_prompt_mounted_path "/mnt/backup" >/dev/null || true ;; unmount) if (( ${#usb_mp[@]} == 0 )); then continue fi local unmenu=() j=1 mp for mp in "${usb_mp[@]}"; do unmenu+=("$j" "$mp"); ((j++)) done local pick pick=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Unmount USB drive")" \ --menu "\n$(translate "Pick a drive to unmount:")" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${unmenu[@]}" \ 3>&1 1>&2 2>&3) || continue local victim="${usb_mp[$((pick-1))]}" if umount "$victim" 2>/tmp/proxmenux-umount.log; then rmdir "$victim" 2>/dev/null || true dialog --backtitle "ProxMenux" --colors \ --msgbox "$(translate "Unmounted") \Z4${victim}\Zn" 8 70 else local err err=$(cat /tmp/proxmenux-umount.log 2>/dev/null) dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Unmount failed")" \ --msgbox "$(translate "Could not unmount") \Z1${victim}\Zn.\n\n${err}" 12 78 fi ;; back) break ;; esac done } _bk_manage_destinations() { while true; do # Option 4 (keyfile management) only appears when a keyfile is # actually installed on disk. Without one there's nothing to # manage — the keyfile gets materialised during job creation # (Generate / Import flow), not from this destinations menu. # Once the first encrypted job is created, this entry becomes # visible on the next open. local -a menu_items=( 1 "$(translate "Proxmox Backup Server (PBS) destinations")" 2 "$(translate "Borg repositories")" 3 "$(translate "Local archive targets (paths + USB mount/unmount)")" ) if [[ -s "$HB_STATE_DIR/pbs-key.conf" ]]; then menu_items+=(4 "$(translate "PBS encryption keyfile (show / replace / remove)")") fi menu_items+=(0 "$(translate "Return")") local choice choice=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Configure backup destinations")" \ --menu "\n$(translate "Pre-configure destinations so you don't have to enter them every time you back up.")" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "${menu_items[@]}" \ 3>&1 1>&2 2>&3) || break case "$choice" in 1) hb_select_pbs_repository || true ;; 2) local _discard="" hb_select_borg_repo _discard || true ;; 3) _bk_manage_local_destinations ;; 4) _bk_manage_pbs_encryption_keyfile ;; 0) break ;; esac done } # Dedicated management submenu for the PBS encryption keyfile. # Available as option 4 of "Configure backup destinations". All # destructive actions offer a backup-to-/root step first and require # an explicit confirmation before touching disk. _bk_manage_pbs_encryption_keyfile() { local key_file="$HB_STATE_DIR/pbs-key.conf" local pass_file="$HB_STATE_DIR/pbs-key.pass" local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" while true; do local installed=0 [[ -s "$key_file" ]] && installed=1 # Precompute values used both by the compact prompt and the # detailed info modal. local fp="" kdf_hint="unknown" installed_at="" if (( installed )); then if command -v proxmox-backup-client >/dev/null 2>&1; then fp=$(proxmox-backup-client key show --output-format json "$key_file" 2>/dev/null \ | grep -oE '"fingerprint"[[:space:]]*:[[:space:]]*"[^"]*"' \ | head -1 | sed 's/.*"\([^"]*\)".*/\1/') fi if grep -qE '"kdf":[[:space:]]*(null|"None")' "$key_file" 2>/dev/null; then kdf_hint="none" elif grep -q '"Scrypt"' "$key_file" 2>/dev/null; then kdf_hint="scrypt" fi installed_at=$(date -r "$key_file" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "") fi # Menu prompt: ONE line summarising the two things that # actually matter (installed + recoverable). Everything else # goes in the "Show detailed keyfile info" action which uses # --msgbox — that widget honours embedded newlines, whereas # dialog --menu collapses them into spaces on many distros and # produced an unreadable wall of text no matter how tight the # per-field layout was. local prompt="" if (( installed )); then local recov_state="\Zb\Z1no — set a recovery passphrase\Zn" [[ -s "$recovery_enc" ]] && recov_state="\Zb\Z4yes\Zn" prompt="Encryption keyfile: \Zb\Z4installed\Zn • Recoverable: ${recov_state}" else prompt="No encryption keyfile installed on this host." fi local choice if (( installed )); then choice=$(dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Manage PBS encryption keyfile")" \ --menu "\n${prompt}\n" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "info" "$(hb_translate "Show detailed keyfile info")" \ "download" "$(hb_translate "Copy the keyfile to a path for offsite backup")" \ "import" "$(hb_translate "Import a keyfile from an absolute path (replaces the current one)")" \ "escrow" "$(hb_translate "Upload to PBS: enable, disable or rotate the recovery passphrase")" \ "remove" "$(hb_translate "Remove keyfile from this host")" \ "back" "$(hb_translate "← Return")" \ 3>&1 1>&2 2>&3) || break else choice=$(dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Manage PBS encryption keyfile")" \ --menu "\n${prompt}\n" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "back" "$(hb_translate "← Return")" \ 3>&1 1>&2 2>&3) || break fi case "$choice" in info) # Terminal output pattern used across ProxMenux (logo + # msg_title + labeled TAB/BGN/BL/CL rows + "press enter # to return"), same shape as _bk_pbs's post-backup # summary. Fits arbitrarily wide fingerprints without # the wrapping issues that dialog --msgbox has, and lets # the operator copy-paste the fingerprint from a real # terminal buffer. show_proxmenux_logo msg_title "$(translate "PBS encryption keyfile — detailed info")" echo -e "" echo -e "${TAB}${BGN}$(translate "Path:")${CL} ${BL}${key_file}${CL}" [[ -n "$installed_at" ]] && \ echo -e "${TAB}${BGN}$(translate "Installed at:")${CL} ${BL}${installed_at}${CL}" echo -e "${TAB}${BGN}$(translate "KDF:")${CL} ${BL}${kdf_hint}${CL}" [[ -n "$fp" ]] && \ echo -e "${TAB}${BGN}$(translate "Fingerprint:")${CL} ${BL}${fp}${CL}" if [[ -s "$pass_file" ]]; then echo -e "${TAB}${BGN}$(translate "Keyfile passphrase stored:")${CL} ${BL}$(translate "yes (scrypt-unlock passphrase stored)")${CL}" else echo -e "${TAB}${BGN}$(translate "Keyfile passphrase stored:")${CL} ${BL}$(translate "no (kdf=none, not needed)")${CL}" fi if [[ -s "$recovery_enc" ]]; then echo -e "${TAB}${BGN}$(translate "Recovery blob:")${CL} ${BL}$(translate "yes")${CL}" echo -e "${TAB}${BGN}$(translate "Recoverable:")${CL} ${BL}$(translate "yes (via PBS escrow + recovery passphrase)")${CL}" else echo -e "${TAB}${BGN}$(translate "Recovery blob:")${CL} ${BL}$(translate "no")${CL}" echo -e "${TAB}${BGN}$(translate "Recoverable:")${CL} ${BL}$(translate "no (no escrow blob — set a recovery passphrase to enable recovery)")${CL}" fi echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r ;; download) # Copy the keyfile to an operator-supplied path so # they can scp/USB it offsite. Same trust boundary as # the Monitor's Download button. local dst dst=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Download keyfile")" \ --inputbox "$(hb_translate "Destination path for the copy (typically outside ProxMenux state):")"$'\n\n'"$(hb_translate "Default:") /tmp/pbs-key.conf" \ 12 78 "/tmp/pbs-key.conf" 3>&1 1>&2 2>&3) || continue dst="$(echo "$dst" | xargs)" [[ -z "$dst" ]] && continue if cp -f "$key_file" "$dst" 2>/dev/null; then chmod 600 "$dst" 2>/dev/null || true dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile copied")" \ --msgbox "$(hb_translate "Keyfile copied to:")"$'\n\n'"$dst"$'\n\n'"$(hb_translate "Move that copy offsite (USB, password manager, another host). Delete it from this path when done.")" \ 13 78 else dialog --backtitle "ProxMenux" --title "$(hb_translate "Copy failed")" \ --msgbox "$(hb_translate "Could not write to:") $dst" 9 70 fi ;; import) # Import a new keyfile from an absolute path — replaces # the currently installed one only if the operator # completes the import dialog. Cancel / empty path / # missing file all leave the current keyfile in place. local warn_body warn_body="\Zb\Z1$(hb_translate "This replaces the currently installed keyfile.")\Zn"$'\n\n' warn_body+="$(hb_translate "Backups already on PBS were encrypted with the current key — downloading them will fail unless you first Download the current keyfile to keep a copy.")"$'\n\n' warn_body+="$(hb_translate "Continue with the import?")" if ! dialog --backtitle "ProxMenux" --colors --yesno "$warn_body" 14 78; then continue fi # No upfront wipe. `_hb_pbs_import_dialog` only touches # the canonical path after the operator has typed a # valid absolute path AND finished the whole wizard; # on cancel or missing file it returns non-zero and # the current keyfile stays untouched. _hb_pbs_import_dialog || true ;; escrow) # Toggle Upload to PBS Yes/No + set or rotate the # recovery passphrase — same three transitions the # Monitor exposes on the KeyfileActionsBar. local cur_mode="none" [[ -s "$HB_STATE_DIR/pbs-key.mode" ]] && cur_mode="$(cat "$HB_STATE_DIR/pbs-key.mode" 2>/dev/null | tr -d '[:space:]')" local esc_action if [[ "$cur_mode" == "full" ]]; then esc_action=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "PBS upload")" \ --menu "$(hb_translate "Upload to PBS is currently: yes. Pick an action:")" \ 12 78 3 \ "rotate" "$(hb_translate "Rotate the recovery passphrase")" \ "stop" "$(hb_translate "Stop uploading to PBS")" \ "cancel" "$(hb_translate "Cancel")" \ 3>&1 1>&2 2>&3) || continue else esc_action=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "PBS upload")" \ --menu "$(hb_translate "Upload to PBS is currently: no. Pick an action:")" \ 10 78 2 \ "start" "$(hb_translate "Start uploading to PBS — sets a recovery passphrase")" \ "cancel" "$(hb_translate "Cancel")" \ 3>&1 1>&2 2>&3) || continue fi case "$esc_action" in start|rotate) _hb_pbs_prompt_recovery_pass || continue local pass="$HB_PBS_PASS_RESULT" if _hb_pbs_finalize_recovery "$pass" full; then _hb_pbs_write_escrow_mode full dialog --backtitle "ProxMenux" \ --msgbox "$(hb_translate "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.")" 10 70 fi ;; stop) if dialog --backtitle "ProxMenux" --colors --defaultno --yesno \ "\Zb\Z1$(hb_translate "Stop uploading?")\Zn"$'\n\n'"$(hb_translate "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.")" \ 13 78; then rm -f "$recovery_enc" 2>/dev/null _hb_pbs_write_escrow_mode none dialog --backtitle "ProxMenux" \ --msgbox "$(hb_translate "Upload to PBS disabled.")" 8 60 fi ;; esac ;; remove) # Destructive removal of the local keyfile. No copies # kept anywhere — the operator is expected to hit # Download first if they want to save the key. local warn_body warn_body="\Zb\Z1$(hb_translate "This is a destructive action")\Zn"$'\n\n' warn_body+="$(hb_translate "Backups already stored on PBS were encrypted with the current keyfile. After this action:")"$'\n' warn_body+=" • $(hb_translate "New backups on this host will be unencrypted until a new keyfile is set up.")"$'\n' warn_body+=" • $(hb_translate "Downloading pre-existing encrypted backups from this host will fail unless you kept a copy of the current key.")"$'\n' warn_body+=" • $(hb_translate "Existing recovery envelopes already on PBS stay intact — they still recover the old key with its original passphrase.")"$'\n\n' warn_body+="$(hb_translate "Use Download first if you want to save a copy of the current key. Continue?")" if ! dialog --backtitle "ProxMenux" --colors --defaultno --yesno "$warn_body" 18 80; then continue fi rm -f "$key_file" "$recovery_enc" "$pass_file" \ "$HB_STATE_DIR/pbs-key.mode" 2>/dev/null dialog --backtitle "ProxMenux" \ --msgbox "$(hb_translate "Keyfile removed.")" 8 60 ;; back|"") break ;; esac done } _bk_manage_extra_paths() { while true; do local -a paths=() mapfile -t paths < <(hb_load_extra_paths) local count=${#paths[@]} # Descriptive header for the manage menu. We avoid listing the actual # paths here — a user with dozens of entries would blow the dialog # box height and force scrolling. The count is enough; "− Remove a # path" shows the full list when the user actually needs to see it. local preview="" if (( count == 0 )); then preview="$(hb_translate "You haven't added any custom paths yet.")" else preview="$(hb_translate "Currently"): \Zb\Z4${count}\Zn $(hb_translate "custom path(s) saved.")" fi preview+=$'\n\n'"$(hb_translate "Custom paths are included in BOTH default and custom backup profiles.")" local choice choice=$(dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Manage custom backup paths")" \ --menu "\n${preview}\n" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "view" "$(translate "View current paths")" \ "add" "$(translate "+ Add a path")" \ "del" "$(translate "− Remove a path")" \ "back" "$(translate "← Return")" \ 3>&1 1>&2 2>&3) || break case "$choice" in view) if (( count == 0 )); then dialog --backtitle "ProxMenux" --msgbox \ "$(translate "You haven't added any custom paths yet.")" 8 60 continue fi local list_body="" pv for pv in "${paths[@]}"; do list_body+="• ${pv}"$'\n' done dialog --backtitle "ProxMenux" \ --title "$(translate "Custom backup paths") (${count})" \ --msgbox "\n${list_body}" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" ;; add) local new_path new_path=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Add custom path")" \ --inputbox "$(translate "Absolute path to a file or directory you want backed up:")" \ "$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/root/" 3>&1 1>&2 2>&3) || continue new_path="${new_path%/}" [[ -z "$new_path" ]] && continue if [[ ! -e "$new_path" ]]; then dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Path not found")" \ --msgbox "\Z1${new_path}\Zn\n\n$(translate "does not exist on this host. Path not added.")" 10 70 continue fi hb_add_extra_path "$new_path" ;; del) if (( count == 0 )); then dialog --backtitle "ProxMenux" --msgbox \ "$(translate "You haven't added any custom paths yet.")" 8 60 continue fi local del_options=() j=1 p for p in "${paths[@]}"; do del_options+=("$j" "$p" "off"); ((j++)) done local del_selected del_selected=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Remove custom paths")" \ --default-button ok \ --separate-output --checklist \ "\n$(translate "Tick the paths to remove (they will not be deleted from disk — only from this list):")" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${del_options[@]}" \ 3>&1 1>&2 2>&3) || continue # Empty selection → nothing to do [[ -z "$del_selected" ]] && continue local sel while read -r sel; do [[ -z "$sel" ]] && continue hb_del_extra_path "${paths[$((sel-1))]}" done <<< "$del_selected" ;; back) break ;; esac done } backup_menu() { while true; do local choice # 12 visible rows (3 separators + 6 actions + Return + blanks). # Override the global HB_UI_MENU_LIST=10 locally so every row # fits without a scrollbar; the height bump to 24 keeps the # dialog chrome (title + borders + question prompt) from # squeezing the list area. choice=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Host Config Backup")" \ --menu "\n$(translate "Select backup method and profile:")" \ 24 "$HB_UI_MENU_W" 13 \ "" "$(translate "────────────────── Default profile ───────────────────")" \ 1 "$(translate "Backup to Proxmox Backup Server (PBS)")" \ 2 "$(translate "Backup to Borg repository")" \ 3 "$(translate "Backup to local archive (.tar.zst)")" \ "" " " \ "" "$(translate "─────── Custom profile (choose paths manually) ───────")" \ 4 "$(translate "Custom backup to PBS")" \ 5 "$(translate "Custom backup to Borg")" \ 6 "$(translate "Custom backup to local archive")" \ "" " " \ 0 "$(translate "Return")" \ 3>&1 1>&2 2>&3) || return 0 case "$choice" in 1) _bk_pbs default ;; 2) _bk_borg default ;; 3) _bk_local default ;; 4) _bk_pbs custom ;; 5) _bk_borg custom ;; 6) _bk_local custom ;; 0) break ;; esac done } # ========================================================== # RESTORE — EXTRACT TO STAGING # ========================================================== _rs_extract_pbs() { local staging_root="$1" local log_file log_file="/tmp/proxmenux-pbs-restore-$(date +%Y%m%d_%H%M%S).log" local -a snapshots=() archives=() local snapshot archive hb_require_cmd proxmox-backup-client proxmox-backup-client || return 1 hb_select_pbs_repository || return 1 # If we're restoring on a fresh host (or one where the keyfile # was wiped) the encrypted snapshots are unreadable until we # restore the keyfile. Look for a recovery blob in PBS and let # the operator decrypt it with their passphrase. We try this # silently up-front so subsequent steps (snapshot list, files, # restore) Just Work whether or not the snapshots happen to be # encrypted. Failure here is non-fatal: a missing recovery # blob plus an unencrypted snapshot is a perfectly valid case # and the rest of the flow handles it. if [[ ! -f "$HB_STATE_DIR/pbs-key.conf" ]]; then hb_pbs_try_keyfile_recovery "$HB_STATE_DIR/pbs-key.conf" || true fi # Current proxmox-backup-client prints both `snapshot list` and # `snapshot files` as a Unicode box-drawing table even when piped # — the old awk-by-whitespace parser captures the `│` column # separators instead of the data and ends up with an empty array. # We now request --output-format json and parse with jq, then # convert the epoch returned by `snapshot list` to the UTC ISO # form (`YYYY-MM-DDTHH:MM:SSZ`) that `snapshot files` and # `restore` actually accept as the snapshot path. # mapfile -t snapshots < <( PBS_PASSWORD="$HB_PBS_SECRET" \ PBS_FINGERPRINT="${HB_PBS_FINGERPRINT:-}" \ proxmox-backup-client snapshot list \ --repository "$HB_PBS_REPOSITORY" \ --output-format json 2>/dev/null \ | jq -r '.[] | select(."backup-type" == "host" and ( ((."backup-id" | startswith("proxmenux-keyrecovery-")) or ((."backup-id" | startswith("hostcfg-")) and (."backup-id" | endswith("-keyrecovery")))) | not )) | "\(."backup-type")|\(."backup-id")|\(."backup-time")"' 2>/dev/null \ | while IFS='|' read -r _type _id _epoch; do local _iso _iso=$(date -u -d "@${_epoch}" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ || date -u -r "${_epoch}" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ || echo "${_epoch}") echo "${_type}/${_id}/${_iso}" done \ | sort -r | awk '!seen[$0]++' ) if [[ ${#snapshots[@]} -eq 0 ]]; then # Surface error as a blocking dialog so the operator can read # it. msg_error alone gets erased the moment we `return 1` # because the restore_menu loop redraws the source picker # immediately afterward. dialog --backtitle "ProxMenux" --title "$(translate "No backups")" \ --msgbox "$(translate "No host backups were found in this PBS repository:")"$'\n\n'"$HB_PBS_REPOSITORY" \ 10 78 return 1 fi local menu=() i=1 for snapshot in "${snapshots[@]}"; do menu+=("$i" "$snapshot"); ((i++)); done local sel sel=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Select backup to restore")" \ --menu "\n$(translate "Available host backups:")" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1 snapshot="${snapshots[$((sel-1))]}" # `snapshot files` filenames carry a `.didx` (chunk index) or # `.blob` suffix that doesn't match the bare `.pxar` name that # `restore` expects. Strip it before filtering. mapfile -t archives < <( PBS_PASSWORD="$HB_PBS_SECRET" \ PBS_FINGERPRINT="${HB_PBS_FINGERPRINT:-}" \ proxmox-backup-client snapshot files "$snapshot" \ --repository "$HB_PBS_REPOSITORY" \ --output-format json 2>/dev/null \ | jq -r '.[].filename' 2>/dev/null \ | sed -e 's/\.didx$//' -e 's/\.blob$//' \ | grep '\.pxar$' || true ) if [[ ${#archives[@]} -eq 0 ]]; then dialog --backtitle "ProxMenux" --title "$(translate "No archives")" \ --msgbox "$(translate "No .pxar archives were found in this backup:")"$'\n\n'"$snapshot" \ 10 78 return 1 fi if printf '%s\n' "${archives[@]}" | grep -qx "hostcfg.pxar"; then archive="hostcfg.pxar" else menu=(); i=1 for archive in "${archives[@]}"; do menu+=("$i" "$archive"); ((i++)); done sel=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Select archive")" \ --menu "\n$(translate "Available archives:")" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "${menu[@]}" 3>&1 1>&2 2>&3) || return 1 archive="${archives[$((sel-1))]}" fi show_proxmenux_logo msg_title "$(translate "Restore from PBS → staging")" echo -e "" echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${HB_PBS_REPOSITORY}${CL}" echo -e "${TAB}${BGN}$(translate "Backup:")${CL} ${BL}${snapshot}${CL}" echo -e "${TAB}${BGN}$(translate "Archive:")${CL} ${BL}${archive}${CL}" echo -e "${TAB}${BGN}$(translate "Staging directory:")${CL} ${BL}${staging_root}${CL}" echo -e "" msg_info "$(translate "Extracting data from PBS...")" stop_spinner local key_opt="" enc_pass="" [[ -f "$HB_STATE_DIR/pbs-key.conf" ]] && key_opt="--keyfile $HB_STATE_DIR/pbs-key.conf" [[ -f "$HB_STATE_DIR/pbs-encryption-pass.txt" ]] && \ enc_pass="$(<"$HB_STATE_DIR/pbs-encryption-pass.txt")" : > "$log_file" # PIPESTATUS check: `... | tee` masks the binary's exit code # with tee's (always 0). Without this, a failed decrypt or # missing keyfile would silently "succeed" — the staging # would be empty/garbage and _rs_check_layout would then say # "Incompatible archive", which is misleading. We capture the # client's actual exit code separately. local pbs_rc # shellcheck disable=SC2086 env \ PBS_PASSWORD="$HB_PBS_SECRET" \ PBS_ENCRYPTION_PASSWORD="${enc_pass}" \ PBS_FINGERPRINT="${HB_PBS_FINGERPRINT:-}" \ proxmox-backup-client restore \ "$snapshot" "$archive" "$staging_root" \ --repository "$HB_PBS_REPOSITORY" \ --allow-existing-dirs true \ $key_opt \ 2>&1 | tee -a "$log_file" pbs_rc=${PIPESTATUS[0]} if [[ $pbs_rc -eq 0 ]]; then msg_ok "$(translate "Extraction completed.")" return 0 fi # Decide whether this is the "encrypted snapshot without a usable # keyfile" pattern. proxmox-backup-client emits several distinct # messages depending on how the mismatch surfaces: # • missing key - manifest was created with key XX:XX:... # • wrong key - unable to verify signature since manifest's # key AA:AA:.. does not match provided key BB:BB:.. # • unable to load encryption key / no key file / no key found # • failed to decrypt / decrypt error # If any hits, surface a helpful error and pull the offending # line verbatim into the dialog so the operator has the fingerprint # of the correct key without hunting through a scrolling terminal. local extra_hint="" if grep -qiE 'encryption key|unable to (load|read) key|no key (file|found)|decrypt|failed to decrypt|missing key|wrong key|was created with key|manifest.?s key|does not match' "$log_file" 2>/dev/null; then # Extract the specific PBS diagnostic line (single line — the # subsequent messages usually restate the same fact). local pbc_err pbc_err=$(grep -iE 'missing key|wrong key|was created with key|manifest.?s key|does not match|unable to (load|read) key|no key (file|found)' "$log_file" 2>/dev/null \ | sed -E 's/[[:space:]]+$//' | head -1) extra_hint=$'\n\n'"$(translate "This backup is encrypted.")" if [[ -n "$pbc_err" ]]; then extra_hint+=$'\n\n'"$(translate "proxmox-backup-client reported:")"$'\n'" $pbc_err" fi if [[ -f "$HB_STATE_DIR/pbs-key.conf" ]]; then extra_hint+=$'\n\n'"$(translate "A keyfile is installed at:")"$'\n'" $HB_STATE_DIR/pbs-key.conf"$'\n'"$(translate "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.")" else extra_hint+=$'\n\n'"$(translate "Copy the correct keyfile to this host and rerun Restore — or pick an unencrypted backup.")" fi fi dialog --backtitle "ProxMenux" --title "$(translate "PBS extraction failed")" \ --msgbox "$(translate "Could not extract from PBS.")"$'\n\n'"$(translate "Backup:") $snapshot"$'\n'"$(translate "Archive:") $archive$extra_hint"$'\n\n'"$(translate "Full log:") $log_file" \ 22 84 return 1 } _rs_extract_borg() { local staging_root="$1" local borg_bin repo log_file log_file="/tmp/proxmenux-borg-restore-$(date +%Y%m%d_%H%M%S).log" local -a archives=() local archive borg_bin=$(hb_ensure_borg) || return 1 hb_select_borg_repo repo || return 1 # Same persistence path as backup: per-target pw file # ($HB_STATE_DIR/borg-pass-.txt), legacy global pw, or # prompt-once-and-save fallback. Bug fix: the old code only # honored the legacy global file and re-prompted otherwise, # defeating the saved-target UX. hb_prepare_borg_passphrase || return 1 # Pull NAME|START in one shot — borg supports strftime via :%fmt # in --format. Sort newest-first by the ISO timestamp so the most # recent backup is always on top regardless of archive naming. local -a archive_lines=() mapfile -t archive_lines < <( "$borg_bin" list "$repo" \ --format '{start:%Y-%m-%d %H:%M:%S}|{archive}{NL}' /dev/null \ | sort -r ) if [[ ${#archive_lines[@]} -eq 0 ]]; then msg_error "$(translate "No archives found in this Borg repository.")" return 1 fi archives=() local -a archive_labels=() local _start _name for line in "${archive_lines[@]}"; do _start="${line%%|*}" _name="${line#*|}" archives+=("$_name") # Menu label: ISO datetime first (sortable, fixed width), # then archive name. Easier to scan when several backups # ran the same day. archive_labels+=("${_start} · ${_name}") done local menu=() i=1 for archive in "${archive_labels[@]}"; do menu+=("$i" "$archive"); ((i++)); done local sel sel=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Select archive to restore")" \ --menu "\n$(translate "Available Borg archives (newest first):")" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "${menu[@]}" 3>&1 1>&2 2>&3) || return 1 archive="${archives[$((sel-1))]}" show_proxmenux_logo msg_title "$(translate "Restore from Borg → staging")" echo -e "" echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${repo}${CL}" echo -e "${TAB}${BGN}$(translate "Archive:")${CL} ${BL}${archive}${CL}" echo -e "${TAB}${BGN}$(translate "Staging directory:")${CL} ${BL}${staging_root}${CL}" echo -e "" msg_info "$(translate "Extracting data from Borg...")" stop_spinner : > "$log_file" if (cd "$staging_root" && "$borg_bin" extract --progress \ "$repo::$archive" 2>&1 | tee -a "$log_file"); then msg_ok "$(translate "Extraction completed.")" return 0 else msg_error "$(translate "Borg extraction failed.")" hb_show_log "$log_file" "$(translate "Borg restore error log")" return 1 fi } _rs_extract_local() { local staging_root="$1" local log_file source_dir archive hb_require_cmd tar tar || return 1 source_dir=$(hb_prompt_restore_source_dir) || return 1 # Loop the picker on every recoverable failure so a corrupt # archive doesn't dump the operator back to the top-level # restore menu (which they then read as "the script never # offered me a restore mode"). They stay in the same dir, # pick another archive, or explicitly cancel out. while true; do archive=$(hb_prompt_local_archive "$source_dir" \ "$(translate "Select backup archive to restore")") || return 1 log_file="/tmp/proxmenux-local-restore-$(date +%Y%m%d_%H%M%S).log" show_proxmenux_logo msg_title "$(translate "Restore from local archive → staging")" echo -e "" echo -e "${TAB}${BGN}$(translate "Archive:")${CL} ${BL}${archive}${CL}" echo -e "${TAB}${BGN}$(translate "Archive size:")${CL} ${BL}$(hb_file_size "$archive")${CL}" echo -e "${TAB}${BGN}$(translate "Staging directory:")${CL} ${BL}${staging_root}${CL}" echo -e "" msg_info "$(translate "Extracting archive...")" stop_spinner : > "$log_file" # Wipe staging from a previous failed attempt so we don't # mix partial extractions across retries. find "$staging_root" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null if [[ "$archive" == *.zst ]]; then tar --zstd -xf "$archive" -C "$staging_root" >>"$log_file" 2>&1 else tar -xf "$archive" -C "$staging_root" >>"$log_file" 2>&1 fi local rc=$? if [[ $rc -eq 0 ]]; then msg_ok "$(translate "Extraction completed.")" return 0 fi msg_error "$(translate "Extraction failed.")" hb_show_log "$log_file" "$(translate "Local restore error log")" # Recoverable: most often a corrupted archive (interrupted # mid-write, bad disk sector, partial copy). Give the user # a clear next step instead of silently bouncing back. local recover_msg recover_choice recover_msg="$(translate "The archive could not be extracted.")"$'\n\n' recover_msg+="$(translate "Most common cause: the archive is corrupted (interrupted write, partial copy, or storage issue).")"$'\n\n' recover_msg+="$(translate "Archive:") $archive" recover_choice=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Restore failed")" \ --menu "$recover_msg" 16 80 4 \ 1 "$(translate "Try another archive")" \ 2 "$(translate "Delete this corrupt archive and pick another")" \ 0 "$(translate "Cancel restore")" \ 3>&1 1>&2 2>&3) || return 1 case "$recover_choice" in 1) continue ;; # back to the picker 2) if whiptail --title "$(translate "Delete archive")" \ --yesno "$(translate "Permanently delete this archive and its sidecar?")"$'\n\n'"$archive" \ 11 78; then rm -f "$archive" "${archive}.proxmenux.json" msg_ok "$(translate "Archive deleted.")" fi continue ;; 0|*) return 1 ;; esac done } # Ensure staging has rootfs/ layout (Borg may nest) _rs_check_layout() { local staging_root="$1" # Case 1: new format — rootfs/ already present [[ -d "$staging_root/rootfs" ]] && return 0 # Case 2: nested format (old Borg archives may include absolute tmp paths) local -a rootfs_hits=() mapfile -t rootfs_hits < <(find "$staging_root" -mindepth 2 -maxdepth 6 -type d -name rootfs 2>/dev/null) if [[ ${#rootfs_hits[@]} -gt 1 ]]; then dialog --backtitle "ProxMenux" \ --title "$(translate "Incompatible archive")" \ --msgbox "$(translate "Multiple rootfs directories were found in this archive. Restore cannot continue automatically.")" \ 9 76 || true return 1 fi if [[ ${#rootfs_hits[@]} -eq 1 ]]; then local rootfs_dir nested rootfs_dir="${rootfs_hits[0]}" nested="$(dirname "$rootfs_dir")" mv "$rootfs_dir" "$staging_root/rootfs" if [[ -d "$nested/metadata" ]]; then mv "$nested/metadata" "$staging_root/metadata" fi mkdir -p "$staging_root/metadata" return 0 fi # Case 3: flat format — config dirs extracted directly at staging root # (archives created by older scripts that didn't use staging layout) if [[ -d "$staging_root/etc" || -d "$staging_root/var" || \ -d "$staging_root/root" || -d "$staging_root/usr" ]]; then local tmp tmp=$(mktemp -d "$staging_root/.rootfs_wrap.XXXXXX") local item for item in "$staging_root"/*/; do [[ "$item" == "$tmp/" ]] && continue mv "$item" "$tmp/" 2>/dev/null || true done find "$staging_root" -maxdepth 1 -type f -exec mv {} "$tmp/" \; 2>/dev/null || true mv "$tmp" "$staging_root/rootfs" mkdir -p "$staging_root/metadata" return 0 fi local incompatible_msg incompatible_msg="$(translate "This archive does not contain a recognized backup layout.")"$'\n\n'"$(translate "Expected: rootfs/ directory, or /etc /var /root at archive root.")"$'\n'"$(translate "Use 'Export to file' to save it and inspect manually.")" dialog --backtitle "ProxMenux" \ --title "$(translate "Incompatible archive")" \ --msgbox "$incompatible_msg" 12 72 || true return 1 } # ========================================================== # RESTORE — REVIEW & APPLY # ========================================================== _rs_show_metadata() { local staging_root="$1" local meta="$staging_root/metadata" local tmp tmp=$(mktemp) || return 1 trap 'rm -f "$tmp"; trap - INT TERM; kill -s INT "$$"' INT TERM { echo "═══ $(hb_translate "Backup information") ═══" echo "" if [[ -f "$meta/run_info.env" ]]; then while IFS='=' read -r k v; do printf " %-20s %s\n" "$k:" "$v" done < "$meta/run_info.env" fi echo "" echo "═══ $(hb_translate "Paths included in backup") ═══" if [[ -f "$meta/selected_paths.txt" ]]; then sed 's/^/ \//' "$meta/selected_paths.txt" fi echo "" if [[ -f "$meta/missing_paths.txt" && -s "$meta/missing_paths.txt" ]]; then echo "═══ $(hb_translate "Paths not found at backup time") ═══" sed 's/^/ /' "$meta/missing_paths.txt" echo "" fi if [[ -f "$meta/pveversion.txt" ]]; then echo "═══ Proxmox version ═══" cat "$meta/pveversion.txt" echo "" fi if [[ -f "$meta/lsblk.txt" ]]; then echo "═══ Disk layout (lsblk -f) ═══" cat "$meta/lsblk.txt" echo "" fi } > "$tmp" dialog --backtitle "ProxMenux" --exit-label "OK" \ --title "$(translate "Backup metadata")" \ --textbox "$tmp" 28 110 || true rm -f "$tmp" trap - INT TERM } _rs_preview_diff() { local staging_root="$1" local -a paths=() hb_load_restore_paths "$staging_root" paths local tmp tmp=$(mktemp) || return 1 trap 'rm -f "$tmp"; trap - INT TERM; kill -s INT "$$"' INT TERM { echo "$(hb_translate "Diff: current system vs backup (--- system +++ backup)")" echo "" local rel src dst for rel in "${paths[@]}"; do src="$staging_root/rootfs/$rel" dst="/$rel" [[ -e "$src" ]] || continue echo "══════ /$rel ══════" if [[ -d "$src" ]]; then diff -qr "$dst" "$src" 2>/dev/null || true else diff -u "$dst" "$src" 2>/dev/null || true fi echo "" done } > "$tmp" dialog --backtitle "ProxMenux" --exit-label "OK" \ --title "$(translate "Preview: changes that would be applied")" \ --textbox "$tmp" 28 130 || true rm -f "$tmp" trap - INT TERM } _rs_export_to_file() { local staging_root="$1" local dest_dir archive archive_size t_start elapsed log_file local stage_bytes pipefail_state tar_ok dest_dir=$(hb_prompt_dest_dir) || return 1 archive="$dest_dir/hostcfg-export-$(hostname)-$(date +%Y%m%d_%H%M%S).tar.gz" log_file="/tmp/proxmenux-export-$(date +%Y%m%d_%H%M%S).log" show_proxmenux_logo msg_title "$(translate "Export backup data to file")" echo -e "" echo -e "${TAB}${BGN}$(translate "Staging source:")${CL} ${BL}${staging_root}${CL}" echo -e "${TAB}${BGN}$(translate "Output archive:")${CL} ${BL}${archive}${CL}" echo -e "" echo -e "${TAB}$(translate "No changes will be made to the running system.")" echo -e "" stop_spinner t_start=$SECONDS tar_ok=0 : > "$log_file" if hb_ensure_pv; then # Stream tar through pv so the operator sees a live progress # bar instead of staring at a frozen title for minutes. We # mirror the same pattern used by the local backup path # (_bk_local) so the experience is consistent across # create-archive and export-archive flows. stage_bytes=$(du -sb "$staging_root" 2>/dev/null | awk '{print $1}') pipefail_state=$(set -o | awk '$1=="pipefail" {print $2}') set -o pipefail echo -e "${TAB}$(translate "Compressing") $(numfmt --to=iec-i --suffix=B "$stage_bytes" 2>/dev/null || printf '%s bytes' "$stage_bytes") → $archive" echo if tar -cf - -C "$staging_root" . 2>>"$log_file" \ | pv -s "$stage_bytes" | gzip > "$archive" 2>>"$log_file"; then tar_ok=1 fi [[ "$pipefail_state" == "off" ]] && set +o pipefail else # Offline / apt unavailable — silently fall back to a plain # tar so we still produce the archive. No "install pv" message: # if we couldn't install it ourselves, sending the operator off # to apt is just shifting our problem onto them. msg_info "$(translate "Creating export archive...")" stop_spinner if tar -czf "$archive" -C "$staging_root" . >>"$log_file" 2>&1; then tar_ok=1 fi fi if [[ $tar_ok -eq 1 && -f "$archive" ]]; then elapsed=$((SECONDS - t_start)) archive_size=$(hb_file_size "$archive") echo -e "" echo -e "${TAB}${BOLD}$(translate "Export completed:")${CL}" echo -e "${TAB}${BGN}$(translate "Archive:")${CL} ${BL}${archive}${CL}" echo -e "${TAB}${BGN}$(translate "Archive size:")${CL} ${BL}${archive_size}${CL}" echo -e "${TAB}${BGN}$(translate "Duration:")${CL} ${BL}$(hb_human_elapsed "$elapsed")${CL}" echo -e "" msg_ok "$(translate "Export completed. The running system has not been modified.")" echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r return 0 else msg_error "$(translate "Export failed.")" hb_show_log "$log_file" "$(translate "Export error log")" echo -e "" msg_success "$(translate "Press Enter to return to menu...")" read -r return 1 fi } _rs_warn_dangerous() { local staging_root="$1" local -a paths=() hb_load_restore_paths "$staging_root" paths local -a warnings=() local rel for rel in "${paths[@]}"; do local cls warn cls=$(hb_classify_path "$rel") if [[ "$cls" == "dangerous" ]]; then warn=$(hb_path_warning "$rel") [[ -n "$warn" ]] && warnings+=("/$rel") fi done [[ ${#warnings[@]} -eq 0 ]] && return 0 local tmp; tmp=$(mktemp) { echo "$(hb_translate "WARNING — This backup contains paths that are risky to restore on a running system:")" echo "" for w in "${warnings[@]}"; do echo " ⚠ $w" local detail; detail=$(hb_path_warning "${w#/}") [[ -n "$detail" ]] && echo " $detail" echo "" done echo "$(hb_translate "Recommendation: use 'Export to file' for these paths and apply manually during a maintenance window.")" } > "$tmp" dialog --backtitle "ProxMenux" \ --title "$(translate "Security Warning — read before applying")" \ --exit-label "$(translate "I have read this")" \ --textbox "$tmp" 24 92 || true rm -f "$tmp" } _rs_is_ssh_session() { [[ -n "${SSH_CONNECTION:-}" || -n "${SSH_CLIENT:-}" || -n "${SSH_TTY:-}" ]] } _rs_paths_include_network() { local rel for rel in "$@"; do [[ "$rel" == etc/network || "$rel" == etc/network/* || "$rel" == etc/resolv.conf ]] && return 0 done return 1 } _rs_write_cluster_recovery_helper() { local recovery_root="$1" local helper="${recovery_root}/apply-cluster-restore.sh" cat > "$helper" </dev/null || true } _rs_apply() { local staging_root="$1" local group="$2" # hot | reboot | all shift 2 local -a paths=() if [[ $# -gt 0 ]]; then paths=("$@") else hb_load_restore_paths "$staging_root" paths fi local backup_root # Pre-restore safety snapshot lives outside /root for the same # reason as the cluster recovery dir — restoring /root with # `rsync --delete` would otherwise wipe it mid-flow. backup_root="/var/lib/proxmenux/pre-restore/$(date +%Y%m%d_%H%M%S)" mkdir -p "$backup_root" local applied=0 skipped=0 t_start elapsed local cluster_recovery_root="" CLUSTER_DATA_EXTRACTED="" t_start=$SECONDS local rel src dst cls for rel in "${paths[@]}"; do src="$staging_root/rootfs/$rel" dst="/$rel" [[ -e "$src" ]] || { ((skipped++)); continue; } # Smart-restore hardware-drift skip list (populated by # _rs_run_complete_guided when hb_assess_hardware_drift flags # paths that would break on this host's hardware). Each path # in $RS_SKIP_PATHS is one absolute path per line. Matching is # exact-or-descendant so "/etc/zfs/zpool.cache" listed in the # skip set covers itself when rel == "etc/zfs/zpool.cache". if [[ -n "${RS_SKIP_PATHS:-}" ]]; then local _abs="/$rel" _skip="" while IFS= read -r _skip; do [[ -z "$_skip" ]] && continue if [[ "$_abs" == "$_skip" || "$_abs" == "$_skip"/* ]]; then ((skipped++)) continue 2 fi done <<<"$RS_SKIP_PATHS" fi # Never restore cluster virtual filesystem data live. # Extract it for manual recovery in maintenance mode. # Path note: this used to live under /root/proxmenux-recovery/, # but a later iteration of the same loop applies /root from # the backup with `rsync --delete`, which wipes anything # under /root that isn't in the backup — including our # freshly-extracted recovery dir. We now stage it under # /var/lib/proxmenux/recovery/, which sits next to # restore-pending/ and isn't touched by any path apply. if [[ "$rel" == etc/pve* ]] || [[ "$rel" == var/lib/pve-cluster* ]]; then if [[ -z "$cluster_recovery_root" ]]; then cluster_recovery_root="/var/lib/proxmenux/recovery/$(date +%Y%m%d_%H%M%S)" mkdir -p "$cluster_recovery_root" fi mkdir -p "$cluster_recovery_root/$(dirname "$rel")" cp -a "$src" "$cluster_recovery_root/$rel" 2>/dev/null || true CLUSTER_DATA_EXTRACTED="$cluster_recovery_root" ((skipped++)) continue fi cls=$(hb_classify_path "$rel") case "$group" in hot) [[ "$cls" != "hot" ]] && { ((skipped++)); continue; } ;; reboot) [[ "$cls" != "reboot" ]] && { ((skipped++)); continue; } ;; all) ;; # apply everything esac # /etc/zfs: opt-in only if [[ "$rel" == "etc/zfs" || "$rel" == "etc/zfs/"* ]]; then [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" != "1" ]] && { ((skipped++)); continue; } fi # Save current before overwriting if [[ -e "$dst" ]]; then mkdir -p "$backup_root/$(dirname "$rel")" cp -a "$dst" "$backup_root/$rel" 2>/dev/null || true fi # Apply if [[ -d "$src" ]]; then mkdir -p "$dst" # /usr/local/share/proxmenux/: symmetric to the backup-time excludes # in lib_host_backup_common.sh. We keep the destination's freshly- # installed code (scripts/, web/, AppImage/, monitor-app/, utils.sh) # and only restore the user's state (components_status.json, dbs, # configs). Without these excludes --delete would wipe the entire # /scripts/ tree on the target and the pending-restore boot service # would fail to find its own entry point. local -a rsync_extra=() if [[ "$rel" == "usr/local/share/proxmenux" ]]; then rsync_extra+=( --exclude "scripts/" --exclude "web/" --exclude "monitor-app/" --exclude "monitor-app.*/" --exclude "AppImage/" --exclude "images/" --exclude "json/" --exclude "utils.sh" --exclude "helpers_cache.json" --exclude "ProxMenux-Monitor.AppImage*" --exclude "install_proxmenux*.sh" --exclude "restore-pending/" ) fi rsync -aAXH --delete "${rsync_extra[@]}" "$src/" "$dst/" 2>/dev/null && ((applied++)) || ((skipped++)) else mkdir -p "$(dirname "$dst")" cp -a "$src" "$dst" 2>/dev/null && ((applied++)) || ((skipped++)) fi done elapsed=$((SECONDS - t_start)) # Skip `systemctl daemon-reload` when invoked from the Monitor # (HB_MONITOR_FLOW=1). The reload itself doesn't restart the # Monitor's unit, but it marks units as "needs restart" and a # later systemctl call against the Monitor would cut the WS # session. The restored unit files are already on disk — they # take effect at the next reboot, which the Monitor flow asks # for explicitly at the end. if [[ "$group" == "hot" || "$group" == "all" ]] && [[ "${HB_MONITOR_FLOW:-0}" != "1" ]]; then systemctl daemon-reload >/dev/null 2>&1 || true fi echo -e "" echo -e "${TAB}${BOLD}$(translate "Restore applied:")${CL}" echo -e "${TAB}${BGN}$(translate "Group:")${CL} ${BL}${group}${CL}" echo -e "${TAB}${BGN}$(translate "Paths applied:")${CL} ${BL}${applied}${CL}" echo -e "${TAB}${BGN}$(translate "Paths skipped:")${CL} ${BL}${skipped}${CL}" echo -e "${TAB}${BGN}$(translate "Duration:")${CL} ${BL}$(hb_human_elapsed "$elapsed")${CL}" echo -e "${TAB}${BGN}$(translate "Pre-restore backup:")${CL} ${BL}${backup_root}${CL}" echo -e "" if [[ "$group" == "hot" ]]; then msg_ok "$(translate "Hot changes applied. No reboot needed for these paths.")" else msg_warn "$(translate "Changes applied. A system reboot is recommended for them to take full effect.")" fi if [[ -n "$CLUSTER_DATA_EXTRACTED" ]]; then export HB_CLUSTER_DATA_EXTRACTED="$CLUSTER_DATA_EXTRACTED" _rs_write_cluster_recovery_helper "$CLUSTER_DATA_EXTRACTED" msg_ok "$(translate "Cluster data will be applied automatically at next boot.")" msg_info2 "$(translate "Optional safety helper if you ever need to re-apply manually:") $CLUSTER_DATA_EXTRACTED/apply-cluster-restore.sh" else unset HB_CLUSTER_DATA_EXTRACTED fi } _rs_collect_plan_stats() { local staging_root="$1" local -a paths=() hb_load_restore_paths "$staging_root" paths RS_PLAN_TOTAL=0 RS_PLAN_HOT=0 RS_PLAN_REBOOT=0 RS_PLAN_DANGEROUS=0 RS_PLAN_HAS_CLUSTER=0 RS_PLAN_HAS_NETWORK=0 RS_PLAN_HAS_ZFS=0 local rel cls RS_PLAN_TOTAL=${#paths[@]} for rel in "${paths[@]}"; do cls=$(hb_classify_path "$rel") case "$cls" in hot) ((RS_PLAN_HOT++)) ;; reboot) ((RS_PLAN_REBOOT++)) ;; dangerous) ((RS_PLAN_DANGEROUS++)) ;; esac [[ "$rel" == etc/network* ]] && RS_PLAN_HAS_NETWORK=1 [[ "$rel" == etc/pve* || "$rel" == var/lib/pve-cluster* ]] && RS_PLAN_HAS_CLUSTER=1 [[ "$rel" == etc/zfs* ]] && RS_PLAN_HAS_ZFS=1 done } _rs_show_plan_summary() { local staging_root="$1" local meta="$staging_root/metadata" # dialog --colors needs --msgbox/--yesno/--infobox (not --textbox), # so we build the body as a string. local body body=$'\n'"\Zb═══ $(translate "Restore plan summary") ═══\ZB"$'\n\n' if [[ -f "$meta/run_info.env" ]]; then body+="\Zb$(translate "Backup origin metadata:")\ZB"$'\n' while IFS='=' read -r k v; do [[ -z "$k" ]] && continue body+="$(printf ' %-20s \Z4%s\Zn' "${k}:" "$v")"$'\n' done < "$meta/run_info.env" body+=$'\n' fi # Reboot-required and live-unsafe both go to the pending set and # are applied by the post-boot dispatcher — to the operator they're # the same bucket "things that complete after reboot". local _reboot_total=$(( RS_PLAN_REBOOT + RS_PLAN_DANGEROUS )) body+="\Zb$(translate "Detected paths in this backup:")\ZB \Zb\Z4${RS_PLAN_TOTAL}\Zn"$'\n' body+=" • $(translate "Safe to apply now"): \Zb\Z4${RS_PLAN_HOT}\Zn"$'\n' body+=" • $(translate "Require reboot"): \Zb\Z4${_reboot_total}\Zn"$'\n' body+=$'\n' if [[ "$RS_PLAN_HAS_NETWORK" -eq 1 ]]; then body+=" • $(translate "Includes /etc/network (may drop SSH immediately)")"$'\n' fi if [[ "$RS_PLAN_HAS_CLUSTER" -eq 1 ]]; then body+=" • \Z4$(translate "Includes cluster data (/etc/pve, /var/lib/pve-cluster)")\Zn"$'\n' body+=" $(translate "These paths will not be restored live and will be extracted for manual recovery.")"$'\n' fi if [[ "$RS_PLAN_HAS_ZFS" -eq 1 ]]; then if [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" == "1" ]]; then body+=" • $(translate "Includes /etc/zfs"): \Zb$(translate "ENABLED for restore")\ZB"$'\n' else body+=" • $(translate "Includes /etc/zfs"): \Zb$(translate "DISABLED unless you enable it")\ZB"$'\n' fi fi body+=$'\n' body+="\Zb$(translate "Recommendation: start with Complete restore.")\ZB" dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Restore plan")" \ --msgbox "$body" 24 94 || true } _rs_prompt_zfs_opt_in() { local staging_root="$1" export HB_RESTORE_INCLUDE_ZFS=0 if [[ ! -d "$staging_root/rootfs/etc/zfs" ]]; then return 0 fi # /etc/zfs/ on a Proxmox host ALWAYS contains package defaults # (zfs-functions, zpool.d/, zed.d/) — they're shipped by the # zfsutils-linux package and identical across PVE installs. # Only zpool.cache (and the keys/ subdir) carry host-specific # state, because zpool.cache references the source host's # physical disks by GUID. Anything else is safe to restore. local cache="$staging_root/rootfs/etc/zfs/zpool.cache" if [[ ! -f "$cache" ]]; then # No host-specific bits — restore defaults silently. export HB_RESTORE_INCLUDE_ZFS=1 return 0 fi # zpool.cache IS present. Two cases: # - Same host restore (recovery on the source machine) → quietly # include; the cache is correct for this host by definition. # - Cross-host restore → loud warning: pool GUIDs in the cache # won't match the target's disks, and Proxmox would try to # import non-existent pools at next boot. local msg if [[ "${HB_COMPAT_SAME_HOST:-0}" == "1" ]]; then msg="$(translate "Backup includes /etc/zfs/zpool.cache. Restore it (same host detected)?")" else msg="$(translate "This backup includes /etc/zfs/zpool.cache (host-specific ZFS state).")"$'\n\n'"$(translate "Restore it ONLY if the target host has the same pools and disks as the source. Otherwise Proxmox may try to import non-existent pools at next boot.")" fi if whiptail --title "$(translate "ZFS configuration")" \ --yesno "$msg" 12 78; then export HB_RESTORE_INCLUDE_ZFS=1 fi } _rs_finish_flow() { echo -e "" if [[ "${HB_MONITOR_FLOW:-0}" == "1" ]]; then # Same UX as the TUI but the prompt explicitly says "close" # — the Monitor's ScriptTerminalModal sees isComplete=true on # WS close and dismisses (via the onComplete prop) so the # operator doesn't need to click the Close button. msg_success "$(translate "Press Enter to close...")" read -r return 0 fi msg_success "$(translate "Press Enter to return to menu...")" read -r } # Lists components that the post-boot dispatcher will reinstall in background # after reboot, by reading the backup's components_status.json. Mirrors the # COMPONENT_INSTALLERS array in apply_cluster_postboot.sh — keep both in sync. # Echoes "|