diff --git a/AppImage/ProxMenux-1.2.2.3-beta.AppImage b/AppImage/ProxMenux-1.2.2.3-beta.AppImage index 77723030..fcd8c730 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 5103cf87..5f95262d 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -7d9181c2adaab229a27f7002de121117a9343bdcd5465cc6cf3510de627005c3 ProxMenux-1.2.2.3-beta.AppImage +df4fe78ec4e77e333adf069528d9df274db16810bbcb110bfc86a5b8a0dc5a42 ProxMenux-1.2.2.3-beta.AppImage diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 77b66600..46c38c3f 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -346,12 +346,15 @@ const formatRunAt = (iso: string | null) => { // `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, +// 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". +function KeyfileActionsBar({ onReplaced, mutateStatus, }: { - reuseText: string onReplaced: () => void mutateStatus: () => Promise }) { @@ -412,10 +415,7 @@ function KeyfileManageInline({ return (
-
- -
{reuseText}
-
+
Manage installed keyfile
- {pbsEncrypt && pbsRecoveryStatus?.has_keyfile && ( - setPbsEncryptMode("new")} - mutateStatus={mutatePbsRecovery} - /> - )} {pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
@@ -3322,10 +3341,6 @@ function CreateJobDialog({ {pbsEncrypt && (
-
- - Recovery passphrase (strongly recommended) -
{pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? ( /* Escrow already saved — mirror the shell: skip the prompt unless the operator opts to change. */ @@ -3345,6 +3360,23 @@ function CreateJobDialog({
) : ( <> + {/* 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.

@@ -4148,13 +4180,6 @@ function ManualBackupDialog({
- {pbsEncrypt && pbsRecoveryStatus?.has_keyfile && ( - setPbsEncryptMode("new")} - mutateStatus={mutatePbsRecovery} - /> - )} {pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
@@ -4229,10 +4254,6 @@ function ManualBackupDialog({ {pbsEncrypt && (
-
- - Recovery passphrase (strongly recommended) -
{pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? (
@@ -4250,6 +4271,16 @@ function ManualBackupDialog({
) : ( <> + {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.

diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index b9fa3061..357a26a9 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -15614,17 +15614,40 @@ def api_host_backups_manual_run(): # form when they need a separate retention bucket. pbs_backup_id = f'hostcfg-{socket.gethostname()}' pbs_fingerprint = (payload.get('pbs_fingerprint') or '').strip() - pbs_encrypt = bool(payload.get('pbs_encrypt')) + # Encryption mode: matches the job-create endpoint exactly. + # Reads the modern `pbs_encrypt_mode` string ("none"/"new"/ + # "existing") first, falls back to legacy `pbs_encrypt` bool + # only if the modern field is absent. Both dialogs on the + # front-end send the modern field — the older ManualBackup + # code path was ignoring it and silently downgrading every + # encrypted manual backup to plain because it only looked at + # the legacy `pbs_encrypt` boolean, which the front-end + # stopped sending. + pbs_encrypt_mode = (payload.get('pbs_encrypt_mode') or '').strip().lower() + if not pbs_encrypt_mode: + pbs_encrypt_mode = 'new' if bool(payload.get('pbs_encrypt')) else 'none' + if pbs_encrypt_mode not in ('none', 'new', 'existing'): + return jsonify({'error': "pbs_encrypt_mode must be 'none', 'new' or 'existing'"}), 400 lines.append(f'PBS_REPOSITORY={pbs_repo}') lines.append(f'PBS_PASSWORD={pbs_pass}') lines.append(f'PBS_BACKUP_ID={pbs_backup_id}') if pbs_fingerprint: lines.append(f'PBS_FINGERPRINT={pbs_fingerprint}') - if pbs_encrypt: + if pbs_encrypt_mode == 'new': kf = _pbs_keyfile_get_or_create(True) if not kf: return jsonify({'error': 'failed to create PBS encryption keyfile'}), 500 lines.append(f'PBS_KEYFILE={kf}') + elif pbs_encrypt_mode == 'existing': + if not os.path.isfile(_PBS_KEYFILE_PATH): + return jsonify({ + 'error': ( + "pbs_encrypt_mode='existing' but no keyfile is installed at " + f"{_PBS_KEYFILE_PATH} — import one first with " + "POST /api/host-backups/pbs-encryption/import" + ), + }), 400 + lines.append(f'PBS_KEYFILE={_PBS_KEYFILE_PATH}') elif backend == 'local': explicit = (payload.get('local_dest_dir') or '').strip() dest_dir = explicit.rstrip('/') if explicit else _get_local_target()['effective'] @@ -16846,6 +16869,18 @@ def _run_pbs_export(task_id: str, repo: dict, snapshot: str, output_path: str): env = {**os.environ, 'PBS_PASSWORD': pwd} if fp: env['PBS_FINGERPRINT'] = fp + # Load the scrypt-unlock passphrase stored during import. Without + # this env var proxmox-backup-client can't decrypt a scrypt + # keyfile at restore time and fails with "no password input + # mechanism available". kdf=none keyfiles don't need it (empty + # file → env stays empty). Matches how the scheduled runner + # resolves PBS_ENCRYPTION_PASSWORD in run_scheduled_backup.sh. + try: + if os.path.isfile(_PBS_KEYFILE_PASS_PATH) and os.path.getsize(_PBS_KEYFILE_PASS_PATH) > 0: + with open(_PBS_KEYFILE_PASS_PATH, 'r') as _pf: + env['PBS_ENCRYPTION_PASSWORD'] = _pf.read() + except OSError: + pass # 1) Pull the .pxar archive out of the snapshot. PBS stores the # host backup as `hostcfg.pxar.didx`; restoring it as `hostcfg.pxar`