mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user