diff --git a/AppImage/ProxMenux-1.2.2.2-beta.AppImage b/AppImage/ProxMenux-1.2.2.2-beta.AppImage index 1c2eeccb..7ae6357b 100755 Binary files a/AppImage/ProxMenux-1.2.2.2-beta.AppImage and b/AppImage/ProxMenux-1.2.2.2-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 5bb8485c..eb40403b 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -ad6a863fc2e344e5fc5ffeb66b09de8339bfc6b8d3dfcaa8020b4fe89abb4d2e ProxMenux-1.2.2.2-beta.AppImage +6a3aefbea44ba7260ee0a69685504ecc66c3d45ae1ddaf98deb6940975ce8e58 ProxMenux-1.2.2.2-beta.AppImage diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 48064d1c..2528e69e 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -44,7 +44,9 @@ import { Info, ListTree, } from "lucide-react" +import { ScriptTerminalModal } from "./script-terminal-modal" import { fetchApi, getApiUrl } from "../lib/api-config" +import { fetchTerminalTicket } from "../lib/terminal-ws" import { formatStorage } from "../lib/utils" // ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ── @@ -964,37 +966,88 @@ function InspectModal({ const [showArchiveFullLog, setShowArchiveFullLog] = useState(false) const [showDeleteArchiveConfirm, setShowDeleteArchiveConfirm] = useState(false) const [viewingContents, setViewingContents] = useState(false) + // Restore flow state: + // restorePreparing — extract running (blocking spinner overlay) + // restoreOptions — modal open after prepare succeeded + // restoreTerminal — ScriptTerminalModal open with monitor_apply.sh + // The extract happens BEFORE the options modal so the custom + // checklist can be filled from `components_available` (what's + // actually inside this backup) instead of a hardcoded list. + const [restorePreparing, setRestorePreparing] = useState(false) + const [restoreError, setRestoreError] = useState(null) + const [restoreOptions, setRestoreOptions] = useState<{ + stagingPath: string + pathsAvailable: string[] + rollbackPlan?: RollbackPlan + } | null>(null) + const [restoreTerminal, setRestoreTerminal] = useState<{ + stagingPath: string + mode: "full" | "custom" + paths: string[] + } | null>(null) + + const beginRestore = async () => { + if (!archive) return + setRestoreError(null) + setRestorePreparing(true) + const body: Record = { source: archive.source } + if (archive.source === "local") { + if (!localArc?.path) { setRestoreError("Local archive path missing"); setRestorePreparing(false); return } + body.path = localArc.path + } else if (remoteArc) { + body.repo_name = remoteArc.repo_name + body.snapshot = remoteArc.snapshot + } else { + setRestoreError("Snapshot info missing") + setRestorePreparing(false) + return + } + try { + const r: any = await fetchApi("/api/host-backups/restore/prepare", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + if (!r?.staging_path) throw new Error("backend did not return a staging path") + setRestoreOptions({ + stagingPath: r.staging_path, + pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [], + rollbackPlan: r.rollback_plan, + }) + } catch (e) { + setRestoreError(e instanceof Error ? e.message : String(e)) + } finally { + setRestorePreparing(false) + } + } const [deletingArchive, setDeletingArchive] = useState(false) const [archiveDeleteResult, setArchiveDeleteResult] = useState(null) - // Local download — direct file streamed straight from the server. + // Local download. Uses a single-use ticket appended to the URL + + // `` instead of fetch+blob — the previous fetch+blob + // approach loaded the whole archive into the browser's RAM via + // URL.createObjectURL(), which broke around 2 GB on most browsers. + // With the ticket-on-URL approach the browser streams the response + // straight to disk, so archive size is bounded only by free space. const downloadLocalArchive = async () => { if (!localArc) return setDownloading(true) setError(null) try { - const token = typeof window !== "undefined" - ? localStorage.getItem("proxmenux-auth-token") || "" - : "" - const r = await fetch( - getApiUrl(`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/download`), - { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }, - ) - if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`) - const blob = await r.blob() - const url = URL.createObjectURL(blob) + const ticket = await fetchTerminalTicket() + let url = getApiUrl(`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/download`) + if (ticket) url += `?ticket=${encodeURIComponent(ticket)}` const a = document.createElement("a") a.href = url a.download = localArc.id document.body.appendChild(a) a.click() document.body.removeChild(a) - setTimeout(() => URL.revokeObjectURL(url), 1000) } catch (e) { setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) } finally { + // The download itself runs in the browser's network stack; + // we just initiated it. Clear the spinner immediately. setDownloading(false) } } @@ -1048,26 +1101,20 @@ function InspectModal({ if (!task || task.state !== "completed") { throw new Error(task?.error || "export did not complete") } - // Stream the resulting .tar.zst. Server cleans the file up - // automatically once the response finishes. - const token = typeof window !== "undefined" - ? localStorage.getItem("proxmenux-auth-token") || "" - : "" - const r = await fetch( - getApiUrl(`/api/host-backups/remote-archives/export/${encodeURIComponent(started.task_id)}/download`), - { headers: token ? { Authorization: `Bearer ${token}` } : {} }, - ) - if (!r.ok) throw new Error(`download HTTP ${r.status}`) - const blob = await r.blob() - const url = URL.createObjectURL(blob) + // Stream the resulting .tar.zst with a ticketed URL + . + // Same rationale as downloadLocalArchive: bypass fetch+blob to + // avoid the ~2 GB browser-RAM limit; the browser writes + // straight to disk. + const ticket = await fetchTerminalTicket() + let url = getApiUrl(`/api/host-backups/remote-archives/export/${encodeURIComponent(started.task_id)}/download`) + if (ticket) url += `?ticket=${encodeURIComponent(ticket)}` + const safeSnap = remoteArc.snapshot.replace(/\//g, "_") const a = document.createElement("a") a.href = url - const safeSnap = remoteArc.snapshot.replace(/\//g, "_") a.download = `${remoteArc.backend}-${remoteArc.repo_name}-${safeSnap}.tar.zst` document.body.appendChild(a) a.click() document.body.removeChild(a) - setTimeout(() => URL.revokeObjectURL(url), 1000) setExportTask(null) } catch (e) { setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) @@ -1172,318 +1219,190 @@ function InspectModal({ return ( <> { if (!v) onClose() }}> - - - - - {archive?.display_id} + + + + + {archive?.display_id} {archive && ( - {archive.source} )} + {remoteArc?.encrypted && ( + + + + )} - - {isRemote - ? `Inspect the ${backendLabel} snapshot metadata and download it as a .tar.zst — the server extracts it on demand only when you click Download.` - : "Pick a restore mode, optionally run the preflight check, then get the exact shell command to apply the restore. Nothing on this host is changed from here — the apply step happens from a terminal."} - - {/* ── Remote snapshot view (PBS or Borg) — info card + on-demand export-then-download. */} - {isRemote && remoteArc && ( -
-
-
{backendLabel} snapshot
+ {/* ── Body: same shape for the 3 backends ─────────────── */} + +
+ {/* Snapshot info — uniform grid, with backend-specific + rows mixed in only when they carry data. */} +
+
Snapshot
-
Repository: {remoteArc.repo_repository}
-
Repo name: {remoteArc.repo_name}
-
- {remoteArc.backend === "pbs" ? "Backup group:" : "Archive name:"}{" "} - {remoteArc.backend === "pbs" ? `${remoteArc.backup_type}/${remoteArc.backup_id}` : remoteArc.backup_id} -
-
Snapshot time: {formatMtime(remoteArc.backup_time)}
- {remoteArc.size_bytes > 0 &&
Size: {formatBytes(remoteArc.size_bytes)}
} - {remoteArc.owner &&
Owner: {remoteArc.owner}
} - {remoteArc.borg_id &&
Borg id: {remoteArc.borg_id}
} + {/* Time + size — present for every backend. */} + {archive && (archive.source === "pbs" || archive.source === "borg") && remoteArc ? ( + <> +
Snapshot time: {formatMtime(remoteArc.backup_time)}
+ {remoteArc.size_bytes > 0 &&
Size: {formatBytes(remoteArc.size_bytes)}
} +
Repository: {remoteArc.repo_repository}
+
Repo name: {remoteArc.repo_name}
+
+ {remoteArc.backend === "pbs" ? "Backup group:" : "Archive name:"}{" "} + {remoteArc.backend === "pbs" ? `${remoteArc.backup_type}/${remoteArc.backup_id}` : remoteArc.backup_id} +
+ {remoteArc.owner &&
Owner: {remoteArc.owner}
} + {remoteArc.borg_id &&
Borg id: {remoteArc.borg_id}
} + + ) : localArc ? ( + <> +
Created: {formatMtime(localArc.mtime)}
+
Size: {formatBytes(localArc.size_bytes)}
+
Path: {localArc.path}
+ {localArc.job_id &&
Job id: {localArc.job_id}
} + {localArc.profile &&
Profile: {localArc.profile}
} + {localArc.source_hostname &&
Source host: {localArc.source_hostname}
} +
Detected via: {localArc.detected_via}
+ + ) : null}
- {remoteArc.files.length > 0 && ( -
+ {/* PBS pxar files list — only PBS exposes this. */} + {remoteArc?.files && remoteArc.files.length > 0 && ( +
Files in this snapshot
    {remoteArc.files.map((f) => (
  • {f.filename} - {formatStorage(f.size)} + {formatBytes(f.size)}
  • ))}
)} -
+
-
-
-
-

Download this snapshot

-

- The server will {remoteArc.backend === "pbs" ? "restore the snapshot from PBS" : "borg-extract the archive"} and pack it as a .tar.zst, then stream it to your browser. The extraction only starts when you click Download. -

-
-
- - -
-
- - {exportTask && ( -
-
- - {exportTask.state} - — {exportTask.message} -
- {exportTask.state === "failed" && exportTask.error && ( -
{exportTask.error}
- )} - {exportTask.state === "completed" && exportTask.size_bytes > 0 && ( -
Packed size: {formatBytes(exportTask.size_bytes)}
- )} -
- )} - - {error && ( -
- {error} -
- )} -
- -

- {remoteArc.backend === "pbs" - ? "Restore-to-this-host for PBS snapshots is best done from the PBS side with proxmox-backup-client restore. Use Download to pull the snapshot to your computer for off-host inspection or cross-host transfer." - : "Restore-to-this-host for Borg archives is best done with borg extract. Use Download to pull the archive to your computer for off-host inspection or cross-host transfer."} -

-
- )} - - {/* ── Local archive view — full restore wizard (manifest + preflight + apply instructions). */} - {archive?.source === "local" && (<> - {/* Manifest summary — optional. If the archive has no manifest - (older backup format), we just skip it instead of blocking - the operator from continuing with the restore. */} - {manifestErr ? ( -
- This archive doesn't carry a manifest snapshot — you can still pick a restore mode and get the instructions below. -
- ) : !manifest ? ( -
- - Reading manifest... -
- ) : ( - - )} - - {/* ── Run log — what the scheduler wrote for THIS archive ── */} - {archiveLog && archiveLog.log_path && archiveLog.tail.length > 0 && ( -
-

- Run log -

-
-
+            {/* Run log — only Local has a co-located .log. */}
+            {archive?.source === "local" && archiveLog && archiveLog.log_path && archiveLog.tail.length > 0 && (
+              
+

+ Run log +

+
+
 {archiveLog.tail.join("\n")}
-              
-
- - tail · {formatBytes(archiveLog.size)} - {archiveLog.log_path} - - -
-
-
- )} +
+
+ + tail · {formatBytes(archiveLog.size)} + {archiveLog.log_path} + + +
+
+
+ )} - {/* Mode selector + main "Restore" action */} -
-
-
- - -
+ {/* In-flight export task feedback (Download for PBS/Borg). */} + {exportTask && ( +
+
+ + {exportTask.state} + — {exportTask.message} +
+ {exportTask.state === "failed" && exportTask.error && ( +
{exportTask.error}
+ )} + {exportTask.state === "completed" && exportTask.size_bytes > 0 && ( +
Packed size: {formatBytes(exportTask.size_bytes)}
+ )} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + + {/* ── Footer: same 4 buttons for the 3 backends ───────── + Restore (green) · Download (blue) · View contents (blue) + on the left · Delete (red) on the right. On mobile only + Restore keeps its label — the others are icon-only to fit + the narrower viewport without wrapping. */} +
+
+ - - -
-

- The archive downloads as a .tar.zst file. To extract: - {" "}tar -I zstd -xf {localArc?.id || "<file>"} - {" "}(Linux/macOS with zstd installed). On Windows, 7-Zip 21.0+ opens it natively. Double-click won't work — no OS opens .zst out of the box. -

- - {error && ( -
- {error} -
- )} - - {report && } - - {/* Restore instructions — appear whenever the operator clicks - "Restore" (with or without a preflight beforehand). */} - {instructions && ( -
-

Apply this restore

-
-
- {instructions.archive_basename} - {instructions.mode_label} - {instructions.reboot_required && ( - - reboot required after - - )} -
- -
-
Run this from a terminal:
- { - const sel = window.getSelection() - if (sel) { - sel.removeAllRanges() - const range = document.createRange() - range.selectNodeContents(e.currentTarget) - sel.addRange(range) - } - }} - > - {instructions.shell_command} - -
- - {instructions.menu_path.length > 0 && ( -
-
Then:
-
    - {instructions.menu_path.map((step, i) => ( -
  1. {step}
  2. - ))} -
-
- )} - - {instructions.notes.length > 0 && ( -
- {instructions.notes.map((note, i) => ( -

- - {note} -

- ))} -
- )} -
-
+ {archive?.source === "local" && ( + )}
- )}
@@ -1498,6 +1417,102 @@ function InspectModal({ display_id={archive?.display_id} /> + {/* ── Restore error toast (prepare failed) ─────────────────── */} + {restoreError && ( + setRestoreError(null)}> + + + + + Restore preparation failed + + + {restoreError} + + +
+ +
+
+
+ )} + + {/* ── Restore options modal: opens only AFTER the prepare step, + so the Custom checklist already knows what's inside. ──── */} + { + const sp = restoreOptions?.stagingPath + setRestoreOptions(null) + if (sp) { + fetchApi("/api/host-backups/restore/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ staging_path: sp }), + }).catch(() => undefined) + } + }} + onLaunch={(mode, paths) => { + if (!restoreOptions) return + const sp = restoreOptions.stagingPath + setRestoreOptions(null) + setRestoreTerminal({ stagingPath: sp, mode, paths }) + }} + /> + + {/* ── Restore terminal — uses the canonical ScriptTerminalModal + (the same component Hardware/Security/Settings use to run + their scripts). Stable `key` per stagingPath so each restore + session is a fresh terminal. */} + {restoreTerminal && ( + { + const sp = restoreTerminal.stagingPath + fetchApi("/api/host-backups/restore/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ staging_path: sp }), + }).catch(() => undefined) + setRestoreTerminal(null) + }} + // Auto-dismiss when the script's PTY closes (i.e. the bash + // wrapper exited cleanly — the operator pressed Enter at the + // "Press Enter to close" prompt). Without this the operator + // would have to click Close manually after each restore. + onComplete={() => { + const sp = restoreTerminal.stagingPath + fetchApi("/api/host-backups/restore/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ staging_path: sp }), + }).catch(() => undefined) + setRestoreTerminal(null) + }} + scriptPath="/usr/local/share/proxmenux/scripts/backup_restore/restore/monitor_apply.sh" + scriptName="monitor_apply" + title={`Restore — ${restoreTerminal.mode === "full" ? "Complete" : "Custom by paths"}`} + description={ + restoreTerminal.mode === "custom" + ? `${restoreTerminal.paths.length} path(s) selected` + : "Complete restore — applies the whole backup" + } + params={{ + EXECUTION_MODE: "web", + STAGING: restoreTerminal.stagingPath, + MODE: restoreTerminal.mode, + ...(restoreTerminal.mode === "custom" && restoreTerminal.paths.length > 0 + ? { PATHS: restoreTerminal.paths.join(",") } + : {}), + }} + /> + )} + {/* ── Confirm archive deletion ───────────────────────────── */} @@ -6648,7 +6663,7 @@ function PbsKeyfileRecoveryDialog({ Cancel + + + + {error && ( +
+ {error} +
+ )} + + )} + + {step === "custom" && ( +
+

+ Pick the paths to restore. Your selection feeds _rs_run_custom_restore via the HB_PRESELECTED_PATHS env var — same downstream code as the TUI. Reboot-required paths (kernel, modules, fstab, …) will be detected by the bash flow and you can schedule them for next boot from there. +

+
+ setFilter(e.target.value)} + placeholder="Filter paths…" + className="h-8 text-xs" + /> + +
+
+
    + {filteredPaths.map((p) => ( +
  • + +
  • + ))} + {filteredPaths.length === 0 && ( +
  • No paths match the filter.
  • + )} +
+
+
+ Selected: {selected.size} / {pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""} +
+ {error && ( +
+ {error} +
+ )} +
+ )} + +
+ {step === "custom" && ( + + )} + + {step === "custom" && ( + + )} +
+
+
+ ) +} diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index 59c67769..e3fa77e3 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -49,6 +49,12 @@ interface ScriptTerminalModalProps { description: string scriptName?: string params?: Record + // 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(null) const wsRef = useRef(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?.() } } diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 2b488705..4fca98a3 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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//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//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//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 diff --git a/AppImage/scripts/jwt_middleware.py b/AppImage/scripts/jwt_middleware.py index 9b05c1d0..69725972 100644 --- a/AppImage/scripts/jwt_middleware.py +++ b/AppImage/scripts/jwt_middleware.py @@ -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
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 ). + 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`. diff --git a/scripts/backup_restore/apply_cluster_postboot.sh b/scripts/backup_restore/apply_cluster_postboot.sh index ac3238f7..3e03233a 100755 --- a/scripts/backup_restore/apply_cluster_postboot.sh +++ b/scripts/backup_restore/apply_cluster_postboot.sh @@ -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 diff --git a/scripts/backup_restore/apply_pending_restore.sh b/scripts/backup_restore/apply_pending_restore.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh old mode 100644 new mode 100755 index 9cefe0c4..3110f622 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -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" </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 diff --git a/scripts/backup_restore/backup_scheduler.sh b/scripts/backup_restore/backup_scheduler.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/collectors/build_manifest.sh b/scripts/backup_restore/collectors/build_manifest.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/collectors/collect_guests.sh b/scripts/backup_restore/collectors/collect_guests.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/collectors/collect_hardware.sh b/scripts/backup_restore/collectors/collect_hardware.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/collectors/collect_kernel.sh b/scripts/backup_restore/collectors/collect_kernel.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/collectors/collect_proxmenux_state.sh b/scripts/backup_restore/collectors/collect_proxmenux_state.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/collectors/collect_source_host.sh b/scripts/backup_restore/collectors/collect_source_host.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/collectors/collect_storage.sh b/scripts/backup_restore/collectors/collect_storage.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/restore/compute_rollback_plan.sh b/scripts/backup_restore/restore/compute_rollback_plan.sh new file mode 100755 index 00000000..598bde5e --- /dev/null +++ b/scripts/backup_restore/restore/compute_rollback_plan.sh @@ -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 +# +# 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 \n' "$0" >&2 + exit 64 +fi + +# ── Discover guest configs (VMs + LXCs) on both sides ── +# /etc/pve/nodes//qemu-server/.conf for VMs +# /etc/pve/nodes//lxc/.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// + 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' diff --git a/scripts/backup_restore/restore/list_paths.sh b/scripts/backup_restore/restore/list_paths.sh new file mode 100755 index 00000000..18bf893e --- /dev/null +++ b/scripts/backup_restore/restore/list_paths.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# ========================================================== +# ProxMenux — list backup paths actually present in staging +# ========================================================== +# Reads /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 /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 +# +# 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 \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' diff --git a/scripts/backup_restore/restore/monitor_apply.sh b/scripts/backup_restore/restore/monitor_apply.sh new file mode 100755 index 00000000..11ef25d3 --- /dev/null +++ b/scripts/backup_restore/restore/monitor_apply.sh @@ -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 /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. diff --git a/scripts/backup_restore/restore/parse_manifest.sh b/scripts/backup_restore/restore/parse_manifest.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/restore/preflight_checks.sh b/scripts/backup_restore/restore/preflight_checks.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/restore/reinstall_drivers.sh b/scripts/backup_restore/restore/reinstall_drivers.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/restore/remap_network.sh b/scripts/backup_restore/restore/remap_network.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/restore/restore_modes.sh b/scripts/backup_restore/restore/restore_modes.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/restore/run_restore.sh b/scripts/backup_restore/restore/run_restore.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/restore/validate_storage.sh b/scripts/backup_restore/restore/validate_storage.sh old mode 100644 new mode 100755 diff --git a/scripts/backup_restore/run_scheduled_backup.sh b/scripts/backup_restore/run_scheduled_backup.sh old mode 100644 new mode 100755 diff --git a/scripts/gpu_tpu/nvidia_installer.sh b/scripts/gpu_tpu/nvidia_installer.sh index c256650a..1d769632 100644 --- a/scripts/gpu_tpu/nvidia_installer.sh +++ b/scripts/gpu_tpu/nvidia_installer.sh @@ -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