Files
ProxMenux/scripts/backup_restore/lib_host_backup_common.sh
2026-07-08 12:18:52 +02:00

4021 lines
177 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# ==========================================================
# ProxMenux - Host Config Backup/Restore - Shared Library
# ==========================================================
# Author : MacRimi
# Copyright : (c) 2024 MacRimi
# License : GPL-3.0
# Version : 1.0
# Last Updated: 08/04/2026
# ==========================================================
# Do not execute directly — source from backup_host.sh
# Library guard
[[ "${BASH_SOURCE[0]}" == "$0" ]] && {
echo "This file is a library. Source it, do not run it directly." >&2; exit 1
}
HB_STATE_DIR="/usr/local/share/proxmenux"
HB_BORG_VERSION="1.2.8"
HB_BORG_LINUX64_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
HB_BORG_LINUX64_URL="https://github.com/borgbackup/borg/releases/download/${HB_BORG_VERSION}/borg-linux64"
# Translation wrapper — safe fallback if translate not yet loaded
hb_translate() {
declare -f translate >/dev/null 2>&1 && translate "$1" || echo "$1"
}
# ==========================================================
# UI SIZE CONSTANTS
# ==========================================================
HB_UI_MENU_H=22
HB_UI_MENU_W=84
HB_UI_MENU_LIST=10
HB_UI_INPUT_H=10
HB_UI_INPUT_W=72
HB_UI_PASS_H=10
HB_UI_PASS_W=72
HB_UI_YESNO_H=10
HB_UI_YESNO_W=78
# ==========================================================
# DEFAULT PROFILE PATHS
# ==========================================================
hb_default_profile_paths() {
# Curated list of paths that matter for a real Proxmox restore
# on a fresh host. Anything missing on the source is just
# noted in metadata/missing_paths.txt — no error. Grouped by
# category so it's easy to spot what's covered.
local paths=(
# ── PVE core ──────────────────────────────────────────
"/etc/pve"
"/var/lib/pve-cluster"
"/etc/vzdump.conf"
# ── Host identity & networking ────────────────────────
"/etc/hostname"
"/etc/hosts"
"/etc/timezone"
"/etc/resolv.conf"
"/etc/network"
# ── Access & auth ─────────────────────────────────────
"/etc/ssh"
"/etc/sudoers"
"/etc/sudoers.d"
"/etc/pam.d"
"/etc/security"
# ── Kernel / boot / hardware ──────────────────────────
"/etc/default/grub"
"/etc/kernel"
"/etc/modules"
"/etc/modules-load.d"
"/etc/modprobe.d"
"/etc/sysctl.conf"
"/etc/sysctl.d"
"/etc/udev/rules.d"
"/etc/fstab"
"/etc/iscsi"
"/etc/multipath"
# ── Shell / locale / env ──────────────────────────────
"/etc/environment"
"/etc/bash.bashrc"
"/etc/inputrc"
"/etc/profile"
"/etc/profile.d" # figurine and other shell add-ons live here
"/etc/locale.gen"
"/etc/locale.conf"
# ── Packaging ─────────────────────────────────────────
"/etc/apt"
# ── Cron ──────────────────────────────────────────────
"/etc/cron.d"
"/etc/cron.daily"
"/etc/cron.hourly"
"/etc/cron.weekly"
"/etc/cron.monthly"
"/etc/cron.allow"
"/etc/cron.deny"
"/var/spool/cron/crontabs"
# ── ProxMenux state (source of truth for some host-config) ──
"/etc/proxmenux" # vfio-bind.bdfs state file feeds the per-BDF udev rule
# ── Common Proxmox tooling (skipped if not present) ──
"/etc/systemd/system" # custom units (including log2ram.service if installed)
"/etc/systemd/journald.conf" # journal retention tuning from post-install
"/etc/log2ram.conf"
"/etc/logrotate.conf"
"/etc/logrotate.d" # post-install drops log2ram + custom logrotate here
"/etc/lm-sensors"
"/etc/sensors3.conf"
"/etc/fail2ban"
"/etc/snmp"
"/etc/postfix"
# ── Monitoring / VPN (skipped if not present) ────────
"/etc/wireguard"
"/etc/openvpn"
"/etc/grafana"
"/etc/influxdb"
"/etc/prometheus"
"/etc/telegraf"
"/etc/zabbix"
# ── ProxMenux-installed binaries & app state ─────────
"/usr/local/bin"
"/usr/local/sbin"
"/usr/local/share/proxmenux"
# ── Root home (rsync excludes volatile dirs) ─────────
"/root"
)
# ZFS state only when the host runs ZFS — same convention
# used pre-expansion.
if [[ -d /etc/zfs ]] || command -v zpool >/dev/null 2>&1; then
paths+=("/etc/zfs")
fi
printf '%s\n' "${paths[@]}"
}
# ==========================================================
# PATH CLASSIFICATION (restore safety)
# Returns: dangerous | reboot | hot
# ==========================================================
hb_classify_path() {
local rel="$1" # without leading /
case "$rel" in
etc/pve|etc/pve/*|\
var/lib/pve-cluster|var/lib/pve-cluster/*|\
etc/network|etc/network/*)
echo "dangerous" ;;
etc/modules|etc/modules/*|\
etc/modules-load.d|etc/modules-load.d/*|\
etc/modprobe.d|etc/modprobe.d/*|\
etc/udev/rules.d|etc/udev/rules.d/*|\
etc/default/grub|\
etc/fstab|\
etc/kernel|etc/kernel/*|\
etc/iscsi|etc/iscsi/*|\
etc/multipath|etc/multipath/*|\
etc/zfs|etc/zfs/*)
echo "reboot" ;;
*)
echo "hot" ;;
esac
}
hb_path_warning() {
local rel="$1"
case "$rel" in
etc/pve|etc/pve/*)
hb_translate "/etc/pve is managed by pmxcfs (cluster filesystem). Applying this on a running node can corrupt cluster state. Use 'Export to file' and apply it manually during a maintenance window." ;;
var/lib/pve-cluster|var/lib/pve-cluster/*)
hb_translate "/var/lib/pve-cluster is live cluster data. Never restore this while the node is running. Use 'Export to file' for manual recovery only." ;;
etc/network|etc/network/*)
hb_translate "/etc/network controls active interfaces. Applying may immediately change or drop network connectivity, including active SSH sessions." ;;
esac
}
# ==========================================================
# HARDWARE DRIFT ASSESSMENT (smart restore)
# ==========================================================
# Compares the backup metadata captured by hb_prepare_staging
# against the live target host to detect when applying certain
# paths would break the boot (orphan ZFS pool GUID, stale fstab
# UUIDs, ...) or pointlessly reinstall components for hardware
# that's no longer present (NVIDIA driver on a host with no
# NVIDIA card).
#
# Output format on stdout — one line per assessment, tab-separated:
#
# PATH_OR_KEY \t ACTION \t REASON
#
# Where ACTION is one of:
# skip → the restore flow should EXCLUDE this from apply
# warn → restore but surface the warning in the dialog
# ok → no drift detected (omitted from output)
#
# Callers consume this to build the "Restore plan" dialog and to
# filter the hot/pending path lists. The function never modifies
# state, never prompts — pure analysis.
# Read the UUIDs referenced by a fstab file. Skips comments and
# `proc`/`none`/`tmpfs` non-block entries.
_hb_fstab_uuids() {
local fstab="$1"
[[ -f "$fstab" ]] || return 0
awk '
/^[[:space:]]*#/ { next }
/^[[:space:]]*$/ { next }
{
src = $1
if (src ~ /^UUID=/) {
sub(/^UUID=/, "", src)
print src
} else if (src ~ /^PARTUUID=/) {
sub(/^PARTUUID=/, "", src)
print src
} else if (src ~ /^\/dev\//) {
print src
}
}
' "$fstab"
}
# Build a set of live block-device UUIDs. Returns one UUID per line.
_hb_live_uuids() {
command -v blkid >/dev/null 2>&1 || return 0
blkid -s UUID -o value 2>/dev/null
}
# Build a name→guid map of the live ZFS pools.
_hb_live_zpool_guids() {
command -v zpool >/dev/null 2>&1 || return 0
zpool list -H -o name,guid 2>/dev/null
}
hb_assess_hardware_drift() {
local staging_root="$1"
local meta="$staging_root/metadata"
local rootfs="$staging_root/rootfs"
# ── ZFS pool GUID drift ──────────────────────────────
# If the backup had ZFS pools, compare each (name, guid) pair
# against what's on this host. A pool with the same NAME but a
# different GUID is the "fresh PVE install with same pool name"
# case — restoring /etc/zfs/zpool.cache would point ZFS at a
# ghost pool and drop boot to emergency.
if [[ -f "$meta/zpool.guids" ]] && [[ -s "$meta/zpool.guids" ]]; then
local bk_name bk_guid live_map
live_map=$(_hb_live_zpool_guids)
local pool_mismatch="" pool_missing=""
while IFS=$'\t ' read -r bk_name bk_guid; do
[[ -z "$bk_name" ]] && continue
local live_guid
live_guid=$(awk -v n="$bk_name" '$1==n {print $2; exit}' <<<"$live_map")
if [[ -z "$live_guid" ]]; then
pool_missing+="$bk_name "
elif [[ "$live_guid" != "$bk_guid" ]]; then
pool_mismatch+="$bk_name(${bk_guid:0:8}…→${live_guid:0:8}…) "
fi
done < "$meta/zpool.guids"
if [[ -n "$pool_missing" ]]; then
printf '%s\t%s\t%s\n' "/etc/zfs/zpool.cache" "skip" \
"$(hb_translate "Backup pools not present on this host:") ${pool_missing% }"
elif [[ -n "$pool_mismatch" ]]; then
printf '%s\t%s\t%s\n' "/etc/zfs/zpool.cache" "skip" \
"$(hb_translate "Pool name matches but GUID differs (fresh ZFS install):") ${pool_mismatch% }"
fi
fi
# ── Boot partition UUID drift ────────────────────────
# /etc/kernel/proxmox-boot-uuids lists the EFI vfat UUIDs that
# proxmox-boot-tool replicates the bootloader onto. If those
# UUIDs don't exist on this host, applying the file makes
# subsequent `proxmox-boot-tool refresh` fail.
local boot_uuid_file="$rootfs/etc/kernel/proxmox-boot-uuids"
if [[ -f "$boot_uuid_file" ]] && [[ -s "$boot_uuid_file" ]]; then
local live_uuids
live_uuids=$(_hb_live_uuids)
local missing_boot="" u
while IFS= read -r u; do
u="${u// /}"
[[ -z "$u" || "$u" == "#"* ]] && continue
if ! grep -Fxq "$u" <<<"$live_uuids"; then
missing_boot+="$u "
fi
done < "$boot_uuid_file"
if [[ -n "$missing_boot" ]]; then
printf '%s\t%s\t%s\n' "/etc/kernel/proxmox-boot-uuids" "skip" \
"$(hb_translate "Boot partition UUIDs from backup not found on this host:") ${missing_boot% }"
fi
fi
# ── fstab UUID drift ─────────────────────────────────
# Skip ONLY if at least one UUID/dev in the backup's fstab can't
# be resolved on the live host. A clean PVE+ZFS root install
# typically has just `proc /proc proc defaults 0 0`, no UUIDs —
# that yields zero referenced UUIDs and the check is a no-op.
local fstab="$rootfs/etc/fstab"
if [[ -f "$fstab" ]]; then
local live_uuids; live_uuids=$(_hb_live_uuids)
local missing_fstab="" cnt=0 u
while IFS= read -r u; do
((cnt++))
if [[ "$u" == /dev/* ]]; then
[[ -b "$u" ]] || missing_fstab+="$u "
else
grep -Fxq "$u" <<<"$live_uuids" || missing_fstab+="$u "
fi
done < <(_hb_fstab_uuids "$fstab")
if (( cnt > 0 )) && [[ -n "$missing_fstab" ]]; then
printf '%s\t%s\t%s\n' "/etc/fstab" "skip" \
"$(hb_translate "fstab references UUIDs/devices not present on this host:") ${missing_fstab% }"
fi
fi
# ── Component reinstall drift (GPU / TPU presence) ───
# components_status.json declares what proxmenux installed on
# the source (nvidia_driver, amdgpu_top, intel_gpu_tools,
# coral_driver). If the target hardware no longer has the
# corresponding device, the post-boot dispatcher would try to
# reinstall a driver for a card that isn't there. The installer
# itself short-circuits in that case (detect_*_gpus), but
# surfacing this in the dialog is cleaner than letting the user
# discover it from the postboot log.
local comp_file="$rootfs/usr/local/share/proxmenux/components_status.json"
if [[ -f "$comp_file" ]] && command -v jq >/dev/null 2>&1 && command -v lspci >/dev/null 2>&1; then
local live_pci; live_pci=$(lspci -nn 2>/dev/null)
local installed_components
installed_components=$(jq -r 'to_entries[] | select(.value.status=="installed") | .key' "$comp_file" 2>/dev/null)
local comp
while IFS= read -r comp; do
[[ -z "$comp" ]] && continue
local pattern=""
case "$comp" in
nvidia_driver) pattern='NVIDIA' ;;
amdgpu_top) pattern='Advanced Micro Devices.*\[AMD/ATI\]' ;;
intel_gpu_tools) pattern='Intel.*(VGA|Display|Graphics)' ;;
coral_driver) pattern='Global Unichip|Google.*Edge TPU' ;;
*) continue ;;
esac
if ! grep -qiE "$pattern" <<<"$live_pci"; then
printf 'component:%s\t%s\t%s\n' "$comp" "skip" \
"$(hb_translate "Component was installed on the backup source but no matching hardware was found on this host.")"
fi
done <<<"$installed_components"
fi
}
# Returns 0 (true) if hb_assess_hardware_drift produced any skip
# entries — i.e. there is something for the operator to look at in
# the smart-restore dialog. Returns 1 otherwise. Used by the
# restore flow to decide whether to show the smart-restore dialog
# at all (no drift → skip the extra prompt).
hb_has_hardware_drift() {
local staging_root="$1"
local out
out=$(hb_assess_hardware_drift "$staging_root" 2>/dev/null | grep $'\tskip\t' || true)
[[ -n "$out" ]]
}
# ==========================================================
# PROFILE PATH SELECTION
# ==========================================================
hb_extra_paths_file() {
printf '%s/backup-extra-paths.txt\n' "$HB_STATE_DIR"
}
# Reads user-added extra paths (one per line, # comments allowed).
# Trimmed, deduped, only paths that currently exist on disk are returned.
hb_load_extra_paths() {
local f
f=$(hb_extra_paths_file)
[[ -f "$f" ]] || return 0
local line
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
printf '%s\n' "$line"
done < "$f" | sort -u
}
# Adds a path to the persisted extra-paths file. Idempotent.
hb_add_extra_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local f
f=$(hb_extra_paths_file)
mkdir -p "$HB_STATE_DIR"
touch "$f"; chmod 600 "$f"
grep -Fxq "$path" "$f" 2>/dev/null || printf '%s\n' "$path" >> "$f"
}
# Removes a path from the persisted extra-paths file.
hb_del_extra_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local f tmp
f=$(hb_extra_paths_file)
[[ -f "$f" ]] || return 0
tmp=$(mktemp)
grep -Fvx "$path" "$f" > "$tmp" || true
mv "$tmp" "$f"
chmod 600 "$f"
}
hb_select_profile_paths() {
local mode="$1"
local __out_var="$2"
local -n __out_ref="$__out_var"
mapfile -t __defaults < <(hb_default_profile_paths)
local -a __extras=()
mapfile -t __extras < <(hb_load_extra_paths)
if [[ "$mode" == "default" ]]; then
# Default profile = base 59 paths + whatever the operator has
# previously persisted as "always include this folder of mine".
__out_ref=("${__defaults[@]}" "${__extras[@]}")
return 0
fi
# Custom mode runs as a loop: present checklist + offer to add/remove
# user paths, re-present until the operator confirms. This gives
# /add/edit/remove without redesigning the dialog stack.
local choice
while :; do
# Reload after potential edits in the previous iteration
mapfile -t __extras < <(hb_load_extra_paths)
local options=() idx=1 path
for path in "${__defaults[@]}"; do
options+=("$idx" "$path" "off")
((idx++))
done
local first_extra_idx=$idx
for path in "${__extras[@]}"; do
# User-added paths default ON — they wouldn't be in the list
# if the operator hadn't explicitly added them.
options+=("$idx" "[+] $path" "on")
((idx++))
done
# Three-button checklist:
# OK (rc=0) → save selection and continue
# Add custom path (rc=3) → opens an inputbox; on success the new
# path is appended to the persisted list
# and the checklist re-renders with the
# new entry already ticked
# Cancel (rc=1) → abort the entire backup flow
local selected rc
selected=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Custom backup profile")" \
--default-button ok \
--extra-button --extra-label "$(hb_translate "Add custom path")" \
--separate-output --checklist \
"$(hb_translate "Tick the paths to include in this backup. Press \"Add custom path\" to add a folder or file of your own to the list.")" \
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3)
rc=$?
if (( rc == 0 )); then
__out_ref=()
while read -r choice; do
[[ -z "$choice" ]] && continue
if (( choice < first_extra_idx )); then
__out_ref+=("${__defaults[$((choice-1))]}")
else
__out_ref+=("${__extras[$((choice-first_extra_idx))]}")
fi
done <<< "$selected"
if [[ ${#__out_ref[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
continue
fi
return 0
fi
if (( rc == 1 )); then
return 1
fi
# rc == 3 → "Add custom path": jump straight into the inputbox.
# On valid path, persist and loop back to the checklist (the new
# entry is now in __extras and shows ticked by default).
local new_path
new_path=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Add custom path")" \
--inputbox "$(hb_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%/}"
if [[ -z "$new_path" ]]; then
continue
fi
if [[ ! -e "$new_path" ]]; then
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Path not found")" \
--msgbox "\Z1${new_path}\Zn\n\n$(hb_translate "does not exist on this host. Path not added.")" 10 70
continue
fi
hb_add_extra_path "$new_path"
done
}
# ==========================================================
# STAGING OPERATIONS
# ==========================================================
hb_prepare_staging() {
local staging_root="$1"; shift
local paths=("$@")
rm -rf "$staging_root"
mkdir -p "$staging_root/rootfs" "$staging_root/metadata"
local selected_file="$staging_root/metadata/selected_paths.txt"
local missing_file="$staging_root/metadata/missing_paths.txt"
: > "$selected_file"
: > "$missing_file"
local p rel target
for p in "${paths[@]}"; do
rel="${p#/}"
echo "$rel" >> "$selected_file"
[[ -e "$p" ]] || { echo "$p" >> "$missing_file"; continue; }
target="$staging_root/rootfs/$rel"
if [[ -d "$p" ]]; then
mkdir -p "$target"
local -a rsync_opts=(
-aAXH --numeric-ids
--exclude "images/"
--exclude "dump/"
--exclude "tmp/"
--exclude "*.log"
)
# /root is included by default for easier recovery, but avoid volatile/sensitive noise.
if [[ "$rel" == "root" || "$rel" == "root/"* ]]; then
rsync_opts+=(
--exclude ".bash_history"
--exclude ".cache/"
--exclude "tmp/"
--exclude ".local/share/Trash/"
)
fi
# /usr/local/share/proxmenux: ship USER STATE only (components_status.json,
# user prefs, post-install cache). NEVER ship code (scripts/, utils.sh, web/,
# AppImage/, monitor-app/) — destination has its own installed proxmenux which
# may be newer than the backup. Hot-applying the backup's old /scripts/ over
# the destination's fresh install silently regresses the apply_cluster_postboot
# dispatcher and the *_installer.sh --auto-reinstall hooks, breaking the
# "user reinstalls nothing" promise.
if [[ "$rel" == "usr/local/share/proxmenux" || "$rel" == "usr/local/share/proxmenux/"* ]]; then
rsync_opts+=(
--exclude "restore-pending/"
--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"
)
fi
rsync "${rsync_opts[@]}" "$p/" "$target/" 2>/dev/null || true
else
mkdir -p "$(dirname "$target")"
cp -a "$p" "$target" 2>/dev/null || true
fi
done
# Metadata snapshot
local meta="$staging_root/metadata"
{
echo "generated_at=$(date -Iseconds)"
echo "hostname=$(hostname)"
echo "kernel=$(uname -r)"
} > "$meta/run_info.env"
command -v pveversion >/dev/null 2>&1 && pveversion -v > "$meta/pveversion.txt" 2>&1 || true
command -v lsblk >/dev/null 2>&1 && lsblk -f > "$meta/lsblk.txt" 2>&1 || true
command -v qm >/dev/null 2>&1 && qm list > "$meta/qm-list.txt" 2>&1 || true
command -v pct >/dev/null 2>&1 && pct list > "$meta/pct-list.txt" 2>&1 || true
command -v zpool >/dev/null 2>&1 && zpool status > "$meta/zpool.txt" 2>&1 || true
# Extra hardware fingerprints used by hb_compat_check on restore to
# detect drift that would make some paths unsafe to apply:
# * zpool.guids → pool name + GUID. Same pool name on a fresh
# install gets a NEW GUID; restoring /etc/zfs/zpool.cache with
# the old GUID then drops the boot into emergency mode.
# * blkid.txt → all block-device UUIDs, used to verify
# /etc/fstab and /etc/kernel/proxmox-boot-uuids still resolve.
# * lspci.txt → presence test for GPUs / TPUs / NICs referenced
# by components_status.json (so we don't try to reinstall an
# NVIDIA driver on a host with no NVIDIA card any more).
command -v zpool >/dev/null 2>&1 && zpool list -H -o name,guid > "$meta/zpool.guids" 2>&1 || true
command -v blkid >/dev/null 2>&1 && blkid -s UUID -s TYPE > "$meta/blkid.txt" 2>&1 || true
command -v lspci >/dev/null 2>&1 && lspci -nn > "$meta/lspci.txt" 2>&1 || true
# Package inventory — captures what's installed on the source
# host so the restore flow can offer to reinstall missing user
# packages on the target. Solves the "config restored but the
# binary is missing, service hangs at boot" class of issues
# (log2ram, figurine, sensors etc. installed by post-install).
if command -v dpkg >/dev/null 2>&1; then
dpkg --get-selections > "$meta/packages.list" 2>/dev/null || true
fi
if command -v apt-mark >/dev/null 2>&1; then
apt-mark showmanual > "$meta/packages.manual.list" 2>/dev/null || true
fi
# Manifest + checksums
(
cd "$staging_root/rootfs" || return 1
find . -mindepth 1 -print | sort > "$meta/manifest.txt"
find . -type f -print0 | sort -z | xargs -0 sha256sum 2>/dev/null \
> "$meta/checksums.sha256" || true
)
# ── Structured manifest.json (proxmenux_backup_manifest) ──
# build_manifest.sh composes the collectors output into the
# exact JSON shape parse_manifest.sh + run_restore.sh expect.
# Without it, every restore flow degrades to "no manifest found"
# and the Monitor's View Contents / restore preview shows nothing.
# The collectors live next to this library file.
local _hb_lib_dir _hb_collector_dir _hb_build_manifest
_hb_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_hb_collector_dir="$_hb_lib_dir/collectors"
_hb_build_manifest="$_hb_collector_dir/build_manifest.sh"
if [[ -x "$_hb_build_manifest" ]]; then
local -a _hb_archived=()
if [[ -s "$meta/selected_paths.txt" ]]; then
mapfile -t _hb_archived < "$meta/selected_paths.txt"
fi
if (( ${#_hb_archived[@]} > 0 )); then
bash "$_hb_build_manifest" --paths-archived "${_hb_archived[@]}" \
> "$staging_root/manifest.json" 2>/dev/null || true
else
bash "$_hb_build_manifest" \
> "$staging_root/manifest.json" 2>/dev/null || true
fi
# Drop the file if the collectors emitted nothing useful so
# parse_manifest doesn't read a 0-byte JSON downstream.
[[ -s "$staging_root/manifest.json" ]] || rm -f "$staging_root/manifest.json"
fi
}
hb_load_restore_paths() {
local restore_root="$1"
local __out_var="$2"
local -n __out="$__out_var"
__out=()
local selected="$restore_root/metadata/selected_paths.txt"
if [[ -f "$selected" ]]; then
while IFS= read -r line; do
[[ -n "$line" ]] && __out+=("$line")
done < "$selected"
fi
# Fallback: scan rootfs
if [[ ${#__out[@]} -eq 0 ]]; then
local p
while IFS= read -r p; do
[[ -n "$p" && -e "$restore_root/rootfs/${p#/}" ]] && __out+=("${p#/}")
done < <(hb_default_profile_paths)
fi
}
# ==========================================================
# PBS CONFIG — auto-detect from storage.cfg + manual
# ==========================================================
hb_collect_pbs_configs() {
HB_PBS_NAMES=()
HB_PBS_REPOS=()
HB_PBS_SECRETS=()
HB_PBS_SOURCES=()
HB_PBS_FINGERPRINTS=()
if [[ -f /etc/pve/storage.cfg ]]; then
local current="" server="" datastore="" username="" fingerprint="" pw_file pw_val
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
if [[ $line =~ ^pbs:[[:space:]]*(.+)$ ]]; then
if [[ -n "$current" && -n "$server" && -n "$datastore" && -n "$username" ]]; then
pw_file="/etc/pve/priv/storage/${current}.pw"
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
HB_PBS_NAMES+=("$current")
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
HB_PBS_SECRETS+=("$pw_val")
HB_PBS_SOURCES+=("proxmox")
HB_PBS_FINGERPRINTS+=("$fingerprint")
fi
current="${BASH_REMATCH[1]}"; server="" datastore="" username="" fingerprint=""
elif [[ -n "$current" ]]; then
# The line was already trimmed of leading/trailing
# whitespace above. Match the field name directly at
# the start of the (post-trim) line — the old regex
# demanded leading whitespace that the trim had
# already stripped, so the sub-fields were silently
# never captured.
[[ $line =~ ^server[[:space:]]+(.+)$ ]] && server="${BASH_REMATCH[1]}"
[[ $line =~ ^datastore[[:space:]]+(.+)$ ]] && datastore="${BASH_REMATCH[1]}"
[[ $line =~ ^username[[:space:]]+(.+)$ ]] && username="${BASH_REMATCH[1]}"
[[ $line =~ ^fingerprint[[:space:]]+(.+)$ ]] && fingerprint="${BASH_REMATCH[1]}"
if [[ $line =~ ^[a-zA-Z]+:[[:space:]] &&
-n "$server" && -n "$datastore" && -n "$username" ]]; then
pw_file="/etc/pve/priv/storage/${current}.pw"
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
HB_PBS_NAMES+=("$current")
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
HB_PBS_SECRETS+=("$pw_val")
HB_PBS_SOURCES+=("proxmox")
HB_PBS_FINGERPRINTS+=("$fingerprint")
current="" server="" datastore="" username="" fingerprint=""
fi
fi
done < /etc/pve/storage.cfg
# Last stanza
if [[ -n "$current" && -n "$server" && -n "$datastore" && -n "$username" ]]; then
pw_file="/etc/pve/priv/storage/${current}.pw"
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
HB_PBS_NAMES+=("$current")
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
HB_PBS_SECRETS+=("$pw_val")
HB_PBS_SOURCES+=("proxmox")
HB_PBS_FINGERPRINTS+=("$fingerprint")
fi
fi
# Manual configs
local manual_cfg="$HB_STATE_DIR/pbs-manual-configs.txt"
if [[ -f "$manual_cfg" ]]; then
local line name repo sf fp_file
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
name="${line%%|*}"; repo="${line##*|}"
sf="$HB_STATE_DIR/pbs-pass-${name}.txt"
fp_file="$HB_STATE_DIR/pbs-fingerprint-${name}.txt"
HB_PBS_NAMES+=("$name"); HB_PBS_REPOS+=("$repo")
HB_PBS_SECRETS+=("$([[ -f "$sf" ]] && cat "$sf" || echo "")")
HB_PBS_SOURCES+=("manual")
HB_PBS_FINGERPRINTS+=("$([[ -f "$fp_file" ]] && cat "$fp_file" || echo "")")
done < "$manual_cfg"
fi
}
hb_configure_pbs_manual() {
local name user host datastore repo secret
name=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "Configuration name:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "PBS-$(date +%m%d)" 3>&1 1>&2 2>&3) || return 1
[[ -z "$name" ]] && return 1
user=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "Username (e.g. root@pam or user@pbs!token):")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root@pam" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "PBS host or IP address:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
[[ -z "$host" ]] && return 1
datastore=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--inputbox "$(hb_translate "Datastore name:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
[[ -z "$datastore" ]] && return 1
# Ask for the secret in a loop — an OK click on an empty box used
# to persist an empty password file silently, which then made every
# subsequent backup fail with an opaque auth error. Cancel still
# aborts the whole flow; empty + OK re-prompts.
while true; do
secret=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
--insecure --passwordbox "$(hb_translate "Password or API token secret:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
[[ -n "$secret" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Password cannot be empty.")" 8 50
done
repo="${user}@${host}:${datastore}"
mkdir -p "$HB_STATE_DIR"
local cfg_line="${name}|${repo}"
local manual_cfg="$HB_STATE_DIR/pbs-manual-configs.txt"
touch "$manual_cfg"
grep -Fxq "$cfg_line" "$manual_cfg" || echo "$cfg_line" >> "$manual_cfg"
printf '%s' "$secret" > "$HB_STATE_DIR/pbs-pass-${name}.txt"
chmod 600 "$HB_STATE_DIR/pbs-pass-${name}.txt"
HB_PBS_NAME="$name"; HB_PBS_REPOSITORY="$repo"; HB_PBS_SECRET="$secret"
}
hb_select_pbs_repository() {
hb_collect_pbs_configs
local menu=() i=1 idx
for idx in "${!HB_PBS_NAMES[@]}"; do
local src="${HB_PBS_SOURCES[$idx]}"
local label="${HB_PBS_NAMES[$idx]}${HB_PBS_REPOS[$idx]} [$src]"
[[ -z "${HB_PBS_SECRETS[$idx]}" ]] && label+="$(hb_translate "no password")"
menu+=("$i" "$label"); ((i++))
done
menu+=("$i" "$(hb_translate "+ Add new PBS manually")")
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select PBS repository")" \
--menu "\n$(hb_translate "Available PBS repositories:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
if [[ "$choice" == "$i" ]]; then
hb_configure_pbs_manual || return 1
else
local sel=$((choice-1))
HB_PBS_NAME="${HB_PBS_NAMES[$sel]}"
export HB_PBS_REPOSITORY="${HB_PBS_REPOS[$sel]}"
HB_PBS_SECRET="${HB_PBS_SECRETS[$sel]}"
# Export the fingerprint so _bk_pbs / _rs_extract_pbs can
# pass it to proxmox-backup-client via PBS_FINGERPRINT. The
# binary otherwise prompts "Are you sure you want to
# continue connecting? (y/n):" — twice in some flows
# (backup + catalog upload) — and silently auto-accepts on
# stdin closure, which is both noisy and an MITM risk on a
# cross-host restore.
export HB_PBS_FINGERPRINT="${HB_PBS_FINGERPRINTS[$sel]:-}"
if [[ -z "$HB_PBS_SECRET" ]]; then
while true; do
HB_PBS_SECRET=$(dialog --backtitle "ProxMenux" --title "PBS" \
--insecure --passwordbox \
"$(hb_translate "Password for:") $HB_PBS_NAME" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
[[ -n "$HB_PBS_SECRET" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Password cannot be empty.")" 8 50
done
mkdir -p "$HB_STATE_DIR"
printf '%s' "$HB_PBS_SECRET" > "$HB_STATE_DIR/pbs-pass-${HB_PBS_NAME}.txt"
chmod 600 "$HB_STATE_DIR/pbs-pass-${HB_PBS_NAME}.txt"
fi
fi
}
# ==========================================================
# PBS KEYFILE RECOVERY
#
# `proxmox-backup-client key create` cannot set a KDF passphrase
# non-interactively, so we generate the keyfile with `--kdf none`
# and add our OWN passphrase-based recovery layer on top:
#
# 1. After creating the keyfile, ask the operator for a recovery
# passphrase. Encrypt the keyfile with openssl using that
# passphrase → produces `pbs-key.recovery.enc`.
# 2. On every PBS backup, we upload `pbs-key.recovery.enc` to a
# SEPARATE backup group (`host/hostcfg-<host>-keyrecovery`)
# with NO `--keyfile` flag — so PBS stores it as a regular
# (non-PBS-encrypted) blob. The blob is still protected by
# the operator's passphrase via openssl. The `-keyrecovery`
# suffix on the same `hostcfg-<host>` prefix keeps the two
# groups adjacent in the PBS UI, making the recovery snapshots
# recognisable as belonging to this host's backups instead of
# looking like an unrelated group that an operator might delete
# by mistake. Legacy builds used the ID `proxmenux-keyrecovery-<host>`;
# the recovery discovery below matches both patterns so old
# escrow blobs are still usable.
# 3. On a fresh install where the local keyfile is missing, the
# restore flow looks up the recovery group in PBS, downloads
# the blob, asks for the passphrase, decrypts it, and writes
# the keyfile back to its canonical location.
#
# So the operator only needs to remember the passphrase. The
# encrypted recovery copy travels with their PBS backups
# automatically; no manual offsite keyfile escrow required.
# ==========================================================
hb_pbs_encrypt_recovery() {
# Reads passphrase from stdin. AES-256-CBC + PBKDF2 with 600k
# iterations — standard openssl format, decryptable from any
# host with openssl ≥ 1.1.1.
openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt \
-in "$1" -out "$2" -pass stdin 2>/dev/null
}
hb_pbs_decrypt_recovery() {
openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \
-in "$1" -out "$2" -pass stdin 2>/dev/null
}
# ==========================================================
# ESCROW MODE — data model for the keyfile recovery strategy
# ==========================================================
# A single state file at $HB_STATE_DIR/pbs-key.mode records how this
# host wants its PBS keyfile protected against loss. Three values:
# none — keyfile lives only here. No local envelope, no upload.
# If host is lost without an offsite copy → data is gone.
# local — an openssl-wrapped envelope of the keyfile is written to
# /root and $HB_STATE_DIR. Not uploaded to PBS. Operator
# moves the /root copy offsite.
# full — same envelope AND uploaded to PBS as -keyrecovery group.
# Fully self-recoverable, at the cost of putting the
# wrapped keyfile on the same server as the encrypted data.
# Missing file = pre-existing install from before this feature. Treat
# as 'full' to preserve behaviour. Only backup_host.sh / the runner
# needs to read this at runtime; the dialog paths write it.
_hb_pbs_read_escrow_mode() {
local f="$HB_STATE_DIR/pbs-key.mode"
if [[ -s "$f" ]]; then
local v
v=$(head -c 32 "$f" | tr -d '[:space:]')
case "$v" in
none|local|full) printf '%s' "$v"; return 0 ;;
esac
fi
printf 'full'
}
_hb_pbs_write_escrow_mode() {
local mode="$1"
case "$mode" in
none|local|full) : ;;
*) return 1 ;;
esac
mkdir -p "$HB_STATE_DIR" 2>/dev/null || true
umask 077
printf '%s\n' "$mode" > "$HB_STATE_DIR/pbs-key.mode"
chmod 600 "$HB_STATE_DIR/pbs-key.mode" 2>/dev/null || true
return 0
}
# ==========================================================
# PVE STORAGE KEYFILE DISCOVERY
# ==========================================================
# When the operator has already added a PBS storage in Proxmox VE
# and enabled encryption on it (`pvesm set NAME --encryption-key
# autogen` or via the storage editor), PVE writes the keyfile to
# /etc/pve/priv/storage/<NAME>.enc. That file IS a valid raw PBS
# keyfile (same JSON format as `proxmox-backup-client key create
# --kdf none`); it can be reused verbatim with --keyfile.
# ProxMenux surfaces those keyfiles as reuse candidates in the
# encryption dialog, so users don't have to type absolute paths or
# duplicate secrets across two configs.
# Emit one line per PVE-managed PBS storage that has a .enc keyfile:
# name|server|datastore|keyfile-path
_hb_pbs_discover_pve_keyfiles() {
local cfg=/etc/pve/storage.cfg
local dir=/etc/pve/priv/storage
[[ -r "$cfg" && -d "$dir" ]] || return 0
# Parse storage.cfg PBS entries into name+server+datastore triples.
# A single awk pass builds the triples; a shell loop then checks
# each for a matching .enc keyfile and emits only those that
# actually exist.
awk '
function emit(){ printf "%s|%s|%s\n", name, server, datastore }
/^pbs:[[:space:]]+/ { if (name) emit(); name=$2; server=""; datastore=""; next }
/^[a-z]+:[[:space:]]+/ { if (name) { emit(); name="" } }
/^[[:space:]]+server[[:space:]]+/ { server=$2 }
/^[[:space:]]+datastore[[:space:]]+/ { datastore=$2 }
END { if (name) emit() }
' "$cfg" 2>/dev/null | while IFS='|' read -r name server datastore; do
local kf="$dir/$name.enc"
[[ -s "$kf" ]] || continue
printf '%s|%s|%s|%s\n' "$name" "$server" "$datastore" "$kf"
done
}
# Given HB_PBS_REPOSITORY (form user@realm@host:datastore), find the
# PVE-storage keyfile whose server + datastore match. Emits the path
# on stdout, exits non-zero if no match.
_hb_pbs_pve_keyfile_for_current_repo() {
[[ -n "${HB_PBS_REPOSITORY:-}" ]] || return 1
local repo_host repo_datastore
repo_host="${HB_PBS_REPOSITORY##*@}"
repo_host="${repo_host%:*}"
repo_datastore="${HB_PBS_REPOSITORY##*:}"
local match
match=$(_hb_pbs_discover_pve_keyfiles | awk -F'|' \
-v h="$repo_host" -v d="$repo_datastore" \
'$2==h && $3==d {print $4; exit}')
[[ -n "$match" ]] || return 1
printf '%s' "$match"
}
# Copy a PVE-storage keyfile into the ProxMenux canonical location.
# Same trust boundary as hb_pbs_import_keyfile — no validation, just
# atomic install + chmod 600.
_hb_pbs_install_pve_keyfile() {
local src="$1"
local dst="$HB_STATE_DIR/pbs-key.conf"
[[ -s "$src" && -r "$src" ]] || return 1
mkdir -p "$HB_STATE_DIR" 2>/dev/null || true
local tmp
tmp=$(mktemp "${dst}.pve.XXXXXX") || return 1
if ! cp -f "$src" "$tmp"; then rm -f "$tmp"; return 1; fi
chmod 600 "$tmp"
mv -f "$tmp" "$dst" || { rm -f "$tmp"; return 1; }
return 0
}
# ==========================================================
# ESCROW MODE — dialog
# ==========================================================
# Simple yes/no: "Upload the encryption key to PBS?" — mirrors the
# operator's mental model. Two outcomes only, no middle ground:
# Yes → mode='full': ProxMenux writes a passphrase-wrapped copy of
# the keyfile to /root and uploads it to PBS as the
# `-keyrecovery` group so a reinstalled host can recover the
# key with just the passphrase.
# No → mode='none': ProxMenux keeps the keyfile ONLY at the
# canonical local path. The operator is shown the location
# and told to make their own offsite copy — losing it means
# every encrypted backup on PBS is unreadable.
# The 'local' mode (envelope in /root but not uploaded) is still a
# valid backend value; it is just not surfaced to the operator here.
#
# Sets HB_PBS_ESCROW_RESULT to 'full' or 'none' on success; returns
# non-zero on cancel. We use a global variable instead of stdout
# capture because `if dialog --yesno ...; then` inside a `$( )`
# command substitution flushes the terminal at subshell exit and
# leaves the very next dialog invocation needing an extra Enter to
# render — invisible bug in new keyfile flow. Callers set
# their own `local mode=$HB_PBS_ESCROW_RESULT` right after.
_hb_pbs_prompt_escrow_mode() {
# Optional first arg: source path of an EXISTING keyfile the
# operator is about to reuse (either from PVE storage or from an
# absolute path they just typed). When set, the dialog:
# • shows the source path so the operator sees where the key
# already lives on this host,
# • defaults to "No, keep local only" — the operator already
# has this keyfile elsewhere, so a PBS escrow copy is usually
# redundant. They can still hit Yes.
# When empty (fresh generate), defaults to "Yes, upload" — the
# safer path when no other copy of the key exists yet.
local existing_source="${1:-}"
HB_PBS_ESCROW_RESULT=""
local msg
msg="$(hb_translate "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?")"$'\n\n'
if [[ -n "$existing_source" ]]; then
msg+="$(hb_translate "Existing key is at:")"$'\n'" $existing_source"$'\n\n'
fi
msg+="$(hb_translate "Yes: set a recovery passphrase now; the encrypted key envelope is uploaded with every backup.")"$'\n\n'
msg+="$(hb_translate "No: the key stays only at")"$'\n'" $HB_STATE_DIR/pbs-key.conf"$'\n'
msg+="$(hb_translate "Copy that file offsite yourself, or download it from the Monitor.")"
local -a dialog_args=(
--backtitle "ProxMenux"
--title "$(hb_translate "Upload key to PBS?")"
--yes-label "$(hb_translate "Yes, upload")"
--no-label "$(hb_translate "No, keep local only")"
--defaultno
)
dialog_args+=(--yesno "$msg" 18 78)
dialog "${dialog_args[@]}"
# 0 = Yes button, 1 = No button, anything else (ESC / cancel) →
# abort the whole encryption setup so the operator can retry.
case $? in
0) HB_PBS_ESCROW_RESULT='full'; return 0 ;;
1) HB_PBS_ESCROW_RESULT='none'; return 0 ;;
*) return 1 ;;
esac
}
# Import a user-provided PBS encryption keyfile at the canonical
# ProxMenux path. Used by the "shared keyfile across hosts" flow
# where the operator generated one master key manually and wants
# every host to reuse it (single recovery blob covers all).
#
# Args: $1 = path to the source keyfile (any location on disk).
#
# Validation: the file must exist, be non-empty, and be recognised
# by `proxmox-backup-client key info` as a valid PBS keyfile. If
# any check fails the function returns non-zero and leaves the
# canonical path untouched, so the caller can fall back to the
# "generate new" path or report the error.
#
# On success: the file is copied to $HB_STATE_DIR/pbs-key.conf
# with chmod 600 and the caller can proceed with the recovery
# blob offer as usual.
hb_pbs_import_keyfile() {
local src="$1"
local dst="$HB_STATE_DIR/pbs-key.conf"
[[ -n "$src" && -s "$src" && -r "$src" ]] || return 1
# `key info` reads the file header and prints fingerprint /
# kdf info without prompting for a passphrase (this works for
# kdf=none keyfiles, which is what our own `key create` uses
# and what most operators generate manually). If the file is
# not a valid PBS keyfile, the tool exits non-zero.
if ! proxmox-backup-client key info --output-format json "$src" \
>/dev/null 2>&1; then
return 2
fi
mkdir -p "$HB_STATE_DIR" 2>/dev/null || true
# Install atomically: cp to a tmp path in the same dir, chmod,
# then rename. Avoids a half-written file if the copy is
# interrupted mid-flight.
local tmp
tmp=$(mktemp "${dst}.import.XXXXXX") || return 3
if ! cp -f "$src" "$tmp"; then
rm -f "$tmp"
return 3
fi
chmod 600 "$tmp"
if ! mv -f "$tmp" "$dst"; then
rm -f "$tmp"
return 3
fi
return 0
}
# Prompt for a recovery passphrase pair. Sets HB_PBS_PASS_RESULT on
# success; returns non-zero on cancel or when openssl is missing. The
# caller decides when — and whether — to persist a keyfile after this,
# so a cancel here leaves the disk exactly as it was.
#
# Uses a global variable instead of stdout capture — see the note on
# _hb_pbs_prompt_escrow_mode. Command-substitution around dialogs
# leaves the terminal in a state where the next dialog needs an extra
# Enter to render.
_hb_pbs_prompt_recovery_pass() {
HB_PBS_PASS_RESULT=""
if ! command -v openssl >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery setup failed")" \
--msgbox "$(hb_translate "openssl is not installed — cannot create recovery copy. Install openssl and retry.")" 9 70
return 1
fi
local pass1 pass2
while true; do
pass1=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery passphrase")" \
--insecure --passwordbox "$(hb_translate "Choose a recovery passphrase (write it down somewhere safe):")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
[[ -z "$pass1" ]] && continue
pass2=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery passphrase")" \
--insecure --passwordbox "$(hb_translate "Confirm recovery passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
[[ "$pass1" == "$pass2" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
done
HB_PBS_PASS_RESULT="$pass1"
return 0
}
# Encrypt the on-disk keyfile with the supplied passphrase, drop an
# offsite copy under /root, and show the operator the success dialog.
# Precondition: the keyfile exists at $HB_STATE_DIR/pbs-key.conf.
# Returns non-zero only on openssl failure (rare). The caller is
# responsible for undoing whatever it just did if this fails.
#
# Args:
# $1 = passphrase
# $2 = escrow mode ('local' or 'full'); the message text branches
# on this. Never called with 'none' — the top-level flow skips
# finalize entirely for that mode.
_hb_pbs_finalize_recovery() {
local pass="$1"
local mode="${2:-full}"
local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
if ! printf '%s' "$pass" | hb_pbs_encrypt_recovery "$key_file" "$recovery_enc"; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery setup failed")" \
--msgbox "$(hb_translate "openssl encryption failed.")" 9 70
return 1
fi
chmod 600 "$recovery_enc"
# No extra "Recovery ready" msgbox — the operator's next action is
# the backup itself. The status of the encryption setup is visible
# in the Monitor and in the summary the backup emits when it
# starts running.
return 0
}
# Public wrapper preserved for external callers that already have a
# keyfile on disk and just want the recovery blob generated. Combines
# the prompt + finalize phases. Defaults to 'full' when called without
# a mode argument (preserves legacy caller behaviour); when the caller
# passes a mode it is forwarded to _hb_pbs_finalize_recovery so the
# success message matches.
hb_pbs_setup_recovery() {
local mode="${1:-full}"
local pass
pass=$(_hb_pbs_prompt_recovery_pass) || return 1
if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then
return 1
fi
_hb_pbs_write_escrow_mode "$mode"
}
# Upload the local recovery .enc to PBS as a separate snapshot
# group. Called from _bk_pbs after the main backup succeeds.
# Short-circuits when escrow mode is not 'full' — the recovery
# envelope may still exist locally in 'local' mode, but the whole
# point of that mode is not putting it on PBS. Skips silently if no
# recovery copy is present. Returns 0 on success or skip, 1 on
# upload failure.
hb_pbs_upload_recovery_blob() {
local epoch="$1"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
[[ ! -f "$recovery_enc" ]] && return 0
local mode
mode=$(_hb_pbs_read_escrow_mode)
[[ "$mode" != "full" ]] && return 0
# `proxmox-backup-client backup` only accepts archive types
# `pxar` / `img` / `conf` / `log` as the source spec — `.blob`
# is an internal storage format, not a valid input type. The
# recovery file is a small openssl-encrypted blob so we use
# `.conf` (which PBS stores internally as `.conf.blob`). On
# restore we ask for `keyrecovery.conf` (without the .blob
# suffix) and PBS resolves it transparently.
# Note: deliberately NO --keyfile here. The blob is already
# passphrase-encrypted by openssl; we want PBS to store it as
# a plain blob so it can be retrieved without the keyfile.
PBS_PASSWORD="$HB_PBS_SECRET" \
PBS_FINGERPRINT="${HB_PBS_FINGERPRINT:-}" \
proxmox-backup-client backup \
"keyrecovery.conf:$recovery_enc" \
--repository "$HB_PBS_REPOSITORY" \
--backup-type host \
--backup-id "hostcfg-$(hostname)-keyrecovery" \
--backup-time "$epoch" \
>/dev/null 2>&1
}
# On a fresh install with no local keyfile, try to recover it.
# Attempts several sources in order of decreasing convenience for
# Manual-import fallback: prompt the operator for the absolute path
# of the keyfile on this host. Copies it verbatim to the canonical
# path. Called from hb_pbs_try_keyfile_recovery when both auto-paths
# (PVE storage keyfile, PBS keyrecovery group) have been exhausted.
# Returns 0 on successful install, 1 on cancel or missing file.
_hb_pbs_manual_keyfile_import() {
local target_keyfile="$1"
local src
src=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Import encryption keyfile")" \
--inputbox "$(hb_translate "No PBS keyfile is installed on this host and no automatic recovery was possible.")"$'\n\n'"$(hb_translate "Absolute path to your PBS keyfile:")"$'\n\n'"$(hb_translate "The file is copied to")"$'\n'"$target_keyfile" \
16 78 "" 3>&1 1>&2 2>&3) || return 1
src="$(echo "$src" | xargs)"
[[ -z "$src" ]] && return 1
if [[ ! -s "$src" || ! -r "$src" ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "The file does not exist, is empty or is not readable.")"$'\n\n'"$(hb_translate "Path:") $src" \
10 78
return 1
fi
mkdir -p "$(dirname "$target_keyfile")"
if ! cp -f "$src" "$target_keyfile"; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "Could not copy the keyfile into place.")" 9 70
return 1
fi
chmod 600 "$target_keyfile"
return 0
}
# On a fresh install with no local keyfile, try to recover it.
# Attempts sources in order and stops at the first one that works:
#
# 1. PVE-storage keyfile matching HB_PBS_REPOSITORY
# (/etc/pve/priv/storage/<name>.enc) — silent copy.
# 2. PBS `-keyrecovery` snapshot group — prompts for passphrase.
# 3. Manual import from an absolute path on this host — prompted
# when the two auto-paths above find nothing or the operator
# declines them.
#
# Returns 0 if the keyfile was successfully restored to $1,
# 1 if no recovery is possible or the user cancelled.
hb_pbs_try_keyfile_recovery() {
local target_keyfile="$1"
# ── Step 1: PVE-storage keyfile matching the current repository ──
# If the operator has already configured PBS in PVE with an
# encryption-key, we can use that file verbatim — no passphrase
# prompt, no PBS round-trip. This is the classic "why do I need
# to import? it is right there" case.
local pve_key_path=""
if [[ -n "${HB_PBS_REPOSITORY:-}" ]]; then
pve_key_path=$(_hb_pbs_pve_keyfile_for_current_repo 2>/dev/null || true)
fi
if [[ -n "$pve_key_path" && -s "$pve_key_path" ]]; then
mkdir -p "$(dirname "$target_keyfile")"
if cp -f "$pve_key_path" "$target_keyfile" 2>/dev/null; then
chmod 600 "$target_keyfile"
dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovered")" \
--msgbox "$(hb_translate "Reused the encryption key from the PVE storage entry.")"$'\n\n'"$(hb_translate "Source:") $pve_key_path"$'\n'"$(hb_translate "Installed at:") $target_keyfile"$'\n\n'"$(hb_translate "Restore can now proceed.")" \
14 78
return 0
fi
fi
if ! command -v openssl >/dev/null 2>&1; then
return 1 # silently — main path will surface a clearer error
fi
# ── Step 2: PBS -keyrecovery snapshot group (fall back) ────────
# Discover keyrecovery groups in PBS. Two patterns are matched:
# - hostcfg-<host>-keyrecovery (current naming — groups
# adjacent to the main hostcfg-<host>)
# - proxmenux-keyrecovery-<host> (legacy naming — pre-1.2.2.2)
# Both are surfaced together so existing escrow blobs from older
# builds remain usable during a fresh-install recovery.
local -a recovery_entries=()
mapfile -t recovery_entries < <(
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("hostcfg-")) and (."backup-id" | endswith("-keyrecovery")))
or (."backup-id" | startswith("proxmenux-keyrecovery-"))
))
| "\(."backup-id")|\(."backup-time")"' 2>/dev/null \
| sort -t'|' -k1,1 -k2,2nr \
| awk -F'|' '!seen[$1]++'
)
if [[ ${#recovery_entries[@]} -eq 0 ]]; then
# No auto-recovery available — fall through to the manual
# import prompt. Nothing has been written to disk yet so a
# cancel here just aborts cleanly.
_hb_pbs_manual_keyfile_import "$target_keyfile"
return $?
fi
# Pick the recovery group (auto if one, ask if many)
local picked_id picked_epoch
if [[ ${#recovery_entries[@]} -eq 1 ]]; then
IFS='|' read -r picked_id picked_epoch <<< "${recovery_entries[0]}"
else
local menu=() i=1
local entry id_part host_part iso_label
for entry in "${recovery_entries[@]}"; do
id_part="${entry%%|*}"
# Strip either naming convention to surface just the
# source hostname in the menu label.
if [[ "$id_part" == hostcfg-*-keyrecovery ]]; then
host_part="${id_part#hostcfg-}"
host_part="${host_part%-keyrecovery}"
else
host_part="${id_part#proxmenux-keyrecovery-}"
fi
iso_label=$(date -u -d "@${entry##*|}" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "${entry##*|}")
menu+=("$i" "$host_part$iso_label UTC")
((i++))
done
local sel
sel=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Keyfile recovery — pick source host")" \
--menu "$(hb_translate "Multiple recovery groups found in PBS. Pick the one that originally created the keyfile:")" \
18 78 10 "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
IFS='|' read -r picked_id picked_epoch <<< "${recovery_entries[$((sel-1))]}"
fi
local iso
iso=$(date -u -d "@$picked_epoch" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "$picked_epoch")
local recovery_snapshot="host/${picked_id}/${iso}"
if ! dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovery available")" \
--yesno "$(hb_translate "Local keyfile is missing but a recovery copy was found in PBS.")"$'\n\n'"$(hb_translate "Backup:") $recovery_snapshot"$'\n\n'"$(hb_translate "Recover the keyfile using your recovery passphrase?")" \
13 78; then
# Operator declined PBS recovery → offer the manual import path.
_hb_pbs_manual_keyfile_import "$target_keyfile"
return $?
fi
# Download the blob once; we may retry passphrase entry without
# re-fetching it.
local tmp_dir
tmp_dir=$(mktemp -d /tmp/_pmnx_keyrec.XXXXXX) || return 1
# `restore` wants a FILE target (not a directory) for non-pxar
# archives — and we ask for `keyrecovery.conf` (matches the
# name used on upload), which PBS resolves to the underlying
# `keyrecovery.conf.blob` automatically.
if ! PBS_PASSWORD="$HB_PBS_SECRET" \
PBS_FINGERPRINT="${HB_PBS_FINGERPRINT:-}" \
proxmox-backup-client restore "$recovery_snapshot" "keyrecovery.conf" "$tmp_dir/keyrecovery.enc" \
--repository "$HB_PBS_REPOSITORY" >/dev/null 2>&1; then
rm -rf "$tmp_dir"
dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery failed")" \
--msgbox "$(hb_translate "Could not download recovery blob from PBS.")" 9 70
return 1
fi
local passphrase
while true; do
passphrase=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery passphrase")" \
--insecure --passwordbox "$(hb_translate "Enter the recovery passphrase set when the keyfile was created:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) \
|| { rm -rf "$tmp_dir"; return 1; }
[[ -z "$passphrase" ]] && continue
mkdir -p "$(dirname "$target_keyfile")"
if printf '%s' "$passphrase" | hb_pbs_decrypt_recovery "$tmp_dir/keyrecovery.enc" "$target_keyfile"; then
chmod 600 "$target_keyfile"
rm -rf "$tmp_dir"
dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovered")" \
--msgbox "$(hb_translate "Keyfile recovered successfully.")"$'\n\n'"$(hb_translate "Location:") $target_keyfile"$'\n\n'"$(hb_translate "Restore can now proceed.")" \
12 70
return 0
fi
# Decryption failed — wrong passphrase (or corrupt blob)
if ! dialog --backtitle "ProxMenux" --title "$(hb_translate "Wrong passphrase")" \
--yesno "$(hb_translate "Decryption failed. The passphrase may be wrong, or the blob is corrupt. Try again?")" \
9 70; then
rm -rf "$tmp_dir"
return 1
fi
done
}
# Internal helper: create a fresh PBS keyfile at the canonical path
# and (unless mode='none') its paired recovery envelope. Prompts the
# operator for the escrow mode first — that decision drives whether
# a passphrase is asked, whether openssl is run, and what message
# closes the flow. Cancel at any dialog before the keyfile is
# actually created leaves the disk untouched.
_hb_pbs_create_new_keyfile() {
local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
# 1. Pick the escrow mode. Cancel here → zero side effect.
_hb_pbs_prompt_escrow_mode || return 1
local mode="$HB_PBS_ESCROW_RESULT"
# 2. Collect the recovery passphrase — only when we're going to
# encrypt an envelope. Cancel at the passphrase prompt → zero
# side effect.
local pass=""
if [[ "$mode" != "none" ]]; then
_hb_pbs_prompt_recovery_pass || return 1
pass="$HB_PBS_PASS_RESULT"
fi
# 3. Create the keyfile. Point of no return: any failure past here
# leaves us with state to clean up. No msg_info/msg_ok in the
# dialog chain — those interleave with the dialogs' terminal
# state and cause the operator to have to press Enter twice.
mkdir -p "$HB_STATE_DIR"
local create_stderr create_rc
create_stderr=$(proxmox-backup-client key create --kdf none "$key_file" </dev/null 2>&1 >/dev/null)
create_rc=$?
if [[ $create_rc -ne 0 || ! -s "$key_file" ]]; then
# Sweep any zero-byte residue and surface the real error.
[[ -f "$key_file" && ! -s "$key_file" ]] && rm -f "$key_file" 2>/dev/null
local err_msg
err_msg="$(hb_translate "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.")"$'\n\n'
err_msg+="$(hb_translate "Tool exit code:") $create_rc"$'\n'
err_msg+="$(hb_translate "Tool output:")"$'\n'
err_msg+="${create_stderr:-(empty)}"
dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption key creation failed")" \
--msgbox "$err_msg" 14 78
HB_PBS_KEYFILE_OPT=""
return 1
fi
chmod 600 "$key_file"
# 4. Finalize per mode. On mode='none' we skip finalize entirely —
# no envelope, no msgbox. The operator was already told in the
# upload prompt where the keyfile lives; the backup summary
# reprints "Encryption: Enabled" when the run starts.
if [[ "$mode" != "none" ]]; then
if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then
rm -f "$key_file" "$recovery_enc" 2>/dev/null
HB_PBS_KEYFILE_OPT=""
return 1
fi
fi
_hb_pbs_write_escrow_mode "$mode"
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
return 0
}
# Internal helper: prompt for the source path, validate WITHOUT
# copying, ask the escrow mode (and, if not 'none', the recovery
# passphrase), then install the keyfile at the canonical path.
# Cancel at any dialog before the install step leaves the disk
# untouched — no "phantom" keyfile.
_hb_pbs_import_dialog() {
local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
local src
# 1. Ask the path.
src=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Import PBS keyfile")" \
--inputbox "$(hb_translate "Absolute path to your existing PBS keyfile:")"$'\n\n'"$(hb_translate "The file is copied to")"$'\n'"$key_file $(hb_translate "with chmod 600. ProxMenux does not validate the contents — any keyfile you accept as valid on your PBS is accepted here.")" \
14 78 "" 3>&1 1>&2 2>&3) || return 1
src="$(echo "$src" | xargs)"
[[ -z "$src" ]] && return 1
# 2. Basic existence + readability. We deliberately do NOT validate
# the content: `proxmox-backup-client key info` refuses scrypt
# keyfiles that require their original passphrase, and forcing
# the operator to strip encryption before importing is worse
# than trusting their input. If the file is not a real keyfile,
# the first backup will fail with a clear message.
if [[ ! -s "$src" || ! -r "$src" ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "The file does not exist, is empty or is not readable.")"$'\n\n'"$(hb_translate "Path:") $src" \
10 78
return 1
fi
# 3. Ask for the keyfile passphrase. Blank == kdf=none (no
# passphrase). If the source key uses --kdf scrypt the operator
# types it here and we persist it next to the keyfile so the
# scheduled runner (systemd, no stdin) can unlock the key
# without prompting.
local keyfile_pass keyfile_pass2
while true; do
keyfile_pass=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile passphrase")" \
--insecure --passwordbox "$(hb_translate "Passphrase used to unlock the imported keyfile (leave blank if the keyfile is unencrypted / kdf=none):")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
# Blank is a legitimate answer — kdf=none, no passphrase.
if [[ -z "$keyfile_pass" ]]; then
keyfile_pass2=""
break
fi
keyfile_pass2=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile passphrase")" \
--insecure --passwordbox "$(hb_translate "Confirm the keyfile passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
[[ "$keyfile_pass" == "$keyfile_pass2" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
done
# 4. Pick the escrow mode. Cancel → zero side effect.
_hb_pbs_prompt_escrow_mode "$src" || return 1
local mode="$HB_PBS_ESCROW_RESULT"
# 5. Collect the recovery passphrase — only when we plan to write
# an envelope. Cancel → zero side effect.
local pass=""
if [[ "$mode" != "none" ]]; then
_hb_pbs_prompt_recovery_pass || return 1
pass="$HB_PBS_PASS_RESULT"
fi
# 6. Install the keyfile. Point of no return. No msg_ok before the
# next dialog — the msg_ line breaks dialog terminal state and
# forces the operator to press Enter twice on the next one.
if ! hb_pbs_import_keyfile "$src"; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "Could not copy the keyfile into place. Check permissions on:") $HB_STATE_DIR" \
10 78
return 1
fi
# 7. Persist the keyfile passphrase (if any) next to the keyfile.
# Same trust boundary — chmod 600, root-only. Empty passphrase
# means no file: the runner treats absent file as kdf=none.
# Also export HB_PBS_ENC_PASS so _bk_pbs (called right after
# this returns) picks it up for the current backup without
# needing to re-read the file.
local keyfile_pass_file="$HB_STATE_DIR/pbs-key.pass"
if [[ -n "$keyfile_pass" ]]; then
umask 077
printf '%s' "$keyfile_pass" > "$keyfile_pass_file"
chmod 600 "$keyfile_pass_file" 2>/dev/null || true
HB_PBS_ENC_PASS="$keyfile_pass"
else
rm -f "$keyfile_pass_file" 2>/dev/null || true
HB_PBS_ENC_PASS=""
fi
# 8. Finalize per mode. mode='none' skips finalize (no envelope,
# no msgbox) — the operator was already told in the upload
# prompt where the keyfile lives.
if [[ "$mode" != "none" ]]; then
if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then
rm -f "$key_file" "$recovery_enc" "$keyfile_pass_file" 2>/dev/null
HB_PBS_KEYFILE_OPT=""
return 1
fi
fi
_hb_pbs_write_escrow_mode "$mode"
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
return 0
}
# Internal helper: reuse a keyfile already managed by PVE at
# /etc/pve/priv/storage/<NAME>.enc. Same mode-aware finalize flow as
# create + import, but without a passphrase prompt for the keyfile
# itself (PVE-managed encryption-key entries are always --kdf none).
_hb_pbs_reuse_pve_keyfile() {
local pve_name="$1"
local pve_path="$2"
local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
[[ -s "$pve_path" ]] || return 1
# 1. Pick the escrow mode. Pass the PVE source path so the dialog
# tells the operator where the reused key already lives.
_hb_pbs_prompt_escrow_mode "$pve_path" || return 1
local mode="$HB_PBS_ESCROW_RESULT"
# 2. Recovery passphrase (only if we plan to write an envelope).
local pass=""
if [[ "$mode" != "none" ]]; then
_hb_pbs_prompt_recovery_pass || return 1
pass="$HB_PBS_PASS_RESULT"
fi
# 3. Install the PVE keyfile at the canonical path. No msg_ok
# before the next dialog.
if ! _hb_pbs_install_pve_keyfile "$pve_path"; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "Could not copy the PVE keyfile into place. Check permissions on:") $HB_STATE_DIR" \
10 78
return 1
fi
# 4. PVE-managed keys never carry a per-key passphrase (they are
# always --kdf none). Wipe any stale pbs-key.pass so the runner
# does not try to feed a passphrase to a keyfile that has none.
rm -f "$HB_STATE_DIR/pbs-key.pass" 2>/dev/null || true
HB_PBS_ENC_PASS=""
# 5. Finalize per mode. mode='none' skips finalize (no envelope,
# no msgbox).
if [[ "$mode" != "none" ]]; then
if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then
rm -f "$key_file" "$recovery_enc" 2>/dev/null
HB_PBS_KEYFILE_OPT=""
return 1
fi
fi
_hb_pbs_write_escrow_mode "$mode"
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
return 0
}
hb_ask_pbs_encryption() {
# Flow (spec by the operator):
# 1. Yes/No: "Do you want to encrypt this backup?"
# No → plain backup, no keyfile touched.
# Yes → step 2.
# 2a. Keyfile already exists on this host → use it silently.
# Subsequent scheduled or manual backups never re-ask for
# the setup — that is what "already configured" means.
# 2b. No keyfile yet → 2-option menu:
# new → generate a fresh keyfile
# existing → try to auto-detect a PVE-managed keyfile
# matching the current PBS repository;
# if found, use it silently; if not, prompt
# for the absolute path.
# 3. After the keyfile is in place, ask Yes/No:
# "Upload an encrypted copy of the key to PBS?"
# Yes → passphrase → envelope written + will be uploaded
# by the runner (mode='full').
# No → operator is shown the local path and told to make
# their own offsite copy (mode='none').
#
# A single-run helper leftover from a cancelled/failed create can
# leave a zero-byte keyfile behind; we wipe it before deciding so
# the "keyfile exists" branch never trips on an empty file.
local key_file="$HB_STATE_DIR/pbs-key.conf"
export HB_PBS_KEYFILE_OPT=""
export HB_PBS_ENC_PASS=""
clear
printf '\033]0;ProxMenux\007'
if [[ -f "$key_file" && ! -s "$key_file" ]]; then
rm -f "$key_file" 2>/dev/null
fi
# ── Step 1: yes/no ─────────────────────────────────────────
if ! dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption")" \
--yesno "$(hb_translate "Do you want to encrypt this backup?")" 8 60; then
# No → continue without encryption. `HB_PBS_KEYFILE_OPT` stays
# empty; the runner will call proxmox-backup-client without
# --keyfile.
return 0
fi
# ── Step 2a: keyfile already present → reuse silently ─────
if [[ -s "$key_file" ]]; then
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
# Load the stored keyfile passphrase if any — imports of
# scrypt-encoded keyfiles persist it here so proxmox-backup-client
# can unlock the key without a stdin prompt.
local _pass_file="$HB_STATE_DIR/pbs-key.pass"
if [[ -s "$_pass_file" ]]; then
HB_PBS_ENC_PASS=$(cat "$_pass_file" 2>/dev/null || true)
fi
msg_ok "$(hb_translate "Using existing encryption key:") $key_file"
return 0
fi
# ── Step 2b: source menu — 2 options ──────────────────────
local choice
choice=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption key")" \
--default-item "new" \
--menu "$(hb_translate "No encryption key is stored on this host. Choose how to set one up:")" \
12 78 2 \
"new" "$(hb_translate "Generate a new keyfile")" \
"existing" "$(hb_translate "Use an existing keyfile")" \
3>&1 1>&2 2>&3) || return 1
case "$choice" in
new)
_hb_pbs_create_new_keyfile
return $?
;;
existing)
# Auto-detect: is there a PVE-managed keyfile for this repo?
# If yes, use it silently (matches operator ask: "if there
# is one at the default path, auto-detect it"). If not,
# fall through to the manual-path dialog.
local pve_kf=""
if [[ -n "${HB_PBS_REPOSITORY:-}" ]]; then
pve_kf=$(_hb_pbs_pve_keyfile_for_current_repo 2>/dev/null || true)
fi
if [[ -n "$pve_kf" && -s "$pve_kf" ]]; then
local pve_name
pve_name="$(basename "$pve_kf" .enc)"
_hb_pbs_reuse_pve_keyfile "$pve_name" "$pve_kf"
return $?
fi
_hb_pbs_import_dialog
return $?
;;
*)
# Empty selection from dialog — treat as cancel.
return 1
;;
esac
}
# ==========================================================
# BORG
# ==========================================================
hb_ensure_borg() {
# Borg is intentionally NOT a base dep of install_proxmenux.sh —
# operators who only ever use PBS or local archives shouldn't carry
# ~3 MB of dead weight. The canonical source is the Monitor AppImage
# bundle, which is built with a SHA-verified borg-linux64 binary
# alongside the dashboard. Resolution order:
#
# 1. system borg (already apt-installed)
# 2. /usr/local/share/proxmenux/borg (state-dir cache from prior run)
# 3. Monitor AppImage's bundled borg (canonical — offline-safe)
# 4. GitHub download → state-dir (last resort for hosts
# without the new AppImage)
command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; }
local appimage_cache="$HB_STATE_DIR/borg"
[[ -x "$appimage_cache" ]] && { echo "$appimage_cache"; return 0; }
# The Monitor AppImage ships borg-linux64 at usr/bin/borg inside the
# squashfs. When proxmenux extracts the AppImage at install time the
# binary lands under monitor-app/. Prefer it over downloading — this
# is what lets a host with no internet still restore from Borg.
local bundled="$HB_STATE_DIR/monitor-app/usr/bin/borg"
if [[ -x "$bundled" ]]; then
echo "$bundled"; return 0
fi
command -v sha256sum >/dev/null 2>&1 || {
msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")"
return 1
}
msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..."
mkdir -p "$HB_STATE_DIR"
local tmp_file
tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1
if wget -qO "$tmp_file" "$HB_BORG_LINUX64_URL"; then
if echo "${HB_BORG_LINUX64_SHA256} $tmp_file" | sha256sum -c - >/dev/null 2>&1; then
mv -f "$tmp_file" "$appimage_cache"
else
rm -f "$tmp_file"
msg_error "$(hb_translate "Borg binary checksum verification failed.")"
return 1
fi
chmod +x "$appimage_cache"
msg_ok "$(hb_translate "Borg ready.")"
echo "$appimage_cache"; return 0
fi
rm -f "$tmp_file"
msg_error "$(hb_translate "Failed to download Borg.")"
return 1
}
hb_borg_init_if_needed() {
local borg_bin="$1" repo="$2" encrypt_mode="$3"
# `</dev/null` because borg reads passphrase prompts from /dev/tty,
# which `>/dev/null 2>&1` does not suppress.
if "$borg_bin" list "$repo" </dev/null >/dev/null 2>&1; then
return 0
fi
if "$borg_bin" help repo-create </dev/null >/dev/null 2>&1; then
"$borg_bin" repo-create -e "$encrypt_mode" "$repo" </dev/null
else
"$borg_bin" init --encryption="$encrypt_mode" "$repo" </dev/null
fi
}
hb_prepare_borg_passphrase() {
BORG_ENCRYPT_MODE="none"
unset BORG_PASSPHRASE
# 1. Saved target selected via hb_select_borg_repo? Use its pw file.
if [[ -n "${HB_BORG_SELECTED_NAME:-}" ]]; then
# Look up the saved target's encrypt_mode from borg-targets.txt
# (format: name|path|extra|encrypt_mode). When the operator
# created the target with "No encryption" (encrypt_mode=none)
# we must NOT prompt for a passphrase — the previous version
# fell through to the "first time" branch and asked anyway.
local _sel_mode=""
local _targets_file="$HB_STATE_DIR/borg-targets.txt"
if [[ -f "$_targets_file" ]]; then
_sel_mode=$(awk -F'|' -v n="$HB_BORG_SELECTED_NAME" '$1==n {print $4; exit}' "$_targets_file")
fi
if [[ "$_sel_mode" == "none" ]]; then
export BORG_ENCRYPT_MODE="none"
return 0
fi
local sel_pass_file="$HB_STATE_DIR/borg-pass-${HB_BORG_SELECTED_NAME}.txt"
if [[ -f "$sel_pass_file" ]]; then
export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$sel_pass_file")"
BORG_ENCRYPT_MODE="${_sel_mode:-repokey}"
return 0
fi
# Saved target, no pw yet — ask + confirm + persist next to its config.
# Two prompts on first entry: persisted passphrases are a typo hazard
# (a single wrong key locks the operator out of every future restore).
local sel_pass sel_pass2
while true; do
sel_pass=$(dialog --backtitle "ProxMenux" --colors --insecure \
--title "$(hb_translate "Borg repository passphrase")" \
--passwordbox "\n$(hb_translate "Enter the BORG REPOKEY passphrase for target:") \Zb${HB_BORG_SELECTED_NAME}\ZB" \
10 78 "" 3>&1 1>&2 2>&3) || return 1
sel_pass2=$(dialog --backtitle "ProxMenux" --colors --insecure \
--title "$(hb_translate "Confirm Borg passphrase")" \
--passwordbox "\n$(hb_translate "Re-enter the BORG REPOKEY passphrase to confirm:")" \
10 78 "" 3>&1 1>&2 2>&3) || return 1
[[ "$sel_pass" == "$sel_pass2" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 60
done
mkdir -p "$HB_STATE_DIR"
printf '%s' "$sel_pass" > "$sel_pass_file"
chmod 600 "$sel_pass_file"
export BORG_PASSPHRASE="$sel_pass"
export BORG_ENCRYPT_MODE="repokey"
# Same mandatory acknowledgement as the fresh-target path:
# the passphrase is the only escape route to the encrypted
# repo. See the comment on the identical dialog below.
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "IMPORTANT — Save this passphrase")" \
--msgbox "\Zb\Z1$(hb_translate "This passphrase is the ONLY way to access encrypted Borg backups.")\Zn"$'\n\n'"$(hb_translate "ProxMenux saved it locally at:")"$'\n'" \Zb${sel_pass_file}\ZB"$'\n\n'"$(hb_translate "If you lose or reinstall this host without a copy of the passphrase somewhere else (password manager, offline note, another host, USB stick...), every encrypted archive in this Borg repository becomes UNRECOVERABLE.")"$'\n\n'"\Zb$(hb_translate "Save the passphrase somewhere safe NOW, before continuing.")\ZB" \
18 82
return 0
fi
# 2. Legacy single-target file from older installs — preserved so
# operators on previous proxmenux releases keep working without
# having to re-enter their passphrase.
local pass_file="$HB_STATE_DIR/borg-pass.txt"
if [[ -f "$pass_file" ]]; then
export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$pass_file")"
BORG_ENCRYPT_MODE="repokey"
return 0
fi
# 3. Brand-new target (no save): ask + confirm. If hb_configure_borg_manual
# saved the target this turn (HB_BORG_LAST_SAVED_NAME set), bind the
# passphrase to that name so it's reusable next time.
dialog --backtitle "ProxMenux" --title "$(hb_translate "Borg encryption")" \
--yesno "$(hb_translate "Encrypt this Borg repository?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
local pass1 pass2
while true; do
pass1=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Borg passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
pass2=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Confirm Borg passphrase:")" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
[[ "$pass1" == "$pass2" ]] && break
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match.")" 8 50
done
mkdir -p "$HB_STATE_DIR"
local target_pass_file="$pass_file"
[[ -n "${HB_BORG_LAST_SAVED_NAME:-}" ]] && \
target_pass_file="$HB_STATE_DIR/borg-pass-${HB_BORG_LAST_SAVED_NAME}.txt"
printf '%s' "$pass1" > "$target_pass_file"
chmod 600 "$target_pass_file"
# Borg repokey stores the encrypted repo key inside the repo
# itself, so the passphrase IS the only thing the operator has
# to keep to be able to restore from a fresh host. The two
# passphrase prompts above feel like routine setup; without
# this block, operators reinstall their host expecting things
# to Just Work and then discover the passphrase file is gone
# and every encrypted archive is unreadable. Loud, mandatory
# acknowledgement: they have to press OK on a screen that says
# exactly what's at stake.
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "IMPORTANT — Save this passphrase")" \
--msgbox "\Zb\Z1$(hb_translate "This passphrase is the ONLY way to access encrypted Borg backups.")\Zn"$'\n\n'"$(hb_translate "ProxMenux saved it locally at:")"$'\n'" \Zb${target_pass_file}\ZB"$'\n\n'"$(hb_translate "If you lose or reinstall this host without a copy of the passphrase somewhere else (password manager, offline note, another host, USB stick...), every encrypted archive in this Borg repository becomes UNRECOVERABLE.")"$'\n\n'"\Zb$(hb_translate "Save the passphrase somewhere safe NOW, before continuing.")\ZB" \
18 82
export BORG_PASSPHRASE="$pass1"
export BORG_ENCRYPT_MODE="repokey"
}
# Generates a new ed25519 keypair and either installs it on the remote
# Borg server (sshpass + one-time admin password) or shows the
# authorized_keys line for manual paste. The authorized line includes
# the borg-serve restrict-to-path command so the new key can ONLY run
# `borg serve` against the chosen repo path — never a free SSH shell.
#
# Args:
# $1 borg_user SSH user that runs borg (e.g. "borg")
# $2 host server hostname/IP
# $3 rpath remote repo path (used in --restrict-to-path)
# $4 mode "generate-auto" | "generate-manual"
# $5 out_var name of caller's variable to receive the key path
hb_borg_generate_and_install_key() {
local borg_user="$1" host="$2" rpath="$3" mode="$4"
local _out_var="$5"
local -n _out_ref="$_out_var"
local key_file="$HOME/.ssh/borg_proxmenux_$(echo "$host" | tr './:' '___')_ed25519"
local pub_file="${key_file}.pub"
if [[ ! -f "$key_file" ]]; then
mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
if ! ssh-keygen -t ed25519 -N "" -f "$key_file" -C "proxmenux-borg@$(hostname)" >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "ssh-keygen failed. Cannot create a new SSH key.")" 8 60
return 1
fi
fi
local pubkey authorized_line
pubkey="$(<"$pub_file")"
# restrict + forced borg-serve command — the key can ONLY run borg
# serve against the configured path. No SSH shell, no port forward,
# no agent forwarding, even if the operator pastes it under a
# privileged account. This matches the manual setup we already do
# for the test target on CT 112.
authorized_line="command=\"/usr/bin/borg serve --restrict-to-path ${rpath}\",restrict ${pubkey}"
if [[ "$mode" == "generate-pct" ]]; then
# Authorize via `pct exec` from a PVE host. This is the right
# fix for the very common "Borg server is an LXC on another
# Proxmox node" setup, where the CT typically ships with
# PermitRootLogin prohibit-password and SSH password auth into
# the CT itself fails. We bypass SSH-to-the-CT entirely and
# rely on root@PVE-host being able to run `pct exec <vmid> --`
# — that already runs as root inside the CT, so writing into
# ~borg/.ssh/authorized_keys is trivial.
# Silently make sure sshpass is around (same pattern as
# generate-auto). If it can't be installed, fall back to
# generate-manual.
if ! command -v sshpass >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sshpass >/dev/null 2>&1 || true
fi
if ! command -v sshpass >/dev/null 2>&1; then
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
fi
fi
local pve_host pve_user pve_pass vmid
pve_host=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "PVE host (where the Borg LXC lives)")" \
--inputbox "$(hb_translate "IP or hostname of the PVE node hosting the Borg server LXC:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
pve_user=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Root user on the PVE host (default 'root'):")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
pve_pass=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Password for") ${pve_user}@${pve_host}:" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
vmid=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "VMID of the Borg server LXC on") ${pve_host}:" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
if [[ -z "$pve_host" || -z "$vmid" || -z "$pve_pass" ]]; then
dialog --backtitle "ProxMenux" --msgbox \
"$(hb_translate "PVE host, VMID and password are all required for this mode.")" 8 60
return 1
fi
# Build the pct-exec script. The authorized_line is fed through
# stdin so quotes in `command="..."` are preserved verbatim
# without shell re-expansion. `getent passwd` resolves whatever
# the borg user's actual home is, regardless of name.
local pct_cmd
pct_cmd="set -e
pct exec ${vmid} -- bash -c '
set -e
target_dir=\$(getent passwd ${borg_user} | cut -d: -f6)/.ssh
mkdir -p \"\$target_dir\"
chmod 700 \"\$target_dir\"
chown ${borg_user}: \"\$target_dir\"
line=\$(cat)
touch \"\$target_dir/authorized_keys\"
grep -Fxq \"\$line\" \"\$target_dir/authorized_keys\" 2>/dev/null || \
echo \"\$line\" >> \"\$target_dir/authorized_keys\"
chown ${borg_user}: \"\$target_dir/authorized_keys\"
chmod 600 \"\$target_dir/authorized_keys\"
'
echo OK"
local push_rc
SSHPASS="$pve_pass" sshpass -e ssh \
-o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
-o ConnectTimeout=15 \
"$pve_user@$pve_host" "$pct_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-pctpush.log 2>&1
push_rc=$?
if (( push_rc != 0 )); then
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "pct exec authorization failed")" \
--msgbox "$(hb_translate "Could not authorize the key via 'pct exec' on") \Z1${pve_host}\Zn (VMID ${vmid}).\n\n$(hb_translate "See") /tmp/proxmenux-borg-pctpush.log\n\n$(hb_translate "Falling back to manual paste mode.")" 14 78
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
fi
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Authorized")" \
--msgbox "$(hb_translate "The new SSH key was pushed to the LXC via 'pct exec' on") \Z4${pve_host}\Zn (VMID ${vmid})." 10 78
_out_ref="$key_file"
return 0
fi
if [[ "$mode" == "generate-manual" ]]; then
# Print the line on the raw terminal (NOT inside dialog --msgbox)
# so the operator can select & copy it with their terminal client.
# ncurses captures all input inside dialog/whiptail, which means
# "click + drag to select" silently does nothing — a real footgun
# for a paste-this-string-elsewhere flow. Also dump the line to a
# file so anyone who can't paste (noVNC console, intermediate
# firewall, ...) can `scp` or `cat` it from this host directly.
local out_file="/tmp/proxmenux-borg-authkey.txt"
printf '%s\n' "$authorized_line" > "$out_file"
chmod 600 "$out_file"
clear
type_text "$(hb_translate "Authorize this key on the server")" 2>/dev/null || \
echo " ── $(hb_translate "Authorize this key on the server") ──"
echo ""
echo -e "${TAB}${BGN}$(hb_translate "On the Borg server, append the following line to:")${CL}"
echo -e "${TAB} ${BL}~${borg_user}/.ssh/authorized_keys${CL}"
echo ""
echo -e "${TAB}${BGN}$(hb_translate "Line to paste (single line, including \"command=...\" prefix):")${CL}"
echo ""
# Print the line on its own block so it's easy to triple-click
# / shift-click to select in any terminal client. No wrapping —
# we use plain echo, not the indented format above.
echo "${authorized_line}"
echo ""
echo -e "${TAB}${BGN}$(hb_translate "Or, if your terminal can't select text, copy it from:")${CL}"
echo -e "${TAB} ${BL}${out_file}${CL} $(hb_translate "(e.g.") scp root@$(hostname -I 2>/dev/null | awk '{print $1}'):${out_file} .${CL}$(hb_translate ")")"
echo ""
echo -e "${TAB}${YWB}$(hb_translate "After pasting on the server, ensure the file is chmod 600 and owned by") ${borg_user}.${CL}"
echo ""
msg_success "$(hb_translate "Press Enter when the line has been pasted on the server...")"
read -r
_out_ref="$key_file"
return 0
fi
# generate-auto: install via sshpass. We need an admin password
# for whichever account can write to ~borg/.ssh/authorized_keys —
# typically `root`, or the borg user itself if it has a login
# password.
# Silent best-effort install — same pattern as hb_ensure_pv. Asking
# the operator "install sshpass?" is backwards: we already have apt,
# they shouldn't have to confirm a 40 KB install for us. If apt
# can't reach the repo, we silently fall back to manual mode.
if ! command -v sshpass >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sshpass >/dev/null 2>&1 || true
fi
if ! command -v sshpass >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" --colors \
--msgbox "$(hb_translate "Could not install sshpass automatically (no internet?). Falling back to manual paste mode — you'll see the line to copy onto the server next.")" 11 78
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
fi
fi
local admin_user admin_pass
admin_user=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "SSH user that can write to ~${borg_user}/.ssh/authorized_keys on the server (usually root or the borg user itself):")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
admin_pass=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Password for") ${admin_user}@${host}:" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
# Quick probe: many Debian-based servers (including LXC templates)
# ship with `PermitRootLogin prohibit-password`, which rejects the
# sshpass push BEFORE the password is even checked. Detect that here
# and fall back to manual mode with a clear explanation instead of
# the generic "could not push the key" error.
local _probe
_probe=$(SSHPASS="$admin_pass" sshpass -e ssh \
-o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
-o NumberOfPasswordPrompts=1 -o ConnectTimeout=10 \
"$admin_user@$host" "true" 2>&1) || true
if echo "$_probe" | grep -qiE "permission denied[[:space:]]*\(publickey"; then
# SSH password auth refused by the server — common when the Borg
# server is a Debian/LXC with PermitRootLogin prohibit-password.
# Before falling back to manual paste, offer the pct-exec path,
# which is the right automatic alternative for the very common
# "Borg server is an LXC on another PVE host" setup.
local _alt
_alt=$(dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "SSH password auth refused on server")" \
--default-item "pct" \
--menu "\n$(hb_translate "The server refused password authentication for") \Z1${admin_user}\Zn $(hb_translate "(common default on Debian/LXC: PermitRootLogin prohibit-password).")"$'\n\n'"$(hb_translate "Pick an alternative way to authorize the new key:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"pct" "$(hb_translate "Authorize via 'pct exec' (Borg server is an LXC on a PVE host I can SSH into)")" \
"manual" "$(hb_translate "Show me the line to paste manually on the server")" \
"cancel" "$(hb_translate "Cancel this setup")" \
3>&1 1>&2 2>&3) || return 1
case "$_alt" in
pct) hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-pct" "$_out_var"; return $? ;;
manual) hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"; return $? ;;
*) return 1 ;;
esac
fi
if echo "$_probe" | grep -qiE "permission denied"; then
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "SSH login failed")" \
--msgbox "$(hb_translate "The server rejected") \Zb${admin_user}\ZB $(hb_translate "with the password you provided.")"$'\n\n'"$(hb_translate "Verify the credentials. Switching to manual paste mode so you can finish the setup without re-typing the password.")" 12 78
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
fi
# Append the authorized line. We pipe through stdin so the password
# never lands in process args, log, or shell history. -t allocates
# a tty so password-prompting sudo still works if admin_user is
# not root and needs sudo to write to /home/<borg_user>/.
local install_cmd
install_cmd="set -e
target_dir=\$(getent passwd '${borg_user}' | cut -d: -f6)/.ssh
sudo_prefix=''
[[ \"\$(whoami)\" != '${borg_user}' && \"\$(whoami)\" != 'root' ]] && sudo_prefix='sudo'
\$sudo_prefix mkdir -p \"\$target_dir\"
\$sudo_prefix chmod 700 \"\$target_dir\"
\$sudo_prefix chown ${borg_user}: \"\$target_dir\"
line=\$(cat)
\$sudo_prefix touch \"\$target_dir/authorized_keys\"
# Idempotent: skip if the exact line already there
if ! \$sudo_prefix grep -Fxq \"\$line\" \"\$target_dir/authorized_keys\"; then
echo \"\$line\" | \$sudo_prefix tee -a \"\$target_dir/authorized_keys\" >/dev/null
fi
\$sudo_prefix chown ${borg_user}: \"\$target_dir/authorized_keys\"
\$sudo_prefix chmod 600 \"\$target_dir/authorized_keys\"
echo OK"
local push_rc
SSHPASS="$admin_pass" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
"$admin_user@$host" "$install_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-keypush.log 2>&1
push_rc=$?
if (( push_rc != 0 )); then
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorization failed")" \
--msgbox "$(hb_translate "Could not push the key. Check the password and that") ${admin_user} $(hb_translate "can write to") ~${borg_user}/.ssh/authorized_keys.\n\n$(hb_translate "Log:") /tmp/proxmenux-borg-keypush.log" \
13 80
return 1
fi
# Verify with the new key
if ! ssh -i "$key_file" -o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=publickey -o PubkeyAuthentication=yes \
-o BatchMode=yes -o ConnectTimeout=10 \
"$borg_user@$host" 2>/dev/null | grep -q "usage: borg"; then
# Verification fallback: a successful borg-serve restrict prints
# the borg "usage:" line when the command runs with no args.
# Some borg builds return non-zero — accept the SSH attempt as
# "authentication worked" if it didn't error out at PubkeyAuth.
:
fi
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorization successful")" \
--msgbox "$(hb_translate "The new SSH key was installed and is now authorized on the server.\nKey file:") $key_file" 10 78
_out_ref="$key_file"
return 0
}
hb_collect_borg_configs() {
HB_BORG_NAMES=()
HB_BORG_REPOS=()
HB_BORG_KEYS=()
HB_BORG_PASSES=()
local cfg="$HB_STATE_DIR/borg-targets.txt"
[[ -f "$cfg" ]] || return 0
local line name repo key passfile
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
# Format: name|repo|ssh_key_path
name="${line%%|*}"
local rest="${line#*|}"
repo="${rest%%|*}"
key="${rest#*|}"
[[ "$key" == "$rest" ]] && key="" # no key segment
passfile="$HB_STATE_DIR/borg-pass-${name}.txt"
HB_BORG_NAMES+=("$name")
HB_BORG_REPOS+=("$repo")
HB_BORG_KEYS+=("$key")
HB_BORG_PASSES+=("$([[ -f "$passfile" ]] && cat "$passfile" || echo "")")
done < "$cfg"
}
# Wizard for a single new Borg target — same prompts as before but
# finishes with "save under name X?" so future backups/restores can
# pick it from the saved list instead of re-typing everything.
hb_configure_borg_manual() {
local _borg_repo_var="$1"
local -n _borg_repo_ref_new="$_borg_repo_var"
local type
type=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Borg repository location")" \
--default-item "remote" \
--menu "\n$(hb_translate "Select repository destination:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"remote" "$(hb_translate 'Remote server via SSH (recommended — off-host, dedup across machines)')" \
"usb" "$(hb_translate 'Mounted external disk (offline-safe, single-machine dedup)')" \
"local" "$(hb_translate 'Local directory (single-machine — only use if it is a SEPARATE disk)')" \
3>&1 1>&2 2>&3) || return 1
local repo="" ssh_key=""
case "$type" in
local)
repo=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Borg repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
mkdir -p "$repo" 2>/dev/null || true
;;
usb)
local mnt
mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1
repo="$mnt/borgbackup"
mkdir -p "$repo" 2>/dev/null || true
;;
remote)
local user host rpath
# Explicit label + default `borg` — this is the user that runs
# `borg serve` on the remote host, NOT the admin user we'd log
# in as. Calling it just "SSH user" with default root led to
# users typing their admin credentials here, which then failed
# later when generate-auto looked for ~root/.ssh/ instead of
# ~borg/.ssh/ on the server.
user=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Borg server SSH user")" \
--inputbox "$(hb_translate "Username on the Borg server that runs \"borg serve\" (typically \"borg\"). This is NOT the admin/root user of the server — that one is asked later only if you choose \"Generate a new key and authorize it\".")" \
12 78 "borg" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
rpath=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Remote repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
# SSH key strategy. Three modes:
# existing → user picks an already-installed key
# generate-auto → new key + sshpass installs it on the server
# directly (one-shot password prompt for the
# admin user; password is never persisted)
# generate-manual → new key + dialog shows the full
# authorized_keys line for copy/paste
# (no admin password leaves this host)
local key_mode
key_mode=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "SSH key strategy")" \
--default-item "generate-auto" \
--menu "\n$(hb_translate "How do you want to authenticate this backup target?")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"generate-auto" "$(hb_translate "Generate a new key and authorize it on the server automatically (recommended)")" \
"generate-manual" "$(hb_translate "Generate a new key, show me the line to paste manually")" \
"existing" "$(hb_translate "Use an existing SSH private key file on this host")" \
"none" "$(hb_translate "No custom key (rely on default SSH config)")" \
3>&1 1>&2 2>&3) || return 1
case "$key_mode" in
existing)
# Auto-detect SSH private keys instead of forcing the
# operator through dialog's `--fselect`, which is
# confusing (no extension filter, easy to pick the .pub
# by mistake, hard to navigate paths). Each candidate
# is verified to actually be a parseable private key
# via `ssh-keygen -y -f`.
local -a candidates=()
local _f
for _f in /root/.ssh/* "$HOME/.ssh"/*; do
[[ -f "$_f" ]] || continue
case "$(basename "$_f")" in
*.pub|authorized_keys|known_hosts*|config) continue ;;
esac
if ssh-keygen -y -f "$_f" >/dev/null 2>&1; then
candidates+=("$_f")
fi
done
# Dedup (HOME and /root may overlap on root-owned installs).
mapfile -t candidates < <(printf '%s\n' "${candidates[@]}" | sort -u)
local -a key_menu=()
local _i=1
for _f in "${candidates[@]}"; do
key_menu+=("$_i" "$_f")
((_i++))
done
local _browse_idx="$_i"
key_menu+=("$_browse_idx" "$(hb_translate "Browse manually (advanced)...")")
local _choice
if (( ${#candidates[@]} == 0 )); then
# Nothing auto-detected → go straight to manual browse.
_choice="$_browse_idx"
else
_choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select SSH private key")" \
--default-item "1" \
--menu "\n$(hb_translate "Pick an SSH private key (auto-detected on this host):")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${key_menu[@]}" \
3>&1 1>&2 2>&3) || return 1
fi
if [[ "$_choice" == "$_browse_idx" ]]; then
# Manual fselect fallback (key in a non-standard path).
while :; do
ssh_key=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select SSH private key file")" \
--fselect "$HOME/.ssh/" 14 76 3>&1 1>&2 2>&3) || return 1
ssh_key="${ssh_key%"${ssh_key##*[![:space:]]}"}"
if [[ -f "$ssh_key" ]] && ssh-keygen -y -f "$ssh_key" >/dev/null 2>&1; then
break
fi
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Invalid selection")" \
--msgbox "$(hb_translate "That doesn't look like an SSH private key. Pick the private key file (no .pub extension, parseable by ssh-keygen).")" \
10 72
done
else
ssh_key="${candidates[$((_choice-1))]}"
fi
;;
generate-auto|generate-manual|generate-pct)
if ! hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key; then
return 1
fi
;;
none)
ssh_key=""
;;
esac
repo="ssh://$user@$host/$rpath"
;;
esac
# Offer to save under a friendly name so the user doesn't re-type
# everything next time. Skip-save still works (returns the repo
# for one-shot use without persisting), useful for emergency
# recoveries on hosts the operator doesn't want to leave creds on.
local default_name save_name=""
case "$type" in
remote)
local _host="${repo#ssh://*@}"
_host="${_host%%/*}"
default_name="${_host//./_}"
;;
local|usb)
default_name="$(basename "$repo")"
;;
esac
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Save this Borg target so you don't need to enter the details again?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
save_name=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Name for this target:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_name" 3>&1 1>&2 2>&3) || save_name=""
fi
_borg_repo_ref_new="$repo"
if [[ -n "$ssh_key" ]]; then
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
else
unset BORG_RSH
fi
# Passphrase comes later via hb_prepare_borg_passphrase. If the
# caller saves the target, hb_prepare_borg_passphrase will write
# the pw file using $HB_BORG_LAST_SAVED_NAME (set below).
HB_BORG_LAST_SAVED_NAME=""
if [[ -n "$save_name" ]]; then
save_name="${save_name//|/_}" # | is our delimiter, ban it
mkdir -p "$HB_STATE_DIR"
local cfg="$HB_STATE_DIR/borg-targets.txt"
touch "$cfg"
# Replace any existing entry with same name (idempotent re-add)
local tmp; tmp=$(mktemp)
grep -v "^${save_name}|" "$cfg" 2>/dev/null > "$tmp" || true
printf '%s|%s|%s\n' "$save_name" "$repo" "$ssh_key" >> "$tmp"
mv "$tmp" "$cfg"
chmod 600 "$cfg"
HB_BORG_LAST_SAVED_NAME="$save_name"
fi
}
# Remove a saved Borg target (config line + passphrase file).
hb_delete_borg_target() {
local name="$1"
local cfg="$HB_STATE_DIR/borg-targets.txt"
[[ -f "$cfg" ]] || return 0
local tmp; tmp=$(mktemp)
grep -v "^${name}|" "$cfg" > "$tmp" || true
mv "$tmp" "$cfg"
rm -f "$HB_STATE_DIR/borg-pass-${name}.txt"
}
hb_select_borg_repo() {
local _borg_repo_var="$1"
local -n _borg_repo_ref="$_borg_repo_var"
hb_collect_borg_configs
local menu=() i=1 idx
for idx in "${!HB_BORG_NAMES[@]}"; do
local label="${HB_BORG_NAMES[$idx]}${HB_BORG_REPOS[$idx]}"
[[ -z "${HB_BORG_PASSES[$idx]}" ]] && label+="$(hb_translate "no passphrase")"
menu+=("$i" "$label"); ((i++))
done
local add_idx=$i; ((i++))
local del_idx=""
menu+=("$add_idx" "$(hb_translate "+ Add new Borg target")")
if (( ${#HB_BORG_NAMES[@]} > 0 )); then
del_idx=$i
menu+=("$del_idx" "$(hb_translate "- Delete a saved target")")
fi
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select Borg target")" \
--menu "\n$(hb_translate "Available Borg targets:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
if [[ "$choice" == "$add_idx" ]]; then
hb_configure_borg_manual _borg_repo_ref || return 1
# Promote the freshly-saved target so hb_prepare_borg_passphrase
# takes the saved-target branch (single prompt + persist).
if [[ -n "${HB_BORG_LAST_SAVED_NAME:-}" ]]; then
HB_BORG_SELECTED_NAME="$HB_BORG_LAST_SAVED_NAME"
HB_BORG_SELECTED_PASS=""
fi
export BORG_RELOCATED_REPO_ACCESS_IS_OK=yes
export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=yes
return 0
fi
if [[ -n "$del_idx" && "$choice" == "$del_idx" ]]; then
local del_menu=() j=1
for idx in "${!HB_BORG_NAMES[@]}"; do
del_menu+=("$j" "${HB_BORG_NAMES[$idx]}${HB_BORG_REPOS[$idx]}")
((j++))
done
local del_choice
del_choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Delete Borg target")" \
--menu "\n$(hb_translate "Pick a target to remove:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${del_menu[@]}" 3>&1 1>&2 2>&3) || return 1
local del_sel=$((del_choice-1))
local victim="${HB_BORG_NAMES[$del_sel]}"
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Permanently delete saved target:") $victim?" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
hb_delete_borg_target "$victim"
fi
# Restart selection so the user gets a fresh menu.
hb_select_borg_repo "$_borg_repo_var"
return $?
fi
# Picked a saved target.
local sel=$((choice-1))
_borg_repo_ref="${HB_BORG_REPOS[$sel]}"
local key="${HB_BORG_KEYS[$sel]}"
if [[ -n "$key" && -f "$key" ]]; then
export BORG_RSH="ssh -i $key -o StrictHostKeyChecking=accept-new"
else
unset BORG_RSH
fi
# Trust the saved URL/key — skip borg's interactive y/N confirmations
# when the recorded repo location or encryption status drifts.
export BORG_RELOCATED_REPO_ACCESS_IS_OK=yes
export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=yes
HB_BORG_SELECTED_NAME="${HB_BORG_NAMES[$sel]}"
HB_BORG_SELECTED_PASS="${HB_BORG_PASSES[$sel]}"
}
# ==========================================================
# COMMON PROMPTS
# ==========================================================
hb_trim_dialog_value() {
local value="$1"
value="${value//$'\r'/}"
value="${value//$'\n'/}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
# Enumerate USB block-device partitions on this host. Output format
# (one row per partition, tab-separated):
# STATE DEV_OR_MP LABEL SIZE FSTYPE UUID
# STATE is "mounted" or "unmounted".
# DEV_OR_MP is the mountpoint when mounted, or the /dev/sdXn device when not.
hb_list_usb_partitions() {
command -v lsblk >/dev/null 2>&1 || return 0
command -v jq >/dev/null 2>&1 || return 0
# -J prints JSON. -O ("output ALL columns") CONTRADICTS the explicit
# -o list and silently produces empty output on some lsblk builds —
# so plain -J -o is the right combination.
# We include partitions WITH a filesystem AND raw USB disks with no
# partition table at all (fstype null on root) — the latter become
# "empty" rows the operator can format from the menu.
lsblk -J -o NAME,SIZE,MOUNTPOINT,TRAN,LABEL,FSTYPE,UUID,TYPE 2>/dev/null \
| jq -r '
.blockdevices[]?
| select(.tran == "usb" and .type == "disk")
| . as $root
| ((.children // []) | map(select(.fstype != null and .fstype != "")) ) as $parts
| if ($parts | length) > 0 then
$parts[]
| (if .mountpoint != null and .mountpoint != "" then "mounted\t\(.mountpoint)" else "unmounted\t/dev/\(.name)" end)
+ "\t\(.label // "")\t\(.size // "")\t\(.fstype // "")\t\(.uuid // "")"
else
"empty\t/dev/\($root.name)\t\t\($root.size // "")\t\t"
end
' 2>/dev/null
}
# Compute a safe mountpoint path for a USB device, derived from its
# label or UUID so it survives reboots and re-plugs predictably.
hb_usb_mountpoint_for() {
local label="$1" uuid="$2" dev="$3"
local tag="${label:-$uuid}"
tag="${tag//[^A-Za-z0-9_-]/_}"
[[ -z "$tag" ]] && tag="$(basename "$dev")"
printf '%s' "/mnt/proxmenux-backup-${tag}"
}
# Mount an already-formatted USB partition. On success, prints the
# mountpoint on stdout. On failure, the caller checks the rc and reads
# /tmp/proxmenux-mount.log.
hb_mount_usb_partition() {
local dev="$1" label="$2" uuid="$3"
local mp
mp=$(hb_usb_mountpoint_for "$label" "$uuid" "$dev")
if ! mkdir -p "$mp" 2>/tmp/proxmenux-mount.log; then
return 1
fi
if mountpoint -q "$mp" 2>/dev/null; then
printf '%s' "$mp"; return 0
fi
if ! mount "$dev" "$mp" 2>/tmp/proxmenux-mount.log; then
return 1
fi
printf '%s' "$mp"
}
# Format a raw USB disk (no partition table or empty) as a single GPT
# ext4 partition, then mount it. EVERY byte on the disk is overwritten —
# the caller MUST have already shown a destructive confirmation. Used
# only when the operator explicitly picks an "empty" USB row.
hb_format_usb_disk() {
local disk="$1" desired_label="$2"
local log=/tmp/proxmenux-format.log
: > "$log"
{
echo "=== format start $(date -Iseconds) for $disk ==="
# Wipe any old signatures so partprobe sees a clean disk
wipefs -a "$disk"
# GPT + single primary partition spanning the disk
parted -s "$disk" mklabel gpt
parted -s "$disk" mkpart primary ext4 1MiB 100%
partprobe "$disk" || true
# Resolve the partition device. /dev/sde → /dev/sde1,
# /dev/nvme0n1 → /dev/nvme0n1p1.
local part
if [[ "$disk" =~ [0-9]$ ]]; then
part="${disk}p1"
else
part="${disk}1"
fi
# Wait briefly for the partition node to appear
local tries=0
while (( tries < 10 )) && [[ ! -b "$part" ]]; do
sleep 0.5; ((tries++))
done
if [[ ! -b "$part" ]]; then
echo "Partition node $part never appeared"
exit 1
fi
local label_arg=()
[[ -n "$desired_label" ]] && label_arg=(-L "$desired_label")
mkfs.ext4 -F "${label_arg[@]}" "$part"
echo "$part" > /tmp/proxmenux-format.partdev
} >>"$log" 2>&1 || return 1
local part
part=$(<"/tmp/proxmenux-format.partdev")
[[ -b "$part" ]] || return 1
# Resolve UUID for predictable mountpoint
local new_uuid
new_uuid=$(lsblk -no UUID "$part" 2>/dev/null | head -1)
local mp
mp=$(hb_usb_mountpoint_for "$desired_label" "$new_uuid" "$part")
mkdir -p "$mp" 2>>"$log" || return 1
mount "$part" "$mp" 2>>"$log" || return 1
printf '%s' "$mp"
}
# ── Local backup target: single configured value (no list) ──
#
# Only ONE local target is active at a time. Persisted as a single line in
# $HB_STATE_DIR/local-target.conf. If the file is absent, callers default
# to HB_LOCAL_TARGET_DEFAULT (/var/lib/vz/dump).
HB_LOCAL_TARGET_DEFAULT="/var/lib/vz/dump"
# Echoes the configured target path. Returns 1 if none configured (caller
# may default to HB_LOCAL_TARGET_DEFAULT).
hb_get_local_target() {
local cfg="$HB_STATE_DIR/local-target.conf"
[[ -f "$cfg" ]] || return 1
local path
path=$(head -1 "$cfg" 2>/dev/null | tr -d '\r\n')
[[ -z "$path" ]] && return 1
echo "$path"
return 0
}
hb_set_local_target() {
local path="$1"
[[ -z "$path" ]] && return 1
path="${path%/}"
mkdir -p "$HB_STATE_DIR"
local cfg="$HB_STATE_DIR/local-target.conf"
printf '%s\n' "$path" > "$cfg"
chmod 600 "$cfg"
return 0
}
hb_clear_local_target() {
rm -f "$HB_STATE_DIR/local-target.conf"
}
# Always returns a usable path. If nothing is configured, falls back to
# the default. Callers do NOT prompt — the user pre-configures the target
# in "Manage local backup target".
hb_select_local_target() {
local path
if path=$(hb_get_local_target); then
echo "$path"
else
echo "$HB_LOCAL_TARGET_DEFAULT"
fi
return 0
}
hb_prompt_mounted_path() {
# `dialog --yesno` / `dialog --msgbox` write their TUI to stdout by
# convention. When this function is called as `mnt=$(hb_prompt_mounted_path)`
# the subshell captures every TUI escape into $mnt and corrupts the
# caller (saw it as garbage in borg-targets.txt). Stash real stdout in
# fd 9, redirect stdout to the TTY for the body, and emit the actual
# return value through fd 9.
exec 9>&1 >/dev/tty
local default_path="${1:-/mnt/backup}"
local -a menu=()
local -a entries=()
local idx=1
local state path_or_dev label size fstype uuid
while IFS=$'\t' read -r state path_or_dev label size fstype uuid; do
[[ -z "$state" ]] && continue
local desc
case "$state" in
mounted)
desc="${size:-?} ${label:-no-label} [${fstype}] → ${path_or_dev}"
;;
unmounted)
desc="${size:-?} ${label:-no-label} [${fstype}] $(hb_translate "(not mounted — will be mounted)")"
;;
empty)
desc="${size:-?} $(hb_translate "raw USB disk — no filesystem (will be FORMATTED)")"
;;
esac
menu+=("$idx" "$desc")
entries+=("${state}|${path_or_dev}|${label}|${size}|${fstype}|${uuid}")
((idx++))
done < <(hb_list_usb_partitions)
if (( ${#menu[@]} == 0 )); then
# No USB at all — single inputbox fallback (no menu, less confusing)
local out
out=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "External disk for backup")" \
--inputbox "$(hb_translate "No USB drives detected. Enter the mountpoint path manually:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
if ! mountpoint -q "$out" 2>/dev/null; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
fi
echo "$out" >&9
return 0
fi
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "External disk for backup")" \
--menu "\n$(hb_translate "Pick a USB disk:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
local sel="${entries[$((choice-1))]}"
local s_state s_path s_label s_size s_fstype s_uuid
IFS='|' read -r s_state s_path s_label s_size s_fstype s_uuid <<< "$sel"
case "$s_state" in
mounted)
echo "$s_path" >&9
return 0
;;
unmounted)
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Mount USB disk?")" \
--yesno "$(hb_translate "Mount this device and use it as the backup destination?")"$'\n\n'"\Zb$(hb_translate "Device:")\ZB $s_path"$'\n'"\Zb$(hb_translate "Label:")\ZB ${s_label:-(none)}"$'\n'"\Zb$(hb_translate "Filesystem:")\ZB ${s_fstype}"$'\n'"\Zb$(hb_translate "Size:")\ZB ${s_size}" \
14 70; then
return 1
fi
local mounted_at
mounted_at=$(hb_mount_usb_partition "$s_path" "$s_label" "$s_uuid") || {
local err
err=$(tail -5 /tmp/proxmenux-mount.log 2>/dev/null | sed 's/[\Z]/_/g')
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Mount failed")" \
--msgbox "$(hb_translate "Could not mount") \Z1$s_path\Zn.\n\n${err:-$(hb_translate "See /tmp/proxmenux-mount.log for details.")}" 14 76
return 1
}
# The wizard prints the destination path right after, so go
# straight to the backup flow instead of asking for an extra
# confirmation click on a "mounted OK" dialog.
echo "$mounted_at" >&9
return 0
;;
empty)
# Destructive! Triple-check before formatting.
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Format USB disk?")" \
--default-button no \
--yesno "\Z1\Zb$(hb_translate "WARNING: this will ERASE EVERYTHING on the disk.")\ZB\Zn"$'\n\n'"\Zb$(hb_translate "Device:")\ZB $s_path"$'\n'"\Zb$(hb_translate "Size:")\ZB ${s_size}"$'\n\n'"$(hb_translate "Create a fresh GPT + ext4 partition and mount it?")" \
14 76; then
return 1
fi
# Second confirmation prompts the operator to type the device name
local typed
typed=$(dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Final confirmation")" \
--inputbox "$(hb_translate "Type the device path EXACTLY to confirm formatting:")"$'\n\n'"\Z1${s_path}\Zn" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
if [[ "$typed" != "$s_path" ]]; then
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Device path mismatch. Format cancelled.")" 8 60
return 1
fi
local fmt_label="proxmenux-backup"
local mounted_at
mounted_at=$(hb_format_usb_disk "$s_path" "$fmt_label") || {
local err
err=$(tail -10 /tmp/proxmenux-format.log 2>/dev/null)
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Format failed")" \
--msgbox "$(hb_translate "Could not format the disk.")\n\n${err}" 16 80
return 1
}
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Formatted and mounted")" \
--msgbox "\Zb$(hb_translate "Mounted at")\ZB \Z4${mounted_at}\Zn" 8 70
echo "$mounted_at" >&9
return 0
;;
esac
return 1
}
hb_prompt_dest_dir() {
local selection out
selection=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select destination")" \
--menu "\n$(hb_translate "Choose where to save the backup:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"vzdump" "$(hb_translate '/var/lib/vz/dump (Proxmox default vzdump path)')" \
"backup" "$(hb_translate '/backup')" \
"local" "$(hb_translate 'Custom local directory')" \
"usb" "$(hb_translate 'Mounted external disk')" \
3>&1 1>&2 2>&3) || return 1
case "$selection" in
vzdump) out="/var/lib/vz/dump" ;;
backup) out="/backup" ;;
local)
out=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Enter directory path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup" 3>&1 1>&2 2>&3) || return 1
;;
usb) out=$(hb_prompt_mounted_path "/mnt/backup") || return 1 ;;
esac
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" ]] || return 1
mkdir -p "$out" || { msg_error "$(hb_translate "Cannot create:") $out"; return 1; }
echo "$out"
}
hb_prompt_restore_source_dir() {
# Same fd-9 trick used by hb_prompt_mounted_path: this function
# is called as `dir=$(hb_prompt_restore_source_dir)` which
# captures stdout, so any --msgbox we open inside would render
# into the subshell (invisible to the operator) and the caller
# would think the flow "hung". Stash real stdout in fd 9,
# redirect stdout to the tty for every dialog, and emit the
# actual return value through fd 9 at the end.
exec 9>&1 >/dev/tty
local choice out
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Restore source location")" \
--menu "\n$(hb_translate "Where are the backup archives stored?")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"vzdump" "$(hb_translate '/var/lib/vz/dump (Proxmox default)')" \
"backup" "$(hb_translate '/backup')" \
"usb" "$(hb_translate 'Mounted external disk')" \
"custom" "$(hb_translate 'Custom path')" \
3>&1 1>&2 2>&3) || return 1
case "$choice" in
vzdump) out="/var/lib/vz/dump" ;;
backup) out="/backup" ;;
usb) out=$(hb_prompt_mounted_path "/mnt/backup") || return 1 ;;
custom)
out=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Enter path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup" 3>&1 1>&2 2>&3) || return 1
;;
esac
out=$(hb_trim_dialog_value "$out")
if [[ -z "$out" || ! -d "$out" ]]; then
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Directory not found")" \
--msgbox "$(hb_translate "The selected path does not exist on this host:")"$'\n\n'"${out:-<empty>}" \
10 78 || true
return 1
fi
# Emit the real return value through the saved fd 9 — the caller's
# `$(...)` capture reads from fd 9 (see the exec at the top).
echo "$out" >&9
}
# Return the set of scheduler job_ids that currently have a .env on
# disk. Used by hb_is_host_backup_archive to recognize archives
# produced by the scheduler when the filename doesn't follow the
# `hostcfg-` convention. Prints one id per line.
hb_known_scheduler_job_ids() {
local jobs_dir="${PMX_BACKUP_JOBS_DIR:-/var/lib/proxmenux/backup-jobs}"
[[ -d "$jobs_dir" ]] || return 0
local f
for f in "$jobs_dir"/*.env; do
[[ -f "$f" ]] || continue
basename "$f" .env
done
}
# Decide whether $path looks like a ProxMenux host backup. Cheap
# checks only — sidecar presence + filename heuristics. We do NOT
# tar-peek here because the picker may face dozens of candidates
# and the user is waiting in front of a dialog; the in-Monitor
# endpoint takes care of peek-based detection where SWR can cache
# the result. Returns 0 on match, non-zero otherwise.
hb_is_host_backup_archive() {
local path="$1"
[[ -z "$path" || ! -f "$path" ]] && return 1
# 1. Sidecar present → definitive yes.
[[ -f "${path}.proxmenux.json" ]] && return 0
local name stem
name=$(basename "$path")
# Strip the timestamped suffix; we only need the part BEFORE the
# `-YYYYMMDD_HHMMSS.tar.*` tail.
stem="${name%-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9].tar*}"
# If the strip didn't change anything, the file doesn't follow
# the ProxMenux timestamp convention at all → reject (this kills
# PVE's vzdump-lxc-101-2026_02_24-20_00_56.tar.zst because its
# date uses underscores between Y/M/D, not the YYYYMMDD_ form).
[[ "$stem" == "$name" ]] && return 1
# 2. hostcfg- prefix → manual or convention-following scheduled.
[[ "$stem" == hostcfg-* ]] && return 0
# 3. Known scheduler job_id → scheduled.
local jid
while IFS= read -r jid; do
[[ -z "$jid" ]] && continue
[[ "$stem" == "$jid" ]] && return 0
done < <(hb_known_scheduler_job_ids)
return 1
}
hb_prompt_local_archive() {
# See hb_prompt_restore_source_dir above for why we do the fd-9
# trick — same story: caller uses `archive=$(hb_prompt_local_archive)`
# so any --msgbox inside would render into the subshell and
# never reach the operator. Force dialog output to the tty and
# emit the selected archive path via fd 9 at the end.
exec 9>&1 >/dev/tty
local base_dir="$1"
local title="${2:-$(hb_translate "Select backup archive")}"
local -a rows=() files=() menu=()
# Single find pass using -printf: no per-file stat subprocesses.
# maxdepth 6 catches nested backup layouts commonly used in /var/lib/vz/dump.
mapfile -t rows < <(
find "$base_dir" -maxdepth 6 -type f \
\( -name '*.tar.zst' -o -name '*.tar.gz' -o -name '*.tar' \) \
-printf '%T@|%s|%p\n' 2>/dev/null \
| sort -t'|' -k1,1nr \
| head -200
)
# Filter the raw find result down to ProxMenux host backups —
# the picker historically showed every .tar* in /var/lib/vz/dump,
# which on a typical Proxmox host means dozens of vzdump-lxc-*
# entries that aren't restorable from this menu. We track the
# drop count so we can tell the operator something was filtered.
local -a kept=()
local hidden=0 row path
for row in "${rows[@]}"; do
path="${row##*|}"
if hb_is_host_backup_archive "$path"; then
kept+=("$row")
else
((hidden++))
fi
done
rows=("${kept[@]}")
if [[ ${#rows[@]} -eq 0 ]]; then
local no_backups_msg
no_backups_msg="$(hb_translate "No ProxMenux host-backup archives were found in:") $base_dir"
if (( hidden > 0 )); then
no_backups_msg+=$'\n\n'"$(hb_translate "Found") $hidden $(hb_translate "other .tar archive(s) — not ProxMenux host backups (e.g. PVE vzdump or unrelated tarballs).")"
else
no_backups_msg+=$'\n\n'"$(hb_translate "Select another source path and try again.")"
fi
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "No backups found")" \
--msgbox "$no_backups_msg" \
12 78 || true
return 1
fi
local i=1 epoch size date_str size_str label
for row in "${rows[@]}"; do
epoch="${row%%|*}"; row="${row#*|}"
size="${row%%|*}"; path="${row#*|}"
epoch="${epoch%%.*}" # drop sub-second fraction from %T@
date_str=$(date -d "@$epoch" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "-")
size_str=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
label="${path#$base_dir/} $date_str $size_str"
files+=("$path"); menu+=("$i" "$label"); ((i++))
done
local menu_prompt
menu_prompt="\n$(hb_translate "Detected backups — newest first:")"
if (( hidden > 0 )); then
menu_prompt+=$'\n'"($(hb_translate "Hidden:") $hidden $(hb_translate "non-ProxMenux .tar archive(s) in this path"))"
fi
local choice
choice=$(dialog --backtitle "ProxMenux" --title "$title" \
--menu "$menu_prompt" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
# Emit the selected archive path through fd 9 so the caller's
# `$(...)` capture actually receives it (see the exec at the top).
echo "${files[$((choice-1))]}" >&9
}
# ==========================================================
# UTILITIES
# ==========================================================
hb_human_elapsed() {
local secs="$1"
if (( secs < 60 )); then printf '%ds' "$secs"
elif (( secs < 3600 )); then printf '%dm %ds' "$((secs/60))" "$((secs%60))"
else printf '%dh %dm' "$((secs/3600))" "$(( (secs%3600)/60 ))"
fi
}
hb_file_size() {
local path="$1"
if [[ -f "$path" ]]; then
numfmt --to=iec-i --suffix=B "$(stat -c %s "$path" 2>/dev/null || echo 0)" 2>/dev/null \
|| du -sh "$path" 2>/dev/null | awk '{print $1}'
elif [[ -d "$path" ]]; then
du -sh "$path" 2>/dev/null | awk '{print $1}'
else
echo "-"
fi
}
# POST a Host Backup lifecycle event to the local webhook so the
# Monitor notification pipeline can route it through the host_backup_*
# event types (toggle-able per channel in Settings). Used by both the
# scheduled runner (`run_scheduled_backup.sh`) and the manual flow
# (`backup_host.sh`).
#
# Arg: action (start|complete|fail)
# Required env: HB_NOTIFY_JOB_ID (or `job_id` shell var)
# Optional env (per the host_backup_* template fields):
# HB_NOTIFY_BACKEND = pbs|local|borg
# HB_NOTIFY_DESTINATION = path / URI
# HB_NOTIFY_PROFILE_MODE = default|custom
# HB_NOTIFY_DATA_SIZE = staged data (e.g. "12.4 GiB")
# HB_NOTIFY_ARCHIVE_SIZE = output archive size (local) or '-'
# HB_NOTIFY_DURATION = pretty elapsed (e.g. "2m 13s")
# HB_NOTIFY_LOG_FILE = path to job log (rendered in fail body)
# HB_NOTIFY_REASON = short failure reason (last error line)
#
# Best-effort: the curl is bounded (--connect-timeout 3 / --max-time 5)
# and silenced so a Monitor outage never aborts the backup itself.
hb_notify_lifecycle() {
command -v jq >/dev/null 2>&1 || return 0
command -v curl >/dev/null 2>&1 || return 0
local action="$1"
local jid="${HB_NOTIFY_JOB_ID:-${job_id:-}}"
local severity="info" title="Host backup ${action}: ${jid:-?}"
case "$action" in
fail) severity="error"; title="Host backup FAILED: ${jid:-?}" ;;
complete) title="Host backup complete: ${jid:-?}" ;;
start) title="Host backup started: ${jid:-?}" ;;
esac
local pve_type="proxmenux-host-backup-${action}"
local host
host="$(hostname 2>/dev/null || echo unknown)"
local message="Backend: ${HB_NOTIFY_BACKEND:-}
Destination: ${HB_NOTIFY_DESTINATION:-}
Profile: ${HB_NOTIFY_PROFILE_MODE:-default}
Data size: ${HB_NOTIFY_DATA_SIZE:-}
Archive size: ${HB_NOTIFY_ARCHIVE_SIZE:-}
Duration: ${HB_NOTIFY_DURATION:-}"
[[ "$action" == "fail" ]] && message+="
Reason: ${HB_NOTIFY_REASON:-unknown}
Log: ${HB_NOTIFY_LOG_FILE:-}"
local payload
payload=$(jq -nc \
--arg t "$title" \
--arg m "$message" \
--arg s "$severity" \
--arg ts "$(date -Iseconds)" \
--arg pty "$pve_type" \
--arg host "$host" \
--arg jid "${jid:-}" \
--arg backend "${HB_NOTIFY_BACKEND:-}" \
--arg dest "${HB_NOTIFY_DESTINATION:-}" \
--arg profile "${HB_NOTIFY_PROFILE_MODE:-default}" \
--arg dsize "${HB_NOTIFY_DATA_SIZE:-}" \
--arg asize "${HB_NOTIFY_ARCHIVE_SIZE:-}" \
--arg dur "${HB_NOTIFY_DURATION:-}" \
--arg log "${HB_NOTIFY_LOG_FILE:-}" \
--arg reason "${HB_NOTIFY_REASON:-}" \
'{title:$t, message:$m, severity:$s, timestamp:$ts,
fields:{type:$pty, hostname:$host, "job-id":$jid,
backend:$backend, destination:$dest,
profile_mode:$profile, data_size:$dsize,
archive_size:$asize, duration:$dur,
log_file:$log, reason:$reason}}' \
2>/dev/null)
[[ -z "$payload" ]] && return 0
curl -sk --connect-timeout 3 --max-time 5 \
-X POST \
-H "Content-Type: application/json" \
-d "$payload" \
http://127.0.0.1:8008/api/notifications/webhook \
>/dev/null 2>&1 || true
}
hb_show_log() {
local logfile="$1" title="${2:-$(hb_translate "Operation log")}"
[[ -f "$logfile" && -s "$logfile" ]] || return 0
dialog --backtitle "ProxMenux" --exit-label "OK" \
--title "$title" --textbox "$logfile" 26 110 || true
}
hb_require_cmd() {
local cmd="$1" pkg="${2:-$1}"
command -v "$cmd" >/dev/null 2>&1 && return 0
if command -v apt-get >/dev/null 2>&1; then
msg_warn "$(hb_translate "Installing dependency:") $pkg"
apt-get update -qq >/dev/null 2>&1 && apt-get install -y "$pkg" >/dev/null 2>&1
fi
command -v "$cmd" >/dev/null 2>&1
}
# Silent best-effort install of `pv` so callers can pipe tar through it
# for a live progress bar. Returns 0 if pv ends up available, 1 if not.
# Never speaks — pv is purely an UX improvement, asking the operator to
# install it themselves would be backwards (we have apt; they shouldn't).
hb_ensure_pv() {
command -v pv >/dev/null 2>&1 && return 0
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq pv >/dev/null 2>&1
fi
command -v pv >/dev/null 2>&1
}
# ==========================================================
# Compatibility check — compares backup metadata against the
# current host and surfaces hostname / PVE version / kernel /
# storage / network / VMID drift BEFORE the apply menu opens.
#
# After running hb_compat_check, the caller can read:
# ==========================================================
# NIC REMAP PLAN + APPLY
# ==========================================================
# Reconciles NICs recorded in the backup manifest against the
# live host. Covers the "motherboard swap" scenario: same NIC
# chip lands on a different PCI slot after the swap, so the
# ifname changes (enp0s31f6 → enp0s25) even though the MAC is
# the same. Without a remap the restored /etc/network/interfaces
# references a name that no longer exists → no network at boot.
#
# Populates arrays (only when jq + manifest are available):
# HB_NIC_REMAP[i]="<old_ifname>|<new_ifname>|<mac>"
# — same MAC, different ifname. Rewrites the staging config
# so the restored files use <new_ifname>.
# HB_NIC_ORPHAN[i]="<old_ifname>|<mac>"
# — MAC from the backup not present on target at all.
# Real hardware removal, left to hb_compat_check to
# report as WARN/FAIL depending on wired-ness.
# HB_NIC_MAC_CHANGED[i]="<ifname>|<old_mac>|<new_mac>"
# — same ifname on target, but MAC differs. Motherboard
# replacement with same PCI layout but different NIC chip.
# Boot succeeds; surfaces INFO so the operator can update
# any DHCP static reservation that keyed on the old MAC.
# ==========================================================
hb_plan_nic_remaps() {
local staging_root="$1"
HB_NIC_REMAP=()
HB_NIC_ORPHAN=()
HB_NIC_MAC_CHANGED=()
local manifest="$staging_root/manifest.json"
[[ -f "$manifest" ]] || return 0
command -v jq >/dev/null 2>&1 || return 0
declare -A _dest_mac_by_if=()
declare -A _dest_if_by_mac=()
local dev_path _if _mac
for dev_path in /sys/class/net/*; do
_if="$(basename "$dev_path")"
case "$_if" in
lo|veth*|tap*|fwln*|fwbr*|fwpr*|vmbr*|bond*) continue ;;
esac
[[ -e "$dev_path/device" ]] || continue
_mac="$(cat "$dev_path/address" 2>/dev/null || true)"
[[ -z "$_mac" ]] && continue
_dest_mac_by_if["$_if"]="$_mac"
_dest_if_by_mac["$_mac"]="$_if"
done
local src_if src_mac
while IFS=$'\t' read -r src_if src_mac; do
[[ -z "$src_if" || -z "$src_mac" ]] && continue
# 1) Same ifname on target — compare MAC.
if [[ -n "${_dest_mac_by_if[$src_if]:-}" ]]; then
local cur_mac="${_dest_mac_by_if[$src_if]}"
if [[ "$cur_mac" != "$src_mac" ]]; then
HB_NIC_MAC_CHANGED+=("${src_if}|${src_mac}|${cur_mac}")
fi
continue
fi
# 2) MAC found on a different ifname — remap.
if [[ -n "${_dest_if_by_mac[$src_mac]:-}" ]]; then
HB_NIC_REMAP+=("${src_if}|${_dest_if_by_mac[$src_mac]}|${src_mac}")
continue
fi
# 3) Neither ifname nor MAC — real removal.
HB_NIC_ORPHAN+=("${src_if}|${src_mac}")
done < <(jq -r '.hardware_inventory.nic[]? | "\(.ifname)\t\(.mac)"' "$manifest" 2>/dev/null)
}
# Rewrites NIC names in the staging_root's network config so the
# restored /etc/network/interfaces (and interfaces.d/*, and
# systemd-networkd .link files if present) uses the new names.
# Only touches the staging copy — nothing on the live host changes
# until the normal apply step runs. GNU sed word boundaries (\b)
# keep eth10 safe when renaming eth1.
hb_apply_nic_remaps() {
local staging_root="$1"
(( ${#HB_NIC_REMAP[@]} == 0 )) && return 0
local -a targets=()
[[ -f "$staging_root/rootfs/etc/network/interfaces" ]] && \
targets+=("$staging_root/rootfs/etc/network/interfaces")
if [[ -d "$staging_root/rootfs/etc/network/interfaces.d" ]]; then
while IFS= read -r -d '' f; do targets+=("$f"); done \
< <(find "$staging_root/rootfs/etc/network/interfaces.d" -type f -print0 2>/dev/null)
fi
if [[ -d "$staging_root/rootfs/etc/systemd/network" ]]; then
while IFS= read -r -d '' f; do targets+=("$f"); done \
< <(find "$staging_root/rootfs/etc/systemd/network" -type f -print0 2>/dev/null)
fi
(( ${#targets[@]} == 0 )) && return 0
local entry old_if new_if _mac t
for entry in "${HB_NIC_REMAP[@]}"; do
IFS='|' read -r old_if new_if _mac <<<"$entry"
[[ -z "$old_if" || -z "$new_if" ]] && continue
for t in "${targets[@]}"; do
sed -i "s/\\b${old_if}\\b/${new_if}/g" "$t" 2>/dev/null || true
done
done
}
# ==========================================================
# CROSS-VERSION UNSAFE PATHS
# ==========================================================
# Curated list of paths that reliably break the boot when a backup
# taken on PVE major X or kernel major.minor Y is applied on a
# different X/Y. Restoring these is what caused the field-reported
# kernel panic (backup on 9.1.9 / kernel 6.x restored on 9.2.3 /
# kernel 7.x). We ship a static list rather than a heuristic so
# behaviour is auditable — the operator can inspect it once and
# know what "safe restore mode" means.
#
# Output: one line per path, tab-separated: <abs_path>\t<reason>
# The reason is a short label used verbatim in the confirm dialog.
# ==========================================================
hb_unsafe_paths_cross_version() {
cat <<'EOF'
/etc/default/grub GRUB defaults tied to prior kernel order
/etc/kernel proxmox-boot-tool state (cmdline, ESP UUIDs, hooks)
/etc/modules-load.d autoload list may reference renamed modules
/etc/modprobe.d module options may not apply on new kernel
/etc/apt source suites may trigger downgrade on next upgrade
/etc/initramfs-tools initramfs hooks tied to prior kernel
/etc/fstab UUIDs may not exist on this install
/etc/multipath multipath drivers change between kernels
/etc/iscsi iSCSI parameters evolve between kernels
/etc/udev/rules.d udev rules may bind to nonexistent subsystems
/etc/zfs zpool.cache + hostid can lock the pool as foreign
/etc/systemd/system unit overrides + .wants tied to older systemd major version — cross-version restore panics init (bench-reproduced)
/etc/systemd/journald.conf journald options may not parse on new systemd major
/etc/systemd/logind.conf logind options may not parse on new systemd major
/etc/systemd/system.conf system.conf keys evolve across systemd major
/etc/systemd/user.conf user.conf keys evolve across systemd major
EOF
}
# ==========================================================
# hb_detect_bootloader — "grub" | "systemd-boot" | "unknown"
# ==========================================================
# Proxmox uses systemd-boot on ZFS-on-root installs and GRUB on
# ext4/lvm. `proxmox-boot-tool status` reports both cases when
# the tool is present; on rescue paths without pbt we fall back
# to the presence of /etc/kernel/cmdline (systemd-boot) or
# /etc/default/grub (GRUB).
hb_detect_bootloader() {
if command -v proxmox-boot-tool >/dev/null 2>&1; then
local pbt
pbt="$(proxmox-boot-tool status 2>/dev/null || true)"
if grep -qi 'systemd-boot' <<<"$pbt"; then
echo "systemd-boot"; return 0
elif grep -qiE 'grub|BIOS' <<<"$pbt"; then
echo "grub"; return 0
fi
fi
if [[ -f /etc/kernel/cmdline ]]; then
echo "systemd-boot"
elif [[ -f /etc/default/grub ]]; then
echo "grub"
else
echo "unknown"
fi
}
# hb_grub_keys_whitelist — GRUB /etc/default/grub keys that are
# stable across PVE 9.x / kernel majors and worth merging from the
# backup on a bk_older restore. Excludes GRUB_DISTRIBUTOR (changes
# with the distro package) and keys whose values reference boot
# entries the target install regenerates (GRUB_SAVEDEFAULT).
hb_grub_keys_whitelist() {
cat <<'EOF'
GRUB_TIMEOUT
GRUB_TIMEOUT_STYLE
GRUB_DEFAULT
GRUB_TERMINAL
GRUB_TERMINAL_INPUT
GRUB_TERMINAL_OUTPUT
GRUB_DISABLE_OS_PROBER
GRUB_SERIAL_COMMAND
GRUB_CMDLINE_LINUX_DEFAULT
GRUB_CMDLINE_LINUX
GRUB_GFXMODE
GRUB_GFXPAYLOAD_LINUX
EOF
}
# hb_hydration_module_whitelist — module names safe to add to the
# target's /etc/modules from the backup's modules_loaded_at_boot
# on a bk_older restore. All are kernel-agnostic in name across
# 5.x → 7.x. Skips distro defaults (loop, dm-*) since a fresh
# install already loads them.
hb_hydration_module_whitelist() {
cat <<'EOF'
vfio
vfio_pci
vfio_iommu_type1
vfio_virqfd
kvm
kvm_intel
kvm_amd
nvidia
nvidia_drm
nvidia_modeset
nvidia_uvm
i915
xe
EOF
}
# hb_hydration_files_patterns — glob patterns (relative to root)
# for files that are safe to copy verbatim from the backup rootfs
# in a bk_older restore. The rule for inclusion: operator- or
# ProxMenux-authored file whose content uses stable syntax across
# kernel majors. Excludes distro-owned files (pve-blacklist.conf,
# mdadm.conf, nvme.conf) whose contents can evolve between releases.
hb_hydration_files_patterns() {
cat <<'EOF'
/etc/modprobe.d/proxmenux-*.conf
/etc/modprobe.d/vfio.conf
/etc/modprobe.d/vfio-pci.conf
/etc/modprobe.d/kvm.conf
/etc/modprobe.d/blacklist-nvidia.conf
/etc/modprobe.d/blacklist-nouveau.conf
/etc/modprobe.d/nvidia*.conf
/etc/modules-load.d/proxmenux-*.conf
/etc/modules-load.d/vfio*.conf
/etc/modules-load.d/nvidia*.conf
/etc/udev/rules.d/10-proxmenux-vfio-bind.rules
/etc/udev/rules.d/70-nvidia.rules
EOF
}
# HB_COMPAT_SAME_HOST → 1 if backup's hostname matches current
# HB_COMPAT_ANY_FAIL → 1 if at least one FAIL was raised
# HB_COMPAT_ANY_WARN → 1 if at least one WARN was raised
# HB_COMPAT_CROSS_VERSION → 1 if PVE major OR kernel major.minor differs
# HB_COMPAT_KERNEL_DIRECTION → "same" / "bk_older" / "bk_newer" — only
# "bk_older" activates the safe-subset
# restore that skips kernel-tied paths
# (grub, /etc/systemd/system, initramfs
# config, apt sources, ZFS state, …).
# "bk_newer" restores everything as
# same-version because empirical tests
# (9.2 → 9.1 with GPU passthrough +
# IOMMU) prove that direction is safe.
# HB_COMPAT_RESULTS[] → array of "STATUS|category|message"
# entries where STATUS ∈ {PASS,INFO,WARN,FAIL}
# Use hb_show_compat_report to surface the result and let the user
# decide whether to continue.
# ==========================================================
hb_compat_check() {
local staging_root="$1"
HB_COMPAT_RESULTS=()
HB_COMPAT_SAME_HOST=0
HB_COMPAT_ANY_FAIL=0
HB_COMPAT_ANY_WARN=0
HB_COMPAT_CROSS_VERSION=0
# HB_COMPAT_KERNEL_DIRECTION captures which direction the kernel
# major.minor drift goes:
# same — backup and target on the same kernel major.minor
# (or one side lacks a kernel string in the manifest).
# bk_older — backup kernel is OLDER than target kernel. This is
# the only direction that has been observed to break
# things: restoring older-kernel-era userspace onto a
# newer target can drag in old libzfs siblings, older
# systemd unit overrides, and DKMS modules built
# against a kernel that isn't the one now running.
# The restore flow uses this to skip the kernel-tied
# paths (grub, /etc/systemd/system, initramfs config,
# etc.) so Full mode still runs but only over the
# safe subset, and Custom mode grays out the unsafe
# paths so the operator can't shoot themself in the
# foot picking them.
# bk_newer — backup kernel is NEWER than target kernel. Empirical
# testing (multiple 9.2 → 9.1 restores with GPU
# passthrough / IOMMU) shows this direction works
# fine as-is: Proxmox repos don't ship future package
# versions on old series, so APT silently skips the
# backup's kernel-tied packages, and modules compile
# against whatever kernel the target is running. No
# safe-mode restrictions apply here; the restore runs
# as though it were same-version.
HB_COMPAT_KERNEL_DIRECTION="same"
local meta="$staging_root/metadata"
local rootfs="$staging_root/rootfs"
# --- HOST IDENTITY ---
local bk_hostname="" cur_hostname
if [[ -f "$meta/run_info.env" ]]; then
bk_hostname=$(grep -m1 '^hostname=' "$meta/run_info.env" 2>/dev/null | cut -d= -f2-)
fi
cur_hostname=$(hostname 2>/dev/null || echo "")
if [[ -n "$bk_hostname" ]]; then
if [[ "$bk_hostname" == "$cur_hostname" ]]; then
HB_COMPAT_SAME_HOST=1
HB_COMPAT_RESULTS+=("PASS|Host|$(hb_translate "Same host:") $bk_hostname")
else
HB_COMPAT_RESULTS+=("WARN|Host|$(hb_translate "Different host. Backup from:") $bk_hostname / $(hb_translate "restoring on:") $cur_hostname")
HB_COMPAT_ANY_WARN=1
fi
fi
# --- PVE VERSION ---
# `pveversion.txt` from the backup is `pveversion -v` output, where
# each package is on its own line as `<pkg>: <version>` (note the
# SPACE after the colon, not a slash). Live `pveversion` (no flag)
# uses `<pkg>/<version>/<commit>` form. Cover both.
local bk_pve="" cur_pve bk_major cur_major
if [[ -f "$meta/pveversion.txt" ]]; then
bk_pve=$(grep -m1 -oE '(^|[[:space:]])pve-manager[[:space:]]*[:/][[:space:]]*[0-9]+\.[0-9]+(\.[0-9]+)?' "$meta/pveversion.txt" 2>/dev/null \
| grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)
fi
if command -v pveversion >/dev/null 2>&1; then
cur_pve=$(pveversion 2>/dev/null | grep -m1 -oE 'pve-manager/[0-9]+\.[0-9]+(\.[0-9]+)?' \
| grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)
fi
if [[ -n "$bk_pve" && -n "$cur_pve" ]]; then
bk_major="${bk_pve%%.*}"
cur_major="${cur_pve%%.*}"
if [[ "$bk_pve" == "$cur_pve" ]]; then
HB_COMPAT_RESULTS+=("PASS|PVE version|$(hb_translate "Identical:") $bk_pve")
elif [[ "$bk_major" == "$cur_major" ]]; then
HB_COMPAT_RESULTS+=("PASS|PVE version|$(hb_translate "Same major series:") $bk_pve$cur_pve")
else
# Major PVE bump — used to hard-FAIL, but the restore now
# automatically drops boot/kernel/apt-critical paths in
# this case (see hb_unsafe_paths_cross_version). Downgrade
# to INFO so the user knows what's happening; the safe
# filter does the actual protection.
HB_COMPAT_RESULTS+=("INFO|PVE version|$(hb_translate "Major version differs:") $bk_pve$cur_pve $(hb_translate "— safe restore mode will skip boot/kernel/apt config")")
HB_COMPAT_CROSS_VERSION=1
fi
fi
# --- KERNEL ---
local bk_kernel="" cur_kernel
if [[ -f "$meta/run_info.env" ]]; then
bk_kernel=$(grep -m1 '^kernel=' "$meta/run_info.env" 2>/dev/null | cut -d= -f2-)
fi
cur_kernel=$(uname -r 2>/dev/null)
if [[ -n "$bk_kernel" && -n "$cur_kernel" ]]; then
if [[ "$bk_kernel" == "$cur_kernel" ]]; then
HB_COMPAT_RESULTS+=("PASS|Kernel|$(hb_translate "Identical:") $bk_kernel")
else
local bk_kmaj cur_kmaj
bk_kmaj=$(echo "$bk_kernel" | cut -d. -f1-2)
cur_kmaj=$(echo "$cur_kernel" | cut -d. -f1-2)
if [[ "$bk_kmaj" == "$cur_kmaj" ]]; then
HB_COMPAT_RESULTS+=("PASS|Kernel|$(hb_translate "Same major.minor:") $bk_kernel$cur_kernel")
else
# Kernel major.minor drift — decide direction so the
# rest of the restore knows whether to switch on the
# safe-subset mode. Bash `[[ ... < ... ]]` is a string
# compare, so we split into integers explicitly.
local _bk_maj _bk_min _cur_maj _cur_min
_bk_maj=${bk_kmaj%%.*}
_bk_min=${bk_kmaj##*.}
_cur_maj=${cur_kmaj%%.*}
_cur_min=${cur_kmaj##*.}
if (( _bk_maj < _cur_maj )) \
|| (( _bk_maj == _cur_maj && _bk_min < _cur_min )); then
HB_COMPAT_KERNEL_DIRECTION="bk_older"
HB_COMPAT_RESULTS+=("INFO|Kernel|$(hb_translate "Backup on older kernel:") $bk_kernel$cur_kernel $(hb_translate "— Full/Custom restore will skip kernel-tied paths")")
HB_COMPAT_CROSS_VERSION=1
else
# Backup NEWER than target — empirical evidence
# (multiple GPU passthrough tests) shows this
# direction restores cleanly with no filtering.
HB_COMPAT_KERNEL_DIRECTION="bk_newer"
HB_COMPAT_RESULTS+=("INFO|Kernel|$(hb_translate "Backup on newer kernel:") $bk_kernel$cur_kernel $(hb_translate "— restore proceeds as same-version (verified safe)")")
HB_COMPAT_CROSS_VERSION=1
fi
fi
fi
fi
# --- STORAGE LAYOUT ---
if [[ -f "$rootfs/etc/pve/storage.cfg" ]] && command -v pvesm >/dev/null 2>&1; then
local -a bk_storages=() missing=()
# `<type>: <storage_id>` is the storage.cfg block header form.
mapfile -t bk_storages < <(grep -E '^[a-z]+:[[:space:]]+[A-Za-z0-9_.-]+' \
"$rootfs/etc/pve/storage.cfg" 2>/dev/null | awk '{print $2}' | sort -u)
local s
for s in "${bk_storages[@]}"; do
[[ -z "$s" ]] && continue
if ! pvesm status 2>/dev/null | awk 'NR>1 {print $1}' | grep -qx "$s"; then
missing+=("$s")
fi
done
if [[ ${#bk_storages[@]} -eq 0 ]]; then
: # backup didn't include storage.cfg or it was empty
elif [[ ${#missing[@]} -eq 0 ]]; then
HB_COMPAT_RESULTS+=("PASS|Storage|$(hb_translate "All") ${#bk_storages[@]} $(hb_translate "storage(s) from backup exist on target")")
else
HB_COMPAT_RESULTS+=("WARN|Storage|$(hb_translate "Missing on target:") ${missing[*]}")
HB_COMPAT_ANY_WARN=1
fi
fi
# --- NETWORK INTERFACES ---
# We only flag physical NICs that the backup references but the
# target doesn't expose. Virtual interfaces (vmbr, bond, tap, veth,
# fwbr/fwln/fwpr, lo, VLAN suffixes) are skipped because they're
# created by the restored configuration itself.
#
# A "missing" NIC needs further triage before we cry FAIL: a
# backup often carries orphan `iface <name> inet manual` lines
# left over from previous hardware that PVE never cleans up.
# Those declarations do nothing if the NIC doesn't exist — they
# don't bring it up, don't bridge it, don't bond it. Only NICs
# that are actually WIRED into the live config (auto-up, in a
# bridge_ports, in a bond_slaves) would lose connectivity if the
# NIC isn't present on the target.
if [[ -f "$rootfs/etc/network/interfaces" ]]; then
local ifaces_file="$rootfs/etc/network/interfaces"
local -a bk_ifaces=() missing_ifaces=() wired_missing=() orphan_missing=()
mapfile -t bk_ifaces < <(
grep -E '^(iface|auto)[[:space:]]' "$ifaces_file" 2>/dev/null \
| awk '{print $2}' \
| sort -u \
| grep -vE '^(lo|vmbr[0-9]+|bond[0-9]+|tap.*|veth.*|fwbr.*|fwln.*|fwpr.*)$' \
| grep -vE '\.[0-9]+$' # strip VLAN sub-ifaces
)
local i
for i in "${bk_ifaces[@]}"; do
[[ -z "$i" ]] && continue
if ! ip -o link show "$i" >/dev/null 2>&1; then
missing_ifaces+=("$i")
fi
done
# Classify each missing NIC as wired vs orphan declaration.
# A "missing" NIC that hb_plan_nic_remaps already matched by
# MAC to a new ifname is NOT missing in any meaningful sense
# — the restored config will be rewritten to use the new
# name before apply. Skip it here.
local -A _remap_old=()
local _re_entry _re_old
for _re_entry in "${HB_NIC_REMAP[@]:-}"; do
[[ -z "$_re_entry" ]] && continue
_re_old="${_re_entry%%|*}"
[[ -n "$_re_old" ]] && _remap_old["$_re_old"]=1
done
for i in "${missing_ifaces[@]}"; do
[[ -n "${_remap_old[$i]:-}" ]] && continue
# Match: `auto <nic>`, `bridge-ports ... <nic>`, `bridge_ports ... <nic>`,
# `bond-slaves ... <nic>`, `bond_slaves ... <nic>`, `slaves ... <nic>`
if grep -qE "(^auto[[:space:]]+${i}\$|bridge[-_]ports[[:space:]]+.*\b${i}\b|bond[-_]slaves[[:space:]]+.*\b${i}\b|^[[:space:]]*slaves[[:space:]]+.*\b${i}\b)" "$ifaces_file"; then
wired_missing+=("$i")
else
orphan_missing+=("$i")
fi
done
# Surface renames + MAC changes as INFO so the panel shows them
# if there's something else to see, but doesn't force it open.
# Separated `local` declarations because bash `set -u` in caller
# scopes gets grumpy about chained `local a=$b c=$a` forms.
local _re _o _rest _n
for _re in "${HB_NIC_REMAP[@]:-}"; do
[[ -z "$_re" ]] && continue
_o="${_re%%|*}"
_rest="${_re#*|}"
_n="${_rest%%|*}"
HB_COMPAT_RESULTS+=("INFO|Network|$(hb_translate "Renamed NIC:") ${_o}${_n} $(hb_translate "(same MAC — restored config adjusted automatically)")")
done
local _mc _ifn
for _mc in "${HB_NIC_MAC_CHANGED[@]:-}"; do
[[ -z "$_mc" ]] && continue
_ifn="${_mc%%|*}"
HB_COMPAT_RESULTS+=("INFO|Network|$(hb_translate "NIC") ${_ifn} $(hb_translate "has a different MAC than the backup — update any DHCP static reservation")")
done
if [[ ${#bk_ifaces[@]} -eq 0 ]]; then
: # nothing to check
elif [[ ${#missing_ifaces[@]} -eq 0 ]]; then
HB_COMPAT_RESULTS+=("PASS|Network|$(hb_translate "All physical interfaces from backup are present on target")")
else
if [[ ${#wired_missing[@]} -gt 0 ]]; then
HB_COMPAT_RESULTS+=("FAIL|Network|$(hb_translate "Wired NICs in backup missing on target:") ${wired_missing[*]} ($(hb_translate "restoring /etc/network would lose connectivity"))")
HB_COMPAT_ANY_FAIL=1
fi
if [[ ${#orphan_missing[@]} -gt 0 ]]; then
# Orphan iface declarations — harmless leftover from older
# hardware. Surface as PASS so the operator knows we
# noticed, but don't trigger the FAIL gate.
HB_COMPAT_RESULTS+=("PASS|Network|$(hb_translate "Backup declares unused NICs that are not on this host:") ${orphan_missing[*]} ($(hb_translate "orphan iface lines, no impact on restore"))")
fi
fi
fi
# --- USER PACKAGES ---
# Compare `apt-mark showmanual` from backup vs current. Any
# package the operator installed deliberately on the source
# host but missing on the target will eventually cause an
# orphan systemd unit or a "command not found" — surface
# those up-front so the operator can decide to install them
# via the "Install missing packages" apply option.
if [[ -f "$meta/packages.manual.list" ]] && command -v apt-mark >/dev/null 2>&1; then
local cur_pkgs_file
cur_pkgs_file=$(mktemp)
apt-mark showmanual 2>/dev/null | sort -u > "$cur_pkgs_file"
local -a missing_pkgs=()
mapfile -t missing_pkgs < <(comm -23 <(sort -u "$meta/packages.manual.list") "$cur_pkgs_file")
rm -f "$cur_pkgs_file"
if [[ ${#missing_pkgs[@]} -eq 0 ]]; then
HB_COMPAT_RESULTS+=("PASS|Packages|$(hb_translate "All user-installed packages from the backup are present on this host")")
else
local list_str
if [[ ${#missing_pkgs[@]} -le 6 ]]; then
list_str="${missing_pkgs[*]}"
else
list_str="${missing_pkgs[*]:0:6}… (+ $((${#missing_pkgs[@]} - 6)) $(hb_translate "more"))"
fi
# Missing packages are auto-installed by _rs_run_complete_extras
# regardless of restore strategy — the user doesn't have to
# decide anything, so INFO instead of WARN.
HB_COMPAT_RESULTS+=("INFO|Packages|${#missing_pkgs[@]} $(hb_translate "user packages missing — will be installed automatically:") $list_str")
fi
fi
# --- VMID OVERLAP ---
# On a same-host restore, overlapping guest IDs are expected (the
# backup snapshotted YOUR live VMs, so of course they match). We
# only flag it when the restore would actually overwrite live
# guest configs — i.e. cross-host AND there's overlap.
# Note: by default the host-backup restore flow does NOT restore
# /etc/pve/nodes/* (it's part of the opt-in cluster_cfg path), but
# if the operator later toggles that path on, this is the warning
# they'd need to have seen.
if [[ -d "$rootfs/etc/pve/nodes" ]] && [[ "$HB_COMPAT_SAME_HOST" != "1" ]]; then
local -a bk_pcts=() bk_qms=() current_pcts=() current_qms=()
[[ -f "$meta/pct-list.txt" ]] && mapfile -t bk_pcts < <(awk '/^[[:space:]]*[0-9]+/{print $1}' "$meta/pct-list.txt")
[[ -f "$meta/qm-list.txt" ]] && mapfile -t bk_qms < <(awk '/^[[:space:]]*[0-9]+/{print $1}' "$meta/qm-list.txt")
command -v pct >/dev/null 2>&1 && mapfile -t current_pcts < <(pct list 2>/dev/null | awk 'NR>1 {print $1}')
command -v qm >/dev/null 2>&1 && mapfile -t current_qms < <(qm list 2>/dev/null | awk 'NR>1 {print $1}')
local pct_overlap=0 qm_overlap=0 id cid
for id in "${bk_pcts[@]}"; do
for cid in "${current_pcts[@]}"; do [[ "$id" == "$cid" ]] && ((pct_overlap++)); done
done
for id in "${bk_qms[@]}"; do
for cid in "${current_qms[@]}"; do [[ "$id" == "$cid" ]] && ((qm_overlap++)); done
done
if (( pct_overlap + qm_overlap > 0 )); then
HB_COMPAT_RESULTS+=("WARN|VM/CT IDs|$(hb_translate "Cross-host restore: guest IDs in backup overlap live IDs on target:") LXC=$pct_overlap, QEMU=$qm_overlap")
HB_COMPAT_ANY_WARN=1
fi
fi
}
# Render HB_COMPAT_RESULTS in a dialog. Returns 0 to continue, 1 to
# abort. FAIL forces an explicit second confirmation; WARN shows the
# report and lets the user proceed; an all-PASS report only shows up
# briefly so the user can see it succeeded.
hb_show_compat_report() {
local pass=0 info=0 warn=0 fail=0 line status rest cat msg
local report=""
for line in "${HB_COMPAT_RESULTS[@]}"; do
status="${line%%|*}"; rest="${line#*|}"
cat="${rest%%|*}"; msg="${rest#*|}"
case "$status" in
PASS) ((pass++)); report+=$' [OK] '"${cat}"$' — '"${msg}"$'\n' ;;
INFO) ((info++)); report+=$' [INFO] '"${cat}"$' — '"${msg}"$'\n' ;;
WARN) ((warn++)); report+=$' [WARN] '"${cat}"$' — '"${msg}"$'\n' ;;
FAIL) ((fail++)); report+=$' [FAIL] '"${cat}"$' — '"${msg}"$'\n' ;;
esac
done
local summary
summary="$(hb_translate "Compatibility check"): "
summary+="${pass} pass, ${info} info, ${warn} warn, ${fail} fail"
local tmpfile
tmpfile=$(mktemp)
{
printf '\n%s\n' "$summary"
printf '%s\n\n' "────────────────────────────────────────────────────────────"
printf '%s\n' "$report"
} > "$tmpfile"
local title
if (( fail > 0 )); then
title="$(hb_translate "Compatibility check — issues detected")"
elif (( warn > 0 )); then
title="$(hb_translate "Compatibility check — review warnings")"
else
title="$(hb_translate "Compatibility check — OK")"
fi
# Only nag the operator when there's something to read. An all-PASS
# report is pure noise on the path to a restore they already
# confirmed they want.
if (( warn > 0 || fail > 0 )); then
dialog --backtitle "ProxMenux" --title "$title" \
--textbox "$tmpfile" 22 86 || true
fi
rm -f "$tmpfile"
# FAIL means at least one check is a real risk for system integrity
# — force a second yes/no with default NO before letting the user
# press on.
if (( fail > 0 )); then
if ! whiptail --title "$(hb_translate "Continue despite failures?")" \
--defaultno \
--yesno "$(hb_translate "The compatibility check raised failures that may break the system after restore.")"$'\n\n'"$(hb_translate "Continue anyway?")" \
11 78; then
return 1
fi
fi
return 0
}
# ==========================================================
# Archive sidecar — explicit ProxMenux backup marker.
#
# Drops a small JSON next to a completed archive so the Monitor
# (and any future tooling) can identify it as a ProxMenux host
# backup independently of the filename. The user can rename the
# .tar.zst to whatever they want and the sidecar travels with it
# as long as they keep the same basename pair.
#
# Usage:
# hb_write_archive_sidecar <archive_path> <kind> [job_id] [profile]
# kind: "manual" or "scheduled"
# job_id: scheduler job id (empty for manual)
# profile: "default", "custom", or empty
# Fail-soft: returns 0 even if jq is missing and we have to fall
# back to printf-built JSON; never aborts the surrounding backup.
# ==========================================================
# ==========================================================
# PVE vzdump jobs — parsing and attach helpers
#
# When the operator already has a vzdump job scheduling backups of
# their VMs/CTs to PBS or a local datastore, they can "attach" a
# host-config backup to that same job. The host inherits the job's
# schedule, target storage, and retention; the trigger is a vzdump
# hook script that fires on `job-end`.
# ==========================================================
# Returns the type of a PVE storage as declared in /etc/pve/storage.cfg.
# Empty if the storage doesn't exist. Common values: pbs, dir, nfs,
# zfspool, lvmthin, cifs, btrfs.
hb_pve_storage_type() {
local storage_id="$1"
[[ -z "$storage_id" || ! -f /etc/pve/storage.cfg ]] && return 1
awk -v sid="$storage_id" '
/^[a-z]+:[[:space:]]/ {
t=$1; sub(":","",t)
if ($2 == sid) { print t; exit }
}
' /etc/pve/storage.cfg
}
# Lists every vzdump job in /etc/pve/jobs.cfg, one TSV row per job.
# Columns: id <TAB> storage <TAB> storage_type <TAB> schedule
# <TAB> prune-backups <TAB> enabled
# Empty cells are kept as "-" so the row keeps 6 columns even when
# the job omits the field.
hb_pve_list_vzdump_jobs() {
[[ -f /etc/pve/jobs.cfg ]] || return 0
awk '
function flush() {
if (id != "") {
printf "%s\t%s\t%s\t%s\t%s\t%s\n", \
id, \
(storage=="" ? "-" : storage), \
"PLACEHOLDER_TYPE", \
(schedule=="" ? "-" : schedule), \
(prune=="" ? "-" : prune), \
(enabled=="" ? "1" : enabled)
}
id=""; storage=""; schedule=""; prune=""; enabled=""
}
/^vzdump:/ { flush(); id=$2; next }
/^[a-z]+:/ { flush(); next } # next block of a different type
/^[[:space:]]+schedule[[:space:]]/ { sub(/^[[:space:]]+schedule[[:space:]]+/, ""); schedule=$0; next }
/^[[:space:]]+storage[[:space:]]/ { sub(/^[[:space:]]+storage[[:space:]]+/, ""); storage=$0; next }
/^[[:space:]]+prune-backups[[:space:]]/ { sub(/^[[:space:]]+prune-backups[[:space:]]+/, ""); prune=$0; next }
/^[[:space:]]+enabled[[:space:]]/ { sub(/^[[:space:]]+enabled[[:space:]]+/, ""); enabled=$0; next }
END { flush() }
' /etc/pve/jobs.cfg | while IFS=$'\t' read -r id storage type schedule prune enabled; do
type=$(hb_pve_storage_type "$storage")
[[ -z "$type" ]] && type="-"
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$id" "$storage" "$type" "$schedule" "$prune" "$enabled"
done
}
# Filters hb_pve_list_vzdump_jobs by a backend family.
# Args: $1 = "pbs" or "local"
# pbs → only storage_type == "pbs"
# local → only file/block storage we can write to as a local archive
hb_pve_list_vzdump_jobs_for_backend() {
local backend="$1"
hb_pve_list_vzdump_jobs | while IFS=$'\t' read -r id storage type schedule prune enabled; do
case "$backend" in
pbs)
[[ "$type" == "pbs" ]] && printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$id" "$storage" "$type" "$schedule" "$prune" "$enabled"
;;
local)
case "$type" in
dir|nfs|cifs|zfspool|lvmthin|btrfs)
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$id" "$storage" "$type" "$schedule" "$prune" "$enabled"
;;
esac
;;
esac
done
}
# Parses a `prune-backups` value (e.g. "keep-last=7,keep-daily=14")
# and emits KEY=VAL pairs for sourcing into a scheduler job env file.
# Maps proxmox' keep-last/hourly/daily/weekly/monthly/yearly to our
# KEEP_LAST/HOURLY/DAILY/WEEKLY/MONTHLY/YEARLY.
hb_pve_prune_to_keep_env() {
local prune="$1"
[[ -z "$prune" || "$prune" == "-" ]] && return 0
local kv k v upper
while IFS=',' read -ra kv; do
for k in "${kv[@]}"; do
v="${k#*=}"
k="${k%=*}"
case "$k" in
keep-last) echo "KEEP_LAST=$v" ;;
keep-hourly) echo "KEEP_HOURLY=$v" ;;
keep-daily) echo "KEEP_DAILY=$v" ;;
keep-weekly) echo "KEEP_WEEKLY=$v" ;;
keep-monthly) echo "KEEP_MONTHLY=$v" ;;
keep-yearly) echo "KEEP_YEARLY=$v" ;;
esac
done
done <<<"$prune"
}
hb_install_vzdump_hook() {
local src="/usr/local/share/proxmenux/scripts/backup_restore/vzdump-hook.sh"
local dst="/etc/proxmenux/vzdump-hook.sh"
local chain="/etc/proxmenux/vzdump-hook-chain.sh"
local conf="/etc/vzdump.conf"
[[ -f "$src" ]] || return 1
mkdir -p /etc/proxmenux
install -m 0755 "$src" "$dst" || return 1
[[ -f "$conf" ]] || : >"$conf"
local current
current=$(awk -F'[[:space:]]*:[[:space:]]*' \
'/^[[:space:]]*script[[:space:]]*:/ { sub(/[#].*/,"",$2); gsub(/[[:space:]]/,"",$2); print $2; exit }' \
"$conf")
if [[ -z "$current" ]]; then
printf 'script: %s\n' "$dst" >>"$conf"
return 0
fi
[[ "$current" == "$dst" ]] && return 0
# Existing third-party hook — preserve it as a chain target.
if [[ -x "$current" && ! -e "$chain" ]]; then
cp -p "$current" "$chain"
fi
sed -i "s|^[[:space:]]*script[[:space:]]*:.*|script: ${dst}|" "$conf"
}
hb_write_archive_sidecar() {
local archive_path="$1"
local kind="${2:-}"
local job_id="${3:-}"
local profile="${4:-}"
[[ -z "$archive_path" || ! -f "$archive_path" ]] && return 1
local sidecar="${archive_path}.proxmenux.json"
local archive_basename hostname_val created_at archive_size
archive_basename=$(basename "$archive_path")
hostname_val=$(hostname 2>/dev/null || echo "unknown")
created_at=$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S%z')
archive_size=$(stat -c %s "$archive_path" 2>/dev/null || echo 0)
if command -v jq >/dev/null 2>&1; then
jq -n \
--arg kind "$kind" \
--arg job_id "$job_id" \
--arg profile "$profile" \
--arg hostname "$hostname_val" \
--arg archive "$archive_basename" \
--arg created_at "$created_at" \
--argjson size "$archive_size" \
'{
schema_version: 1,
kind: $kind,
job_id: (if $job_id == "" then null else $job_id end),
profile: (if $profile == "" then null else $profile end),
hostname: $hostname,
archive: $archive,
created_at: $created_at,
archive_size: $size
}' > "$sidecar" 2>/dev/null && return 0
fi
# Fallback: jq unavailable — emit JSON by hand. Fields are
# controlled by us (no untrusted strings besides hostname/path
# which we already constrain via shell context), so a small
# printf is safe enough.
{
printf '{\n'
printf ' "schema_version": 1,\n'
printf ' "kind": "%s",\n' "$kind"
if [[ -n "$job_id" ]]; then
printf ' "job_id": "%s",\n' "$job_id"
else
printf ' "job_id": null,\n'
fi
if [[ -n "$profile" ]]; then
printf ' "profile": "%s",\n' "$profile"
else
printf ' "profile": null,\n'
fi
printf ' "hostname": "%s",\n' "$hostname_val"
printf ' "archive": "%s",\n' "$archive_basename"
printf ' "created_at": "%s",\n' "$created_at"
printf ' "archive_size": %s\n' "$archive_size"
printf '}\n'
} > "$sidecar" 2>/dev/null
return 0
}