+ Kernel/boot-tied paths are grayed out and cannot be selected — target runs a newer kernel.
+
+ )}
- Selected: {selected.size} / {pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""}
+ Selected: {selected.size} / {isBkOlder ? selectableCount : pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""}
{error && (
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 28d741dc..5f70e847 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -16905,27 +16905,113 @@ def api_host_backups_restore_prepare():
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
pass
- # Cross-kernel plan: shared with the CLI menu via
- # kernel_pin_plan.sh so both surfaces refuse the same restores.
- # `detected=true` + `action=unavailable` is what the frontend
- # treats as a hard refuse: it shows the "install a kernel first"
- # modal instead of proceeding to the safety checkboxes.
- cross_kernel: dict = {'detected': False}
- kernel_plan_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/kernel_pin_plan.sh'
- if os.path.isfile(kernel_plan_script):
+ # Direction-aware cross-kernel signal for the Web restore UI.
+ # We source the same logic the CLI uses: read the backup's
+ # kernel from the staging's run_info.env, compare major.minor
+ # to the target's current kernel, and classify the direction.
+ # "bk_older" is the only mode that trims the picker; the
+ # blocked list mirrors hb_unsafe_paths_cross_version so both
+ # surfaces skip the exact same set. "bk_newer" and "same" send
+ # empty lists and the Web treats them as a normal restore.
+ cross_kernel: dict = {
+ 'direction': 'same',
+ 'backup_kernel': '',
+ 'target_kernel': '',
+ 'blocked_paths': [],
+ }
+ try:
+ bk_kernel = ''
+ run_info = os.path.join(staging, 'metadata', 'run_info.env')
+ if os.path.isfile(run_info):
+ with open(run_info, 'r') as f:
+ for line in f:
+ if line.startswith('kernel='):
+ bk_kernel = line.strip().split('=', 1)[1]
+ break
try:
- r = subprocess.run(['bash', kernel_plan_script, staging],
- capture_output=True, text=True, timeout=90)
- if r.returncode == 0 and r.stdout.strip():
- cross_kernel = json.loads(r.stdout.strip())
- except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
- pass
+ tg_kernel = subprocess.check_output(['uname', '-r'], text=True).strip()
+ except (OSError, subprocess.CalledProcessError):
+ tg_kernel = ''
+ cross_kernel['backup_kernel'] = bk_kernel
+ cross_kernel['target_kernel'] = tg_kernel
+ def _mm(k: str) -> tuple:
+ parts = (k or '').split('.')
+ try:
+ return (int(parts[0]), int(parts[1]))
+ except (ValueError, IndexError):
+ return (0, 0)
+ if bk_kernel and tg_kernel:
+ bk_mm, tg_mm = _mm(bk_kernel), _mm(tg_kernel)
+ if bk_mm != tg_mm and bk_mm != (0, 0) and tg_mm != (0, 0):
+ if bk_mm < tg_mm:
+ cross_kernel['direction'] = 'bk_older'
+ # Pull the exact list the CLI uses.
+ lib_sh = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/lib_host_backup_common.sh'
+ if os.path.isfile(lib_sh):
+ try:
+ r = subprocess.run(
+ ['bash', '-c',
+ f'source "{lib_sh}"; hb_unsafe_paths_cross_version'],
+ capture_output=True, text=True, timeout=10)
+ if r.returncode == 0:
+ blocked = []
+ for line in r.stdout.splitlines():
+ parts = line.split('\t', 1)
+ if parts and parts[0].strip():
+ blocked.append(parts[0].strip())
+ cross_kernel['blocked_paths'] = blocked
+ except (subprocess.TimeoutExpired, OSError):
+ pass
+ else:
+ cross_kernel['direction'] = 'bk_newer'
+ except (OSError, ValueError):
+ pass
+
+ # Hydration preview — only meaningful in bk_older direction.
+ # Runs the same phases the CLI runs in "plan" mode: no
+ # writes to the live target, just the list of would-be
+ # actions. Same code path as the shell dialog, so the Web
+ # banner and the CLI msgbox always list the same items.
+ hydration = {'applies': False, 'actions': []}
+ if cross_kernel['direction'] == 'bk_older':
+ backup_sh = (
+ f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/backup_host.sh'
+ )
+ if os.path.isfile(backup_sh):
+ try:
+ # Source lib+backup_host in a subshell, set the
+ # direction so any inner guard passes, run the
+ # orchestrator in plan mode, print RS_HYDRATION_ACTIONS
+ # one per line. HB_LIB_ONLY/HB_NO_MAIN etc. are not
+ # needed: sourcing backup_host.sh only defines
+ # functions; nothing runs until we call one.
+ script = (
+ f'set +e; '
+ f'source "{_PROXMENUX_SCRIPTS_DIR}/backup_restore/lib_host_backup_common.sh"; '
+ f'source "{backup_sh}" >/dev/null 2>&1; '
+ f'HB_COMPAT_KERNEL_DIRECTION=bk_older '
+ f'_rs_apply_bk_older_hydration "{staging}" plan >/dev/null 2>&1; '
+ f'printf "%s\\n" "${{RS_HYDRATION_ACTIONS[@]}}"'
+ )
+ r = subprocess.run(
+ ['bash', '-c', script],
+ capture_output=True, text=True, timeout=15)
+ if r.returncode == 0:
+ actions = [
+ ln.strip() for ln in r.stdout.splitlines()
+ if ln.strip()
+ ]
+ hydration['actions'] = actions
+ hydration['applies'] = bool(actions)
+ except (subprocess.TimeoutExpired, OSError):
+ pass
return jsonify({
'staging_path': staging,
'paths_available': paths_available,
'rollback_plan': rollback_plan,
'cross_kernel': cross_kernel,
+ 'hydration': hydration,
})
except Exception as e:
shutil.rmtree(staging, ignore_errors=True)
diff --git a/scripts/backup_restore/apply_pending_restore.sh b/scripts/backup_restore/apply_pending_restore.sh
index 63b2ede8..594eb166 100755
--- a/scripts/backup_restore/apply_pending_restore.sh
+++ b/scripts/backup_restore/apply_pending_restore.sh
@@ -40,9 +40,8 @@ fi
: "${HB_RESTORE_INCLUDE_ZFS:=0}"
: "${HB_COMPAT_CROSS_VERSION:=0}"
-: "${HB_KERNEL_PIN_ACTION:=none}"
-: "${HB_KERNEL_PIN_KVER:=}"
-: "${HB_KERNEL_PIN_PKG:=}"
+: "${HB_COMPAT_KERNEL_DIRECTION:=same}"
+: "${HB_HYDRATION_APPLIED:=0}"
if [[ ! -f "$APPLY_LIST" ]]; then
echo "Apply list missing: $APPLY_LIST"
@@ -50,13 +49,11 @@ if [[ ! -f "$APPLY_LIST" ]]; then
exit 1
fi
-echo "Pending dir: $PENDING_DIR"
-echo "Apply list: $APPLY_LIST"
-echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS"
-echo "Cross-version: $HB_COMPAT_CROSS_VERSION"
-echo "Kernel pin act: $HB_KERNEL_PIN_ACTION"
-echo "Kernel pin ver: $HB_KERNEL_PIN_KVER"
-echo "Kernel pin pkg: $HB_KERNEL_PIN_PKG"
+echo "Pending dir: $PENDING_DIR"
+echo "Apply list: $APPLY_LIST"
+echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS"
+echo "Cross-version: $HB_COMPAT_CROSS_VERSION"
+echo "Kernel direction: $HB_COMPAT_KERNEL_DIRECTION"
# Hardware-drift skips persisted by _rs_prepare_pending_restore.
# Each line is an absolute path; we drop any rel path that matches
@@ -268,6 +265,16 @@ EOF
NEEDS_GRUB=1 ;;
esac
done < "$APPLY_LIST"
+ # bk_older hydration writes tokens/modules/files DIRECTLY to
+ # the live target (bypassing the apply_list because the whole
+ # paths are in RS_SKIP_PATHS). Those changes still need
+ # update-initramfs + update-grub / proxmox-boot-tool refresh
+ # to take effect on the next boot — force the flags so the
+ # post-boot script picks them up.
+ if [[ "${HB_HYDRATION_APPLIED:-0}" == "1" ]]; then
+ NEEDS_INITRAMFS=1
+ NEEDS_GRUB=1
+ fi
echo "Post-boot maintenance flags: initramfs=$NEEDS_INITRAMFS grub=$NEEDS_GRUB"
# Marker as env-style key=value so the post-boot script
@@ -310,60 +317,6 @@ UNITEOF
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable proxmenux-apply-cluster-postboot.service >/dev/null 2>&1 || true
- # ── Cross-kernel: install (if needed) + pin backup's kernel ──
- # When the backup was taken on a different kernel major.minor
- # than the target, the restore flow pre-computed a plan telling
- # us whether the backup's kernel is already on the target or
- # can be pulled from Proxmox repos. Acting here — during the
- # onboot restore, before the cluster-postboot fires — gives us
- # a controlled reboot into the pinned kernel, so the DKMS
- # auto-reinstall in postboot builds every module against the
- # right ABI (the kernel the operator's backup actually expects).
- _kernel_pin_reboot_needed=0
- if [[ "$HB_KERNEL_PIN_ACTION" != "none" && -n "$HB_KERNEL_PIN_KVER" ]]; then
- echo ""
- echo "── Cross-kernel: preparing kernel $HB_KERNEL_PIN_KVER ──"
- if [[ "$HB_KERNEL_PIN_ACTION" == "installable" ]]; then
- echo "Installing $HB_KERNEL_PIN_PKG from Proxmox repositories..."
- apt-get update -qq 2>&1 | tail -5
- if DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$HB_KERNEL_PIN_PKG" 2>&1 | tail -10; then
- echo " ✓ $HB_KERNEL_PIN_PKG installed"
- else
- echo " ✗ apt install failed — pin will fall back to whatever kernels are present"
- fi
- fi
- if command -v proxmox-boot-tool >/dev/null 2>&1; then
- proxmox-boot-tool kernel pin "$HB_KERNEL_PIN_KVER" 2>&1 | sed 's/^/ pin: /'
- proxmox-boot-tool refresh 2>&1 | sed 's/^/ refresh: /'
- _kernel_pin_reboot_needed=1
- else
- echo " ! proxmox-boot-tool not available — kernel pin skipped"
- fi
- fi
-
- if (( _kernel_pin_reboot_needed == 1 )); then
- # Do NOT start the cluster-postboot service in this boot.
- # We must reboot first so the kernel pin takes effect;
- # once the machine is back up on the pinned kernel, the
- # postboot service (enabled with a ConditionPathExists on
- # cluster-apply-pending) will fire automatically and its
- # DKMS reinstalls will target the correct kernel.
- echo ""
- echo "Rebooting into pinned kernel $HB_KERNEL_PIN_KVER..."
- echo "completed" >"$STATE_FILE" 2>/dev/null || true
- # Mark this restore as completed so the on-boot service
- # doesn't re-fire after we reboot.
- _rid="$(basename "$PENDING_DIR")"
- mv "$PENDING_DIR" "${PENDING_BASE}/completed/${_rid}" >/dev/null 2>&1 || true
- rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
- systemctl disable proxmenux-restore-onboot.service >/dev/null 2>&1 || true
- systemctl daemon-reload >/dev/null 2>&1 || true
- sync
- systemctl --no-block reboot
- exit 0
- fi
-
- # Same-kernel path: fire cluster-postboot inline in this boot.
# `systemctl enable` only adds the unit to multi-user.target.wants/.
# It does NOT pull the unit into the currently-running boot
# transaction — by the time we run, multi-user.target may have
diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh
index f3fa06e2..6e709737 100755
--- a/scripts/backup_restore/backup_host.sh
+++ b/scripts/backup_restore/backup_host.sh
@@ -2061,9 +2061,8 @@ CREATED_AT=${created_at}
HB_RESTORE_INCLUDE_ZFS=${HB_RESTORE_INCLUDE_ZFS:-0}
HB_ROLLBACK_EXECUTE=${HB_ROLLBACK_EXECUTE:-0}
HB_COMPAT_CROSS_VERSION=${HB_COMPAT_CROSS_VERSION:-0}
-HB_KERNEL_PIN_ACTION=${HB_KERNEL_PIN_ACTION:-none}
-HB_KERNEL_PIN_KVER=${HB_KERNEL_PIN_KVER:-}
-HB_KERNEL_PIN_PKG=${HB_KERNEL_PIN_PKG:-}
+HB_COMPAT_KERNEL_DIRECTION=${HB_COMPAT_KERNEL_DIRECTION:-same}
+HB_HYDRATION_APPLIED=${HB_HYDRATION_APPLIED:-0}
EOF
# Persist hardware-drift skips so apply_pending_restore.sh can filter
# them at boot. The RS_SKIP_PATHS env var only lives in the restore
@@ -2124,6 +2123,328 @@ _rs_handle_ssh_network_risk() {
return 0
}
+# ==========================================================
+# Kernel-agnostic hydration for bk_older restores
+# ==========================================================
+# In bk_older direction we block whole-file restores of the
+# kernel/boot-tied paths (see hb_unsafe_paths_cross_version) to
+# stop the target's boot from being poisoned by systemd unit
+# overrides, APT sources, GRUB defaults, etc. from an older
+# base. But the operator's OWN customisations inside those
+# paths (IOMMU cmdline, VFIO modules, usb-storage.quirks, custom
+# GRUB_TIMEOUT, ...) are kernel-agnostic and should still land
+# on the target — otherwise a fresh install after a restore
+# forgets everything the operator tuned.
+#
+# The hydration functions below implement a merge-not-copy
+# strategy: they read the operator-authored bits from the
+# manifest (cmdline_extra, modules_loaded_at_boot) or from
+# specific whitelisted files in the staging rootfs, and merge
+# them into the target's live config without touching keys or
+# tokens the target already carries. All four phases are
+# idempotent and additive; running them twice is a no-op.
+# ==========================================================
+
+_rs_hyd_manifest() { echo "$1/metadata/manifest.json"; }
+
+# List operator-authored kernel cmdline tokens from the manifest,
+# one per line. Empty output = nothing to merge.
+_rs_hyd_cmdline_tokens() {
+ local manifest="$1"
+ [[ -f "$manifest" ]] || return 0
+ command -v jq >/dev/null 2>&1 || return 0
+ jq -r '.kernel_params.cmdline_extra // [] | .[]' "$manifest" 2>/dev/null || true
+}
+
+# List modules the backup loaded at boot (from /etc/modules on
+# the source host), one per line.
+_rs_hyd_modules_at_boot() {
+ local manifest="$1"
+ [[ -f "$manifest" ]] || return 0
+ command -v jq >/dev/null 2>&1 || return 0
+ jq -r '.kernel_params.modules_loaded_at_boot // [] | .[]' "$manifest" 2>/dev/null || true
+}
+
+# Read a KEY=VALUE line from a `/etc/default/grub`-style file.
+# Prints the raw VALUE part (including surrounding quotes) if
+# present, empty otherwise. Handles both `KEY=value` and
+# `KEY="value with spaces"` forms.
+_rs_hyd_grub_read_key() {
+ local file="$1" key="$2"
+ [[ -f "$file" ]] || return 0
+ # Grab the LAST assignment (later wins in shell-style config),
+ # skip comment lines, tolerate leading whitespace.
+ grep -E "^[[:space:]]*${key}=" "$file" 2>/dev/null | tail -1 | sed -E "s/^[[:space:]]*${key}=//"
+}
+
+# Write or replace a KEY=VALUE line in a `/etc/default/grub`-style
+# file. If the key already exists (uncommented), rewrites its
+# line in place; otherwise appends the new line at the end. Leaves
+# comment lines untouched.
+_rs_hyd_grub_write_key() {
+ local file="$1" key="$2" value="$3"
+ [[ -f "$file" ]] || return 1
+ if grep -qE "^[[:space:]]*${key}=" "$file"; then
+ # In-place rewrite of the last matching line. Use sed with
+ # a temp file so partial failure doesn't corrupt the target.
+ local tmp
+ tmp="$(mktemp)"
+ awk -v k="$key" -v v="$value" '
+ BEGIN { pat = "^[[:space:]]*" k "=" }
+ $0 ~ pat { print k "=" v; next }
+ { print }
+ ' "$file" > "$tmp" && mv "$tmp" "$file"
+ else
+ printf '%s=%s\n' "$key" "$value" >> "$file"
+ fi
+}
+
+# Strip surrounding double quotes and split a GRUB_CMDLINE_-style
+# value into whitespace-delimited tokens (one per line). Handles
+# escaped inner quotes conservatively.
+_rs_hyd_cmdline_split() {
+ local raw="$1"
+ raw="${raw%\"}"; raw="${raw#\"}"
+ raw="${raw%\'}"; raw="${raw#\'}"
+ # tr will collapse any run of whitespace; xargs normalises.
+ tr -s '[:space:]' '\n' <<<"$raw" | sed '/^$/d'
+}
+
+# Merge two cmdline strings: target wins on ties (same key), any
+# backup token whose KEY (or bare flag) is not in target is
+# appended. Prints the merged cmdline as a single line without
+# surrounding quotes. Also returns 0 if any token was added, 1
+# if nothing changed — useful for the caller to decide whether
+# an update-grub / proxmox-boot-tool refresh is needed.
+_rs_hyd_cmdline_merge() {
+ local target_line="$1" backup_line="$2"
+ local -A target_keys=()
+ local -a merged=()
+ local t bkey
+ while IFS= read -r t; do
+ [[ -z "$t" ]] && continue
+ merged+=("$t")
+ bkey="${t%%=*}"
+ target_keys["$bkey"]=1
+ done < <(_rs_hyd_cmdline_split "$target_line")
+ local added=0
+ while IFS= read -r t; do
+ [[ -z "$t" ]] && continue
+ bkey="${t%%=*}"
+ if [[ -z "${target_keys[$bkey]:-}" ]]; then
+ merged+=("$t")
+ target_keys["$bkey"]=1
+ added=1
+ fi
+ done < <(_rs_hyd_cmdline_split "$backup_line")
+ printf '%s' "${merged[*]}"
+ (( added )) && return 0 || return 1
+}
+
+# Phase 1a — GRUB path
+# ────────────────────
+# Merge cmdline tokens from the backup's cmdline_extra into the
+# target's live GRUB_CMDLINE_LINUX_DEFAULT (never overwrite existing
+# tokens or keys). Also merge whitelisted GRUB_* keys from the
+# backup's /etc/default/grub whose values differ. Reports one
+# summary line per action into the global RS_HYDRATION_ACTIONS[].
+# $3=mode: "plan" reports would-be actions only, "commit" writes.
+_rs_hyd_grub() {
+ local backup_rootfs="$1" manifest="$2" mode="${3:-commit}"
+ local target="/etc/default/grub"
+ local bk_file="$backup_rootfs/etc/default/grub"
+ [[ -f "$target" ]] || return 0
+
+ local changed=0
+
+ # ── cmdline merge ──
+ local bk_tokens
+ bk_tokens="$(_rs_hyd_cmdline_tokens "$manifest" | tr '\n' ' ')"
+ if [[ -n "${bk_tokens// /}" ]]; then
+ local live_default merged_default added_tokens
+ live_default="$(_rs_hyd_grub_read_key "$target" GRUB_CMDLINE_LINUX_DEFAULT)"
+ local stripped="${live_default%\"}"; stripped="${stripped#\"}"
+ if merged_default="$(_rs_hyd_cmdline_merge "$stripped" "$bk_tokens")"; then
+ added_tokens="$(comm -13 \
+ <(printf '%s\n' $stripped | sort -u) \
+ <(printf '%s\n' $merged_default | sort -u) 2>/dev/null | tr '\n' ' ')"
+ [[ "$mode" == "commit" ]] && _rs_hyd_grub_write_key "$target" GRUB_CMDLINE_LINUX_DEFAULT "\"${merged_default}\""
+ RS_HYDRATION_ACTIONS+=("GRUB_CMDLINE_LINUX_DEFAULT — merge tokens: ${added_tokens}")
+ changed=1
+ fi
+ fi
+
+ # ── whitelisted GRUB_* keys from the backup file ──
+ if [[ -f "$bk_file" ]]; then
+ local key bk_val live_val
+ while IFS= read -r key; do
+ [[ -z "$key" ]] && continue
+ [[ "$key" == "GRUB_CMDLINE_LINUX_DEFAULT" || "$key" == "GRUB_CMDLINE_LINUX" ]] && continue
+ bk_val="$(_rs_hyd_grub_read_key "$bk_file" "$key")"
+ [[ -z "$bk_val" ]] && continue
+ live_val="$(_rs_hyd_grub_read_key "$target" "$key")"
+ if [[ "$bk_val" != "$live_val" ]]; then
+ [[ "$mode" == "commit" ]] && _rs_hyd_grub_write_key "$target" "$key" "$bk_val"
+ RS_HYDRATION_ACTIONS+=("${key} — restore operator value from backup")
+ changed=1
+ fi
+ done < <(hb_grub_keys_whitelist)
+ fi
+
+ (( changed )) && return 0 || return 1
+}
+
+# Phase 1b — systemd-boot / ZFS path
+# ──────────────────────────────────
+# /etc/kernel/cmdline is a single-line file holding the ENTIRE
+# kernel cmdline (root=, boot=, rootflags=, plus operator tokens).
+# We MUST NOT copy it verbatim across kernels — the target's fresh
+# install references its own ZFS pool. Instead: keep the target's
+# boot boilerplate, append the operator tokens from cmdline_extra
+# that aren't already there.
+_rs_hyd_kernel_cmdline() {
+ local manifest="$1" mode="${2:-commit}"
+ local target="/etc/kernel/cmdline"
+ [[ -f "$target" ]] || return 0
+
+ local bk_tokens
+ bk_tokens="$(_rs_hyd_cmdline_tokens "$manifest" | tr '\n' ' ')"
+ [[ -z "${bk_tokens// /}" ]] && return 1
+
+ local live merged added_tokens
+ live="$(cat "$target" 2>/dev/null | tr -d '\n')"
+ if merged="$(_rs_hyd_cmdline_merge "$live" "$bk_tokens")"; then
+ added_tokens="$(comm -13 \
+ <(printf '%s\n' $live | sort -u) \
+ <(printf '%s\n' $merged | sort -u) 2>/dev/null | tr '\n' ' ')"
+ [[ "$mode" == "commit" ]] && printf '%s\n' "$merged" > "$target"
+ RS_HYDRATION_ACTIONS+=("/etc/kernel/cmdline (systemd-boot/ZFS) — merge tokens: ${added_tokens}")
+ return 0
+ fi
+ return 1
+}
+
+# Phase 2 — /etc/modules merge
+# ────────────────────────────
+# Append modules from modules_loaded_at_boot that are whitelisted
+# AND not already present in /etc/modules. Preserves comments and
+# existing entries.
+_rs_hyd_modules() {
+ local manifest="$1" mode="${2:-commit}"
+ local target="/etc/modules"
+ [[ -f "$target" ]] || return 0
+
+ local -A live_modules=() whitelist=()
+ local m line
+ while IFS= read -r line; do
+ line="${line%%#*}"
+ m="$(printf '%s' "$line" | xargs)"
+ [[ -n "$m" ]] && live_modules["$m"]=1
+ done < "$target"
+ while IFS= read -r m; do
+ m="$(printf '%s' "$m" | xargs)"
+ [[ -n "$m" ]] && whitelist["$m"]=1
+ done < <(hb_hydration_module_whitelist)
+
+ local added=0
+ while IFS= read -r m; do
+ m="$(printf '%s' "$m" | xargs)"
+ [[ -z "$m" ]] && continue
+ [[ -z "${whitelist[$m]:-}" ]] && continue
+ [[ -n "${live_modules[$m]:-}" ]] && continue
+ [[ "$mode" == "commit" ]] && printf '%s\n' "$m" >> "$target"
+ live_modules["$m"]=1
+ RS_HYDRATION_ACTIONS+=("/etc/modules — add: $m")
+ added=1
+ done < <(_rs_hyd_modules_at_boot "$manifest")
+
+ (( added )) && return 0 || return 1
+}
+
+# Phase 3 — whitelisted files
+# ───────────────────────────
+# For each pattern in hb_hydration_files_patterns, copy the
+# matching file from the staging rootfs to the live target IF the
+# backup carries it and its content differs from the live copy.
+_rs_hyd_files() {
+ local backup_rootfs="$1" mode="${2:-commit}"
+ local pattern src dst
+ local copied=0
+ while IFS= read -r pattern; do
+ [[ -z "$pattern" ]] && continue
+ while IFS= read -r src; do
+ [[ -z "$src" || ! -f "$src" ]] && continue
+ dst="${src#${backup_rootfs}}"
+ [[ "$dst" == /* ]] || continue
+ if ! cmp -s "$src" "$dst" 2>/dev/null; then
+ if [[ "$mode" == "commit" ]]; then
+ mkdir -p "$(dirname "$dst")" 2>/dev/null || true
+ cp -a "$src" "$dst" 2>/dev/null || continue
+ fi
+ RS_HYDRATION_ACTIONS+=("${dst} — restore from backup")
+ copied=1
+ fi
+ done < <(compgen -G "${backup_rootfs}${pattern}" 2>/dev/null || true)
+ done < <(hb_hydration_files_patterns)
+ (( copied )) && return 0 || return 1
+}
+
+# Orchestrator for the 4 phases. Two modes:
+# plan — populates RS_HYDRATION_ACTIONS and RS_HYDRATION_SUMMARY
+# without writing to any live file. Safe to call before
+# the operator's final yes/no.
+# commit — actually writes to the live target and exports
+# HB_HYDRATION_APPLIED=1 if anything changed, so that
+# plan.env carries the flag and apply_pending_restore.sh
+# forces NEEDS_INITRAMFS/NEEDS_GRUB.
+# Both modes are safe to call multiple times: writes are additive
+# and idempotent, plan is side-effect free.
+_rs_apply_bk_older_hydration() {
+ local staging_root="$1" mode="${2:-commit}"
+ local backup_rootfs="$staging_root/rootfs"
+ local manifest
+ manifest="$(_rs_hyd_manifest "$staging_root")"
+
+ RS_HYDRATION_ACTIONS=()
+ RS_HYDRATION_SUMMARY=""
+
+ [[ -d "$backup_rootfs" ]] || return 0
+
+ local bootloader
+ bootloader="$(hb_detect_bootloader)"
+
+ local any=0
+ case "$bootloader" in
+ systemd-boot)
+ _rs_hyd_kernel_cmdline "$manifest" "$mode" && any=1 || true
+ ;;
+ grub|*)
+ _rs_hyd_grub "$backup_rootfs" "$manifest" "$mode" && any=1 || true
+ ;;
+ esac
+ _rs_hyd_modules "$manifest" "$mode" && any=1 || true
+ _rs_hyd_files "$backup_rootfs" "$mode" && any=1 || true
+
+ if (( any )); then
+ if [[ "$mode" == "commit" ]]; then
+ HB_HYDRATION_APPLIED=1
+ export HB_HYDRATION_APPLIED
+ fi
+ local body a header
+ if [[ "$mode" == "plan" ]]; then
+ header="$(translate "Operator config that WILL be re-applied via kernel-agnostic merge")"
+ else
+ header="$(translate "Operator config re-applied via kernel-agnostic merge")"
+ fi
+ body="\Zb${header}\ZB"$'\n\n'
+ for a in "${RS_HYDRATION_ACTIONS[@]}"; do
+ body+=" \Z2•\Zn ${a}"$'\n'
+ done
+ RS_HYDRATION_SUMMARY="$body"
+ fi
+ return 0
+}
+
_rs_run_complete_guided() {
local staging_root="$1"
local -a all_paths=()
@@ -2218,15 +2539,12 @@ _rs_run_complete_guided() {
fi
# ── Cross-version safe-restore filter ────────────────────
- # When the backup was taken on a different PVE major or a
- # different kernel major.minor, restoring boot/kernel/apt
- # config on top of the current install is what causes the
- # post-reboot kernel panic. Fold those paths into RS_SKIP_PATHS
- # so the same downstream machinery that already honours skips
- # (_rs_apply, _rs_collect_pending_paths, apply_pending_restore)
- # keeps them out of the restore.
+ # Only activates on `bk_older` (backup kernel older than target).
+ # The reverse direction (`bk_newer`) restores cleanly as-is —
+ # verified empirically on multiple 9.2 → 9.1 restores with GPU
+ # passthrough / IOMMU / DKMS drivers.
RS_CROSS_VERSION_SKIPS=""
- if [[ "${HB_COMPAT_CROSS_VERSION:-0}" == "1" ]]; then
+ if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
local -a cv_skipped=()
local cv_line cv_path cv_reason cv_rel
# The drift block above trims the trailing newline off
@@ -2265,6 +2583,15 @@ _rs_run_complete_guided() {
RS_SKIP_PATHS="${RS_SKIP_PATHS#$'\n'}"
RS_SKIP_PATHS="${RS_SKIP_PATHS%$'\n'}"
export RS_SKIP_PATHS
+
+ # Kernel-agnostic hydration of operator-authored bits that
+ # would otherwise be lost with the whole-file skip above.
+ # See _rs_apply_bk_older_hydration for the four merge phases.
+ # Runs in "plan" mode BEFORE the confirm dialog so
+ # RS_HYDRATION_SUMMARY can preview what will be re-applied;
+ # the commit runs AFTER the operator says yes, right before
+ # the hot apply.
+ _rs_apply_bk_older_hydration "$staging_root" plan
fi
# Build the rich confirmation body. Replaces the previous 4-strategy
@@ -2334,6 +2661,12 @@ _rs_run_complete_guided() {
if [[ -n "${RS_CROSS_VERSION_SKIPS:-}" ]]; then
body+=$'\n'"${RS_CROSS_VERSION_SKIPS}"
fi
+ # And the counterpart: what gets re-applied via kernel-agnostic
+ # merge. Rendered in green because it's what the operator gets
+ # back for free even though the whole file was skipped above.
+ if [[ -n "${RS_HYDRATION_SUMMARY:-}" ]]; then
+ body+=$'\n'"${RS_HYDRATION_SUMMARY}"
+ fi
body+=$'\n'"\Zb\Z4$(translate "A reboot is required to finish the restore.")\Zn"$'\n\n'
body+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
body+="\Zb$(translate "Continue?")\ZB"
@@ -2385,6 +2718,15 @@ _rs_run_complete_guided() {
show_proxmenux_logo
msg_title "$(translate "Applying safe paths and preparing pending restore")"
+ # Commit the kernel-agnostic hydration BEFORE hot apply so
+ # /etc/modules, /etc/default/grub (or /etc/kernel/cmdline),
+ # and whitelisted vfio/nvidia files carry the operator's
+ # tokens by the time apply_pending_restore.sh reads its
+ # pending list. plan.env picks up HB_HYDRATION_APPLIED=1
+ # and forces NEEDS_INITRAMFS/NEEDS_GRUB post-boot.
+ if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
+ _rs_apply_bk_older_hydration "$staging_root" commit
+ fi
[[ "$hot_count" -gt 0 ]] && _rs_apply "$staging_root" hot
local -a pending_paths=()
@@ -2581,12 +2923,69 @@ _rs_select_component_paths() {
# ── Dialog checklist (one entry per real backup path) ──
if [[ -z "$_rsp_selected" ]]; then
+ # In bk_older restores, kernel-tied paths are excluded from
+ # the list entirely (bash `dialog` has no gray-out primitive,
+ # so we hide the unsafe entries and surface their names in a
+ # msgbox up front). Uses the same list `hb_unsafe_paths_cross_version`
+ # returns — single source of truth with the safe filter used
+ # by Full restore.
+ local -A _rsp_blocked=()
+ local -a _rsp_blocked_list=()
+ if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
+ local _rsp_bp _rsp_reason
+ while IFS=$'\t' read -r _rsp_bp _rsp_reason; do
+ [[ -z "$_rsp_bp" ]] && continue
+ local _rsp_bp_rel="${_rsp_bp#/}"
+ _rsp_blocked["$_rsp_bp_rel"]=1
+ done < <(hb_unsafe_paths_cross_version)
+ fi
+
local -a _rsp_checklist=()
local _rsp_p
for _rsp_p in "${_rsp_backup[@]}"; do
+ # Skip paths whose parent (or itself) is on the blocked
+ # list: `/etc/systemd/system/foo` counts as blocked when
+ # `/etc/systemd/system` is on the list.
+ local _rsp_hit="" _rsp_probe="$_rsp_p"
+ while [[ -n "$_rsp_probe" ]]; do
+ if [[ -n "${_rsp_blocked[$_rsp_probe]:-}" ]]; then
+ _rsp_hit="/$_rsp_probe"
+ break
+ fi
+ [[ "$_rsp_probe" != *"/"* ]] && break
+ _rsp_probe="${_rsp_probe%/*}"
+ done
+ if [[ -n "$_rsp_hit" ]]; then
+ _rsp_blocked_list+=("$_rsp_hit")
+ continue
+ fi
_rsp_checklist+=("$_rsp_p" "/$_rsp_p" "off")
done
+ # If any paths were dropped, show them in a msgbox before the
+ # picker so the operator understands why they don't appear.
+ if (( ${#_rsp_blocked_list[@]} > 0 )); then
+ # Deduplicate — a folder can hide many child paths.
+ local -A _rsp_seen=()
+ local _rsp_list_str=""
+ local _rsp_e
+ for _rsp_e in "${_rsp_blocked_list[@]}"; do
+ [[ -n "${_rsp_seen[$_rsp_e]:-}" ]] && continue
+ _rsp_seen["$_rsp_e"]=1
+ _rsp_list_str+=" • ${_rsp_e}"$'\n'
+ done
+ dialog --backtitle "ProxMenux" --colors \
+ --title "$(translate "Cross-kernel — paths hidden from picker")" \
+ --msgbox "$(translate "The following backup paths are kernel-tied and would break the target host if restored across kernel versions. They are excluded from the selection below:")"$'\n\n'"${_rsp_list_str}"$'\n'"$(translate "If you need any of these, restore this backup on a host with the same kernel major.minor.")" \
+ 20 84
+ fi
+
+ if (( ${#_rsp_checklist[@]} == 0 )); then
+ dialog --backtitle "ProxMenux" --title "$(translate "Nothing selectable")" \
+ --msgbox "$(translate "Every path in this backup is kernel-tied and cannot be restored across kernel versions.")" 9 78
+ return 1
+ fi
+
_rsp_selected=$(dialog --backtitle "ProxMenux" --separate-output \
--title "$(translate "Custom restore by paths")" \
--checklist "\n$(translate "Select the paths to restore (one per backup entry):")" \
@@ -2655,6 +3054,15 @@ _rs_run_custom_restore() {
local hot_count="$RS_SEL_HOT"
local pending_count=$(( RS_SEL_REBOOT + RS_SEL_DANGEROUS ))
+ # In bk_older, preview the kernel-agnostic hydration so the
+ # operator sees UPFRONT what will be re-applied automatically
+ # (IOMMU cmdline, VFIO modules, custom GRUB_TIMEOUT, etc.)
+ # even though the whole-file paths remain blocked from the
+ # custom picker.
+ if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
+ _rs_apply_bk_older_hydration "$staging_root" plan
+ fi
+
local body
body="\Zb$(translate "About to restore") ${RS_SEL_TOTAL} $(translate "selected path(s):")\ZB"$'\n\n'
if (( hot_count > 0 )); then
@@ -2663,6 +3071,9 @@ _rs_run_custom_restore() {
if (( pending_count > 0 )); then
body+=" • $(translate "Schedule") \Zb\Z4${pending_count}\Zn $(translate "for next boot (needs reboot to take effect)")"$'\n'
fi
+ if [[ -n "${RS_HYDRATION_SUMMARY:-}" ]]; then
+ body+=$'\n'"${RS_HYDRATION_SUMMARY}"
+ fi
if (( pending_count > 0 )); then
body+=$'\n'"\Zb\Z4$(translate "A reboot will be required to complete the restore.")\Zn"$'\n'
fi
@@ -2670,13 +3081,20 @@ _rs_run_custom_restore() {
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Confirm custom restore")" \
- --yesno "$body" 14 82; then
+ --yesno "$body" 22 82; then
return 1
fi
show_proxmenux_logo
msg_title "$(translate "Applying custom restore")"
+ # Commit the hydration BEFORE hot apply. Same rationale as in
+ # the Complete flow: plan.env picks up HB_HYDRATION_APPLIED=1
+ # so apply_pending_restore.sh forces initramfs+grub regen.
+ if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
+ _rs_apply_bk_older_hydration "$staging_root" commit
+ fi
+
if (( hot_count > 0 )); then
_rs_apply "$staging_root" hot "${selected_paths[@]}"
fi
@@ -2687,6 +3105,24 @@ _rs_run_custom_restore() {
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
fi
+ elif [[ "${HB_HYDRATION_APPLIED:-0}" == "1" ]]; then
+ # Custom restore picked only hot paths, but hydration
+ # merged tokens into /etc/default/grub or /etc/kernel/cmdline
+ # and/or added modules to /etc/modules. Those need
+ # initramfs + bootloader refresh before the next reboot to
+ # be effective. No post-boot dispatcher runs in this
+ # branch (plan.env is only written by
+ # _rs_prepare_pending_restore), so we do the reflows here.
+ msg_info "$(translate "Regenerating boot artifacts for the merged kernel-agnostic changes...")"
+ update-initramfs -u -k all >/dev/null 2>&1 || true
+ local _bl
+ _bl="$(hb_detect_bootloader)"
+ case "$_bl" in
+ systemd-boot) proxmox-boot-tool refresh >/dev/null 2>&1 || true ;;
+ grub|*) update-grub >/dev/null 2>&1 || true ;;
+ esac
+ stop_spinner 2>/dev/null || true
+ msg_ok "$(translate "Boot artifacts regenerated — reboot the host to activate the merged config.")"
fi
_rs_finish_flow
@@ -2984,6 +3420,25 @@ _rs_apply_menu() {
# confirmation dialog (option 1). It's still reachable on demand
# from option 6 of this menu.
+ # ── Cross-kernel notice (only for bk_older) ───────────────
+ # When the backup was taken on a kernel older than the target,
+ # let the operator know BEFORE they see the mode menu that
+ # Full restore will silently skip kernel-tied paths and that
+ # Custom will present them as blocked. Nothing to click through
+ # for the reverse direction (bk_newer) — those restores go
+ # through as-is because empirical testing shows they work.
+ if [[ "${HB_COMPAT_KERNEL_DIRECTION:-same}" == "bk_older" ]]; then
+ local _bkk="" _cur_k _cur_mm
+ [[ -f "$staging_root/metadata/run_info.env" ]] && \
+ _bkk=$(grep -m1 '^kernel=' "$staging_root/metadata/run_info.env" 2>/dev/null | cut -d= -f2-)
+ _cur_k=$(uname -r 2>/dev/null || echo "?")
+ _cur_mm=$(echo "$_cur_k" | cut -d. -f1-2)
+ dialog --backtitle "ProxMenux" --colors \
+ --title "$(translate "Cross-kernel restore — safe subset only")" \
+ --msgbox "$(translate "This backup was taken on kernel"): \Zb${_bkk}\ZB"$'\n'"$(translate "Target host runs"): \Zb${_cur_k}\ZB"$'\n\n'"\Zb$(translate "Only the essential configuration will be restored"):\ZB $(translate "/etc/pve, network, ssh, users, cron, ProxMenux state, etc.")"$'\n\n'"$(translate "Kernel-tied paths (boot config, /etc/systemd/system, initramfs config, apt sources, ZFS state, ...) will be skipped automatically to protect the target's boot.")"$'\n\n'"$(translate "If you need a complete bit-for-bit restore, use a backup taken on kernel") \Zb${_cur_mm}.x\ZB $(translate "or reinstall Proxmox with a version that ships kernel") \Zb${_bkk}\ZB." \
+ 22 84
+ fi
+
while true; do
local choice
choice=$(dialog --backtitle "ProxMenux" \
@@ -3071,39 +3526,6 @@ restore_menu() {
# silent same-host path and the loud cross-host path.
hb_compat_check "$staging_root"
- # Cross-kernel restore requires the backup's kernel to be
- # bootable on the target — either already installed or
- # obtainable from the Proxmox repositories. When it's
- # neither, refuse cleanly: continuing would leave the
- # operator with drivers built against a kernel that isn't
- # present, and DKMS would silently pin the wrong ABI.
- if [[ "${HB_COMPAT_CROSS_VERSION:-0}" == "1" ]]; then
- local _bk_kernel=""
- if [[ -f "$staging_root/metadata/run_info.env" ]]; then
- _bk_kernel=$(grep -m1 '^kernel=' "$staging_root/metadata/run_info.env" 2>/dev/null | cut -d= -f2-)
- fi
- if [[ -n "$_bk_kernel" ]]; then
- hb_kernel_pin_plan "$_bk_kernel"
- if [[ "$HB_KERNEL_PIN_ACTION" == "unavailable" ]]; then
- # Log so the operator can inspect after the fact.
- local _refuse_log="/var/log/proxmenux/restore-refused-$(date +%Y%m%d_%H%M%S).log"
- mkdir -p "$(dirname "$_refuse_log")" 2>/dev/null
- {
- echo "Refused at: $(date -Iseconds)"
- echo "Backup kernel: $_bk_kernel"
- echo "Target running: $(uname -r)"
- echo "Reason: $HB_KERNEL_PIN_REASON"
- } > "$_refuse_log"
- dialog --backtitle "ProxMenux" --colors \
- --title "$(translate "Restore refused — incompatible kernel")" \
- --msgbox "\Zb\Z1$(translate "This backup was taken on kernel"):\Zn \Zb${_bk_kernel}\ZB"$'\n\n'"$(translate "That kernel is") ${HB_KERNEL_PIN_REASON}."$'\n\n'"$(translate "Restoring without a matching kernel would leave drivers and modules built against a kernel that is not present on this host — the system may fail to boot or run degraded.")"$'\n\n'"\Zb$(translate "How to proceed"):\ZB"$'\n'" • $(translate "Install Proxmox VE from an ISO whose kernel matches")"$'\n'" • $(translate "Or fetch the kernel manually:") apt install proxmox-kernel-${_bk_kernel}"$'\n'" • $(translate "Then relaunch the restore.")"$'\n\n'"$(translate "Details logged at"): $_refuse_log" \
- 22 84
- rm -rf "$staging_root"
- continue
- fi
- fi
- fi
-
if hb_show_compat_report; then
if _rs_apply_menu "$staging_root"; then
rm -rf "$staging_root"
diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh
index 617dc823..0d88eec6 100755
--- a/scripts/backup_restore/lib_host_backup_common.sh
+++ b/scripts/backup_restore/lib_host_backup_common.sh
@@ -2711,85 +2711,6 @@ hb_apply_nic_remaps() {
# Output: one line per path, tab-separated:
\t
# The reason is a short label used verbatim in the confirm dialog.
# ==========================================================
-# ==========================================================
-# KERNEL PIN PLAN — cross-kernel restore only
-# ==========================================================
-# When the backup was taken on a kernel major.minor different
-# from the target's running kernel, the safe and universally
-# correct action is to boot the target with the backup's kernel
-# after the restore. That way every driver / DKMS module that
-# the backup expects (nvidia, coral, intel_gpu, plus every
-# out-of-tree ZFS build) targets a kernel that actually exists
-# and matches, and the auto-reinstall on post-boot builds them
-# against the right ABI. Universal: works for ZFS root, LVM/ext4
-# root, BIOS or UEFI hosts alike.
-#
-# This function decides how to obtain that kernel on the target
-# and publishes the plan for the restore flow to act on.
-#
-# Input:
-# $1 bk_kernel — kernel release string from the backup
-# (e.g. "6.17.2-1-pve")
-# Output (env vars, all exported):
-# HB_KERNEL_PIN_ACTION installed | installable | unavailable
-# HB_KERNEL_PIN_PKG candidate package name to install (empty
-# when installed / unavailable)
-# HB_KERNEL_PIN_KVER the kernel release to pin (same as $1)
-# Also fills HB_KERNEL_PIN_REASON with a short human message
-# when action is "unavailable" (for the refuse dialog).
-# ==========================================================
-hb_kernel_pin_plan() {
- local bk_kernel="$1"
- export HB_KERNEL_PIN_ACTION="unavailable"
- export HB_KERNEL_PIN_PKG=""
- export HB_KERNEL_PIN_KVER="$bk_kernel"
- export HB_KERNEL_PIN_REASON=""
-
- [[ -z "$bk_kernel" ]] && {
- HB_KERNEL_PIN_REASON="backup did not record its kernel release"
- return 0
- }
-
- # Signed package name convention on modern Proxmox: the actual
- # binary kernel image ships as `proxmox-kernel--signed`, the
- # unsigned build is `proxmox-kernel-` (only in special cases).
- # `pve-kernel-` was the legacy name pre-Proxmox 8; try both
- # so this works whether the backup came from an older PVE.
- local -a candidates=(
- "proxmox-kernel-${bk_kernel}-signed"
- "proxmox-kernel-${bk_kernel}"
- "pve-kernel-${bk_kernel}"
- )
- local pkg
- for pkg in "${candidates[@]}"; do
- # Already installed → nothing to fetch, just pin later.
- if dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \
- | grep -q '^install ok installed$'; then
- HB_KERNEL_PIN_ACTION="installed"
- HB_KERNEL_PIN_PKG="$pkg"
- return 0
- fi
- done
- # Not installed. Refresh apt metadata quietly, then look each
- # candidate up. `apt-cache policy` shows a non-empty "Candidate"
- # only when there IS an installable version in the enabled repos.
- apt-get update -qq >/dev/null 2>&1 || true
- for pkg in "${candidates[@]}"; do
- local cand
- cand=$(apt-cache policy "$pkg" 2>/dev/null \
- | awk '/Candidate:/ {print $2; exit}')
- if [[ -n "$cand" && "$cand" != "(none)" ]]; then
- HB_KERNEL_PIN_ACTION="installable"
- HB_KERNEL_PIN_PKG="$pkg"
- return 0
- fi
- done
-
- HB_KERNEL_PIN_ACTION="unavailable"
- HB_KERNEL_PIN_REASON="not installed on this host and not available in the configured Proxmox repositories"
- return 0
-}
-
hb_unsafe_paths_cross_version() {
cat <<'EOF'
/etc/default/grub GRUB defaults tied to prior kernel order
@@ -2811,15 +2732,116 @@ hb_unsafe_paths_cross_version() {
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
-# (triggers the safe-restore path filter that
-# skips grub/kernel/apt/dkms/... — see
-# hb_unsafe_paths_cross_version)
-# HB_COMPAT_RESULTS[] → array of "STATUS|category|message" entries
-# where STATUS ∈ {PASS, INFO, WARN, FAIL}
+# ==========================================================
+# 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.
# ==========================================================
@@ -2830,6 +2852,32 @@ hb_compat_check() {
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"
@@ -2898,10 +2946,28 @@ hb_compat_check() {
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 — same reasoning as the
- # PVE case above. Safe filter handles it.
- HB_COMPAT_RESULTS+=("INFO|Kernel|$(hb_translate "Different kernel major.minor:") $bk_kernel → $cur_kernel $(hb_translate "— safe restore mode will skip kernel-tied config")")
- HB_COMPAT_CROSS_VERSION=1
+ # 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
diff --git a/scripts/backup_restore/restore/kernel_pin_plan.sh b/scripts/backup_restore/restore/kernel_pin_plan.sh
deleted file mode 100644
index 75c281a1..00000000
--- a/scripts/backup_restore/restore/kernel_pin_plan.sh
+++ /dev/null
@@ -1,93 +0,0 @@
-#!/usr/bin/env bash
-# ==========================================================
-# kernel_pin_plan.sh — JSON reporter for cross-kernel restore
-# ==========================================================
-# Usage: bash kernel_pin_plan.sh
-# Output: JSON on stdout — one of:
-#
-# {"detected":false}
-# Backup + target run the same kernel major.minor.
-# No action needed.
-#
-# {"detected":true,
-# "backup_kernel":"6.17.2-1-pve",
-# "target_kernel":"7.0.12-1-pve",
-# "action":"installed",
-# "pkg":"proxmox-kernel-6.17.2-1-pve-signed"}
-# Cross-kernel. The backup's kernel is already installed on the
-# target; the restore flow will pin it.
-#
-# {"detected":true, ...,
-# "action":"installable",
-# "pkg":"proxmox-kernel-6.17.2-1-pve-signed"}
-# Cross-kernel. The kernel is available in Proxmox repos and
-# will be installed before the pin.
-#
-# {"detected":true, ...,
-# "action":"unavailable",
-# "reason":"not installed on this host and not available in the
-# configured Proxmox repositories"}
-# Cross-kernel. The kernel is neither installed nor obtainable.
-# The restore flow must refuse.
-#
-# The heavy lifting is delegated to hb_kernel_pin_plan in
-# lib_host_backup_common.sh, so the CLI menu and the Web Monitor
-# agree byte-for-byte on which restores are safe to start.
-# ==========================================================
-set -euo pipefail
-
-staging_root="${1:-}"
-[[ -z "$staging_root" || ! -d "$staging_root" ]] && {
- printf '{"detected":false,"error":"staging_root missing"}\n'
- exit 0
-}
-
-LIB="/usr/local/share/proxmenux/scripts/backup_restore/lib_host_backup_common.sh"
-if [[ ! -f "$LIB" ]]; then
- printf '{"detected":false,"error":"lib_host_backup_common.sh not found"}\n'
- exit 0
-fi
-# shellcheck source=/dev/null
-source "$LIB"
-
-# Read the backup's kernel release.
-bk_kernel=""
-if [[ -f "$staging_root/metadata/run_info.env" ]]; then
- bk_kernel=$(grep -m1 '^kernel=' "$staging_root/metadata/run_info.env" 2>/dev/null \
- | cut -d= -f2-)
-fi
-cur_kernel=$(uname -r 2>/dev/null || echo "")
-
-if [[ -z "$bk_kernel" || -z "$cur_kernel" ]]; then
- printf '{"detected":false}\n'
- exit 0
-fi
-
-# Compare major.minor — the same rule hb_compat_check uses.
-bk_mm=$(echo "$bk_kernel" | cut -d. -f1-2)
-cur_mm=$(echo "$cur_kernel" | cut -d. -f1-2)
-if [[ "$bk_mm" == "$cur_mm" ]]; then
- printf '{"detected":false}\n'
- exit 0
-fi
-
-# Cross-kernel: compute the pin plan.
-hb_kernel_pin_plan "$bk_kernel"
-
-# Emit JSON. jq if we have it (safer with special chars), else printf.
-if command -v jq >/dev/null 2>&1; then
- jq -cn \
- --arg bkk "$bk_kernel" \
- --arg trk "$cur_kernel" \
- --arg act "${HB_KERNEL_PIN_ACTION:-unavailable}" \
- --arg pkg "${HB_KERNEL_PIN_PKG:-}" \
- --arg rsn "${HB_KERNEL_PIN_REASON:-}" \
- '{detected:true, backup_kernel:$bkk, target_kernel:$trk,
- action:$act, pkg:$pkg, reason:$rsn}'
-else
- printf '{"detected":true,"backup_kernel":"%s","target_kernel":"%s","action":"%s","pkg":"%s","reason":"%s"}\n' \
- "$bk_kernel" "$cur_kernel" \
- "${HB_KERNEL_PIN_ACTION:-unavailable}" \
- "${HB_KERNEL_PIN_PKG:-}" \
- "${HB_KERNEL_PIN_REASON:-}"
-fi