diff --git a/AppImage/ProxMenux-1.2.2.3-beta.AppImage b/AppImage/ProxMenux-1.2.2.3-beta.AppImage index 5c65e76d..8482e6bd 100755 Binary files a/AppImage/ProxMenux-1.2.2.3-beta.AppImage and b/AppImage/ProxMenux-1.2.2.3-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 51d03748..ca43db68 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -7b7cb13e8f2d4044626663d8187018908705e480e81f510ee46f6df7918fef84 ProxMenux-1.2.2.3-beta.AppImage +6da865786f93c94359fd4428876a953f2706d752bd28120510ef5330f1d420aa ProxMenux-1.2.2.3-beta.AppImage diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 46c38c3f..43a9becf 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -338,32 +338,268 @@ const formatRunAt = (iso: string | null) => { // Rendered inside the encryption section when a keyfile is already // installed. Replaces the older static info box that pointed to a // non-existent "Settings → Host backup" panel: the operator now has -// three concrete actions (change stored keyfile passphrase, replace -// keyfile, remove keyfile) with destructive-confirmation modals that -// call the /api/host-backups/pbs-encryption/* endpoints and mutate -// pbsRecoveryStatus so the parent dialog re-renders on success. -// -// `onReplaced` is called after a successful remove/replace-remove so -// the parent can reset its local pbsEncryptMode from "existing" back -// to "new" and let the Generate/Import radio unfold naturally. -// Bare row of three destructive keyfile actions + their confirm -// modals. Meant to be embedded inside the operator's own "change -// mode" container — this component renders no info box, no toggle, -// no Cancel link. The parent handles when to show/hide it and how -// to signal "user wants to keep things as they are". +// concrete actions (download keyfile, update stored passphrase, +// change recovery model, remove keyfile) with destructive-confirmation +// modals that call the /api/host-backups/pbs-encryption/* endpoints +// and mutate pbsRecoveryStatus so the parent dialog re-renders on +// success. There is no "Replace keyfile" action any more — replacing +// the key would silently orphan every encrypted backup that used it. +// The operator instead uses Remove + set up again via the normal flow +// (Generate a new keyfile / Use an existing keyfile), which surfaces +// the source picker properly. +// ────────────────────────────────────────────────────────────── +// PbsKeyfileActions — the trimmed action set the operator sees +// inside every PBS destination row: Download, Upload (import a new +// keyfile), Delete. No stored-passphrase rotation, no escrow toggle +// — those live in the setup dialogs where the operator makes the +// initial call. Placing these three next to the destination they +// belong to matches "the key is a PBS thing" without dragging in +// the fuller management surface. +// ────────────────────────────────────────────────────────────── +function PbsKeyfileActions() { + const { data: info, mutate: mutateInfo } = useSWR<{ + installed: boolean + fingerprint?: string + path: string + escrow_mode?: "none" | "local" | "full" + }>("/api/host-backups/pbs-encryption/keyfile-info", fetcher) + + const [uploadOpen, setUploadOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const [importFile, setImportFile] = useState(null) + const [importPath, setImportPath] = useState("") + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + const closeUpload = () => { + setUploadOpen(false) + setImportFile(null) + setImportPath("") + setBusy(false) + setErr(null) + } + const closeDelete = () => { + setDeleteOpen(false) + setBusy(false) + setErr(null) + } + + const download = () => { + // Same plain--download trick used elsewhere: bypasses fetch so + // an ad-blocker or a stale SWR cache can't intervene. + const a = document.createElement("a") + a.href = "/api/host-backups/pbs-encryption/download-keyfile" + a.download = "pbs-key.conf" + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + } + + const runUpload = async () => { + if (!importFile && !importPath.trim()) { + setErr("Pick a keyfile file or enter an absolute path on this host.") + return + } + setBusy(true) + setErr(null) + try { + const body: Record = { escrow_mode: "none" } + if (importFile) { + const buf = new Uint8Array(await importFile.arrayBuffer()) + let bin = "" + for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) + body.source = "content" + body.content_b64 = btoa(bin) + } else { + body.source = "path" + body.keyfile_path = importPath.trim() + } + await fetchApi("/api/host-backups/pbs-encryption/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + await mutateInfo() + closeUpload() + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + setBusy(false) + } + } + + const runDelete = async () => { + setBusy(true) + setErr(null) + try { + await fetchApi("/api/host-backups/pbs-encryption/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + await mutateInfo() + closeDelete() + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + setBusy(false) + } + } + + const installed = !!info?.installed + + return ( +
+
+
+ Encryption keyfile:{" "} + {installed ? ( + + + installed + + ) : ( + not installed on this host + )} +
+
+ {installed && ( + <> + + + + )} + {!installed && ( + + )} +
+
+ + !v && closeUpload()}> + + + Upload PBS keyfile + + Import a keyfile you already have. It lands at /usr/local/share/proxmenux/pbs-key.conf and every subsequent encrypted backup reuses it. Recovery escrow stays off — use the setup wizard if you want to enable it. + + +
+
+ + setImportFile(e.target.files?.[0] ?? null)} + disabled={busy || !!importPath.trim()} + className="h-9 mt-1" + /> +
+
— or —
+
+ + setImportPath(e.target.value)} + disabled={busy || !!importFile} + placeholder="/usr/local/share/proxmenux/pbs-key.conf" + className="h-9 mt-1 font-mono text-xs" + /> +
+ {err && ( +
{err}
+ )} +
+ + + + +
+
+ + !v && closeDelete()}> + + + + + Delete keyfile + + + Backups already stored on PBS were encrypted with the current keyfile. After this action: + + +
    +
  • New backups will use no encryption on this host until a new keyfile is set up.
  • +
  • Downloading pre-existing encrypted backups from this host will fail unless you kept a copy of the current key.
  • +
  • Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase.
  • +
+ {err && ( +
{err}
+ )} + + + + +
+
+
+ ) +} + function KeyfileActionsBar({ - onReplaced, mutateStatus, + escrowMode, }: { - onReplaced: () => void mutateStatus: () => Promise + escrowMode?: "none" | "local" | "full" }) { - const [action, setAction] = useState(null) + const [action, setAction] = useState(null) const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) const [pass1, setPass1] = useState("") const [pass2, setPass2] = useState("") - const [backupFirst, setBackupFirst] = useState(true) + const [newEscrowMode, setNewEscrowMode] = useState<"none" | "local" | "full">(escrowMode ?? "full") const close = () => { setAction(null) @@ -371,7 +607,7 @@ function KeyfileActionsBar({ setErr(null) setPass1("") setPass2("") - setBackupFirst(true) + setNewEscrowMode(escrowMode ?? "full") } const runPassphrase = async () => { @@ -395,17 +631,29 @@ function KeyfileActionsBar({ } } - const runRemove = async (mode: "remove" | "replace") => { + const runEscrowMode = async () => { + // Local/full → passphrase required + match. None → passphrase ignored. + if (newEscrowMode !== "none") { + if (!pass1) { + setErr("Passphrase is required for local or full escrow.") + return + } + if (pass1 !== pass2) { + setErr("Passphrases do not match.") + return + } + } setBusy(true) setErr(null) try { - await fetchApi("/api/host-backups/pbs-encryption/remove", { + const body: Record = { escrow_mode: newEscrowMode } + if (newEscrowMode !== "none") body.escrow_passphrase = pass1 + await fetchApi("/api/host-backups/pbs-encryption/set-escrow-mode", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ backup_before_remove: backupFirst }), + body: JSON.stringify(body), }) await mutateStatus() - if (mode === "replace") onReplaced() close() } catch (e) { setErr(e instanceof Error ? e.message : String(e)) @@ -413,10 +661,48 @@ function KeyfileActionsBar({ } } + // The UX we expose is binary: "is the key uploaded to PBS?" — yes + // (mode='full') or no (mode='none'). The 'local' backend mode still + // exists but is not surfaced here; if a host somehow reports it, + // show it verbatim so the state isn't lost on the operator. + const uploadOn = escrowMode === "full" + const escrowLabel = escrowMode === undefined + ? undefined + : escrowMode === "full" + ? "Uploaded to PBS on every backup" + : escrowMode === "local" + ? "Local envelope only (advanced mode)" + : "Kept only on this host — you handle the offsite copy" + return (
Manage installed keyfile
+ {escrowLabel !== undefined && ( +
+ Upload to PBS: + {uploadOn ? "Yes" : "No"} — {escrowLabel} +
+ )}
+ -
@@ -478,41 +755,70 @@ function KeyfileActionsBar({ - !v && close()}> + !v && close()}> - - - {action === "replace" ? "Replace keyfile" : "Remove keyfile"} - + {newEscrowMode === "full" ? "Start uploading key to PBS" : "Stop uploading key to PBS"} - Backups already stored on PBS were encrypted with the current keyfile. After this action: + {newEscrowMode === "full" + ? "ProxMenux will wrap the keyfile with a passphrase and upload the envelope to PBS after every backup. A reinstalled host can recover the key with just the passphrase." + : "The passphrase-wrapped copy on this host will be dropped and no envelope will be uploaded on future backups. The keyfile itself stays at its canonical path — but you become responsible for keeping an offsite copy."} -
    -
  • New backups will use {action === "replace" ? "the newly generated / imported keyfile" : "no encryption on this host until a new keyfile is set up"}.
  • -
  • Downloading pre-existing encrypted backups from this host will fail unless you keep a copy of the current key.
  • -
  • Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase.
  • -
- - {err && ( -
{err}
- )} +
+ {newEscrowMode === "full" ? ( + <> +
+ + setPass1(e.target.value)} + placeholder="Long random string — write it down somewhere safe" + className="font-mono h-9 mt-1" + /> +
+
+ + setPass2(e.target.value)} + placeholder="Type it again" + className="font-mono h-9 mt-1" + /> + {pass1 && pass2 && pass1 !== pass2 && ( +

Passphrases don't match.

+ )} +
+ + ) : ( +
+
Losing the keyfile makes every encrypted backup on PBS unreadable.
+
Keyfile path on this host: /usr/local/share/proxmenux/pbs-key.conf
+
Copy that file to an offsite location (USB, password manager, another host) before continuing.
+
+ )} + {err && ( +
{err}
+ )} +
-
+ + {/* NOTE: no Remove keyfile dialog here — deletion lives inside + each PBS destination row (PbsKeyfileActions), so the setup + menus of Create Job / Manual Backup only cover key-related + toggles (download / passphrase / escrow) and never surface + a destructive delete. */}
) } @@ -1235,6 +1541,66 @@ function InspectModal({ rollbackExecute?: boolean } | null>(null) + // ── Keyfile-required inline import ──────────────────────────── + // When this modal opens on an encrypted PBS backup and the local + // keyfile is missing at /usr/local/share/proxmenux/pbs-key.conf, + // Restore / Download / View contents would all fail with + // "missing key". We block the buttons and offer an inline Import + // panel that reuses /api/host-backups/pbs-encryption/import with + // source=content + escrow_mode=none — same endpoint as the setup + // flow, so the imported keyfile lands at the canonical path and is + // reused by every subsequent backup + restore. + const { data: keyfileInfo, mutate: mutateKeyfileInfo } = useSWR<{ + installed: boolean + fingerprint?: string + path: string + }>( + open && remoteArc?.encrypted ? "/api/host-backups/pbs-encryption/keyfile-info" : null, + fetcher, + ) + const needsKeyfile = !!(remoteArc?.encrypted && keyfileInfo && !keyfileInfo.installed) + const [importFile, setImportFile] = useState(null) + const [importPath, setImportPath] = useState("") + const [importing, setImporting] = useState(false) + const [importError, setImportError] = useState(null) + + const runImportKeyfile = async () => { + // Either a file upload OR a typed absolute path on this host — + // both land at /usr/local/share/proxmenux/pbs-key.conf with + // escrow_mode='none'. When both are provided the file wins. + if (!importFile && !importPath.trim()) { + setImportError("Pick a keyfile file or enter an absolute path on this host.") + return + } + setImporting(true) + setImportError(null) + try { + const body: Record = { escrow_mode: "none" } + if (importFile) { + const buf = new Uint8Array(await importFile.arrayBuffer()) + let bin = "" + for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) + body.source = "content" + body.content_b64 = btoa(bin) + } else { + body.source = "path" + body.keyfile_path = importPath.trim() + } + await fetchApi("/api/host-backups/pbs-encryption/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + await mutateKeyfileInfo() + setImportFile(null) + setImportPath("") + } catch (e) { + setImportError(e instanceof Error ? e.message : String(e)) + } finally { + setImporting(false) + } + } + const beginRestore = async () => { if (!archive) return setRestoreError(null) @@ -1633,6 +1999,71 @@ function InspectModal({ {error} )} + + {/* Encrypted-backup gate: no local keyfile → block the + three actions until the operator imports the key. The + import lands at the canonical path with escrow_mode= + 'none', so subsequent runs reuse it silently. */} + {needsKeyfile && ( +
+
+ +
+
Encrypted backup — keyfile required
+
+ This snapshot is encrypted but no local keyfile is installed at + {" "}/usr/local/share/proxmenux/pbs-key.conf. + Import the keyfile that was used at backup time to continue. +
+
+
+
+ + setImportFile(e.target.files?.[0] ?? null)} + disabled={importing || !!importPath.trim()} + className="h-8 text-[11px]" + /> +
+
— or —
+
+ + setImportPath(e.target.value)} + disabled={importing || !!importFile} + placeholder="/usr/local/share/proxmenux/pbs-key.conf" + className="h-8 text-[11px] font-mono" + /> +
+ {importError && ( +
+ {importError} +
+ )} +
+ +
+
+ )} @@ -1645,9 +2076,9 @@ function InspectModal({
- {pbsEncryptMode === "existing" && ( + {pbsEncryptMode === "existing" && pbsPveMatch && ( +
+
+ +
+
Auto-detected from PVE storage '{pbsPveMatch.name}'
+
+ Existing key at {pbsPveMatch.path} +
+
Will be copied to the ProxMenux state directory. No file upload needed.
+
+
+
+ )} + {pbsEncryptMode === "existing" && !pbsPveMatch && (
setPbsImportFile(e.target.files?.[0] ?? null)} - disabled={pbsImportBusy} + disabled={pbsImportBusy || !!pbsImportPath.trim()} className="h-8 text-[11px]" /> -

- ProxMenux does not validate the file — any keyfile you accept as valid on your PBS is accepted here. If it later fails at backup time, the tool's error tells you why. -

+
— or —
-
@@ -3340,15 +3817,13 @@ function CreateJobDialog({ )} {pbsEncrypt && ( -
- {pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? ( - /* Escrow already saved — mirror the shell: skip - the prompt unless the operator opts to change. */ +
+ {pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && (
-
Recovery escrow already configured for this host.
-
Backups created by this job will reuse the existing passphrase. No need to type it again.
+
Keyfile installed. Backups reuse it silently.
+
The current PBS-upload setting stays in effect.
- ) : ( + )} + {pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && ( + <> + + + + )} + {!pbsRecoveryStatus?.has_keyfile && ( <> - {/* When the operator clicked "Change" and a keyfile - is already installed, the keyfile management - actions sit at the top of the panel. This - collapses two prior boxes (keyfile info + recovery - escrow) into one, and only exposes the destructive - buttons when the operator explicitly asks for - change. */} - {pbsRecoveryStatus?.has_keyfile && ( - <> - setPbsEncryptMode("new")} - mutateStatus={mutatePbsRecovery} - /> -
-
Change recovery passphrase
- - )} -

- With a recovery passphrase, an encrypted copy of the keyfile is uploaded to PBS with every backup. If you lose this host, you can recover the keyfile on a fresh install with just the passphrase. Without it, losing the local keyfile means the encrypted backups become unrecoverable forever. -

- - setPbsRecoveryPass(e.target.value)} - placeholder="Long random string — write it down somewhere safe" - className="font-mono mt-1 h-8 text-xs" - /> +
Upload key to PBS?
+
+ + +
-
- - setPbsRecoveryPass2(e.target.value)} - placeholder="Type it again" - className="font-mono mt-1 h-8 text-xs" - /> - {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( -

Passphrases don't match.

- )} -
- {pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && ( - + {pbsUploadToPbs ? ( +
+
+ + setPbsRecoveryPass(e.target.value)} + placeholder="Long random string — write it down somewhere safe" + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPbsRecoveryPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( +

Passphrases don't match.

+ )} +
+
+ ) : ( +
+
Keep an offsite copy of the keyfile.
+
After the job is created, copy /usr/local/share/proxmenux/pbs-key.conf to an external medium (USB, password manager, another host).
+
Without that copy, losing this host makes every encrypted backup on PBS unreadable.
+
)} )} @@ -3751,7 +4254,9 @@ function ManualBackupDialog({ // block — the three modes and the import flow are identical here. const [pbsEncryptMode, setPbsEncryptMode] = useState<"none" | "new" | "existing">("none") const [pbsImportFile, setPbsImportFile] = useState(null) - const [pbsImportKeyfilePass, setPbsImportKeyfilePass] = useState("") + // Absolute-path alternative to the file upload — mirrors the shell + // wizard's `_hb_pbs_import_dialog` when there is no PVE .enc match. + const [pbsImportPath, setPbsImportPath] = useState("") const [pbsImportBusy, setPbsImportBusy] = useState(false) const pbsEncrypt = pbsEncryptMode !== "none" const [pbsRecoveryPass, setPbsRecoveryPass] = useState("") @@ -3761,15 +4266,36 @@ function ManualBackupDialog({ // passphrase if one is already saved — only show the inputs when // there's no escrow yet OR the operator explicitly asks to change. const [pbsRecoveryChange, setPbsRecoveryChange] = useState(false) + // Binary "upload key to PBS?" toggle. `true` maps to escrow_mode='full' + // (passphrase required, envelope uploaded on every backup); `false` + // maps to escrow_mode='none' (keyfile stays local, operator handles + // the offsite copy). Defaults to 'No' — the keyfile is always + // downloadable from the Monitor, so the operator can grab it and + // save it offsite themselves without leaving a passphrase-wrapped + // copy on the PBS server. + const [pbsUploadToPbs, setPbsUploadToPbs] = useState(false) // Whether the host already has an escrow blob configured. When // present, the passphrase input becomes optional ("leave blank to // keep saved"). Refreshed after a successful setup call. const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{ - has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean + has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full" }>( open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null, fetcher, ) + // Auto-discover a PVE-managed keyfile for the currently selected + // PBS repository. When one is found, the "Use an existing keyfile" + // radio uses it silently (matches the shell wizard's PVE auto-detect + // path — the operator doesn't have to upload the same file twice). + const { data: pbsPveDiscover } = useSWR<{ + entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }> + }>( + open && backend === "pbs" && pbsRepository && !pbsRecoveryStatus?.has_keyfile + ? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(pbsRepository)}` + : null, + fetcher, + ) + const pbsPveMatch = pbsPveDiscover?.entries?.find((e) => e.matches_repository) const [localDestDir, setLocalDestDir] = useState("") const [borgRepoSelected, setBorgRepoSelected] = useState("") const [borgPassphrase, setBorgPassphrase] = useState("") @@ -3818,11 +4344,12 @@ function ManualBackupDialog({ setPbsBackupId("") setPbsEncryptMode("none") setPbsImportFile(null) - setPbsImportKeyfilePass("") + setPbsImportPath("") setPbsImportBusy(false) setPbsRecoveryPass("") setPbsRecoveryPass2("") setPbsRecoveryChange(false) + setPbsUploadToPbs(false) setLocalDestDir("") setBorgRepoSelected("") setBorgPassphrase("") @@ -3867,8 +4394,13 @@ function ManualBackupDialog({ // encrypting without recovery would leave the keyfile only on this // host, and a reinstall would render every encrypted backup // unrecoverable. + // Passphrase requirement only kicks in when: encryption is on AND + // there's no keyfile installed yet (fresh setup) AND the operator + // opted to upload the key to PBS. Every other case (reuse existing + // keyfile, or "no upload" mode) doesn't need a passphrase. const pbsRecoveryOk = !(backend === "pbs" && pbsEncrypt) || - (pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange) || + pbsRecoveryStatus?.has_keyfile || + !pbsUploadToPbs || (pbsRecoveryPass !== "" && pbsRecoveryPass === pbsRecoveryPass2) const canSubmit = canAdvanceFrom1 && backendValid && pbsRecoveryOk @@ -3876,72 +4408,66 @@ function ManualBackupDialog({ if (!canSubmit) return setSubmitting(true) setError(null) - // Same three encryption submit paths as CreateJobDialog. Only - // "Import + no keyfile installed yet" actually uploads; the on-disk - // reuse case sends mode=existing without touching the file. - const needsImport = + // Unified encryption setup — mirrors CreateJobDialog. One atomic + // /pbs-encryption/import call covers keyfile install + escrow + // setup with source picked from what the operator provided. + const needsInstall = backend === "pbs" && - pbsEncryptMode === "existing" && + pbsEncrypt && !pbsRecoveryStatus?.has_keyfile - if (needsImport) { - if (!pbsImportFile) { - setError("Pick a keyfile to import.") + if (needsInstall) { + if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) { + setError("Pick a keyfile file or enter an absolute path on this host.") + setSubmitting(false) + return + } + if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) { + setError("Recovery passphrases don't match.") setSubmitting(false) return } setPbsImportBusy(true) try { - const buf = new Uint8Array(await pbsImportFile.arrayBuffer()) - let bin = "" - for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) - const content_b64 = btoa(bin) + let source: "generate" | "pve-storage" | "content" | "path" + if (pbsEncryptMode === "new") source = "generate" + else if (pbsPveMatch) source = "pve-storage" + else if (pbsImportFile) source = "content" + else source = "path" + const body: Record = { + source, + escrow_mode: pbsUploadToPbs ? "full" : "none", + } + if (pbsUploadToPbs) body.escrow_passphrase = pbsRecoveryPass + if (source === "pve-storage") { + body.pve_storage_name = pbsPveMatch!.name + } else if (source === "content") { + const buf = new Uint8Array(await pbsImportFile!.arrayBuffer()) + let bin = "" + for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) + body.content_b64 = btoa(bin) + } else if (source === "path") { + body.keyfile_path = pbsImportPath.trim() + } await fetchApi("/api/host-backups/pbs-encryption/import", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content_b64, - keyfile_passphrase: pbsImportKeyfilePass, - }), + body: JSON.stringify(body), }) mutatePbsRecovery() } catch (e) { - // The import endpoint returns `{error, tool_output, tool_exit_code}` - // on validation failure. Surface the tool output verbatim so the - // operator can tell a real "not a keyfile" from a "keyfile needs - // a passphrase / uses unsupported KDF" and act on it. const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } const detail = err.body?.tool_output ? `${err.message}\n\nproxmox-backup-client output:\n${err.body.tool_output}` : err.message - setError(`Keyfile import failed: ${detail || String(e)}`) + setError(`Encryption setup failed: ${detail || String(e)}`) setPbsImportBusy(false) setSubmitting(false) return } setPbsImportBusy(false) } - // Same pre-step as CreateJob: configure the PBS recovery escrow - // before the run so the runner's post-backup upload of the blob - // has something to push. - if (backend === "pbs" && pbsEncrypt && pbsRecoveryPass) { - if (pbsRecoveryPass !== pbsRecoveryPass2) { - setError("Recovery passphrases don't match.") - setSubmitting(false) - return - } - try { - await fetchApi("/api/host-backups/pbs-recovery/setup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ passphrase: pbsRecoveryPass }), - }) - mutatePbsRecovery() - } catch (e) { - setError(`Recovery setup failed: ${e instanceof Error ? e.message : String(e)}`) - setSubmitting(false) - return - } - } + // NOTE: recovery escrow is now bundled into the /pbs-encryption/import + // call above. No separate /pbs-recovery/setup pass is needed. try { const body: Record = { backend, @@ -4212,39 +4738,51 @@ function ManualBackupDialog({
Reuse the same keyfile another host already has.
- {pbsEncryptMode === "existing" && ( + {pbsEncryptMode === "existing" && pbsPveMatch && ( +
+
+ +
+
Auto-detected from PVE storage '{pbsPveMatch.name}'
+
+ Existing key at {pbsPveMatch.path} +
+
Will be copied to the ProxMenux state directory. No file upload needed.
+
+
+
+ )} + {pbsEncryptMode === "existing" && !pbsPveMatch && (
setPbsImportFile(e.target.files?.[0] ?? null)} - disabled={pbsImportBusy} + disabled={pbsImportBusy || !!pbsImportPath.trim()} className="h-8 text-[11px]" /> -

- ProxMenux does not validate the file — any keyfile accepted by your PBS is accepted here. -

+
— or —
-
@@ -4253,13 +4791,13 @@ function ManualBackupDialog({ )} {pbsEncrypt && ( -
- {pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? ( +
+ {pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && (
-
Recovery escrow already configured for this host.
-
This backup will reuse the existing passphrase. No need to type it again.
+
Keyfile installed. This backup reuses it silently.
+
The current PBS-upload setting stays in effect.
- ) : ( + )} + {pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && ( + <> + + + + )} + {!pbsRecoveryStatus?.has_keyfile && ( <> - {pbsRecoveryStatus?.has_keyfile && ( - <> - setPbsEncryptMode("new")} - mutateStatus={mutatePbsRecovery} - /> -
-
Change recovery passphrase
- - )} -

- With a recovery passphrase an encrypted copy of the keyfile is uploaded to PBS on every backup. Lets you recover it on a fresh host with just the passphrase. -

- - setPbsRecoveryPass(e.target.value)} - placeholder="Long random string — write it down somewhere safe" - className="font-mono mt-1 h-8 text-xs" - /> +
Upload key to PBS?
+
+ + +
-
- - setPbsRecoveryPass2(e.target.value)} - placeholder="Type it again" - className="font-mono mt-1 h-8 text-xs" - /> - {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( -

Passphrases don't match.

- )} -
- {pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && ( - + {pbsUploadToPbs ? ( +
+
+ + setPbsRecoveryPass(e.target.value)} + placeholder="Long random string — write it down somewhere safe" + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPbsRecoveryPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( +

Passphrases don't match.

+ )} +
+
+ ) : ( +
+
Keep an offsite copy of the keyfile.
+
After this backup, copy /usr/local/share/proxmenux/pbs-key.conf to an external medium (USB, password manager, another host).
+
Without that copy, losing this host makes every encrypted backup on PBS unreadable.
+
)} )} @@ -4612,10 +5185,11 @@ function DestinationsSection({ { refreshInterval: 60_000, revalidateOnFocus: false }, ) // Recovery state — only needed when at least one PBS destination - // exists. Drives the "Recover keyfile" banner. + // exists. Drives the "Recover keyfile" banner and the "PBS encryption + // key" management block. const hasPbs = (destinations?.pbs?.length ?? 0) > 0 const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{ - has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean + has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full"; keyfile_fingerprint?: string }>(hasPbs ? "/api/host-backups/pbs-recovery/status" : null, fetcher) const { data: pbsRecoveryAvailable } = useSWR<{ snapshots: Array<{ repo_name: string; repo_repository: string; backup_id: string; source_host: string; backup_time: number; snapshot: string }> @@ -4833,6 +5407,7 @@ function DestinationsSection({ Recover → )} +
{items.map((it) => { const cap = capByEdid.get(it.id) @@ -5234,6 +5809,11 @@ function DestinationRow({ Probing capacity…

)} + {/* Keyfile actions live inside each PBS row — the encryption key + is host-wide but conceptually belongs to PBS usage, so the + Download / Upload / Delete controls sit next to the + destination(s) they support. */} + {item.kind === "pbs" && }
) } diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 23c8ce51..d1f6c302 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -46,6 +46,7 @@ import socket import sqlite3 import subprocess import sys +import tempfile import time import threading import urllib.parse @@ -13746,6 +13747,177 @@ _PBS_RECOVERY_ENC_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.recovery.enc' # proxmox-backup-client can decrypt the key without prompting. # Same trust boundary as the keyfile itself: chmod 600, next to it. _PBS_KEYFILE_PASS_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.pass' +# Escrow-mode state file: one line, 'none'|'local'|'full'. Mirrors the +# shell's $HB_STATE_DIR/pbs-key.mode. Missing file = legacy install = +# 'full' (the pre-3-mode behaviour) so upgrades are silent. +_PBS_ESCROW_MODE_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.mode' +_PBS_ESCROW_MODES = ('none', 'local', 'full') +# PVE stores per-storage encryption keyfiles here when the operator +# enables `encryption-key` on a PBS storage entry. Same JSON keyfile +# format as `proxmox-backup-client key create --kdf none` — usable +# verbatim with --keyfile. Reusing it avoids duplicating a secret +# across the PVE config and ProxMenux state. +_PVE_STORAGE_KEY_DIR = '/etc/pve/priv/storage' +_PVE_STORAGE_CFG = '/etc/pve/storage.cfg' + + +def _pbs_read_escrow_mode() -> str: + """Read the current escrow mode ('none'|'local'|'full'). Falls back + to 'full' for legacy installs where the mode file doesn't exist — + that matches the pre-feature behaviour.""" + try: + with open(_PBS_ESCROW_MODE_PATH, 'r') as f: + v = f.read(32).strip() + if v in _PBS_ESCROW_MODES: + return v + except OSError: + pass + return 'full' + + +def _pbs_write_escrow_mode(mode: str) -> bool: + if mode not in _PBS_ESCROW_MODES: + return False + try: + os.makedirs(_BACKUP_STATE_DIR, exist_ok=True) + fd = os.open( + _PBS_ESCROW_MODE_PATH, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + with os.fdopen(fd, 'w') as f: + f.write(mode + '\n') + except OSError: + return False + return True + + +def _pbs_discover_pve_keyfiles() -> list: + """List PVE-managed PBS storage entries that carry an encryption + keyfile at /etc/pve/priv/storage/.enc. Returns + [{name, server, datastore, path}] for each matching entry. Empty + list on any read error — this is best-effort discovery. + """ + if not (os.path.isfile(_PVE_STORAGE_CFG) and os.path.isdir(_PVE_STORAGE_KEY_DIR)): + return [] + entries = [] + current = None + try: + with open(_PVE_STORAGE_CFG, 'r') as f: + for raw_line in f: + line = raw_line.rstrip('\n') + if not line.strip(): + continue + # Non-indented block start: ": " + if not line[0].isspace(): + if current and current.get('type') == 'pbs': + entries.append(current) + stripped = line.strip() + if ':' in stripped: + typ, _, name = stripped.partition(':') + name = name.strip() + current = {'type': typ.strip(), 'name': name} + else: + current = None + continue + # Indented body line: " key value" + if current is None: + continue + parts = line.strip().split(None, 1) + if len(parts) == 2: + current[parts[0]] = parts[1] + if current and current.get('type') == 'pbs': + entries.append(current) + except OSError: + return [] + + result = [] + for e in entries: + name = e.get('name') or '' + if not name: + continue + kf = os.path.join(_PVE_STORAGE_KEY_DIR, f'{name}.enc') + if not os.path.isfile(kf): + continue + result.append({ + 'name': name, + 'server': e.get('server', ''), + 'datastore': e.get('datastore', ''), + 'path': kf, + }) + return result + + +def _pbs_pve_keyfile_for_repository(repository: str) -> dict: + """Given a PBS repository string 'user@realm@host:datastore', return + the matching PVE-storage keyfile entry (dict) or {} if none matches. + """ + if not repository or ':' not in repository or '@' not in repository: + return {} + # Split repo on last '@' and last ':' — user/realm may contain '@' + # in the token form. + host_and_ds = repository.rsplit('@', 1)[-1] + host, _, datastore = host_and_ds.rpartition(':') + if not host or not datastore: + return {} + for e in _pbs_discover_pve_keyfiles(): + if e.get('server') == host and e.get('datastore') == datastore: + return e + return {} + + +def _pbs_do_finalize_recovery(passphrase: str, mode: str) -> tuple: + """Wrap the on-disk keyfile with the operator's passphrase (writes + _PBS_RECOVERY_ENC_PATH) and persist the escrow mode. Called by + both import and set-mode flows. + + - mode 'full' or 'local' → envelope written + mode file updated. + - mode 'none' should never reach here — the caller must skip the + call entirely. Guarded by an assert for safety. + + Every artefact ProxMenux writes lives under _BACKUP_STATE_DIR + (usually /usr/local/share/proxmenux/). No copies leak into /root/ + — the operator uses the Monitor's Download button when they want + the keyfile offsite. Returns (ok, '') — the second slot is kept + for API compatibility with older callers that expected an offsite + path there. + """ + assert mode in ('local', 'full'), 'finalize_recovery called with invalid mode' + if not shutil.which('openssl'): + return False, '' + if not os.path.isfile(_PBS_KEYFILE_PATH): + return False, '' + if not _pbs_recovery_encrypt(passphrase, _PBS_KEYFILE_PATH, _PBS_RECOVERY_ENC_PATH): + return False, '' + _pbs_write_escrow_mode(mode) + return True, '' + + +def _pbs_install_pve_keyfile(src_path: str) -> bool: + """Copy a PVE-storage keyfile into the ProxMenux canonical location. + Atomic: writes to a temp path in the same directory, chmods, then + renames. Returns True on success.""" + if not (src_path and os.path.isfile(src_path)): + return False + try: + os.makedirs(_BACKUP_STATE_DIR, exist_ok=True) + fd, tmp = tempfile.mkstemp( + prefix='pbs-key.', suffix='.pve', dir=_BACKUP_STATE_DIR, + ) + try: + with os.fdopen(fd, 'wb') as fout, open(src_path, 'rb') as fin: + fout.write(fin.read()) + os.chmod(tmp, 0o600) + os.replace(tmp, _PBS_KEYFILE_PATH) + except OSError: + try: + os.remove(tmp) + except OSError: + pass + return False + except OSError: + return False + return True def _pbs_keyfile_get_or_create(create_if_missing: bool = True) -> str: @@ -13905,6 +14077,7 @@ def api_pbs_encryption_keyfile_info(): and os.path.getsize(_PBS_KEYFILE_PASS_PATH) > 0 ), 'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH), + 'escrow_mode': _pbs_read_escrow_mode(), }) @@ -13919,38 +14092,26 @@ def api_pbs_encryption_remove(): passphrase and are the safe way to reach any pre-existing backup encrypted with the removed key. - Body: `{backup_before_remove: bool}` (default true) + Deleting is destructive and unconditional — the operator is + expected to click Download first if they want a copy. ProxMenux + never scatters files under /root/ any more. """ - payload = request.get_json(silent=True) or {} - do_backup = bool(payload.get('backup_before_remove', True)) if not os.path.isfile(_PBS_KEYFILE_PATH): return jsonify({'error': 'no keyfile installed'}), 404 - backed_up = [] - if do_backup: - ts = time.strftime('%Y%m%d_%H%M%S', time.localtime()) - for src, dst_suffix in ( - (_PBS_KEYFILE_PATH, '.conf'), - (_PBS_KEYFILE_PASS_PATH, '.pass'), - (_PBS_RECOVERY_ENC_PATH, '.recovery.enc'), - ): - if not os.path.isfile(src): - continue - dst = f'/root/pbs-key.old-{ts}{dst_suffix}' - try: - with open(src, 'rb') as fin, open(os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), 'wb') as fout: - fout.write(fin.read()) - backed_up.append(dst) - except OSError as e: - return jsonify({ - 'error': f'backup to /root failed for {src}: {e}', - 'partial_backups': backed_up, - }), 500 + backed_up: list = [] # Wipe order: recovery blob first so a mid-flight failure leaves us - # in a clean, backup-still-decryptable-with-blob state. + # in a clean, backup-still-decryptable-with-blob state. The escrow + # mode file goes last because it's the smallest side-effect and + # missing = 'full' (legacy default), which is a safe fallback. removed = [] - for path in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH, _PBS_KEYFILE_PATH): + for path in ( + _PBS_RECOVERY_ENC_PATH, + _PBS_KEYFILE_PASS_PATH, + _PBS_KEYFILE_PATH, + _PBS_ESCROW_MODE_PATH, + ): try: os.remove(path) removed.append(path) @@ -14023,6 +14184,10 @@ def api_pbs_recovery_status(): # manifest's expected fingerprint after a download error # ("missing key — manifest was created with key XX:XX:..."). 'keyfile_fingerprint': _pbs_keyfile_fingerprint(), + # Current escrow model: 'none' | 'local' | 'full'. Missing on + # legacy hosts (pre-3-mode feature) — the reader falls back to + # 'full' silently, matching the pre-feature behaviour. + 'escrow_mode': _pbs_read_escrow_mode(), }) @@ -14174,35 +14339,41 @@ def api_host_backups_restore_log(): @app.route('/api/host-backups/pbs-encryption/import', methods=['POST']) @require_auth def api_pbs_encryption_import(): - """Install a user-provided PBS keyfile at the canonical shared path. + """Install a PBS keyfile at the canonical shared path and set the + escrow mode in one atomic step. - Body: ``{"content_b64": ""}`` + Body (all fields optional except as noted): - Use case: operators who generated their own PBS master key and want - to share it across every host (single recovery blob covers all, - cross-host restore is possible). The keyfile is written to a tmp - path first and validated with ``proxmox-backup-client key info``; - only if the tool accepts it as a real PBS keyfile does it get - promoted to the canonical ``_PBS_KEYFILE_PATH``. Any prior - keyfile + recovery blob at that location are replaced atomically — - the old blob was encrypted for the old key and would no longer be - decryptable anyway. + source: "content" (default) | "pve-storage" + content_b64: # source=content + pve_storage_name: "" # source=pve-storage + keyfile_passphrase: # source=content only + escrow_mode: "none" | "local" | "full" # default "full" + escrow_passphrase: # required unless escrow_mode='none' + + Behaviour by escrow_mode: + - none: keyfile installed, no envelope, no /root copy, no upload + - local: envelope written + copied to /root, no PBS upload + - full: envelope written + copied to /root, runner uploads to PBS + + Any prior recovery blob at the canonical path is dropped — it was + encrypted for the old key. The escrow mode file is updated to + reflect the new choice; downstream runs (both shell and runner) + read it back. """ payload = request.get_json(silent=True) or {} - content_b64 = payload.get('content_b64') or '' - if not content_b64: - return jsonify({'error': 'content_b64 is required'}), 400 + source = (payload.get('source') or 'content').strip().lower() + if source not in ('content', 'pve-storage', 'generate', 'path'): + return jsonify({'error': "source must be 'content', 'pve-storage', 'generate' or 'path'"}), 400 - import base64 - try: - content = base64.b64decode(content_b64, validate=True) - except Exception: - return jsonify({'error': 'content_b64 is not valid base64'}), 400 - - # PBS keyfiles are compact JSON blobs (~200 bytes). Cap at 8 KB so - # a malformed upload can't fill the state dir with garbage. - if not content or len(content) > 8192: - return jsonify({'error': 'keyfile size out of expected range (1..8192 bytes)'}), 400 + escrow_mode = (payload.get('escrow_mode') or 'full').strip().lower() + if escrow_mode not in _PBS_ESCROW_MODES: + return jsonify({'error': f'escrow_mode must be one of {_PBS_ESCROW_MODES}'}), 400 + escrow_pass = payload.get('escrow_passphrase') or '' + if escrow_mode != 'none' and not escrow_pass: + return jsonify({'error': 'escrow_passphrase is required when escrow_mode is not "none"'}), 400 + if escrow_mode != 'none' and not shutil.which('openssl'): + return jsonify({'error': 'openssl is not installed; cannot build the recovery envelope'}), 500 if not shutil.which('proxmox-backup-client'): return jsonify({'error': 'proxmox-backup-client is not installed on this host'}), 500 @@ -14212,21 +14383,115 @@ def api_pbs_encryption_import(): except OSError as e: return jsonify({'error': f'cannot create state directory: {e}'}), 500 - # Strip a leading UTF-8 BOM (b'\xef\xbb\xbf') if the operator's - # editor added one — the byte order mark makes the file no longer - # a valid JSON document and downstream tools refuse it with a - # cryptic parse error. - if content.startswith(b'\xef\xbb\xbf'): - content = content[3:] + # ── Resolve the keyfile bytes based on source ────────────────── + keyfile_pass = '' + if source == 'generate': + # Fresh keyfile generated server-side via proxmox-backup-client. + # We short-circuit the "load bytes → atomic install" pipeline + # below because the tool already writes the file at the + # canonical path. + stale_recovery = _PBS_RECOVERY_ENC_PATH + try: + os.remove(stale_recovery) + except FileNotFoundError: + pass + except OSError: + pass + # Wipe any zero-byte residue so the "already installed" branch + # in _pbs_keyfile_get_or_create doesn't misfire. + try: + if os.path.isfile(_PBS_KEYFILE_PATH) and os.path.getsize(_PBS_KEYFILE_PATH) == 0: + os.remove(_PBS_KEYFILE_PATH) + except OSError: + pass + # If a keyfile is already present the operator must remove it + # first — silent replacement here would drop backups they still + # need to decrypt. + if os.path.isfile(_PBS_KEYFILE_PATH): + return jsonify({ + 'error': "a keyfile is already installed at " + f"{_PBS_KEYFILE_PATH}. Remove it first via /pbs-encryption/remove.", + }), 409 + if not _pbs_keyfile_get_or_create(True): + return jsonify({'error': 'failed to generate the encryption keyfile'}), 500 + keyfile_pass = '' + offsite_copy = '' + if escrow_mode == 'none': + _pbs_write_escrow_mode('none') + else: + ok, offsite_copy = _pbs_do_finalize_recovery(escrow_pass, escrow_mode) + if not ok: + return jsonify({'error': 'openssl encryption of the recovery envelope failed'}), 500 + return jsonify({ + 'status': 'ok', + 'source': 'generate', + 'keyfile_path': _PBS_KEYFILE_PATH, + 'keyfile_pass_stored': False, + 'escrow_mode': escrow_mode, + 'recovery_ready': escrow_mode != 'none', + 'offsite_copy': offsite_copy, + }) - # Optional passphrase for a scrypt-encoded keyfile. Blank means the - # keyfile is kdf=none. We do NOT verify that the passphrase actually - # unlocks the keyfile — that would require the operator to be - # present and the flow to be interactive. If it's wrong, backups - # will fail loudly with proxmox-backup-client's own error message. - keyfile_pass = payload.get('keyfile_passphrase') or '' + if source == 'path': + # Read a keyfile from an absolute path on this host. Mirrors + # the shell wizard's "import from path" branch — the operator + # dropped the file somewhere on disk (via scp, USB, etc.) and + # types the path. Same size cap + BOM strip as source=content. + src_path = (payload.get('keyfile_path') or '').strip() + if not src_path: + return jsonify({'error': "keyfile_path is required when source is 'path'"}), 400 + if not os.path.isabs(src_path): + return jsonify({'error': 'keyfile_path must be an absolute path'}), 400 + if not os.path.isfile(src_path): + return jsonify({'error': f'no file at {src_path}'}), 404 + try: + with open(src_path, 'rb') as f: + content = f.read(8192 + 1) + except OSError as e: + return jsonify({'error': f'cannot read {src_path}: {e}'}), 500 + if not content or len(content) > 8192: + return jsonify({'error': 'keyfile size out of expected range (1..8192 bytes)'}), 400 + if content.startswith(b'\xef\xbb\xbf'): + content = content[3:] + keyfile_pass = '' # keyfile passphrase never used by this branch - import tempfile + elif source == 'content': + content_b64 = payload.get('content_b64') or '' + if not content_b64: + return jsonify({'error': "content_b64 is required when source is 'content'"}), 400 + import base64 + try: + content = base64.b64decode(content_b64, validate=True) + except Exception: + return jsonify({'error': 'content_b64 is not valid base64'}), 400 + if not content or len(content) > 8192: + return jsonify({'error': 'keyfile size out of expected range (1..8192 bytes)'}), 400 + # Strip a leading UTF-8 BOM if the operator's editor added one. + if content.startswith(b'\xef\xbb\xbf'): + content = content[3:] + # Optional keyfile passphrase (scrypt KDF). + keyfile_pass = payload.get('keyfile_passphrase') or '' + else: + # source == 'pve-storage' + pve_name = (payload.get('pve_storage_name') or '').strip() + if not pve_name: + return jsonify({'error': "pve_storage_name is required when source is 'pve-storage'"}), 400 + pve_path = os.path.join(_PVE_STORAGE_KEY_DIR, f'{pve_name}.enc') + if not os.path.isfile(pve_path): + return jsonify({ + 'error': f'no PVE-managed keyfile at {pve_path}', + }), 404 + try: + with open(pve_path, 'rb') as f: + content = f.read(8192 + 1) + except OSError as e: + return jsonify({'error': f'cannot read PVE keyfile: {e}'}), 500 + if not content or len(content) > 8192: + return jsonify({'error': 'PVE keyfile out of expected size range (1..8192 bytes)'}), 400 + # PVE-managed keyfiles are always --kdf none by construction. + keyfile_pass = '' + + # ── Atomic install to _PBS_KEYFILE_PATH ──────────────────────── tmp_fd, tmp_path = tempfile.mkstemp(prefix='pbs-key.import.', dir=_BACKUP_STATE_DIR) try: try: @@ -14236,19 +14501,8 @@ def api_pbs_encryption_import(): except OSError as e: return jsonify({'error': f'cannot write tmp keyfile: {e}'}), 500 - # No content validation. The operator brings their own key — - # any KDF, any format, any origin. If it later doesn't work - # with proxmox-backup-client the failure will surface at - # backup time with a clear error. This is a deliberate - # trust-the-operator design: keeping `key info` validation - # here rejected legitimate scrypt-encoded keyfiles that could - # only be inspected with their original passphrase, which is - # exactly the case ProxMenux can't provide in a non-interactive - # flow. - - # The old recovery blob was encrypted with the old key — a new - # blob has to be built by re-running /pbs-recovery/setup once - # the operator confirms this import. + # The old recovery blob was encrypted with the old key — drop it. + # The paired passphrase file goes too; the new source drives it. for stale in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH): try: os.remove(stale) @@ -14263,9 +14517,8 @@ def api_pbs_encryption_import(): except OSError as e: return jsonify({'error': f'cannot promote keyfile: {e}'}), 500 - # Persist the keyfile passphrase alongside the key, chmod 600. - # Same trust boundary as the keyfile itself: root-only. Empty - # value → don't write anything (blank passphrase == kdf=none). + # Persist the keyfile passphrase (if any). Same trust boundary + # as the keyfile itself. if keyfile_pass: try: fd = os.open(_PBS_KEYFILE_PASS_PATH, @@ -14275,11 +14528,23 @@ def api_pbs_encryption_import(): except OSError as e: return jsonify({'error': f'cannot store keyfile passphrase: {e}'}), 500 + # ── Finalize per escrow mode ─────────────────────────────── + offsite_copy = '' + if escrow_mode == 'none': + _pbs_write_escrow_mode('none') + else: + ok, offsite_copy = _pbs_do_finalize_recovery(escrow_pass, escrow_mode) + if not ok: + return jsonify({'error': 'openssl encryption of the recovery envelope failed'}), 500 + return jsonify({ 'status': 'ok', + 'source': source, 'keyfile_path': _PBS_KEYFILE_PATH, 'keyfile_pass_stored': bool(keyfile_pass), - 'recovery_reset': True, + 'escrow_mode': escrow_mode, + 'recovery_ready': escrow_mode != 'none', + 'offsite_copy': offsite_copy, }) finally: try: @@ -14289,6 +14554,124 @@ def api_pbs_encryption_import(): pass +@app.route('/api/host-backups/pbs-encryption/download-keyfile', methods=['GET']) +@require_auth +def api_pbs_encryption_download_keyfile(): + """Send the installed PBS keyfile as a downloadable attachment. + This is how the operator gets an offsite copy without exposing + the file over shell — they hit Download, save it somewhere safe + (USB, password manager, another host), and can later re-import + it on a reinstalled host via the same Import flow. Returns 404 + when no keyfile is installed.""" + if not os.path.isfile(_PBS_KEYFILE_PATH): + return jsonify({'error': 'no keyfile installed'}), 404 + filename = f'pbs-key.conf' + try: + # send_file handles the streaming + Content-Disposition header; + # forcing as_attachment makes the browser show a save dialog + # instead of rendering the JSON inline. + return send_file( + _PBS_KEYFILE_PATH, + mimetype='application/octet-stream', + as_attachment=True, + download_name=filename, + ) + except OSError as e: + return jsonify({'error': f'cannot read keyfile: {e}'}), 500 + + +@app.route('/api/host-backups/pbs-encryption/discover-pve-keyfiles', methods=['GET']) +@require_auth +def api_pbs_discover_pve_keyfiles(): + """List PBS storage entries in /etc/pve/storage.cfg that carry an + encryption keyfile at /etc/pve/priv/storage/.enc. When a + `repository` query param is provided, the entry (if any) whose + server + datastore match that repository is flagged with + `matches_repository: true` so the frontend can pre-select it as + the recommended import source. + + Response: + { + entries: [ + {name, server, datastore, path, matches_repository (bool)} + ] + } + """ + repo = (request.args.get('repository') or '').strip() + matched = _pbs_pve_keyfile_for_repository(repo) if repo else {} + matched_path = matched.get('path', '') if matched else '' + entries = [] + for e in _pbs_discover_pve_keyfiles(): + entries.append({ + **e, + 'matches_repository': (bool(matched_path) and e['path'] == matched_path), + }) + return jsonify({'entries': entries}) + + +@app.route('/api/host-backups/pbs-encryption/set-escrow-mode', methods=['POST']) +@require_auth +def api_pbs_set_escrow_mode(): + """Change the escrow mode for an already-installed keyfile without + touching the keyfile itself. + + Body: + escrow_mode: "none" | "local" | "full" + escrow_passphrase: # required when going to local/full + # or when current mode is 'none' + + Transitions: + any → none: drops the local envelope + /root offsite copy; + writes mode='none'. Uploaded PBS envelopes are NOT + touched (they stay recoverable with their original + passphrase). + any → local/full: encrypts a fresh envelope from the current + keyfile with the provided passphrase. When + going from 'none' the passphrase is mandatory; + when just switching between local/full it is + also required because we always rebuild the + envelope so both modes stay consistent. + """ + if not os.path.isfile(_PBS_KEYFILE_PATH): + return jsonify({'error': 'no keyfile installed'}), 404 + payload = request.get_json(silent=True) or {} + new_mode = (payload.get('escrow_mode') or '').strip().lower() + if new_mode not in _PBS_ESCROW_MODES: + return jsonify({'error': f'escrow_mode must be one of {_PBS_ESCROW_MODES}'}), 400 + + if new_mode == 'none': + # Wipe local envelope + best-effort /root copies. Uploaded PBS + # blobs stay — the operator may still recover the old key via + # its old passphrase using the keyrecovery snapshot group. + for p in (_PBS_RECOVERY_ENC_PATH,): + try: + os.remove(p) + except FileNotFoundError: + pass + except OSError as e: + return jsonify({'error': f'cannot remove {p}: {e}'}), 500 + _pbs_write_escrow_mode('none') + return jsonify({ + 'status': 'ok', + 'escrow_mode': 'none', + 'recovery_ready': False, + }) + + # new_mode is 'local' or 'full' → rebuild the envelope from scratch. + passphrase = payload.get('escrow_passphrase') or '' + if not passphrase: + return jsonify({'error': 'escrow_passphrase is required for local/full modes'}), 400 + ok, offsite = _pbs_do_finalize_recovery(passphrase, new_mode) + if not ok: + return jsonify({'error': 'openssl encryption of the recovery envelope failed'}), 500 + return jsonify({ + 'status': 'ok', + 'escrow_mode': new_mode, + 'recovery_ready': True, + 'offsite_copy': offsite, + }) + + @app.route('/api/host-backups/pbs-recovery/setup', methods=['POST']) @require_auth def api_pbs_recovery_setup(): @@ -14326,6 +14709,11 @@ def api_pbs_recovery_setup(): if not _pbs_recovery_encrypt(passphrase, _PBS_KEYFILE_PATH, _PBS_RECOVERY_ENC_PATH): return jsonify({'error': 'openssl encryption failed'}), 500 + # Legacy setup implies the operator wants the full escrow behaviour + # (envelope uploaded to PBS by the runner). New callers should use + # /pbs-encryption/set-escrow-mode with an explicit mode, but this + # endpoint remains for compatibility with older Monitor builds. + _pbs_write_escrow_mode('full') return jsonify({'status': 'ok', 'recovery_path': _PBS_RECOVERY_ENC_PATH}) diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh index acd00cba..77cebf9d 100755 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -1195,17 +1195,18 @@ _rs_extract_pbs() { # error rather than the raw log. local extra_hint="" if grep -qiE 'encryption key|unable to (load|read) key|no key (file|found)|decrypt|failed to decrypt' "$log_file" 2>/dev/null; then - extra_hint=$'\n\n'"$(translate "This backup is encrypted but no keyfile is available on this host.")" + extra_hint=$'\n\n'"$(translate "This backup is encrypted.")" if [[ -f "$HB_STATE_DIR/pbs-key.conf" ]]; then - extra_hint+=$'\n\n'"$(translate "A keyfile is present but doesn't match the one used to create the backup. Make sure you have the correct keyfile from the source host.")" + extra_hint+=$'\n\n'"$(translate "A keyfile is present at:")"$'\n'" $HB_STATE_DIR/pbs-key.conf"$'\n'"$(translate "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.")" else - extra_hint+=$'\n\n'"$(translate "No keyfile recovery copy was found in PBS for this backup — it was created before the recovery feature existed. The encrypted content cannot be recovered.")" + extra_hint+=$'\n\n'"$(translate "The automatic recovery chain (PVE storage key, local envelope in /root, PBS keyrecovery group) did not find a usable keyfile for this snapshot.")" + extra_hint+=$'\n\n'"$(translate "Copy your keyfile to:")"$'\n'" $HB_STATE_DIR/pbs-key.conf"$'\n'"$(translate "and run Restore again — or pick an unencrypted backup.")" fi fi dialog --backtitle "ProxMenux" --title "$(translate "PBS extraction failed")" \ --msgbox "$(translate "Could not extract from PBS.")"$'\n\n'"$(translate "Backup:") $snapshot"$'\n'"$(translate "Archive:") $archive$extra_hint" \ - 16 78 + 20 78 hb_show_log "$log_file" "$(translate "PBS restore error log")" return 1 } diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh index 3325f7eb..ab69bf21 100755 --- a/scripts/backup_restore/lib_host_backup_common.sh +++ b/scripts/backup_restore/lib_host_backup_common.sh @@ -903,6 +903,184 @@ hb_pbs_decrypt_recovery() { -in "$1" -out "$2" -pass stdin 2>/dev/null } +# ========================================================== +# ESCROW MODE — data model for the keyfile recovery strategy +# ========================================================== +# A single state file at $HB_STATE_DIR/pbs-key.mode records how this +# host wants its PBS keyfile protected against loss. Three values: +# none — keyfile lives only here. No local envelope, no upload. +# If host is lost without an offsite copy → data is gone. +# local — an openssl-wrapped envelope of the keyfile is written to +# /root and $HB_STATE_DIR. Not uploaded to PBS. Operator +# moves the /root copy offsite. +# full — same envelope AND uploaded to PBS as -keyrecovery group. +# Fully self-recoverable, at the cost of putting the +# wrapped keyfile on the same server as the encrypted data. +# Missing file = pre-existing install from before this feature. Treat +# as 'full' to preserve behaviour. Only backup_host.sh / the runner +# needs to read this at runtime; the dialog paths write it. + +_hb_pbs_read_escrow_mode() { + local f="$HB_STATE_DIR/pbs-key.mode" + if [[ -s "$f" ]]; then + local v + v=$(head -c 32 "$f" | tr -d '[:space:]') + case "$v" in + none|local|full) printf '%s' "$v"; return 0 ;; + esac + fi + printf 'full' +} + +_hb_pbs_write_escrow_mode() { + local mode="$1" + case "$mode" in + none|local|full) : ;; + *) return 1 ;; + esac + mkdir -p "$HB_STATE_DIR" 2>/dev/null || true + umask 077 + printf '%s\n' "$mode" > "$HB_STATE_DIR/pbs-key.mode" + chmod 600 "$HB_STATE_DIR/pbs-key.mode" 2>/dev/null || true + return 0 +} + +# ========================================================== +# PVE STORAGE KEYFILE DISCOVERY +# ========================================================== +# When the operator has already added a PBS storage in Proxmox VE +# and enabled encryption on it (`pvesm set NAME --encryption-key +# autogen` or via the storage editor), PVE writes the keyfile to +# /etc/pve/priv/storage/.enc. That file IS a valid raw PBS +# keyfile (same JSON format as `proxmox-backup-client key create +# --kdf none`); it can be reused verbatim with --keyfile. +# ProxMenux surfaces those keyfiles as reuse candidates in the +# encryption dialog, so users don't have to type absolute paths or +# duplicate secrets across two configs. + +# Emit one line per PVE-managed PBS storage that has a .enc keyfile: +# name|server|datastore|keyfile-path +_hb_pbs_discover_pve_keyfiles() { + local cfg=/etc/pve/storage.cfg + local dir=/etc/pve/priv/storage + [[ -r "$cfg" && -d "$dir" ]] || return 0 + + # Parse storage.cfg PBS entries into name+server+datastore triples. + # A single awk pass builds the triples; a shell loop then checks + # each for a matching .enc keyfile and emits only those that + # actually exist. + awk ' + function emit(){ printf "%s|%s|%s\n", name, server, datastore } + /^pbs:[[:space:]]+/ { if (name) emit(); name=$2; server=""; datastore=""; next } + /^[a-z]+:[[:space:]]+/ { if (name) { emit(); name="" } } + /^[[:space:]]+server[[:space:]]+/ { server=$2 } + /^[[:space:]]+datastore[[:space:]]+/ { datastore=$2 } + END { if (name) emit() } + ' "$cfg" 2>/dev/null | while IFS='|' read -r name server datastore; do + local kf="$dir/$name.enc" + [[ -s "$kf" ]] || continue + printf '%s|%s|%s|%s\n' "$name" "$server" "$datastore" "$kf" + done +} + +# Given HB_PBS_REPOSITORY (form user@realm@host:datastore), find the +# PVE-storage keyfile whose server + datastore match. Emits the path +# on stdout, exits non-zero if no match. +_hb_pbs_pve_keyfile_for_current_repo() { + [[ -n "${HB_PBS_REPOSITORY:-}" ]] || return 1 + local repo_host repo_datastore + repo_host="${HB_PBS_REPOSITORY##*@}" + repo_host="${repo_host%:*}" + repo_datastore="${HB_PBS_REPOSITORY##*:}" + local match + match=$(_hb_pbs_discover_pve_keyfiles | awk -F'|' \ + -v h="$repo_host" -v d="$repo_datastore" \ + '$2==h && $3==d {print $4; exit}') + [[ -n "$match" ]] || return 1 + printf '%s' "$match" +} + +# Copy a PVE-storage keyfile into the ProxMenux canonical location. +# Same trust boundary as hb_pbs_import_keyfile — no validation, just +# atomic install + chmod 600. +_hb_pbs_install_pve_keyfile() { + local src="$1" + local dst="$HB_STATE_DIR/pbs-key.conf" + [[ -s "$src" && -r "$src" ]] || return 1 + mkdir -p "$HB_STATE_DIR" 2>/dev/null || true + local tmp + tmp=$(mktemp "${dst}.pve.XXXXXX") || return 1 + if ! cp -f "$src" "$tmp"; then rm -f "$tmp"; return 1; fi + chmod 600 "$tmp" + mv -f "$tmp" "$dst" || { rm -f "$tmp"; return 1; } + return 0 +} + +# ========================================================== +# ESCROW MODE — dialog +# ========================================================== +# Simple yes/no: "Upload the encryption key to PBS?" — mirrors the +# operator's mental model. Two outcomes only, no middle ground: +# Yes → mode='full': ProxMenux writes a passphrase-wrapped copy of +# the keyfile to /root and uploads it to PBS as the +# `-keyrecovery` group so a reinstalled host can recover the +# key with just the passphrase. +# No → mode='none': ProxMenux keeps the keyfile ONLY at the +# canonical local path. The operator is shown the location +# and told to make their own offsite copy — losing it means +# every encrypted backup on PBS is unreadable. +# The 'local' mode (envelope in /root but not uploaded) is still a +# valid backend value; it is just not surfaced to the operator here. +# +# Sets HB_PBS_ESCROW_RESULT to 'full' or 'none' on success; returns +# non-zero on cancel. We use a global variable instead of stdout +# capture because `if dialog --yesno ...; then` inside a `$( )` +# command substitution flushes the terminal at subshell exit and +# leaves the very next dialog invocation needing an extra Enter to +# render — invisible bug in ‘new’ keyfile flow. Callers set +# their own `local mode=$HB_PBS_ESCROW_RESULT` right after. +_hb_pbs_prompt_escrow_mode() { + # Optional first arg: source path of an EXISTING keyfile the + # operator is about to reuse (either from PVE storage or from an + # absolute path they just typed). When set, the dialog: + # • shows the source path so the operator sees where the key + # already lives on this host, + # • defaults to "No, keep local only" — the operator already + # has this keyfile elsewhere, so a PBS escrow copy is usually + # redundant. They can still hit Yes. + # When empty (fresh generate), defaults to "Yes, upload" — the + # safer path when no other copy of the key exists yet. + local existing_source="${1:-}" + HB_PBS_ESCROW_RESULT="" + + local msg + msg="$(hb_translate "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?")"$'\n\n' + if [[ -n "$existing_source" ]]; then + msg+="$(hb_translate "Existing key is at:")"$'\n'" $existing_source"$'\n\n' + fi + msg+="$(hb_translate "Yes: set a recovery passphrase now; the encrypted key envelope is uploaded with every backup.")"$'\n\n' + msg+="$(hb_translate "No: the key stays only at")"$'\n'" $HB_STATE_DIR/pbs-key.conf"$'\n' + msg+="$(hb_translate "Copy that file offsite yourself, or download it from the Monitor.")" + + local -a dialog_args=( + --backtitle "ProxMenux" + --title "$(hb_translate "Upload key to PBS?")" + --yes-label "$(hb_translate "Yes, upload")" + --no-label "$(hb_translate "No, keep local only")" + --defaultno + ) + dialog_args+=(--yesno "$msg" 18 78) + + dialog "${dialog_args[@]}" + # 0 = Yes button, 1 = No button, anything else (ESC / cancel) → + # abort the whole encryption setup so the operator can retry. + case $? in + 0) HB_PBS_ESCROW_RESULT='full'; return 0 ;; + 1) HB_PBS_ESCROW_RESULT='none'; return 0 ;; + *) return 1 ;; + esac +} + # Import a user-provided PBS encryption keyfile at the canonical # ProxMenux path. Used by the "shared keyfile across hosts" flow # where the operator generated one master key manually and wants @@ -953,13 +1131,17 @@ hb_pbs_import_keyfile() { return 0 } -# Prompt for a recovery passphrase pair; on OK returns the passphrase on -# stdout. Cancel or empty input returns non-zero WITHOUT creating any -# file. The caller decides when — and whether — to persist a keyfile -# and its recovery blob after this. Splitting the prompt out of the -# side-effectful phase means "user cancels passphrase" leaves the disk -# in exactly the state it was before the encryption flow started. +# Prompt for a recovery passphrase pair. Sets HB_PBS_PASS_RESULT on +# success; returns non-zero on cancel or when openssl is missing. The +# caller decides when — and whether — to persist a keyfile after this, +# so a cancel here leaves the disk exactly as it was. +# +# Uses a global variable instead of stdout capture — see the note on +# _hb_pbs_prompt_escrow_mode. Command-substitution around dialogs +# leaves the terminal in a state where the next dialog needs an extra +# Enter to render. _hb_pbs_prompt_recovery_pass() { + HB_PBS_PASS_RESULT="" if ! command -v openssl >/dev/null 2>&1; then dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery setup failed")" \ --msgbox "$(hb_translate "openssl is not installed — cannot create recovery copy. Install openssl and retry.")" 9 70 @@ -978,7 +1160,8 @@ _hb_pbs_prompt_recovery_pass() { dialog --backtitle "ProxMenux" \ --msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50 done - printf '%s' "$pass1" + HB_PBS_PASS_RESULT="$pass1" + return 0 } # Encrypt the on-disk keyfile with the supplied passphrase, drop an @@ -986,8 +1169,15 @@ _hb_pbs_prompt_recovery_pass() { # Precondition: the keyfile exists at $HB_STATE_DIR/pbs-key.conf. # Returns non-zero only on openssl failure (rare). The caller is # responsible for undoing whatever it just did if this fails. +# +# Args: +# $1 = passphrase +# $2 = escrow mode ('local' or 'full'); the message text branches +# on this. Never called with 'none' — the top-level flow skips +# finalize entirely for that mode. _hb_pbs_finalize_recovery() { local pass="$1" + local mode="${2:-full}" local key_file="$HB_STATE_DIR/pbs-key.conf" local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" @@ -998,44 +1188,43 @@ _hb_pbs_finalize_recovery() { fi chmod 600 "$recovery_enc" - # Drop an easy-export copy in /root so the operator can scp/USB - # it offsite without spelunking through HB_STATE_DIR. - local export_copy="/root/pbs-key.recovery-$(hostname)-$(date +%Y%m%d).enc" - if cp "$recovery_enc" "$export_copy" 2>/dev/null; then - chmod 600 "$export_copy" - else - export_copy="" - fi - - local success_msg - success_msg="$(hb_translate "Recovery configured.")"$'\n\n' - success_msg+="$(hb_translate "Every PBS backup from now on will also upload the encrypted recovery copy to PBS — automatically, no extra steps from you.")"$'\n\n' - success_msg+="$(hb_translate "If you lose this host: install ProxMenux on a fresh PVE host, point it at the same PBS, and the restore flow will offer to recover the keyfile using your passphrase.")" - if [[ -n "$export_copy" ]]; then - success_msg+=$'\n\n'"$(hb_translate "Offsite copy (optional):") $export_copy" - fi - dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery ready")" \ - --msgbox "$success_msg" 18 80 + # No extra "Recovery ready" msgbox — the operator's next action is + # the backup itself. The status of the encryption setup is visible + # in the Monitor and in the summary the backup emits when it + # starts running. return 0 } # Public wrapper preserved for external callers that already have a # keyfile on disk and just want the recovery blob generated. Combines -# the prompt + finalize phases. +# the prompt + finalize phases. Defaults to 'full' when called without +# a mode argument (preserves legacy caller behaviour); when the caller +# passes a mode it is forwarded to _hb_pbs_finalize_recovery so the +# success message matches. hb_pbs_setup_recovery() { + local mode="${1:-full}" local pass pass=$(_hb_pbs_prompt_recovery_pass) || return 1 - _hb_pbs_finalize_recovery "$pass" + if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then + return 1 + fi + _hb_pbs_write_escrow_mode "$mode" } # Upload the local recovery .enc to PBS as a separate snapshot # group. Called from _bk_pbs after the main backup succeeds. -# Skips silently if no recovery copy is present. Returns 0 on -# success or skip, 1 on upload failure. +# Short-circuits when escrow mode is not 'full' — the recovery +# envelope may still exist locally in 'local' mode, but the whole +# point of that mode is not putting it on PBS. Skips silently if no +# recovery copy is present. Returns 0 on success or skip, 1 on +# upload failure. hb_pbs_upload_recovery_blob() { local epoch="$1" local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" [[ ! -f "$recovery_enc" ]] && return 0 + local mode + mode=$(_hb_pbs_read_escrow_mode) + [[ "$mode" != "full" ]] && return 0 # `proxmox-backup-client backup` only accepts archive types # `pxar` / `img` / `conf` / `log` as the source spec — `.blob` @@ -1058,16 +1247,77 @@ hb_pbs_upload_recovery_blob() { >/dev/null 2>&1 } -# On a fresh install with no local keyfile, try to recover it -# from PBS. Returns 0 if the keyfile was successfully restored -# to $1, 1 if no recovery is possible or the user cancelled. +# On a fresh install with no local keyfile, try to recover it. +# Attempts several sources in order of decreasing convenience for +# Manual-import fallback: prompt the operator for the absolute path +# of the keyfile on this host. Copies it verbatim to the canonical +# path. Called from hb_pbs_try_keyfile_recovery when both auto-paths +# (PVE storage keyfile, PBS keyrecovery group) have been exhausted. +# Returns 0 on successful install, 1 on cancel or missing file. +_hb_pbs_manual_keyfile_import() { + local target_keyfile="$1" + local src + src=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Import encryption keyfile")" \ + --inputbox "$(hb_translate "No PBS keyfile is installed on this host and no automatic recovery was possible.")"$'\n\n'"$(hb_translate "Absolute path to your PBS keyfile:")"$'\n\n'"$(hb_translate "The file is copied to")"$'\n'"$target_keyfile" \ + 16 78 "" 3>&1 1>&2 2>&3) || return 1 + src="$(echo "$src" | xargs)" + [[ -z "$src" ]] && return 1 + if [[ ! -s "$src" || ! -r "$src" ]]; then + dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \ + --msgbox "$(hb_translate "The file does not exist, is empty or is not readable.")"$'\n\n'"$(hb_translate "Path:") $src" \ + 10 78 + return 1 + fi + mkdir -p "$(dirname "$target_keyfile")" + if ! cp -f "$src" "$target_keyfile"; then + dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \ + --msgbox "$(hb_translate "Could not copy the keyfile into place.")" 9 70 + return 1 + fi + chmod 600 "$target_keyfile" + return 0 +} + +# On a fresh install with no local keyfile, try to recover it. +# Attempts sources in order and stops at the first one that works: +# +# 1. PVE-storage keyfile matching HB_PBS_REPOSITORY +# (/etc/pve/priv/storage/.enc) — silent copy. +# 2. PBS `-keyrecovery` snapshot group — prompts for passphrase. +# 3. Manual import from an absolute path on this host — prompted +# when the two auto-paths above find nothing or the operator +# declines them. +# +# Returns 0 if the keyfile was successfully restored to $1, +# 1 if no recovery is possible or the user cancelled. hb_pbs_try_keyfile_recovery() { local target_keyfile="$1" + # ── Step 1: PVE-storage keyfile matching the current repository ── + # If the operator has already configured PBS in PVE with an + # encryption-key, we can use that file verbatim — no passphrase + # prompt, no PBS round-trip. This is the classic "why do I need + # to import? it is right there" case. + local pve_key_path="" + if [[ -n "${HB_PBS_REPOSITORY:-}" ]]; then + pve_key_path=$(_hb_pbs_pve_keyfile_for_current_repo 2>/dev/null || true) + fi + if [[ -n "$pve_key_path" && -s "$pve_key_path" ]]; then + mkdir -p "$(dirname "$target_keyfile")" + if cp -f "$pve_key_path" "$target_keyfile" 2>/dev/null; then + chmod 600 "$target_keyfile" + dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovered")" \ + --msgbox "$(hb_translate "Reused the encryption key from the PVE storage entry.")"$'\n\n'"$(hb_translate "Source:") $pve_key_path"$'\n'"$(hb_translate "Installed at:") $target_keyfile"$'\n\n'"$(hb_translate "Restore can now proceed.")" \ + 14 78 + return 0 + fi + fi + if ! command -v openssl >/dev/null 2>&1; then return 1 # silently — main path will surface a clearer error fi + # ── Step 2: PBS -keyrecovery snapshot group (fall back) ──────── # Discover keyrecovery groups in PBS. Two patterns are matched: # - hostcfg--keyrecovery (current naming — groups # adjacent to the main hostcfg-) @@ -1092,8 +1342,11 @@ hb_pbs_try_keyfile_recovery() { ) if [[ ${#recovery_entries[@]} -eq 0 ]]; then - return 1 # no recovery available — main flow will fail later on - # the actual decrypt, with a clear message + # No auto-recovery available — fall through to the manual + # import prompt. Nothing has been written to disk yet so a + # cancel here just aborts cleanly. + _hb_pbs_manual_keyfile_import "$target_keyfile" + return $? fi # Pick the recovery group (auto if one, ask if many) @@ -1129,9 +1382,13 @@ hb_pbs_try_keyfile_recovery() { iso=$(date -u -d "@$picked_epoch" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "$picked_epoch") local recovery_snapshot="host/${picked_id}/${iso}" - dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovery available")" \ + if ! dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovery available")" \ --yesno "$(hb_translate "Local keyfile is missing but a recovery copy was found in PBS.")"$'\n\n'"$(hb_translate "Backup:") $recovery_snapshot"$'\n\n'"$(hb_translate "Recover the keyfile using your recovery passphrase?")" \ - 13 78 || return 1 + 13 78; then + # Operator declined PBS recovery → offer the manual import path. + _hb_pbs_manual_keyfile_import "$target_keyfile" + return $? + fi # Download the blob once; we may retry passphrase entry without # re-fetching it. @@ -1180,26 +1437,36 @@ hb_pbs_try_keyfile_recovery() { # Internal helper: create a fresh PBS keyfile at the canonical path -# and its paired recovery blob. The passphrase is prompted BEFORE the -# keyfile hits disk — so if the operator cancels at the passphrase -# prompt, nothing is created and nothing needs cleaning up. The only -# time we ever have to remove a keyfile here is if openssl fails to -# encrypt the recovery blob (rare); that's a real error, not a cancel. +# and (unless mode='none') its paired recovery envelope. Prompts the +# operator for the escrow mode first — that decision drives whether +# a passphrase is asked, whether openssl is run, and what message +# closes the flow. Cancel at any dialog before the keyfile is +# actually created leaves the disk untouched. _hb_pbs_create_new_keyfile() { local key_file="$HB_STATE_DIR/pbs-key.conf" local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" - # 1. Collect the recovery passphrase. Cancel here → zero side effect. - local pass - pass=$(_hb_pbs_prompt_recovery_pass) || return 1 + # 1. Pick the escrow mode. Cancel here → zero side effect. + _hb_pbs_prompt_escrow_mode || return 1 + local mode="$HB_PBS_ESCROW_RESULT" - # 2. Create the keyfile. Point of no return: any failure past here - # leaves us with state to clean up. - msg_info "$(hb_translate "Creating PBS encryption key...")" + # 2. Collect the recovery passphrase — only when we're going to + # encrypt an envelope. Cancel at the passphrase prompt → zero + # side effect. + local pass="" + if [[ "$mode" != "none" ]]; then + _hb_pbs_prompt_recovery_pass || return 1 + pass="$HB_PBS_PASS_RESULT" + fi + + # 3. Create the keyfile. Point of no return: any failure past here + # leaves us with state to clean up. No msg_info/msg_ok in the + # dialog chain — those interleave with the dialogs' terminal + # state and cause the operator to have to press Enter twice. mkdir -p "$HB_STATE_DIR" - local create_stderr + local create_stderr create_rc create_stderr=$(proxmox-backup-client key create --kdf none "$key_file" &1 >/dev/null) - local create_rc=$? + create_rc=$? if [[ $create_rc -ne 0 || ! -s "$key_file" ]]; then # Sweep any zero-byte residue and surface the real error. [[ -f "$key_file" && ! -s "$key_file" ]] && rm -f "$key_file" 2>/dev/null @@ -1214,24 +1481,29 @@ _hb_pbs_create_new_keyfile() { return 1 fi chmod 600 "$key_file" - msg_ok "$(hb_translate "Encryption key created:") $key_file" - # 3. Encrypt the recovery blob with the already-collected passphrase. - # Only an openssl-level failure lands us in the wipe branch. - if ! _hb_pbs_finalize_recovery "$pass"; then - rm -f "$key_file" "$recovery_enc" 2>/dev/null - HB_PBS_KEYFILE_OPT="" - return 1 + # 4. Finalize per mode. On mode='none' we skip finalize entirely — + # no envelope, no msgbox. The operator was already told in the + # upload prompt where the keyfile lives; the backup summary + # reprints "Encryption: Enabled" when the run starts. + if [[ "$mode" != "none" ]]; then + if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then + rm -f "$key_file" "$recovery_enc" 2>/dev/null + HB_PBS_KEYFILE_OPT="" + return 1 + fi fi + _hb_pbs_write_escrow_mode "$mode" HB_PBS_KEYFILE_OPT="--keyfile $key_file" return 0 } # Internal helper: prompt for the source path, validate WITHOUT -# copying, ask the recovery passphrase, and only then install the -# keyfile at the canonical path. Cancel at any dialog before the -# install step leaves the disk untouched — no "phantom" keyfile. +# copying, ask the escrow mode (and, if not 'none', the recovery +# passphrase), then install the keyfile at the canonical path. +# Cancel at any dialog before the install step leaves the disk +# untouched — no "phantom" keyfile. _hb_pbs_import_dialog() { local key_file="$HB_STATE_DIR/pbs-key.conf" local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" @@ -1280,22 +1552,29 @@ _hb_pbs_import_dialog() { --msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50 done - # 4. Collect the recovery passphrase. Cancel here → zero side effect - # (the source file is still where it was; we haven't copied yet). - local pass - pass=$(_hb_pbs_prompt_recovery_pass) || return 1 + # 4. Pick the escrow mode. Cancel → zero side effect. + _hb_pbs_prompt_escrow_mode "$src" || return 1 + local mode="$HB_PBS_ESCROW_RESULT" - # 5. Install the keyfile. Point of no return. - # hb_pbs_import_keyfile just copies + chmods; content is not inspected. + # 5. Collect the recovery passphrase — only when we plan to write + # an envelope. Cancel → zero side effect. + local pass="" + if [[ "$mode" != "none" ]]; then + _hb_pbs_prompt_recovery_pass || return 1 + pass="$HB_PBS_PASS_RESULT" + fi + + # 6. Install the keyfile. Point of no return. No msg_ok before the + # next dialog — the msg_ line breaks dialog terminal state and + # forces the operator to press Enter twice on the next one. if ! hb_pbs_import_keyfile "$src"; then dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \ --msgbox "$(hb_translate "Could not copy the keyfile into place. Check permissions on:") $HB_STATE_DIR" \ 10 78 return 1 fi - msg_ok "$(hb_translate "Imported existing encryption key:") $key_file" - # 6. Persist the keyfile passphrase (if any) next to the keyfile. + # 7. Persist the keyfile passphrase (if any) next to the keyfile. # Same trust boundary — chmod 600, root-only. Empty passphrase # means no file: the runner treats absent file as kdf=none. # Also export HB_PBS_ENC_PASS so _bk_pbs (called right after @@ -1312,30 +1591,97 @@ _hb_pbs_import_dialog() { HB_PBS_ENC_PASS="" fi - # 7. Encrypt the recovery blob with the recovery passphrase. - # openssl failure is the only path that has to undo the install. - if ! _hb_pbs_finalize_recovery "$pass"; then - rm -f "$key_file" "$recovery_enc" "$keyfile_pass_file" 2>/dev/null - HB_PBS_KEYFILE_OPT="" + # 8. Finalize per mode. mode='none' skips finalize (no envelope, + # no msgbox) — the operator was already told in the upload + # prompt where the keyfile lives. + if [[ "$mode" != "none" ]]; then + if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then + rm -f "$key_file" "$recovery_enc" "$keyfile_pass_file" 2>/dev/null + HB_PBS_KEYFILE_OPT="" + return 1 + fi + fi + + _hb_pbs_write_escrow_mode "$mode" + HB_PBS_KEYFILE_OPT="--keyfile $key_file" + return 0 +} + +# Internal helper: reuse a keyfile already managed by PVE at +# /etc/pve/priv/storage/.enc. Same mode-aware finalize flow as +# create + import, but without a passphrase prompt for the keyfile +# itself (PVE-managed encryption-key entries are always --kdf none). +_hb_pbs_reuse_pve_keyfile() { + local pve_name="$1" + local pve_path="$2" + local key_file="$HB_STATE_DIR/pbs-key.conf" + local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" + + [[ -s "$pve_path" ]] || return 1 + + # 1. Pick the escrow mode. Pass the PVE source path so the dialog + # tells the operator where the reused key already lives. + _hb_pbs_prompt_escrow_mode "$pve_path" || return 1 + local mode="$HB_PBS_ESCROW_RESULT" + + # 2. Recovery passphrase (only if we plan to write an envelope). + local pass="" + if [[ "$mode" != "none" ]]; then + _hb_pbs_prompt_recovery_pass || return 1 + pass="$HB_PBS_PASS_RESULT" + fi + + # 3. Install the PVE keyfile at the canonical path. No msg_ok + # before the next dialog. + if ! _hb_pbs_install_pve_keyfile "$pve_path"; then + dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \ + --msgbox "$(hb_translate "Could not copy the PVE keyfile into place. Check permissions on:") $HB_STATE_DIR" \ + 10 78 return 1 fi + # 4. PVE-managed keys never carry a per-key passphrase (they are + # always --kdf none). Wipe any stale pbs-key.pass so the runner + # does not try to feed a passphrase to a keyfile that has none. + rm -f "$HB_STATE_DIR/pbs-key.pass" 2>/dev/null || true + HB_PBS_ENC_PASS="" + + # 5. Finalize per mode. mode='none' skips finalize (no envelope, + # no msgbox). + if [[ "$mode" != "none" ]]; then + if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then + rm -f "$key_file" "$recovery_enc" 2>/dev/null + HB_PBS_KEYFILE_OPT="" + return 1 + fi + fi + + _hb_pbs_write_escrow_mode "$mode" HB_PBS_KEYFILE_OPT="--keyfile $key_file" return 0 } hb_ask_pbs_encryption() { - # Two-step flow: + # Flow (spec by the operator): # 1. Yes/No: "Do you want to encrypt this backup?" # No → plain backup, no keyfile touched. # Yes → step 2. # 2a. Keyfile already exists on this host → use it silently. - # 2b. No keyfile yet → menu with 2 options: - # new → generate + confirm passphrase (legacy flow). - # import → prompt path, validate, copy into place. - # cancel → ABORT the whole backup (operator asked for - # encryption but didn't pick a method; we don't - # silently downgrade to plain). + # Subsequent scheduled or manual backups never re-ask for + # the setup — that is what "already configured" means. + # 2b. No keyfile yet → 2-option menu: + # new → generate a fresh keyfile + # existing → try to auto-detect a PVE-managed keyfile + # matching the current PBS repository; + # if found, use it silently; if not, prompt + # for the absolute path. + # 3. After the keyfile is in place, ask Yes/No: + # "Upload an encrypted copy of the key to PBS?" + # Yes → passphrase → envelope written + will be uploaded + # by the runner (mode='full'). + # No → operator is shown the local path and told to make + # their own offsite copy (mode='none'). + # # A single-run helper leftover from a cancelled/failed create can # leave a zero-byte keyfile behind; we wipe it before deciding so # the "keyfile exists" branch never trips on an empty file. @@ -1373,21 +1719,36 @@ hb_ask_pbs_encryption() { return 0 fi - # ── Step 2b: no keyfile → let the operator generate or import ─ + # ── Step 2b: source menu — 2 options ────────────────────── local choice choice=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption key")" \ + --default-item "new" \ --menu "$(hb_translate "No encryption key is stored on this host. Choose how to set one up:")" \ 12 78 2 \ - "new" "$(hb_translate "Generate a new keyfile (one per host — safest isolation)")" \ - "import" "$(hb_translate "Import an existing keyfile from a path (shared across hosts)")" \ - 3>&1 1>&2 2>&3) || return 1 # Cancel → abort backup. + "new" "$(hb_translate "Generate a new keyfile")" \ + "existing" "$(hb_translate "Use an existing keyfile")" \ + 3>&1 1>&2 2>&3) || return 1 case "$choice" in new) _hb_pbs_create_new_keyfile return $? ;; - import) + existing) + # Auto-detect: is there a PVE-managed keyfile for this repo? + # If yes, use it silently (matches operator ask: "if there + # is one at the default path, auto-detect it"). If not, + # fall through to the manual-path dialog. + local pve_kf="" + if [[ -n "${HB_PBS_REPOSITORY:-}" ]]; then + pve_kf=$(_hb_pbs_pve_keyfile_for_current_repo 2>/dev/null || true) + fi + if [[ -n "$pve_kf" && -s "$pve_kf" ]]; then + local pve_name + pve_name="$(basename "$pve_kf" .enc)" + _hb_pbs_reuse_pve_keyfile "$pve_name" "$pve_kf" + return $? + fi _hb_pbs_import_dialog return $? ;;