#!/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 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" \ 1 "$(translate "Proxmox Backup Server (PBS) destinations")" \ 2 "$(translate "Borg repositories")" \ 3 "$(translate "Local archive targets (paths + USB mount/unmount)")" \ 4 "$(translate "PBS encryption keyfile (show / replace / remove)")" \ 0 "$(translate "Return")" \ 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 } # Backup the current keyfile + its paired files to /root under a # timestamped prefix. Returns the timestamp on success (empty on # failure). Never overwrites existing files. Used before any # destructive change (replace / remove) so the operator can still # decrypt pre-existing backups if they need to. _bk_pbs_backup_keyfile_to_root() { local ts 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" [[ -f "$key_file" ]] || { echo ""; return 1; } ts=$(date +%Y%m%d_%H%M%S) umask 077 cp -f "$key_file" "/root/pbs-key.old-${ts}.conf" 2>/dev/null || { echo ""; return 1; } chmod 600 "/root/pbs-key.old-${ts}.conf" 2>/dev/null || true if [[ -f "$pass_file" ]]; then cp -f "$pass_file" "/root/pbs-key.old-${ts}.pass" 2>/dev/null || true chmod 600 "/root/pbs-key.old-${ts}.pass" 2>/dev/null || true fi if [[ -f "$recovery_enc" ]]; then cp -f "$recovery_enc" "/root/pbs-key.old-${ts}.recovery.enc" 2>/dev/null || true chmod 600 "/root/pbs-key.old-${ts}.recovery.enc" 2>/dev/null || true fi echo "$ts" } # Wipe local keyfile + pass + recovery. Never touches PBS. _bk_pbs_wipe_local_keyfile() { rm -f "$HB_STATE_DIR/pbs-key.conf" \ "$HB_STATE_DIR/pbs-key.pass" \ "$HB_STATE_DIR/pbs-key.recovery.enc" 2>/dev/null } # 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 local preview="" if (( installed )); then local fp="" kdf_hint="unknown" installed_at="" if command -v proxmox-backup-client >/dev/null 2>&1; then fp=$(proxmox-backup-client key info --output-format json "$key_file" 2>/dev/null \ | grep -oE '"fingerprint"[[:space:]]*:[[:space:]]*"[^"]*"' \ | head -1 | sed 's/.*"\([^"]*\)".*/\1/') fi if grep -q '"kdf":[[:space:]]*"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 "") preview="$(hb_translate "Installed:") \Zb\Z2$(hb_translate "yes")\Zn"$'\n' preview+="$(hb_translate "Path:") $key_file"$'\n' [[ -n "$installed_at" ]] && preview+="$(hb_translate "Installed at:") $installed_at"$'\n' preview+="$(hb_translate "KDF:") $kdf_hint"$'\n' [[ -n "$fp" ]] && preview+="$(hb_translate "Fingerprint:") $fp"$'\n' preview+="$(hb_translate "Keyfile passphrase stored:") " [[ -s "$pass_file" ]] && preview+="\Zb\Z2$(hb_translate "yes")\Zn"$'\n' || preview+="\Zb$(hb_translate "no")\Zn"$'\n' preview+="$(hb_translate "Recovery escrow present:") " [[ -s "$recovery_enc" ]] && preview+="\Zb\Z2$(hb_translate "yes")\Zn"$'\n' || preview+="\Zb$(hb_translate "no")\Zn"$'\n' else preview="$(hb_translate "No keyfile installed on this host.")"$'\n\n' preview+="$(hb_translate "PBS encrypted backups won't work until one is generated or imported. Both actions happen during job creation the first time you tick 'Encrypt backups'.")" fi local choice if (( installed )); then choice=$(dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Manage PBS encryption keyfile")" \ --menu "\n${preview}\n" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "info" "$(hb_translate "Show detailed keyfile info")" \ "pass" "$(hb_translate "Update stored keyfile passphrase")" \ "replace" "$(hb_translate "Replace keyfile (generate new or import)")" \ "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${preview}\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) # Full `key info` output (may need passphrase for scrypt). local info_out info_out=$(proxmox-backup-client key info "$key_file" 2>&1) dialog --backtitle "ProxMenux" \ --title "$(hb_translate "Keyfile info")" \ --msgbox "$info_out" 20 80 ;; pass) local new_pass new_pass2 while true; do new_pass=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Update keyfile passphrase")" \ --insecure --passwordbox "$(hb_translate "New passphrase used to unlock the keyfile (leave blank to remove the stored passphrase — for kdf=none keyfiles):")" \ "$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || { new_pass=""; break; } if [[ -z "$new_pass" ]]; then new_pass2="" break fi new_pass2=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Update keyfile passphrase")" \ --insecure --passwordbox "$(hb_translate "Confirm new passphrase:")" \ "$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || { new_pass=""; break; } [[ "$new_pass" == "$new_pass2" ]] && break dialog --backtitle "ProxMenux" \ --msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50 done if [[ -n "$new_pass" ]]; then umask 077 printf '%s' "$new_pass" > "$pass_file" chmod 600 "$pass_file" 2>/dev/null || true dialog --backtitle "ProxMenux" \ --msgbox "$(hb_translate "Stored keyfile passphrase updated.")" 8 60 else rm -f "$pass_file" 2>/dev/null dialog --backtitle "ProxMenux" \ --msgbox "$(hb_translate "Stored keyfile passphrase removed.")" 8 60 fi ;; replace|remove) local action_title="$(hb_translate "Replace keyfile")" [[ "$choice" == "remove" ]] && action_title="$(hb_translate "Remove keyfile")" # Destructive warning + confirmation 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 will use the new (or no) keyfile.")"$'\n' warn_body+=" • $(hb_translate "Downloading pre-existing encrypted backups from this host will fail unless you keep a copy of the current key.")"$'\n' warn_body+=" • $(hb_translate "Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase.")"$'\n\n' warn_body+="$(hb_translate "Continue?")" if ! dialog --backtitle "ProxMenux" --colors --yesno "$warn_body" 16 80; then continue fi local do_backup=1 if ! dialog --backtitle "ProxMenux" \ --title "$(hb_translate "Backup current key")" \ --yesno "$(hb_translate "Save a copy of the current keyfile + passphrase + recovery blob under /root/pbs-key.old-.* before ${action_title,,}?")" \ 10 78; then do_backup=0 fi local ts="" if (( do_backup )); then ts=$(_bk_pbs_backup_keyfile_to_root) if [[ -z "$ts" ]]; then dialog --backtitle "ProxMenux" \ --msgbox "$(hb_translate "Backup to /root failed. Aborting.")" 8 60 continue fi fi _bk_pbs_wipe_local_keyfile if [[ "$choice" == "replace" ]]; then # Delegate to the yes/no + generate/import flow. # HB_ASK_ENCRYPT_INSTRUCTIONS is unused here, we # directly call the two helpers. local rmenu rmenu=$(dialog --backtitle "ProxMenux" \ --title "$(hb_translate "Replace keyfile")" \ --menu "$(hb_translate "How should the new keyfile be set up?")" \ 10 78 2 \ "new" "$(hb_translate "Generate a new keyfile (per host — safest isolation)")" \ "import" "$(hb_translate "Import an existing keyfile from a path (shared across hosts)")" \ 3>&1 1>&2 2>&3) || rmenu="" case "$rmenu" in new) _hb_pbs_create_new_keyfile || true ;; import) _hb_pbs_import_dialog || true ;; esac fi local done_msg="$action_title $(hb_translate "completed.")" [[ -n "$ts" ]] && done_msg+=$'\n\n'"$(hb_translate "Backup saved with prefix:") /root/pbs-key.old-${ts}" dialog --backtitle "ProxMenux" --msgbox "$done_msg" 10 78 ;; 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 # keyfile" pattern. proxmox-backup-client emits messages like # `unable to load encryption key` / `no key found` / `Failed # to decrypt` when that's the cause. If so, surface a helpful # error rather than the raw log. local extra_hint="" if grep -qiE 'encryption key|unable to (load|read) key|no key (file|found)|decrypt|failed to decrypt' "$log_file" 2>/dev/null; then extra_hint=$'\n\n'"$(translate "This backup is encrypted but no keyfile is available on this host.")" if [[ -f "$HB_STATE_DIR/pbs-key.conf" ]]; then extra_hint+=$'\n\n'"$(translate "A keyfile is present but doesn't match the one used to create the backup. Make sure you have the correct keyfile from the source host.")" else extra_hint+=$'\n\n'"$(translate "No keyfile recovery copy was found in PBS for this backup — it was created before the recovery feature existed. The encrypted content cannot be recovered.")" 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" \ 16 78 hb_show_log "$log_file" "$(translate "PBS restore error log")" 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 "|