diff --git a/AppImage/ProxMenux-1.2.2.3-beta.AppImage b/AppImage/ProxMenux-1.2.2.3-beta.AppImage index e7068360..4962484b 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 723c49a9..0291a74a 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -7eb39d1bb3aed0750f32708fab66bf5c2c95a47da5722b9bd351b34c7c31b3ee ProxMenux-1.2.2.3-beta.AppImage +ab9f30de3517bf2aa0746691e230186e647fb68ad57a298837c7c871700e0c4c ProxMenux-1.2.2.3-beta.AppImage diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 45eb763c..77b66600 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -333,6 +333,190 @@ const formatRunAt = (iso: string | null) => { } } +// ── PBS keyfile management (shared between CreateJob + ManualBackup) ── +// +// 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. +function KeyfileManageInline({ + reuseText, + onReplaced, + mutateStatus, +}: { + reuseText: string + onReplaced: () => void + mutateStatus: () => Promise +}) { + 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 close = () => { + setAction(null) + setBusy(false) + setErr(null) + setPass1("") + setPass2("") + setBackupFirst(true) + } + + 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 runRemove = async (mode: "remove" | "replace") => { + setBusy(true) + setErr(null) + try { + await fetchApi("/api/host-backups/pbs-encryption/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ backup_before_remove: backupFirst }), + }) + await mutateStatus() + if (mode === "replace") onReplaced() + close() + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + setBusy(false) + } + } + + return ( +
+
+ +
{reuseText}
+
+
+ + + +
+ + !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()}> + + + + + {action === "replace" ? "Replace keyfile" : "Remove keyfile"} + + + Backups already stored on PBS were encrypted with the current keyfile. After this action: + + +
    +
  • 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}
+ )} + + + + +
+
+
+ ) +} + export function HostBackup() { const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>( "/api/host-backups/jobs", @@ -3058,12 +3242,11 @@ function CreateJobDialog({ {pbsEncrypt && pbsRecoveryStatus?.has_keyfile && ( -
- -
- An encryption key is already installed on this host — it will be reused for this job. To replace it, first import a new one from Settings → Host backup or remove the existing keyfile. -
-
+ setPbsEncryptMode("new")} + mutateStatus={mutatePbsRecovery} + /> )} {pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && ( @@ -3966,12 +4149,11 @@ function ManualBackupDialog({ {pbsEncrypt && pbsRecoveryStatus?.has_keyfile && ( -
- -
- An encryption key is already installed on this host — it will be reused for this backup. -
-
+ setPbsEncryptMode("new")} + mutateStatus={mutatePbsRecovery} + /> )} {pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && ( diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 15014209..6e059210 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -13842,6 +13842,153 @@ def _pbs_recovery_decrypt(passphrase: str, in_path: str, out_path: str) -> bool: return True +@app.route('/api/host-backups/pbs-encryption/keyfile-info', methods=['GET']) +@require_auth +def api_pbs_encryption_keyfile_info(): + """Snapshot of the installed keyfile for the "Manage keyfile" panel. + Extends /pbs-recovery/status with metadata that only makes sense + when a keyfile is actually installed (installed_at, kdf hint). + Returns 200 with `installed: false` when no keyfile is present so + the panel can render a neutral empty state without special-casing + 404s in the caller.""" + installed = os.path.isfile(_PBS_KEYFILE_PATH) + if not installed: + return jsonify({ + 'installed': False, + 'path': _PBS_KEYFILE_PATH, + }) + installed_at = '' + try: + installed_at = time.strftime( + '%Y-%m-%dT%H:%M:%SZ', + time.gmtime(os.path.getmtime(_PBS_KEYFILE_PATH)), + ) + except OSError: + pass + # Best-effort KDF hint from the JSON keyfile body itself. `key info` + # would be the authoritative source but refuses scrypt keyfiles + # without their passphrase, so parsing the JSON manually gives us + # a hint even for imported scrypt keys the operator brought their + # own passphrase for. + kdf_hint = 'unknown' + try: + with open(_PBS_KEYFILE_PATH, 'r') as f: + raw = f.read(4096) + body = json.loads(raw) + kdf_raw = body.get('kdf') + if isinstance(kdf_raw, str): + kdf_hint = kdf_raw.lower() + elif isinstance(kdf_raw, dict): + # scrypt keyfiles carry `"kdf": {"Scrypt": {...}}`. + kdf_hint = next(iter(kdf_raw)).lower() if kdf_raw else 'unknown' + except (OSError, json.JSONDecodeError, ValueError, StopIteration): + pass + return jsonify({ + 'installed': True, + 'path': _PBS_KEYFILE_PATH, + 'fingerprint': _pbs_keyfile_fingerprint(), + 'kdf_hint': kdf_hint, + 'installed_at': installed_at, + 'has_keyfile_passphrase': ( + os.path.isfile(_PBS_KEYFILE_PASS_PATH) + and os.path.getsize(_PBS_KEYFILE_PASS_PATH) > 0 + ), + 'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH), + }) + + +@app.route('/api/host-backups/pbs-encryption/remove', methods=['POST']) +@require_auth +def api_pbs_encryption_remove(): + """Remove the installed keyfile + its paired local artifacts + (passphrase file, recovery blob). Optionally backs the three up to + /root under a timestamped prefix so the operator can still decrypt + or rebuild them later. Never touches the recovery blob(s) already + uploaded to PBS — those stay recoverable with their original + passphrase and are the safe way to reach any pre-existing backup + encrypted with the removed key. + + Body: `{backup_before_remove: bool}` (default true) + """ + 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 + + # Wipe order: recovery blob first so a mid-flight failure leaves us + # in a clean, backup-still-decryptable-with-blob state. + removed = [] + for path in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH, _PBS_KEYFILE_PATH): + try: + os.remove(path) + removed.append(path) + except FileNotFoundError: + pass + except OSError as e: + return jsonify({ + 'error': f'remove failed for {path}: {e}', + 'backed_up': backed_up, + 'removed': removed, + }), 500 + return jsonify({ + 'status': 'ok', + 'backed_up': backed_up, + 'removed': removed, + }) + + +@app.route('/api/host-backups/pbs-encryption/passphrase', methods=['POST']) +@require_auth +def api_pbs_encryption_passphrase(): + """Update ONLY the stored keyfile passphrase (pbs-key.pass) without + touching the keyfile itself. Useful when the operator rotates the + passphrase on their own PBS setup and needs ProxMenux to catch up. + Body: `{new_passphrase}`. Empty value wipes the file — the runner + then treats the keyfile as kdf=none. + """ + payload = request.get_json(silent=True) or {} + new_pass = payload.get('new_passphrase') or '' + if not os.path.isfile(_PBS_KEYFILE_PATH): + return jsonify({'error': 'no keyfile installed'}), 404 + if not new_pass: + try: + os.remove(_PBS_KEYFILE_PASS_PATH) + except FileNotFoundError: + pass + except OSError as e: + return jsonify({'error': f'cannot remove passphrase file: {e}'}), 500 + return jsonify({'status': 'ok', 'has_keyfile_passphrase': False}) + try: + fd = os.open(_PBS_KEYFILE_PASS_PATH, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w') as f: + f.write(new_pass) + except OSError as e: + return jsonify({'error': f'cannot write passphrase file: {e}'}), 500 + return jsonify({'status': 'ok', 'has_keyfile_passphrase': True}) + + @app.route('/api/host-backups/pbs-recovery/status', methods=['GET']) @require_auth def api_pbs_recovery_status(): diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh index fa55494e..21079ec9 100755 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -652,6 +652,7 @@ _bk_manage_destinations() { 1 "$(translate "Proxmox Backup Server (PBS) destinations")" \ 2 "$(translate "Borg repositories")" \ 3 "$(translate "Local archive targets (paths + USB mount/unmount)")" \ + 4 "$(translate "PBS encryption keyfile (show / replace / remove)")" \ 0 "$(translate "Return")" \ 3>&1 1>&2 2>&3) || break @@ -666,11 +667,202 @@ _bk_manage_destinations() { 3) _bk_manage_local_destinations ;; + 4) + _bk_manage_pbs_encryption_keyfile + ;; 0) break ;; esac 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 +# an explicit confirmation before touching disk. +_bk_manage_pbs_encryption_keyfile() { + local 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" + while true; do + local installed=0 + [[ -s "$key_file" ]] && installed=1 + + local preview="" + if (( installed )); then + local fp="" kdf_hint="unknown" installed_at="" + if command -v proxmox-backup-client >/dev/null 2>&1; then + fp=$(proxmox-backup-client key info --output-format json "$key_file" 2>/dev/null \ + | grep -oE '"fingerprint"[[:space:]]*:[[:space:]]*"[^"]*"' \ + | head -1 | sed 's/.*"\([^"]*\)".*/\1/') + fi + if grep -q '"kdf":[[:space:]]*"None"' "$key_file" 2>/dev/null; then + kdf_hint="none" + elif grep -q '"Scrypt"' "$key_file" 2>/dev/null; then + kdf_hint="scrypt" + fi + installed_at=$(date -r "$key_file" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "") + preview="$(hb_translate "Installed:") \Zb\Z2$(hb_translate "yes")\Zn"$'\n' + preview+="$(hb_translate "Path:") $key_file"$'\n' + [[ -n "$installed_at" ]] && preview+="$(hb_translate "Installed at:") $installed_at"$'\n' + preview+="$(hb_translate "KDF:") $kdf_hint"$'\n' + [[ -n "$fp" ]] && preview+="$(hb_translate "Fingerprint:") $fp"$'\n' + preview+="$(hb_translate "Keyfile passphrase stored:") " + [[ -s "$pass_file" ]] && preview+="\Zb\Z2$(hb_translate "yes")\Zn"$'\n' || preview+="\Zb$(hb_translate "no")\Zn"$'\n' + preview+="$(hb_translate "Recovery escrow present:") " + [[ -s "$recovery_enc" ]] && preview+="\Zb\Z2$(hb_translate "yes")\Zn"$'\n' || preview+="\Zb$(hb_translate "no")\Zn"$'\n' + else + preview="$(hb_translate "No keyfile installed on this host.")"$'\n\n' + preview+="$(hb_translate "PBS encrypted backups won't work until one is generated or imported. Both actions happen during job creation the first time you tick 'Encrypt backups'.")" + fi + + local choice + if (( installed )); then + choice=$(dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Manage PBS encryption keyfile")" \ + --menu "\n${preview}\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")" \ + 3>&1 1>&2 2>&3) || break + else + choice=$(dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Manage PBS encryption keyfile")" \ + --menu "\n${preview}\n" \ + "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ + "back" "$(hb_translate "← Return")" \ + 3>&1 1>&2 2>&3) || break + fi + + case "$choice" in + info) + # Full `key info` output (may need passphrase for scrypt). + local info_out + info_out=$(proxmox-backup-client key info "$key_file" 2>&1) + dialog --backtitle "ProxMenux" \ + --title "$(hb_translate "Keyfile info")" \ + --msgbox "$info_out" 20 80 + ;; + 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 + else + rm -f "$pass_file" 2>/dev/null + dialog --backtitle "ProxMenux" \ + --msgbox "$(hb_translate "Stored keyfile passphrase removed.")" 8 60 + fi + ;; + replace|remove) + local action_title="$(hb_translate "Replace keyfile")" + [[ "$choice" == "remove" ]] && action_title="$(hb_translate "Remove keyfile")" + # Destructive warning + confirmation + 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 + 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 + ;; + back|"") break ;; + esac + done +} + _bk_manage_extra_paths() { while true; do local -a paths=()