host-backup(pbs): fix scheduled-job encryption + surface real key import error + pass --keyfile on downloads

Three bugs against the PBS encryption flow:

1. Create-scheduled-job with encryption failed with "Recovery setup
   failed: no PBS keyfile present" whenever the operator picked
   "Generate a new keyfile" but had no keyfile installed yet. The
   frontend called /pbs-recovery/setup before creating the job, but
   the keyfile was only materialised later during job creation. The
   endpoint now generates the keyfile atomically if missing before
   building the escrow blob — same prompt-first order the CLI wizard
   applies. Existing keyfiles are still trusted and never rotated.

2. Importing a valid PBS keyfile via the Web dialog returned a
   generic "did not recognise this file as a valid PBS keyfile" that
   hid the real reason (kdf mismatch, missing passphrase, corrupt
   JSON, ...). The endpoint now attaches the stderr of
   `proxmox-backup-client key info` as `tool_output` and the frontend
   renders it verbatim inside the red banner. Also strips a leading
   UTF-8 BOM before validating so an editor-inserted BOM stops being
   silently classified as "invalid keyfile".

3. Downloading an encrypted PBS snapshot failed with "missing key —
   manifest was created with key XX:XX:..." even when the correct
   keyfile was installed at /usr/local/share/proxmenux/pbs-key.conf,
   because the restore worker invoked `proxmox-backup-client restore`
   without `--keyfile`. The flag is now passed whenever a local
   keyfile exists (PBS ignores it for unencrypted archives). On a
   fingerprint mismatch the error now appends the installed key's
   fingerprint so it can be compared side-by-side with the manifest's
   expected value — same fingerprint also exposed via
   /pbs-recovery/status for the UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-07-05 23:14:52 +02:00
parent 4b022d05f2
commit 26b47e63d9
2 changed files with 118 additions and 22 deletions

View File

@@ -452,7 +452,7 @@ export function HostBackup() {
) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : (
<div className="space-y-2">
{actionError && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{actionError}
</div>
)}
@@ -1419,7 +1419,7 @@ function InspectModal({
)}
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)}
@@ -2389,7 +2389,15 @@ function CreateJobDialog({
})
mutatePbsRecovery()
} catch (e) {
setError(`Keyfile import failed: ${e instanceof Error ? e.message : String(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)}`)
setPbsImportBusy(false)
setSubmitting(false)
return
@@ -3390,7 +3398,7 @@ function CreateJobDialog({
})()}
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)}
@@ -3653,7 +3661,15 @@ function ManualBackupDialog({
})
mutatePbsRecovery()
} catch (e) {
setError(`Keyfile import failed: ${e instanceof Error ? e.message : String(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)}`)
setPbsImportBusy(false)
setSubmitting(false)
return
@@ -4188,7 +4204,7 @@ function ManualBackupDialog({
</div>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)}
@@ -4527,7 +4543,7 @@ function DestinationsSection({
</Button>
</div>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)}
@@ -5539,7 +5555,7 @@ function AddDestinationDialog({
</div>
)}
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)}
@@ -5905,7 +5921,7 @@ function ExtraPathsSection() {
</p>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)}
@@ -6090,7 +6106,7 @@ function UsbDrivesSection() {
</p>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)}
@@ -6441,7 +6457,7 @@ function JobDetailModal({
</DialogHeader>
{actionError && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{actionError}
</div>
)}
@@ -6818,7 +6834,7 @@ function ManualJobWatchModal({
</DialogHeader>
{actionError && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{actionError}
</div>
)}
@@ -7065,7 +7081,7 @@ function PbsKeyfileRecoveryDialog({
</div>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)}

View File

@@ -13762,6 +13762,30 @@ def _pbs_keyfile_get_or_create(create_if_missing: bool = True) -> str:
return ''
def _pbs_keyfile_fingerprint(path: str = '') -> str:
"""Return the fingerprint of a PBS keyfile as reported by
`proxmox-backup-client key info`, or '' if the file is missing or
the tool is unavailable. Fingerprints look like
`AA:BB:CC:DD:EE:FF:GG:HH` and identify the exact key used to encrypt
a backup — displaying them next to a manifest's expected fingerprint
is what tells an operator whether they have the right key or a
different-but-similar one."""
kf = path or _PBS_KEYFILE_PATH
if not os.path.isfile(kf) or not shutil.which('proxmox-backup-client'):
return ''
try:
r = subprocess.run(
['proxmox-backup-client', 'key', 'info', '--output-format', 'json', kf],
capture_output=True, text=True, timeout=10,
)
if r.returncode != 0 or not r.stdout:
return ''
info = json.loads(r.stdout)
return (info.get('fingerprint') or '').strip()
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
return ''
def _pbs_recovery_encrypt(passphrase: str, in_path: str, out_path: str) -> bool:
"""openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt < passphrase.
Mirrors hb_pbs_encrypt_recovery in the shell so a blob produced
@@ -13821,6 +13845,11 @@ def api_pbs_recovery_status():
'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH),
'keyfile_path': _PBS_KEYFILE_PATH,
'recovery_path': _PBS_RECOVERY_ENC_PATH,
# Fingerprint of the installed keyfile — empty when no keyfile
# is present. Exposed so the Web UI can render it next to the
# manifest's expected fingerprint after a download error
# ("missing key — manifest was created with key XX:XX:...").
'keyfile_fingerprint': _pbs_keyfile_fingerprint(),
})
@@ -14010,6 +14039,14 @@ 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 `key info` refuses it with a parse
# error that reads like a generic "not a keyfile", which sends
# the operator down the wrong debugging path.
if content.startswith(b'\xef\xbb\xbf'):
content = content[3:]
import tempfile
tmp_fd, tmp_path = tempfile.mkstemp(prefix='pbs-key.import.', dir=_BACKUP_STATE_DIR)
try:
@@ -14029,8 +14066,17 @@ def api_pbs_encryption_import():
return jsonify({'error': f'validation failed: {type(e).__name__}'}), 500
if r.returncode != 0:
# Surface the real tool output so the operator can tell a
# bad-file error from a needs-passphrase error (keyfiles
# generated with `--kdf scrypt` need to be decrypted with
# their original passphrase before they can be reused as an
# import — ProxMenux only supports kdf=none for the
# canonical shared keyfile).
tool_output = (r.stderr or r.stdout or '').strip()
return jsonify({
'error': 'proxmox-backup-client did not recognise this file as a valid PBS keyfile',
'tool_output': tool_output[:800] if tool_output else '(no output from proxmox-backup-client key info)',
'tool_exit_code': r.returncode,
}), 400
# The old recovery blob was encrypted with the old key — a new
@@ -14069,6 +14115,15 @@ def api_pbs_recovery_setup():
the operator's recovery passphrase. Body: {passphrase}. Idempotent
— re-running it with a new passphrase replaces the escrow.
If no keyfile is installed yet, one is generated on the spot before
the escrow is built. This lets the Create-job / Manual-backup Web
flow call this endpoint the moment the operator confirms the
passphrase (prompt-first, matching the CLI wizard): keyfile plus
recovery blob are both created atomically, so a valid job can be
stored right after with pbs_encrypt_mode='existing'. Previously
the endpoint 400'd with 'no PBS keyfile present' because the
keyfile was only materialised later, during job creation.
The blob is uploaded to PBS by the runner after every successful
encrypted backup (see _sb_run_pbs / hb_pbs_upload_recovery_blob),
so the operator can rebuild the keyfile from any host with just
@@ -14077,10 +14132,17 @@ def api_pbs_recovery_setup():
passphrase = payload.get('passphrase') or ''
if not passphrase:
return jsonify({'error': 'passphrase is required'}), 400
if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({'error': 'no PBS keyfile present — enable encryption on a job first'}), 400
if not shutil.which('openssl'):
return jsonify({'error': 'openssl is not installed; cannot create recovery blob'}), 500
# Materialise the keyfile if it isn't already there. A pre-existing
# keyfile is trusted and reused — we never rotate it silently.
if not os.path.isfile(_PBS_KEYFILE_PATH):
if not shutil.which('proxmox-backup-client'):
return jsonify({'error': 'proxmox-backup-client is not installed on this host'}), 500
if not _pbs_keyfile_get_or_create(True):
return jsonify({'error': 'failed to create PBS encryption keyfile'}), 500
if not _pbs_recovery_encrypt(passphrase, _PBS_KEYFILE_PATH, _PBS_RECOVERY_ENC_PATH):
return jsonify({'error': 'openssl encryption failed'}), 500
return jsonify({'status': 'ok', 'recovery_path': _PBS_RECOVERY_ENC_PATH})
@@ -16607,17 +16669,35 @@ def _run_pbs_export(task_id: str, repo: dict, snapshot: str, output_path: str):
# 1) Pull the .pxar archive out of the snapshot. PBS stores the
# host backup as `hostcfg.pxar.didx`; restoring it as `hostcfg.pxar`
# writes the file tree under <target>/hostcfg/.
# If a local keyfile is installed we always pass --keyfile: PBS
# ignores it for unencrypted archives and needs it for encrypted
# ones. Without this flag an encrypted download fails with
# "missing key - manifest was created with key XX:XX:..." even
# when the operator has the correct key on disk.
_update(state='restoring', message='Pulling snapshot from PBS')
target_tree = os.path.join(staging, 'tree')
os.makedirs(target_tree, exist_ok=True)
r = subprocess.run(
['proxmox-backup-client', 'restore',
'--repository', repo['repository'],
snapshot, 'hostcfg.pxar', target_tree],
capture_output=True, text=True, timeout=1800, env=env,
)
cmd = ['proxmox-backup-client', 'restore',
'--repository', repo['repository']]
if os.path.isfile(_PBS_KEYFILE_PATH):
cmd.extend(['--keyfile', _PBS_KEYFILE_PATH])
cmd.extend([snapshot, 'hostcfg.pxar', target_tree])
r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800, env=env)
if r.returncode != 0:
_update(state='failed', error=(r.stderr.strip() or 'pbs restore failed'))
err = (r.stderr.strip() or 'pbs restore failed')
# Fingerprint mismatch is the most common encrypted-restore
# failure and the error message alone doesn't tell the
# operator whether the installed keyfile is the wrong one
# or PBS just isn't finding it. Include the local keyfile
# fingerprint so a side-by-side compare with the manifest's
# XX:XX:... value is possible without shell access.
if 'missing key' in err.lower() or 'was created with key' in err.lower():
local_fp = _pbs_keyfile_fingerprint()
if local_fp:
err += f'\n\nLocal keyfile fingerprint: {local_fp}'
else:
err += '\n\n(No local PBS keyfile installed at ' + _PBS_KEYFILE_PATH + ')'
_update(state='failed', error=err)
return
# 2) Pack the restored tree as a .tar.zst — same format the