diff --git a/AppImage/ProxMenux-1.2.2.3-beta.AppImage b/AppImage/ProxMenux-1.2.2.3-beta.AppImage index 8482e6bd..08fb9913 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 ca43db68..50036eb4 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -6da865786f93c94359fd4428876a953f2706d752bd28120510ef5330f1d420aa ProxMenux-1.2.2.3-beta.AppImage +8db4067d1bcd3d7cd440d4de72d449e8b47480787f3650b35213c83a4bfade40 ProxMenux-1.2.2.3-beta.AppImage diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 43a9becf..ddb6b76b 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -594,48 +594,21 @@ function KeyfileActionsBar({ mutateStatus: () => Promise escrowMode?: "none" | "local" | "full" }) { - const [action, setAction] = useState(null) const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) const [pass1, setPass1] = useState("") const [pass2, setPass2] = useState("") - const [newEscrowMode, setNewEscrowMode] = useState<"none" | "local" | "full">(escrowMode ?? "full") + // Pending radio state — reflects the operator's intended mode after + // Apply. Seeded from the current mode so a No-op click on Apply is + // rejected. Binary UX ('none' vs 'full') matches the setup wizard. + const currentIsFull = escrowMode === "full" + const [pendingMode, setPendingMode] = useState<"none" | "full">(currentIsFull ? "full" : "none") - const close = () => { - setAction(null) - setBusy(false) - setErr(null) - setPass1("") - setPass2("") - setNewEscrowMode(escrowMode ?? "full") - } - - const runPassphrase = async () => { - if (pass1 !== pass2) { - setErr("Passphrases do not match.") - return - } - setBusy(true) - setErr(null) - try { - await fetchApi("/api/host-backups/pbs-encryption/passphrase", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ new_passphrase: pass1 }), - }) - await mutateStatus() - close() - } catch (e) { - setErr(e instanceof Error ? e.message : String(e)) - setBusy(false) - } - } - - const runEscrowMode = async () => { - // Local/full → passphrase required + match. None → passphrase ignored. - if (newEscrowMode !== "none") { + const runApply = async () => { + // Yes → passphrase required + match. No → passphrase ignored. + if (pendingMode === "full") { if (!pass1) { - setErr("Passphrase is required for local or full escrow.") + setErr("Recovery passphrase is required.") return } if (pass1 !== pass2) { @@ -646,179 +619,168 @@ function KeyfileActionsBar({ setBusy(true) setErr(null) try { - const body: Record = { escrow_mode: newEscrowMode } - if (newEscrowMode !== "none") body.escrow_passphrase = pass1 + const body: Record = { escrow_mode: pendingMode } + if (pendingMode === "full") body.escrow_passphrase = pass1 await fetchApi("/api/host-backups/pbs-encryption/set-escrow-mode", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) await mutateStatus() - close() + setPass1("") + setPass2("") } catch (e) { setErr(e instanceof Error ? e.message : String(e)) + } finally { setBusy(false) } } - // 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" + // Contextual Apply-button label. Three transitions: + // No → Yes → "Start uploading" + // Yes → No → "Stop uploading" + // Yes → Yes (new pw) → "Update passphrase" (rewraps envelope) + const applyLabel = + pendingMode === "full" && !currentIsFull + ? "Start uploading" + : pendingMode === "none" && currentIsFull + ? "Stop uploading" + : pendingMode === "full" && currentIsFull + ? "Update passphrase" + : "Apply" + + // Apply is enabled when there is a real change to commit. + const canApply = ( + (pendingMode !== (currentIsFull ? "full" : "none")) || // mode change + (pendingMode === "full" && !!pass1 && pass1 === pass2) // passphrase update + ) + + const download = () => { + 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) + } return ( -
+
Manage installed keyfile
- {escrowLabel !== undefined && ( -
+ + {/* Current status — icon + colour by state, no truncated fp. */} + {escrowMode !== undefined && ( +
Upload to PBS: - {uploadOn ? "Yes" : "No"} — {escrowLabel} + {currentIsFull ? ( + + + Yes — envelope uploaded on every backup + + ) : ( + + No — kept only on this host + + )}
)} + + {/* Change mode + passphrase — one integrated form, no popover. + Radio picks the intent; the passphrase pair unfolds when the + intent is Yes (both for a first-time upload and for a + passphrase rotation while already in Yes). */} +
+
Upload key to PBS?
+
+ + +
+ {pendingMode === "full" && ( +
+
+ + setPass1(e.target.value)} + placeholder={currentIsFull ? "Type a new passphrase to rotate" : "Long random string — write it down somewhere safe"} + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pass1 && pass2 && pass1 !== pass2 && ( +

Passphrases don't match.

+ )} +
+
+ )} + {err && ( +
{err}
+ )} +
+ +
+
+
- -
- !v && close()}> - - - Update stored keyfile passphrase - - This only rotates the passphrase ProxMenux uses to unlock the keyfile at backup time (stored in /usr/local/share/proxmenux/pbs-key.pass). It does not re-encrypt the keyfile itself. - - -
-
- - setPass1(e.target.value)} placeholder="Leave blank to remove the stored passphrase" className="font-mono h-9" /> -
- {pass1 && ( -
- - setPass2(e.target.value)} className="font-mono h-9" /> -
- )} - {err && ( -
{err}
- )} -
- - - - -
-
- - !v && close()}> - - - {newEscrowMode === "full" ? "Start uploading key to PBS" : "Stop uploading key to PBS"} - - {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."} - - -
- {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. */} + {/* Deletion lives inside the PBS destination row (PbsKeyfileActions); + setup menus of Create Job / Manual Backup never surface it. */}
) } diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh index 77cebf9d..5c3ba85b 100755 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -687,38 +687,6 @@ _bk_manage_destinations() { done } -# Backup the current keyfile + its paired files to /root under a -# timestamped prefix. Returns the timestamp on success (empty on -# failure). Never overwrites existing files. Used before any -# destructive change (replace / remove) so the operator can still -# decrypt pre-existing backups if they need to. -_bk_pbs_backup_keyfile_to_root() { - local ts key_file="$HB_STATE_DIR/pbs-key.conf" - local pass_file="$HB_STATE_DIR/pbs-key.pass" - local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" - [[ -f "$key_file" ]] || { echo ""; return 1; } - ts=$(date +%Y%m%d_%H%M%S) - umask 077 - cp -f "$key_file" "/root/pbs-key.old-${ts}.conf" 2>/dev/null || { echo ""; return 1; } - chmod 600 "/root/pbs-key.old-${ts}.conf" 2>/dev/null || true - if [[ -f "$pass_file" ]]; then - cp -f "$pass_file" "/root/pbs-key.old-${ts}.pass" 2>/dev/null || true - chmod 600 "/root/pbs-key.old-${ts}.pass" 2>/dev/null || true - fi - if [[ -f "$recovery_enc" ]]; then - cp -f "$recovery_enc" "/root/pbs-key.old-${ts}.recovery.enc" 2>/dev/null || true - chmod 600 "/root/pbs-key.old-${ts}.recovery.enc" 2>/dev/null || true - fi - echo "$ts" -} - -# Wipe local keyfile + pass + recovery. Never touches PBS. -_bk_pbs_wipe_local_keyfile() { - rm -f "$HB_STATE_DIR/pbs-key.conf" \ - "$HB_STATE_DIR/pbs-key.pass" \ - "$HB_STATE_DIR/pbs-key.recovery.enc" 2>/dev/null -} - # Dedicated management submenu for the PBS encryption keyfile. # Available as option 4 of "Configure backup destinations". All # destructive actions offer a backup-to-/root step first and require @@ -770,11 +738,12 @@ _bk_manage_pbs_encryption_keyfile() { --title "$(translate "Manage PBS encryption keyfile")" \ --menu "\n${prompt}\n" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ - "info" "$(hb_translate "Show detailed keyfile info")" \ - "pass" "$(hb_translate "Update stored keyfile passphrase")" \ - "replace" "$(hb_translate "Replace keyfile (generate new or import)")" \ - "remove" "$(hb_translate "Remove keyfile from this host")" \ - "back" "$(hb_translate "← Return")" \ + "info" "$(hb_translate "Show detailed keyfile info")" \ + "download" "$(hb_translate "Copy the keyfile to a path for offsite backup")" \ + "import" "$(hb_translate "Import a keyfile from an absolute path (replaces the current one)")" \ + "escrow" "$(hb_translate "Upload to PBS: enable, disable or rotate the recovery passphrase")" \ + "remove" "$(hb_translate "Remove keyfile from this host")" \ + "back" "$(hb_translate "← Return")" \ 3>&1 1>&2 2>&3) || break else choice=$(dialog --backtitle "ProxMenux" --colors \ @@ -819,86 +788,108 @@ _bk_manage_pbs_encryption_keyfile() { msg_success "$(translate "Press Enter to return to menu...")" read -r ;; - pass) - local new_pass new_pass2 - while true; do - new_pass=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Update keyfile passphrase")" \ - --insecure --passwordbox "$(hb_translate "New passphrase used to unlock the keyfile (leave blank to remove the stored passphrase — for kdf=none keyfiles):")" \ - "$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || { new_pass=""; break; } - if [[ -z "$new_pass" ]]; then - new_pass2="" - break - fi - new_pass2=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Update keyfile passphrase")" \ - --insecure --passwordbox "$(hb_translate "Confirm new passphrase:")" \ - "$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || { new_pass=""; break; } - [[ "$new_pass" == "$new_pass2" ]] && break - dialog --backtitle "ProxMenux" \ - --msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50 - done - if [[ -n "$new_pass" ]]; then - umask 077 - printf '%s' "$new_pass" > "$pass_file" - chmod 600 "$pass_file" 2>/dev/null || true - dialog --backtitle "ProxMenux" \ - --msgbox "$(hb_translate "Stored keyfile passphrase updated.")" 8 60 + download) + # Copy the keyfile to an operator-supplied path so + # they can scp/USB it offsite. Same trust boundary as + # the Monitor's Download button. + local dst + dst=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Download keyfile")" \ + --inputbox "$(hb_translate "Destination path for the copy (typically outside ProxMenux state):")"$'\n\n'"$(hb_translate "Default:") /tmp/pbs-key.conf" \ + 12 78 "/tmp/pbs-key.conf" 3>&1 1>&2 2>&3) || continue + dst="$(echo "$dst" | xargs)" + [[ -z "$dst" ]] && continue + if cp -f "$key_file" "$dst" 2>/dev/null; then + chmod 600 "$dst" 2>/dev/null || true + dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile copied")" \ + --msgbox "$(hb_translate "Keyfile copied to:")"$'\n\n'"$dst"$'\n\n'"$(hb_translate "Move that copy offsite (USB, password manager, another host). Delete it from this path when done.")" \ + 13 78 else - rm -f "$pass_file" 2>/dev/null - dialog --backtitle "ProxMenux" \ - --msgbox "$(hb_translate "Stored keyfile passphrase removed.")" 8 60 + dialog --backtitle "ProxMenux" --title "$(hb_translate "Copy failed")" \ + --msgbox "$(hb_translate "Could not write to:") $dst" 9 70 fi ;; - replace|remove) - local action_title="$(hb_translate "Replace keyfile")" - [[ "$choice" == "remove" ]] && action_title="$(hb_translate "Remove keyfile")" - # Destructive warning + confirmation + import) + # Import a new keyfile from an absolute path — replaces + # the currently installed one only if the operator + # completes the import dialog. Cancel / empty path / + # missing file all leave the current keyfile in place. + local warn_body + warn_body="\Zb\Z1$(hb_translate "This replaces the currently installed keyfile.")\Zn"$'\n\n' + warn_body+="$(hb_translate "Backups already on PBS were encrypted with the current key — downloading them will fail unless you first Download the current keyfile to keep a copy.")"$'\n\n' + warn_body+="$(hb_translate "Continue with the import?")" + if ! dialog --backtitle "ProxMenux" --colors --yesno "$warn_body" 14 78; then + continue + fi + # No upfront wipe. `_hb_pbs_import_dialog` only touches + # the canonical path after the operator has typed a + # valid absolute path AND finished the whole wizard; + # on cancel or missing file it returns non-zero and + # the current keyfile stays untouched. + _hb_pbs_import_dialog || true + ;; + escrow) + # Toggle Upload to PBS Yes/No + set or rotate the + # recovery passphrase — same three transitions the + # Monitor exposes on the KeyfileActionsBar. + local cur_mode="none" + [[ -s "$HB_STATE_DIR/pbs-key.mode" ]] && cur_mode="$(cat "$HB_STATE_DIR/pbs-key.mode" 2>/dev/null | tr -d '[:space:]')" + local esc_action + if [[ "$cur_mode" == "full" ]]; then + esc_action=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "PBS upload")" \ + --menu "$(hb_translate "Upload to PBS is currently: yes. Pick an action:")" \ + 12 78 3 \ + "rotate" "$(hb_translate "Rotate the recovery passphrase")" \ + "stop" "$(hb_translate "Stop uploading to PBS")" \ + "cancel" "$(hb_translate "Cancel")" \ + 3>&1 1>&2 2>&3) || continue + else + esc_action=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "PBS upload")" \ + --menu "$(hb_translate "Upload to PBS is currently: no. Pick an action:")" \ + 10 78 2 \ + "start" "$(hb_translate "Start uploading to PBS — sets a recovery passphrase")" \ + "cancel" "$(hb_translate "Cancel")" \ + 3>&1 1>&2 2>&3) || continue + fi + case "$esc_action" in + start|rotate) + _hb_pbs_prompt_recovery_pass || continue + local pass="$HB_PBS_PASS_RESULT" + if _hb_pbs_finalize_recovery "$pass" full; then + _hb_pbs_write_escrow_mode full + dialog --backtitle "ProxMenux" \ + --msgbox "$(hb_translate "Upload to PBS enabled. The envelope is uploaded on every encrypted backup.")" 10 70 + fi + ;; + stop) + if dialog --backtitle "ProxMenux" --colors --defaultno --yesno \ + "\Zb\Z1$(hb_translate "Stop uploading?")\Zn"$'\n\n'"$(hb_translate "The local envelope is dropped and future backups do not upload anything. Uploaded envelopes already on PBS stay intact and remain recoverable with their original passphrase.")" \ + 13 78; then + rm -f "$recovery_enc" 2>/dev/null + _hb_pbs_write_escrow_mode none + dialog --backtitle "ProxMenux" \ + --msgbox "$(hb_translate "Upload to PBS disabled.")" 8 60 + fi + ;; + esac + ;; + remove) + # Destructive removal of the local keyfile. No copies + # kept anywhere — the operator is expected to hit + # Download first if they want to save the key. local warn_body warn_body="\Zb\Z1$(hb_translate "This is a destructive action")\Zn"$'\n\n' warn_body+="$(hb_translate "Backups already stored on PBS were encrypted with the current keyfile. After this action:")"$'\n' - warn_body+=" • $(hb_translate "New backups will use the new (or no) keyfile.")"$'\n' - warn_body+=" • $(hb_translate "Downloading pre-existing encrypted backups from this host will fail unless you keep a copy of the current key.")"$'\n' - warn_body+=" • $(hb_translate "Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase.")"$'\n\n' - warn_body+="$(hb_translate "Continue?")" - if ! dialog --backtitle "ProxMenux" --colors --yesno "$warn_body" 16 80; then + warn_body+=" • $(hb_translate "New backups on this host will be unencrypted until a new keyfile is set up.")"$'\n' + warn_body+=" • $(hb_translate "Downloading pre-existing encrypted backups from this host will fail unless you kept a copy of the current key.")"$'\n' + warn_body+=" • $(hb_translate "Existing recovery envelopes already on PBS stay intact — they still recover the old key with its original passphrase.")"$'\n\n' + warn_body+="$(hb_translate "Use Download first if you want to save a copy of the current key. Continue?")" + if ! dialog --backtitle "ProxMenux" --colors --defaultno --yesno "$warn_body" 18 80; then continue fi - local do_backup=1 - if ! dialog --backtitle "ProxMenux" \ - --title "$(hb_translate "Backup current key")" \ - --yesno "$(hb_translate "Save a copy of the current keyfile + passphrase + recovery blob under /root/pbs-key.old-.* before ${action_title,,}?")" \ - 10 78; then - do_backup=0 - fi - local ts="" - if (( do_backup )); then - ts=$(_bk_pbs_backup_keyfile_to_root) - if [[ -z "$ts" ]]; then - dialog --backtitle "ProxMenux" \ - --msgbox "$(hb_translate "Backup to /root failed. Aborting.")" 8 60 - continue - fi - fi - _bk_pbs_wipe_local_keyfile - if [[ "$choice" == "replace" ]]; then - # Delegate to the yes/no + generate/import flow. - # HB_ASK_ENCRYPT_INSTRUCTIONS is unused here, we - # directly call the two helpers. - local rmenu - rmenu=$(dialog --backtitle "ProxMenux" \ - --title "$(hb_translate "Replace keyfile")" \ - --menu "$(hb_translate "How should the new keyfile be set up?")" \ - 10 78 2 \ - "new" "$(hb_translate "Generate a new keyfile (per host — safest isolation)")" \ - "import" "$(hb_translate "Import an existing keyfile from a path (shared across hosts)")" \ - 3>&1 1>&2 2>&3) || rmenu="" - case "$rmenu" in - new) _hb_pbs_create_new_keyfile || true ;; - import) _hb_pbs_import_dialog || true ;; - esac - fi - local done_msg="$action_title $(hb_translate "completed.")" - [[ -n "$ts" ]] && done_msg+=$'\n\n'"$(hb_translate "Backup saved with prefix:") /root/pbs-key.old-${ts}" - dialog --backtitle "ProxMenux" --msgbox "$done_msg" 10 78 + rm -f "$key_file" "$recovery_enc" "$pass_file" \ + "$HB_STATE_DIR/pbs-key.mode" 2>/dev/null + dialog --backtitle "ProxMenux" \ + --msgbox "$(hb_translate "Keyfile removed.")" 8 60 ;; back|"") break ;; esac diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh index ab69bf21..683fbfd6 100755 --- a/scripts/backup_restore/lib_host_backup_common.sh +++ b/scripts/backup_restore/lib_host_backup_common.sh @@ -1485,13 +1485,17 @@ _hb_pbs_create_new_keyfile() { # 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. + # reprints "Encryption: Enabled" when the run starts. Any + # envelope leftover from a previous keyfile is dropped so the + # runner does not upload a stale, undecipherable blob. 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 + else + rm -f "$recovery_enc" 2>/dev/null || true fi _hb_pbs_write_escrow_mode "$mode" @@ -1593,13 +1597,17 @@ _hb_pbs_import_dialog() { # 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. + # prompt where the keyfile lives. Any leftover envelope from a + # previous keyfile (wrapped with the OLD key) is dropped here so + # the runner does not upload a stale, undecipherable blob. 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 + else + rm -f "$recovery_enc" 2>/dev/null || true fi _hb_pbs_write_escrow_mode "$mode" @@ -1647,13 +1655,17 @@ _hb_pbs_reuse_pve_keyfile() { HB_PBS_ENC_PASS="" # 5. Finalize per mode. mode='none' skips finalize (no envelope, - # no msgbox). + # no msgbox). Drop any envelope left over from a previous + # keyfile — it is wrapped with an OLD key the runner cannot + # decrypt any more. 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 + else + rm -f "$recovery_enc" 2>/dev/null || true fi _hb_pbs_write_escrow_mode "$mode"