update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-06-21 22:41:54 +02:00
parent 8e92df5bd7
commit 09a67bf4e6
30 changed files with 1495 additions and 473 deletions

View File

@@ -1 +1 @@
ad6a863fc2e344e5fc5ffeb66b09de8339bfc6b8d3dfcaa8020b4fe89abb4d2e ProxMenux-1.2.2.2-beta.AppImage
6a3aefbea44ba7260ee0a69685504ecc66c3d45ae1ddaf98deb6940975ce8e58 ProxMenux-1.2.2.2-beta.AppImage

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,12 @@ interface ScriptTerminalModalProps {
description: string
scriptName?: string
params?: Record<string, string>
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
// flow so "Press Enter to close" in the bash script actually
// closes the modal without an extra click. Other callers ignore.
onComplete?: () => void
}
export function ScriptTerminalModal({
@@ -58,6 +64,7 @@ export function ScriptTerminalModal({
title,
description,
params = { EXECUTION_MODE: "web" },
onComplete,
}: ScriptTerminalModalProps) {
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
@@ -95,6 +102,13 @@ export function ScriptTerminalModal({
paramsRef.current = params
}, [params])
// Same trick for onComplete — we want the latest callback inside
// the ws.onclose handler without re-running the connection effect.
const onCompleteRef = useRef<(() => void) | undefined>(undefined)
useEffect(() => {
onCompleteRef.current = onComplete
}, [onComplete])
const attemptReconnect = useCallback(() => {
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
return
@@ -195,6 +209,7 @@ const initMessage = {
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
} else {
setIsComplete(true)
onCompleteRef.current?.()
}
}
}
@@ -382,6 +397,7 @@ const initMessage = {
if (!isComplete) {
setIsComplete(true)
onCompleteRef.current?.()
}
}

View File

@@ -78,7 +78,7 @@ from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
from notification_manager import notification_manager # noqa: E402
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
from jwt_middleware import require_auth # noqa: E402
from jwt_middleware import require_auth, require_auth_or_ticket # noqa: E402
import auth_manager # noqa: E402
# -------------------------------------------------------------------
@@ -15826,7 +15826,7 @@ def api_host_backups_remote_archive_export_status(task_id):
@app.route('/api/host-backups/remote-archives/export/<task_id>/download', methods=['GET'])
@require_auth
@require_auth_or_ticket
def api_host_backups_remote_archive_export_download(task_id):
"""Stream the packed export to the browser. Same auth + send_file
pattern as the local archive download. Cleans up the tarball after
@@ -16121,6 +16121,22 @@ def _inspect_compose_json(staging: str) -> dict:
out['files_truncated'] = truncated
out['files_total_count'] = len(files)
# ── Rollback plan ──
# Compare backup state vs current host to surface VMs/LXCs that
# would be removed and components that would be uninstalled by
# a "restore to backup state" operation. Read-only here — the
# actual rollback is executed by apply_cluster_postboot.sh after
# the operator confirms in the Restore flow.
rollback_script = f'{scripts_dir}/compute_rollback_plan.sh'
if os.path.isfile(rollback_script):
try:
r = subprocess.run(['bash', rollback_script, staging],
capture_output=True, text=True, timeout=20)
if r.returncode == 0 and r.stdout.strip():
out['rollback_plan'] = json.loads(r.stdout)
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
pass
# ── Bundle the loose metadata sidecars for quick reference ──
if os.path.isdir(metadata):
meta_view: dict = {}
@@ -16197,6 +16213,166 @@ def api_host_backups_inspect_archive():
pass
# ──────────────────────────────────────────────────────────────
# Restore prepare/cleanup — Monitor → shell TUI handoff
# ──────────────────────────────────────────────────────────────
# The "Restore" button in the Inspect modal goes through TWO steps:
# 1. POST /restore/prepare → backend extracts the snapshot to a
# persistent staging dir under _RESTORE_STAGING_ROOT. Returns
# the staging path so the next step can find it.
# 2. The frontend launches a ScriptTerminalModal pointing at
# restore/monitor_apply.sh with that staging path + selected
# mode/components. monitor_apply.sh sources backup_host.sh and
# calls _rs_run_complete_guided / _rs_run_custom_restore — the
# exact same apply path the TUI uses.
#
# Staging dirs auto-expire: every new prepare() removes any leftover
# from previous runs older than _RESTORE_STAGING_TTL_SECONDS so a
# crashed Monitor session doesn't accumulate gigabytes on /tmp.
_RESTORE_STAGING_ROOT = '/tmp'
_RESTORE_STAGING_PREFIX = 'pmnx-restore-'
_RESTORE_STAGING_TTL_SECONDS = 6 * 3600 # 6 hours
def _restore_staging_gc():
"""Remove stale Monitor restore staging dirs. Best-effort; any
OSError silently skips the entry so a permission glitch on one
item doesn't block the new prepare."""
import time
now = time.time()
try:
entries = os.listdir(_RESTORE_STAGING_ROOT)
except OSError:
return
for entry in entries:
if not entry.startswith(_RESTORE_STAGING_PREFIX):
continue
full = os.path.join(_RESTORE_STAGING_ROOT, entry)
try:
age = now - os.path.getmtime(full)
except OSError:
continue
if age > _RESTORE_STAGING_TTL_SECONDS:
try:
shutil.rmtree(full, ignore_errors=True)
except OSError:
pass
@app.route('/api/host-backups/restore/prepare', methods=['POST'])
@require_auth
def api_host_backups_restore_prepare():
"""Extract a snapshot to a persistent staging directory. The
frontend follows up by launching monitor_apply.sh against that
staging via ScriptTerminalModal. Body is the same shape as
/inspect-archive: {source, repo_name?, snapshot?, path?}.
Returns: {staging_path}."""
payload = request.get_json(silent=True) or {}
source = (payload.get('source') or '').strip()
if source not in ('pbs', 'borg', 'local'):
return jsonify({'error': 'source must be pbs, borg, or local'}), 400
repo_dict: dict = {}
snapshot = ''
if source == 'pbs':
repo_name = (payload.get('repo_name') or '').strip()
snapshot = (payload.get('snapshot') or '').strip()
if not repo_name or not snapshot:
return jsonify({'error': 'repo_name and snapshot are required for pbs'}), 400
for r in _list_pbs_destinations():
if r.get('name') == repo_name:
repo_dict = r
break
if not repo_dict:
return jsonify({'error': f'PBS repo "{repo_name}" not configured'}), 404
elif source == 'borg':
repo_name = (payload.get('repo_name') or '').strip()
snapshot = (payload.get('snapshot') or '').strip()
if not repo_name or not snapshot:
return jsonify({'error': 'repo_name and snapshot are required for borg'}), 400
for r in _list_borg_destinations():
if r.get('name') == repo_name:
repo_dict = r
break
if not repo_dict:
return jsonify({'error': f'Borg repo "{repo_name}" not configured'}), 404
else:
path = (payload.get('path') or '').strip()
if not path or not os.path.isfile(path):
return jsonify({'error': f'local archive not found: {path}'}), 404
repo_dict = {'path': path}
_restore_staging_gc()
import tempfile
staging = tempfile.mkdtemp(prefix=_RESTORE_STAGING_PREFIX, dir=_RESTORE_STAGING_ROOT)
try:
ok, err = _inspect_extract_to_staging(source, repo_dict, snapshot, staging)
if not ok:
shutil.rmtree(staging, ignore_errors=True)
return jsonify({'error': err}), 500
if not _inspect_normalize_layout(staging):
shutil.rmtree(staging, ignore_errors=True)
return jsonify({'error': 'archive layout not recognized — no rootfs/ found'}), 500
# List the real backup paths so the Custom restore checklist
# only offers what's actually present (default profile + the
# operator's extras). Mirrors what _rs_select_component_paths
# in backup_host.sh now does after the paths-not-components
# refactor — perfect backup↔restore parity.
paths_available: list = []
list_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/list_paths.sh'
if os.path.isfile(list_script):
try:
r = subprocess.run(['bash', list_script, staging],
capture_output=True, text=True, timeout=30)
if r.returncode == 0 and r.stdout.strip():
paths_available = json.loads(r.stdout.strip())
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
pass
rollback_plan: dict = {}
rollback_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/compute_rollback_plan.sh'
if os.path.isfile(rollback_script):
try:
r = subprocess.run(['bash', rollback_script, staging],
capture_output=True, text=True, timeout=20)
if r.returncode == 0 and r.stdout.strip():
rollback_plan = json.loads(r.stdout)
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
pass
return jsonify({
'staging_path': staging,
'paths_available': paths_available,
'rollback_plan': rollback_plan,
})
except Exception as e:
shutil.rmtree(staging, ignore_errors=True)
return jsonify({'error': str(e)}), 500
@app.route('/api/host-backups/restore/cleanup', methods=['POST'])
@require_auth
def api_host_backups_restore_cleanup():
"""Explicit cleanup for a staging dir. Called by the UI after the
ScriptTerminalModal closes. Best-effort: the GC in prepare() will
catch anything missed here within _RESTORE_STAGING_TTL_SECONDS."""
payload = request.get_json(silent=True) or {}
staging = (payload.get('staging_path') or '').strip()
# Defense in depth: only allow paths under the staging root with
# the expected prefix. Anything else could be the operator trying
# to coerce the endpoint into rm-ing arbitrary files.
if (not staging
or not staging.startswith(os.path.join(_RESTORE_STAGING_ROOT, _RESTORE_STAGING_PREFIX))
or '..' in staging.split(os.sep)):
return jsonify({'error': 'invalid staging_path'}), 400
try:
shutil.rmtree(staging, ignore_errors=True)
except OSError as e:
return jsonify({'error': str(e)}), 500
return jsonify({'status': 'ok'})
@app.route('/api/host-backups/archives/<path:archive_id>/manifest', methods=['GET'])
@require_auth
def api_host_backups_archive_manifest(archive_id):
@@ -16341,7 +16517,7 @@ def api_host_backups_archive_prepare_restore(archive_id):
@app.route('/api/host-backups/archives/<path:archive_id>/download', methods=['GET'])
@require_auth
@require_auth_or_ticket
def api_host_backups_archive_download(archive_id):
"""Stream the .tar.zst archive back to the operator with a sane
Content-Disposition. send_file handles range requests + streaming

View File

@@ -66,6 +66,47 @@ def require_auth(f):
return decorated_function
def require_auth_or_ticket(f):
"""Like `require_auth` but ALSO accepts a single-use `?ticket=...`
query parameter (same tickets `/api/terminal/ticket` issues for
WebSockets). Use on endpoints that the browser needs to invoke
from an <a download href="..."> tag — anchor tags can't send the
Authorization header, so the caller fetches a ticket first and
appends it to the URL.
Use only for streaming downloads where adding `?ticket=...` is
the only practical way to authenticate; everything else stays
on plain Bearer-token auth."""
@wraps(f)
def decorated_function(*args, **kwargs):
config = load_auth_config()
if not config.get("enabled", False) or config.get("declined", False):
return f(*args, **kwargs)
# First try the Bearer header (fetch from JS uses this).
auth_header = request.headers.get('Authorization')
if auth_header:
parts = auth_header.split()
if len(parts) == 2 and parts[0].lower() == 'bearer':
if verify_token(parts[1]):
return f(*args, **kwargs)
# Fall through to single-use ticket (works for <a download>).
try:
from flask_terminal_routes import _consume_terminal_ticket
if _consume_terminal_ticket(request.args.get('ticket', '')):
return f(*args, **kwargs)
except ImportError:
pass
return jsonify({
"error": "Authentication required",
"message": "Provide a Bearer token in the Authorization header or a fresh ?ticket=... from /api/terminal/ticket"
}), 401
return decorated_function
def require_admin_scope(f):
"""Like `require_auth` but ALSO requires the token's `scope == full_admin`.

View File

@@ -378,6 +378,40 @@ POSTBOOT_END_EPOCH=$(date +%s)
POSTBOOT_DURATION=$((POSTBOOT_END_EPOCH - $(stat -c %Y "$LOG_FILE")))
POSTBOOT_DURATION_FMT=$(printf '%dm%02ds' $((POSTBOOT_DURATION / 60)) $((POSTBOOT_DURATION % 60)))
# ── Rollback delta report (read-only) ──────────────────────────
# If _rs_prepare_pending_restore left a rollback.json in the
# pending dir, surface the deltas that a full rollback would
# touch: VMs/LXCs created after the backup that are still here,
# extra components not present in the backup. The operator
# decides what to do with them — we don't destroy guests or
# uninstall packages automatically (left to a future R2.D fase 2
# once every installer ships an --auto-uninstall mode).
ROLLBACK_PLAN_FILE=""
[[ -n "$PENDING_DIR" && -f "$PENDING_DIR/rollback.json" ]] && \
ROLLBACK_PLAN_FILE="$PENDING_DIR/rollback.json"
if [[ -n "$ROLLBACK_PLAN_FILE" ]] && command -v jq >/dev/null 2>&1; then
rb_vm_extras=$(jq -r '.vms_to_remove | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null)
rb_lxc_extras=$(jq -r '.lxcs_to_remove | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null)
rb_comp_extras=$(jq -r '.components_to_uninstall | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null)
if [[ -n "$rb_vm_extras" || -n "$rb_lxc_extras" || -n "$rb_comp_extras" ]]; then
echo ""
echo "── Rollback delta report ──"
echo "These entries exist on the host but were NOT in the restored backup."
echo "Review them and remove manually if a clean rollback is desired:"
[[ -n "$rb_vm_extras" ]] && echo " • VMs created after the backup: $rb_vm_extras"
[[ -n "$rb_lxc_extras" ]] && echo " • LXCs created after the backup: $rb_lxc_extras"
[[ -n "$rb_comp_extras" ]] && echo " • Components installed after the backup: $rb_comp_extras"
echo ""
echo "Manual cleanup commands (run only if you want to truly roll back):"
for _id in $(echo "$rb_vm_extras" | tr ',' ' '); do
[[ -n "$_id" ]] && echo " qm stop $_id 2>/dev/null; qm destroy $_id --purge"
done
for _id in $(echo "$rb_lxc_extras" | tr ',' ' '); do
[[ -n "$_id" ]] && echo " pct stop $_id 2>/dev/null; pct destroy $_id --purge"
done
fi
fi
# ── Notify ProxMenux Monitor that we're done ───────────────────
# Routes through the user's configured channels (Telegram, Discord,
# ntfy, etc.). Localhost-only endpoint, no auth needed. We try

0
scripts/backup_restore/apply_pending_restore.sh Normal file → Executable file
View File

367
scripts/backup_restore/backup_host.sh Normal file → Executable file
View File

@@ -1456,8 +1456,16 @@ _rs_apply() {
done
elapsed=$((SECONDS - t_start))
[[ "$group" == "hot" || "$group" == "all" ]] && \
# Skip `systemctl daemon-reload` when invoked from the Monitor
# (HB_MONITOR_FLOW=1). The reload itself doesn't restart the
# Monitor's unit, but it marks units as "needs restart" and a
# later systemctl call against the Monitor would cut the WS
# session. The restored unit files are already on disk — they
# take effect at the next reboot, which the Monitor flow asks
# for explicitly at the end.
if [[ "$group" == "hot" || "$group" == "all" ]] && [[ "${HB_MONITOR_FLOW:-0}" != "1" ]]; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
echo -e ""
echo -e "${TAB}${BOLD}$(translate "Restore applied:")${CL}"
@@ -1603,6 +1611,15 @@ _rs_prompt_zfs_opt_in() {
_rs_finish_flow() {
echo -e ""
if [[ "${HB_MONITOR_FLOW:-0}" == "1" ]]; then
# Same UX as the TUI but the prompt explicitly says "close"
# — the Monitor's ScriptTerminalModal sees isComplete=true on
# WS close and dismisses (via the onComplete prop) so the
# operator doesn't need to click the Close button.
msg_success "$(translate "Press Enter to close...")"
read -r
return 0
fi
msg_success "$(translate "Press Enter to return to menu...")"
read -r
}
@@ -1661,6 +1678,10 @@ _rs_offer_reboot_after_pending() {
local prompt="$(translate "Pending restore prepared. A reboot is required to complete it.")"$'\n\n'"${bg_block}$(translate "Reboot now?")"
# Same dialog for TUI and Monitor — mirrors the post-install
# flow (Yes → reboot, No → "You can reboot later manually").
# The Monitor's PTY backs whiptail correctly, so the operator
# sees the same Yes/No prompt as in the shell.
if whiptail --title "$(translate "Reboot Required")" \
--yesno "$prompt" 22 90; then
msg_warn "$(translate "Rebooting the system...")"
@@ -1800,6 +1821,19 @@ _rs_prepare_pending_restore() {
[[ -d "$staging_root/metadata" ]] && cp -a "$staging_root/metadata/." "$pending_dir/metadata/" 2>/dev/null || true
# Persist the rollback plan so apply_cluster_postboot.sh can
# surface a clear "what differs vs backup" report after boot.
# Read-only here — the script just informs the operator about
# VMs/LXCs/components that exist now but not in the backup.
# Actual destroy is left manual until --auto-uninstall lands
# on every installer.
local _rb_script="${SCRIPT_DIR}/restore/compute_rollback_plan.sh"
[[ ! -x "$_rb_script" ]] && _rb_script="${LOCAL_SCRIPTS:-/usr/local/share/proxmenux/scripts}/backup_restore/restore/compute_rollback_plan.sh"
if [[ -x "$_rb_script" ]]; then
bash "$_rb_script" "$staging_root" > "$pending_dir/rollback.json" 2>/dev/null || true
[[ ! -s "$pending_dir/rollback.json" ]] && rm -f "$pending_dir/rollback.json"
fi
cat > "$pending_dir/plan.env" <<EOF
RESTORE_ID=${restore_id}
CREATED_AT=${created_at}
@@ -2061,18 +2095,22 @@ _rs_component_is_available() {
}
_rs_unique_paths() {
local __out_var="$1"
# All locals prefixed with `_rup_` so the nameref doesn't bind
# to one of OUR locals when a caller passes the same array name
# (e.g. `_rs_unique_paths uniq "$@"` — bash would otherwise
# resolve `__out_ref → uniq` to our local instead of the caller's).
local _rup_out_var="$1"
shift
local -A seen=()
local -a uniq=()
local p
for p in "$@"; do
[[ -z "$p" || -n "${seen[$p]}" ]] && continue
seen["$p"]=1
uniq+=("$p")
local -A _rup_seen=()
local -a _rup_uniq=()
local _rup_p
for _rup_p in "$@"; do
[[ -z "$_rup_p" || -n "${_rup_seen[$_rup_p]}" ]] && continue
_rup_seen["$_rup_p"]=1
_rup_uniq+=("$_rup_p")
done
local -n __out_ref="$__out_var"
__out_ref=("${uniq[@]}")
local -n _rup_out_ref="$_rup_out_var"
_rup_out_ref=("${_rup_uniq[@]}")
}
_rs_collect_stats_for_paths() {
@@ -2125,52 +2163,95 @@ _rs_warn_dangerous_paths() {
_rs_select_component_paths() {
local staging_root="$1"
local __out_var="$2"
local -n __out_ref="$__out_var"
# IMPORTANT: every local inside this function must use a unique
# prefix (`_rsp_`). Bash nameref resolution walks the dynamic
# scope outwards and stops at the first matching name — if we
# declare `local -a selected_paths=()` while the caller's array
# is also called `selected_paths`, _rs_unique_paths's
# `local -n __out_ref="selected_paths"` would resolve to OUR
# local one and the caller's array silently stays empty (which
# surfaced as the "Selected paths produced no entries to apply"
# dialog despite a non-empty selection).
local _rsp_out_var="$2"
local -n _rsp_out_ref="$_rsp_out_var"
local -a component_ids=(
network ssh_access host_identity cron_jobs apt_repos kernel_boot
systemd_custom scripts root_config root_ssh zfs_cfg postfix_cfg cluster_cfg
)
local -a checklist=()
local comp_id
for comp_id in "${component_ids[@]}"; do
_rs_component_is_available "$staging_root" "$comp_id" || continue
checklist+=("$comp_id" "$(_rs_component_label "$comp_id")" "off")
done
if [[ ${#checklist[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "No components available")" \
--msgbox "$(translate "No restorable components were detected in this backup.")" 8 68
return 1
# ── Build the list of paths actually present in this backup ──
local -a _rsp_backup=()
local _rsp_selected_file="$staging_root/metadata/selected_paths.txt"
if [[ -f "$_rsp_selected_file" ]]; then
local _rsp_rel
while IFS= read -r _rsp_rel; do
[[ -z "$_rsp_rel" ]] && continue
_rsp_rel="${_rsp_rel#/}"
[[ -e "$staging_root/rootfs/$_rsp_rel" ]] && _rsp_backup+=("$_rsp_rel")
done < "$_rsp_selected_file"
else
# Pre-metadata backup: walk rootfs/ at depth 1-2.
local _rsp_entry
while IFS= read -r _rsp_entry; do
[[ -z "$_rsp_entry" ]] && continue
_rsp_entry="${_rsp_entry#./}"
_rsp_backup+=("$_rsp_entry")
done < <(cd "$staging_root/rootfs" 2>/dev/null \
&& find . -mindepth 1 -maxdepth 2 -print 2>/dev/null)
fi
local selected
selected=$(dialog --backtitle "ProxMenux" --separate-output \
--title "$(translate "Custom restore by components")" \
--checklist "\n$(translate "Select components to restore:")" \
24 94 14 "${checklist[@]}" 3>&1 1>&2 2>&3) || return 1
if [[ -z "$selected" ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "No components selected")" \
--msgbox "$(translate "Select at least one component to continue.")" 8 66
return 1
fi
local -a selected_paths=()
while IFS= read -r comp_id; do
[[ -z "$comp_id" ]] && continue
local rel
while IFS= read -r rel; do
[[ -n "$rel" && -e "$staging_root/rootfs/$rel" ]] && selected_paths+=("$rel")
done < <(_rs_component_paths "$comp_id")
done <<< "$selected"
_rs_unique_paths "$__out_var" "${selected_paths[@]}"
if [[ ${#__out_ref[@]} -eq 0 ]]; then
if [[ ${#_rsp_backup[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "No paths available")" \
--msgbox "$(translate "Selected components have no matching paths in this backup.")" 8 72
--msgbox "$(translate "This backup does not contain any restorable paths.")" 8 70
return 1
fi
# ── Non-interactive bypass for the Monitor ──
local _rsp_selected=""
if [[ -n "${HB_PRESELECTED_PATHS:-}" ]]; then
local IFS=','
local _rsp_pp
for _rsp_pp in $HB_PRESELECTED_PATHS; do
_rsp_pp="${_rsp_pp#"${_rsp_pp%%[![:space:]]*}"}"
_rsp_pp="${_rsp_pp%"${_rsp_pp##*[![:space:]]}"}"
[[ -z "$_rsp_pp" ]] && continue
_rsp_pp="${_rsp_pp#/}"
local _rsp_known
for _rsp_known in "${_rsp_backup[@]}"; do
[[ "$_rsp_pp" == "$_rsp_known" ]] && { _rsp_selected+="${_rsp_pp}"$'\n'; break; }
done
done
unset IFS
fi
# ── Dialog checklist (one entry per real backup path) ──
if [[ -z "$_rsp_selected" ]]; then
local -a _rsp_checklist=()
local _rsp_p
for _rsp_p in "${_rsp_backup[@]}"; do
_rsp_checklist+=("$_rsp_p" "/$_rsp_p" "off")
done
_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):")" \
24 94 16 "${_rsp_checklist[@]}" 3>&1 1>&2 2>&3) || return 1
if [[ -z "$_rsp_selected" ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "Nothing selected")" \
--msgbox "$(translate "Select at least one path to continue.")" 8 66
return 1
fi
fi
local -a _rsp_picked=()
local _rsp_line
while IFS= read -r _rsp_line; do
[[ -z "$_rsp_line" ]] && continue
_rsp_picked+=("$_rsp_line")
done <<< "$_rsp_selected"
_rs_unique_paths "$_rsp_out_var" "${_rsp_picked[@]}"
if [[ ${#_rsp_out_ref[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "Nothing to restore")" \
--msgbox "$(translate "Selected paths produced no entries to apply.")" 8 70
return 1
fi
return 0
@@ -2183,127 +2264,74 @@ _rs_run_custom_restore() {
_rs_select_component_paths "$staging_root" selected_paths || return 1
_rs_collect_stats_for_paths "${selected_paths[@]}"
while true; do
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Custom restore")" \
--menu "\n$(translate "Selected component paths:") ${RS_SEL_TOTAL}" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Apply safe changes now") (${RS_SEL_HOT})" \
2 "$(translate "Apply safe + reboot-required") ($((RS_SEL_HOT + RS_SEL_REBOOT)))" \
3 "$(translate "Apply all selected now (advanced)") (${RS_SEL_TOTAL})" \
4 "$(translate "Reselect components")" \
5 "$(translate "Apply safe now + schedule remaining for next boot")" \
6 "$(translate "Schedule selected components for next boot (no live apply)")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || return 1
# ── Smart auto-strategy (no 6-option menu any more) ──
# The classifier already split every selected path into:
# RS_SEL_HOT — applyable live, no risk
# RS_SEL_REBOOT — needs reboot to take effect
# RS_SEL_DANGEROUS — applyable live but risky (e.g. network
# from a SSH session)
# The operator doesn't need to know that taxonomy. We just:
# 1. Show ONE confirmation summarizing what happens.
# 2. Apply hot paths now.
# 3. Schedule reboot + dangerous paths for next boot.
# 4. Tell the operator a reboot is needed when there's pending.
case "$choice" in
1)
if [[ "$RS_SEL_HOT" -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "Nothing to apply")" \
--msgbox "$(translate "No safe-now paths in selected components.")" 8 60
continue
fi
if ! whiptail --title "$(translate "Confirm")" \
--yesno "$(translate "Apply safe changes from selected components now?")" 9 72; then
continue
fi
show_proxmenux_logo
msg_title "$(translate "Applying selected safe changes")"
_rs_apply "$staging_root" hot "${selected_paths[@]}"
[[ "$RS_SEL_REBOOT" -gt 0 || "$RS_SEL_DANGEROUS" -gt 0 ]] && \
msg_warn "$(translate "Some selected paths were not applied in safe mode.")"
_rs_finish_flow
return 0
;;
if [[ "$RS_SEL_TOTAL" -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "Nothing to restore")" \
--msgbox "$(translate "Selected paths produced no entries to apply.")" 8 70
return 1
fi
2)
if [[ $((RS_SEL_HOT + RS_SEL_REBOOT)) -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "Nothing to apply")" \
--msgbox "$(translate "No safe/reboot paths in selected components.")" 8 64
continue
fi
if ! whiptail --title "$(translate "Confirm")" \
--yesno "$(translate "Apply safe + reboot-required paths from selected components now?")"$'\n\n'"$(translate "Risky live paths will be skipped.")" \
11 78; then
continue
fi
show_proxmenux_logo
msg_title "$(translate "Applying selected safe + reboot changes")"
[[ "$RS_SEL_HOT" -gt 0 ]] && _rs_apply "$staging_root" hot "${selected_paths[@]}"
[[ "$RS_SEL_REBOOT" -gt 0 ]] && _rs_apply "$staging_root" reboot "${selected_paths[@]}"
[[ "$RS_SEL_DANGEROUS" -gt 0 ]] && \
msg_warn "$(translate "Risky selected paths were skipped in this mode.")"
_rs_finish_flow
return 0
;;
# SSH/network protection — if the operator is on a SSH session
# and selected network paths, offer to move EVERYTHING to next
# boot (avoids disconnection mid-restore). _rs_handle_ssh_network_risk
# returns 2 when it has already scheduled for boot — in that case
# we're done.
local ssh_network_rc
_rs_handle_ssh_network_risk "$staging_root" "${selected_paths[@]}"
ssh_network_rc=$?
[[ $ssh_network_rc -eq 2 ]] && return 0
[[ $ssh_network_rc -ne 0 ]] && return 1
3)
local ssh_network_rc
_rs_handle_ssh_network_risk "$staging_root" "${selected_paths[@]}"
ssh_network_rc=$?
[[ $ssh_network_rc -eq 2 ]] && return 0
[[ $ssh_network_rc -ne 0 ]] && continue
local hot_count="$RS_SEL_HOT"
local pending_count=$(( RS_SEL_REBOOT + RS_SEL_DANGEROUS ))
[[ "$RS_SEL_DANGEROUS" -gt 0 ]] && _rs_warn_dangerous_paths "${selected_paths[@]}"
if ! whiptail --title "$(translate "Final confirmation")" \
--yesno "$(translate "Apply ALL selected component paths now? This can include risky paths.")" \
10 78; then
continue
fi
show_proxmenux_logo
msg_title "$(translate "Applying all selected component paths")"
_rs_apply "$staging_root" all "${selected_paths[@]}"
_rs_finish_flow
return 0
;;
local body
body="\Zb$(translate "About to restore") ${RS_SEL_TOTAL} $(translate "selected path(s):")\ZB"$'\n\n'
if (( hot_count > 0 )); then
body+="$(translate "Apply") \Zb\Z4${hot_count}\Zn $(translate "now")"$'\n'
fi
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 (( pending_count > 0 )); then
body+=$'\n'"\Zb\Z4$(translate "A reboot will be required to complete the restore.")\Zn"$'\n'
fi
body+=$'\n'"\Zb$(translate "Continue?")\ZB"
4)
_rs_select_component_paths "$staging_root" selected_paths || continue
_rs_collect_stats_for_paths "${selected_paths[@]}"
;;
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Confirm custom restore")" \
--yesno "$body" 14 82; then
return 1
fi
5)
if ! whiptail --title "$(translate "Confirm")" \
--yesno "$(translate "Apply safe selected paths now and schedule remaining selected paths for next boot?")" \
10 82; then
continue
fi
show_proxmenux_logo
msg_title "$(translate "Applying safe selected paths and preparing pending restore")"
[[ "$RS_SEL_HOT" -gt 0 ]] && _rs_apply "$staging_root" hot "${selected_paths[@]}"
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${selected_paths[@]}")
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
fi
_rs_finish_flow
return 0
;;
show_proxmenux_logo
msg_title "$(translate "Applying custom restore")"
6)
if ! whiptail --title "$(translate "Confirm")" \
--yesno "$(translate "Schedule selected component paths for next boot without applying live changes now?")" \
10 82; then
continue
fi
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths all_selected "${selected_paths[@]}")
show_proxmenux_logo
msg_title "$(translate "Preparing selected pending restore")"
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to apply the scheduled restore.")"
fi
_rs_finish_flow
return 0
;;
if (( hot_count > 0 )); then
_rs_apply "$staging_root" hot "${selected_paths[@]}"
fi
0)
return 1
;;
esac
done
if (( pending_count > 0 )); then
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${selected_paths[@]}")
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
fi
fi
_rs_finish_flow
return 0
}
# Extras the Complete restore runs INLINE after applying file
@@ -2562,4 +2590,9 @@ main_menu() {
done
}
main_menu
# Only spawn the TUI when invoked directly. Sourcing the file
# (e.g. from restore/monitor_apply.sh) imports all the functions
# without triggering the main menu.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main_menu
fi

0
scripts/backup_restore/backup_scheduler.sh Normal file → Executable file
View File

0
scripts/backup_restore/collectors/build_manifest.sh Normal file → Executable file
View File

0
scripts/backup_restore/collectors/collect_guests.sh Normal file → Executable file
View File

0
scripts/backup_restore/collectors/collect_hardware.sh Normal file → Executable file
View File

0
scripts/backup_restore/collectors/collect_kernel.sh Normal file → Executable file
View File

View File

View File

0
scripts/backup_restore/collectors/collect_storage.sh Normal file → Executable file
View File

0
scripts/backup_restore/lib_host_backup_common.sh Normal file → Executable file
View File

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux — compute rollback plan (read-only)
# ==========================================================
# Compares a staged restore vs the current host state and emits
# a JSON document describing what a "rollback to backup state"
# would do:
#
# {
# "backup_time": "...",
# "vms_in_backup": [100, 101],
# "vms_in_host": [100, 101, 105],
# "vms_to_remove": [105], // exist now, not in backup
# "vms_to_restore": [100, 101], // present in both → reapplied
# "lxcs_in_backup": [201],
# "lxcs_in_host": [201, 202],
# "lxcs_to_remove": [202],
# "lxcs_to_restore": [201],
# "components_to_uninstall": ["coral_driver"], // installed now, missing in backup
# "components_to_reinstall": ["nvidia_driver"] // installed in backup
# }
#
# No side effects whatsoever — pure inspection. The actual
# rollback is applied later by apply_cluster_postboot.sh after
# the operator confirms in the UI.
#
# Usage:
# compute_rollback_plan.sh <staging-directory>
#
# The staging directory must already be the post-extract layout
# (rootfs/ + metadata/) — list_paths.sh's normalization should
# have run first.
# ==========================================================
set -e
STAGING="${1:-}"
if [[ -z "$STAGING" || ! -d "$STAGING" ]]; then
printf 'compute_rollback_plan: usage: %s <staging-directory>\n' "$0" >&2
exit 64
fi
# ── Discover guest configs (VMs + LXCs) on both sides ──
# /etc/pve/nodes/<node>/qemu-server/<id>.conf for VMs
# /etc/pve/nodes/<node>/lxc/<id>.conf for LXCs
# We collect just the numeric IDs; the rollback only needs to
# know "exists in A, exists in B" — actual config rewrite is
# done by the standard restore path for /etc/pve.
_collect_ids() {
# $1 = root directory, $2 = subdir under /etc/pve/nodes/<n>/
local root="$1" sub="$2"
if [[ -d "$root/etc/pve/nodes" ]]; then
find "$root/etc/pve/nodes" -mindepth 3 -maxdepth 3 -path "*/$sub/*.conf" -print 2>/dev/null \
| sed -E "s|.*/([0-9]+)\\.conf$|\\1|" | sort -un
fi
}
mapfile -t vms_backup < <(_collect_ids "$STAGING/rootfs" qemu-server)
mapfile -t vms_host < <(_collect_ids "/" qemu-server)
mapfile -t lxcs_backup < <(_collect_ids "$STAGING/rootfs" lxc)
mapfile -t lxcs_host < <(_collect_ids "/" lxc)
_diff() {
# Echoes lines from A that are NOT in B.
local -n a_ref=$1
local -n b_ref=$2
local x y found
for x in "${a_ref[@]}"; do
found=0
for y in "${b_ref[@]}"; do
[[ "$x" == "$y" ]] && { found=1; break; }
done
(( found == 0 )) && echo "$x"
done
}
mapfile -t vms_to_remove < <(_diff vms_host vms_backup)
mapfile -t vms_to_restore < <(_diff vms_backup vms_host || true; for x in "${vms_backup[@]}"; do for y in "${vms_host[@]}"; do [[ "$x" == "$y" ]] && echo "$x"; done; done | sort -un)
mapfile -t vms_to_restore < <(printf '%s\n' "${vms_backup[@]}")
mapfile -t lxcs_to_remove < <(_diff lxcs_host lxcs_backup)
mapfile -t lxcs_to_restore < <(printf '%s\n' "${lxcs_backup[@]}")
# ── Components: parse JSON on both sides, list deltas ──
backup_components_file="$STAGING/rootfs/usr/local/share/proxmenux/components_status.json"
host_components_file="/usr/local/share/proxmenux/components_status.json"
_installed_keys() {
# Echoes one component key per line for `status == "installed"`.
local f="$1"
[[ ! -f "$f" ]] && return 0
command -v jq >/dev/null 2>&1 || { return 0; }
jq -r 'to_entries[] | select(.value.status == "installed") | .key' "$f" 2>/dev/null | sort -u
}
mapfile -t comps_backup < <(_installed_keys "$backup_components_file")
mapfile -t comps_host < <(_installed_keys "$host_components_file")
mapfile -t comps_to_uninstall < <(_diff comps_host comps_backup)
mapfile -t comps_to_reinstall < <(printf '%s\n' "${comps_backup[@]}")
# ── Backup timestamp from metadata for the UI ──
backup_time=""
if [[ -f "$STAGING/metadata/run_info.env" ]]; then
backup_time=$(grep -m1 '^generated_at=' "$STAGING/metadata/run_info.env" 2>/dev/null \
| cut -d= -f2-)
fi
# ── Emit JSON. Pure printf — no jq required for the writer ──
_json_arr_int() {
printf '['
local first=1 v
for v in "$@"; do
[[ -z "$v" ]] && continue
if (( first )); then printf '%s' "$v"; first=0
else printf ',%s' "$v"
fi
done
printf ']'
}
_json_arr_str() {
printf '['
local first=1 v esc
for v in "$@"; do
[[ -z "$v" ]] && continue
esc="${v//\\/\\\\}"; esc="${esc//\"/\\\"}"
if (( first )); then printf '"%s"' "$esc"; first=0
else printf ',"%s"' "$esc"
fi
done
printf ']'
}
printf '{'
printf '"backup_time":"%s"' "${backup_time//\"/\\\"}"
printf ',"vms_in_backup":'; _json_arr_int "${vms_backup[@]}"
printf ',"vms_in_host":'; _json_arr_int "${vms_host[@]}"
printf ',"vms_to_remove":'; _json_arr_int "${vms_to_remove[@]}"
printf ',"vms_to_restore":'; _json_arr_int "${vms_to_restore[@]}"
printf ',"lxcs_in_backup":'; _json_arr_int "${lxcs_backup[@]}"
printf ',"lxcs_in_host":'; _json_arr_int "${lxcs_host[@]}"
printf ',"lxcs_to_remove":'; _json_arr_int "${lxcs_to_remove[@]}"
printf ',"lxcs_to_restore":'; _json_arr_int "${lxcs_to_restore[@]}"
printf ',"components_to_uninstall":'; _json_arr_str "${comps_to_uninstall[@]}"
printf ',"components_to_reinstall":'; _json_arr_str "${comps_to_reinstall[@]}"
printf '}\n'

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux — list backup paths actually present in staging
# ==========================================================
# Reads <staging>/metadata/selected_paths.txt (the canonical
# record of WHICH paths went into this backup) and emits a JSON
# array of those that still exist under <staging>/rootfs/.
#
# This replaces the 13-component grouping used previously: the
# Custom restore checklist now shows one entry per real path
# (default profile + any operator-added extras), so what you
# backed up is exactly what you can restore.
#
# Usage:
# list_paths.sh <staging-directory>
#
# Output: JSON array of paths with a leading slash, e.g.:
# ["/etc/pve","/etc/network","/opt/mistuff"]
# ==========================================================
set -e
STAGING="${1:-}"
if [[ -z "$STAGING" || ! -d "$STAGING" ]]; then
printf 'list_paths: usage: %s <staging-directory>\n' "$0" >&2
exit 64
fi
SELECTED="$STAGING/metadata/selected_paths.txt"
ROOTFS="$STAGING/rootfs"
paths=()
if [[ -f "$SELECTED" ]]; then
# The file stores paths without the leading slash (etc/pve,
# var/lib/pve-cluster, ...). Filter to only those that are
# actually present in the staged rootfs/ — anything in
# missing_paths.txt at backup time would otherwise show up
# here even though there's nothing to restore.
while IFS= read -r rel; do
[[ -z "$rel" ]] && continue
rel="${rel#/}"
if [[ -e "$ROOTFS/$rel" ]]; then
paths+=("/$rel")
fi
done < "$SELECTED"
else
# No selected_paths.txt sidecar — backup might be from a
# pre-metadata era. Walk rootfs/ at depth 1-2 and use those
# as the path list. Best-effort fallback.
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
paths+=("/${entry#./}")
done < <(cd "$ROOTFS" 2>/dev/null && find . -mindepth 1 -maxdepth 2 -print 2>/dev/null | sed 's|^\./||')
fi
# Emit JSON array — keep it dependency-free (no jq).
printf '['
first=1
for p in "${paths[@]}"; do
esc="${p//\\/\\\\}"
esc="${esc//\"/\\\"}"
if (( first )); then
printf '"%s"' "$esc"
first=0
else
printf ',"%s"' "$esc"
fi
done
printf ']\n'

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# ==========================================================
# ProxMenux Monitor restore wrapper
# ==========================================================
# Bridges the Monitor's "Restore" button to the TUI restore
# functions in backup_host.sh. The Monitor takes care of the
# extraction (snapshot → staging directory + manifest), then
# launches this script in a ScriptTerminalModal so the operator
# sees the same dialog flow used by the shell TUI.
#
# The Monitor's script_runner only forwards env vars (no argv).
# Inputs (env):
# STAGING — absolute path to the prepared staging dir
# MODE — "full" or "custom"
# PATHS — CSV of absolute backup paths, only when MODE=custom
# (e.g. "/etc/pve,/etc/network,/opt/mistuff").
# Each must be present in <staging>/metadata/selected_paths.txt
# or the wrapper drops it. Omit to let the TUI checklist
# pop up so the operator can pick interactively.
# ==========================================================
# Note: NO `set -e` here. backup_host.sh + its sourced utils/lib
# emit a few non-zero exit codes during load (locale checks,
# optional commands missing, etc.) — under set -e those abort the
# wrapper silently and the operator only sees "Connection closed"
# in the Monitor terminal with no clue why. We rely on explicit
# error handling inside _rs_run_complete_guided / _rs_run_custom_restore.
STAGING="${STAGING:-}"
MODE="${MODE:-full}"
PATHS="${PATHS:-}"
# Also accept --staging / --mode / --paths positional args for
# direct CLI use outside the Monitor (handy for debugging).
while [[ $# -gt 0 ]]; do
case "$1" in
--staging) shift; STAGING="${1:-}" ;;
--mode) shift; MODE="${1:-full}" ;;
--paths) shift; PATHS="${1:-}" ;;
*) ;;
esac
shift
done
if [[ -z "$STAGING" || ! -d "$STAGING" ]]; then
printf 'monitor_apply: STAGING missing or not a directory (%s)\n' "$STAGING" >&2
exit 64
fi
HB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Source the library + the TUI. backup_host.sh carries a guard so
# sourcing it does NOT spawn the main menu — only the functions
# (_rs_check_layout, _rs_run_complete_guided, _rs_run_custom_restore,
# _rs_apply, ...) get loaded.
# shellcheck source=/dev/null
. "$HB_DIR/lib_host_backup_common.sh"
# shellcheck source=/dev/null
. "$HB_DIR/backup_host.sh"
# Normalize the staging layout to the canonical rootfs/+metadata/
# shape. The Monitor's Python extract path already does this, but
# rerunning here keeps the wrapper safe when invoked directly.
if ! _rs_check_layout "$STAGING"; then
printf 'monitor_apply: archive layout not recognized in %s\n' "$STAGING" >&2
exit 65
fi
# Tell the apply helpers that we're running under the Monitor.
# Two effects:
# • _rs_apply skips its final `systemctl daemon-reload` so the
# Monitor service unit isn't marked "needs restart" and the
# WebSocket survives the apply (the restored unit file IS
# written to disk — it'll take effect at the next reboot).
# • _rs_run_complete_guided / _rs_run_custom_restore skip the
# second "Reboot now?" dialog and instead emit one final
# "Restore prepared — reboot when ready" line. The operator
# closes the modal in the web UI and reboots from the host
# panel; no need for two consecutive yes/no prompts.
export HB_MONITOR_FLOW=1
case "$MODE" in
full)
# The TUI path runs `_rs_collect_plan_stats` before calling
# `_rs_run_complete_guided`. We were skipping it here and that's
# why the confirm dialog showed "0 rutas seguras ahora /
# 0 rutas para el próximo arranque" when launched from the Monitor.
_rs_collect_plan_stats "$STAGING"
_rs_run_complete_guided "$STAGING"
;;
custom)
if [[ -n "$PATHS" ]]; then
HB_PRESELECTED_PATHS="$PATHS" _rs_run_custom_restore "$STAGING"
else
_rs_run_custom_restore "$STAGING"
fi
;;
*)
printf 'monitor_apply: unknown MODE %s (expected: full | custom)\n' "$MODE" >&2
exit 66
;;
esac
# Clean exit. _rs_finish_flow under HB_MONITOR_FLOW already returns
# without waiting for Enter, so falling off the end here closes the
# PTY → the WS closes → the modal sees `isComplete=true` (script_runner
# sends "[Script exited with code 0]" on normal termination) so the
# ScriptTerminalModal won't try to auto-reconnect.

0
scripts/backup_restore/restore/parse_manifest.sh Normal file → Executable file
View File

0
scripts/backup_restore/restore/preflight_checks.sh Normal file → Executable file
View File

0
scripts/backup_restore/restore/reinstall_drivers.sh Normal file → Executable file
View File

0
scripts/backup_restore/restore/remap_network.sh Normal file → Executable file
View File

0
scripts/backup_restore/restore/restore_modes.sh Normal file → Executable file
View File

0
scripts/backup_restore/restore/run_restore.sh Normal file → Executable file
View File

0
scripts/backup_restore/restore/validate_storage.sh Normal file → Executable file
View File

0
scripts/backup_restore/run_scheduled_backup.sh Normal file → Executable file
View File

View File

@@ -1629,8 +1629,28 @@ auto_reinstall_from_state() {
echo "No NVIDIA GPU detected on this host — skipping reinstall" | tee -a "$LOG_FILE"
return 0
fi
# Skip if the GPU is bound to vfio-pci (reserved for VM passthrough);
# installing the host driver would conflict with the passthrough config.
# Skip when the host is configured for VFIO passthrough. Two
# signals — config wins over runtime:
#
# 1. modprobe.d declares the nvidia* modules blacklisted (the
# file ProxMenux drops when switching the GPU to VM mode is
# `proxmenux-nvidia-vfio-blacklist.conf`, but any blacklist
# file counts).
# 2. The PCI device is bound to vfio-pci.
#
# The config check has to come FIRST because right after a host
# restore + reboot, the binding may not yet be effective (the
# GPU temporarily shows "no driver" while udev/initramfs settle).
# Reinstalling NVIDIA in that window forces the module onto a
# PCI device vfio-pci has already reserved → "NVRM: Try unloading
# the conflicting kernel module" and exit 1.
if [[ -f /etc/modprobe.d/proxmenux-nvidia-vfio-blacklist.conf ]] \
|| grep -qrhE '^[[:space:]]*blacklist[[:space:]]+nvidia([[:space:]_]|$)' \
/etc/modprobe.d/ 2>/dev/null; then
echo "NVIDIA blacklisted (host is VFIO-configured) — skipping host driver reinstall" \
| tee -a "$LOG_FILE"
return 0
fi
local _vfio_dev
for _vfio_dev in /sys/bus/pci/devices/*; do
[[ "$(cat "$_vfio_dev/vendor" 2>/dev/null)" == "0x10de" ]] || continue