mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-05 15:26:22 +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:
co-authored by
Claude Opus 4.7
parent
4b022d05f2
commit
26b47e63d9
@@ -452,7 +452,7 @@ export function HostBackup() {
|
|||||||
) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : (
|
) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{actionError && (
|
{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}
|
{actionError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1419,7 +1419,7 @@ function InspectModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2389,7 +2389,15 @@ function CreateJobDialog({
|
|||||||
})
|
})
|
||||||
mutatePbsRecovery()
|
mutatePbsRecovery()
|
||||||
} catch (e) {
|
} 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)
|
setPbsImportBusy(false)
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
return
|
return
|
||||||
@@ -3390,7 +3398,7 @@ function CreateJobDialog({
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3653,7 +3661,15 @@ function ManualBackupDialog({
|
|||||||
})
|
})
|
||||||
mutatePbsRecovery()
|
mutatePbsRecovery()
|
||||||
} catch (e) {
|
} 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)
|
setPbsImportBusy(false)
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
return
|
return
|
||||||
@@ -4188,7 +4204,7 @@ function ManualBackupDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4527,7 +4543,7 @@ function DestinationsSection({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -5539,7 +5555,7 @@ function AddDestinationDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -5905,7 +5921,7 @@ function ExtraPathsSection() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -6090,7 +6106,7 @@ function UsbDrivesSection() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -6441,7 +6457,7 @@ function JobDetailModal({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{actionError && (
|
{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}
|
{actionError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -6818,7 +6834,7 @@ function ManualJobWatchModal({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{actionError && (
|
{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}
|
{actionError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -7065,7 +7081,7 @@ function PbsKeyfileRecoveryDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13762,6 +13762,30 @@ def _pbs_keyfile_get_or_create(create_if_missing: bool = True) -> str:
|
|||||||
return ''
|
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:
|
def _pbs_recovery_encrypt(passphrase: str, in_path: str, out_path: str) -> bool:
|
||||||
"""openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt < passphrase.
|
"""openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt < passphrase.
|
||||||
Mirrors hb_pbs_encrypt_recovery in the shell so a blob produced
|
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),
|
'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH),
|
||||||
'keyfile_path': _PBS_KEYFILE_PATH,
|
'keyfile_path': _PBS_KEYFILE_PATH,
|
||||||
'recovery_path': _PBS_RECOVERY_ENC_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:
|
except OSError as e:
|
||||||
return jsonify({'error': f'cannot create state directory: {e}'}), 500
|
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
|
import tempfile
|
||||||
tmp_fd, tmp_path = tempfile.mkstemp(prefix='pbs-key.import.', dir=_BACKUP_STATE_DIR)
|
tmp_fd, tmp_path = tempfile.mkstemp(prefix='pbs-key.import.', dir=_BACKUP_STATE_DIR)
|
||||||
try:
|
try:
|
||||||
@@ -14029,8 +14066,17 @@ def api_pbs_encryption_import():
|
|||||||
return jsonify({'error': f'validation failed: {type(e).__name__}'}), 500
|
return jsonify({'error': f'validation failed: {type(e).__name__}'}), 500
|
||||||
|
|
||||||
if r.returncode != 0:
|
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({
|
return jsonify({
|
||||||
'error': 'proxmox-backup-client did not recognise this file as a valid PBS keyfile',
|
'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
|
}), 400
|
||||||
|
|
||||||
# The old recovery blob was encrypted with the old key — a new
|
# 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
|
the operator's recovery passphrase. Body: {passphrase}. Idempotent
|
||||||
— re-running it with a new passphrase replaces the escrow.
|
— 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
|
The blob is uploaded to PBS by the runner after every successful
|
||||||
encrypted backup (see _sb_run_pbs / hb_pbs_upload_recovery_blob),
|
encrypted backup (see _sb_run_pbs / hb_pbs_upload_recovery_blob),
|
||||||
so the operator can rebuild the keyfile from any host with just
|
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 ''
|
passphrase = payload.get('passphrase') or ''
|
||||||
if not passphrase:
|
if not passphrase:
|
||||||
return jsonify({'error': 'passphrase is required'}), 400
|
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'):
|
if not shutil.which('openssl'):
|
||||||
return jsonify({'error': 'openssl is not installed; cannot create recovery blob'}), 500
|
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):
|
if not _pbs_recovery_encrypt(passphrase, _PBS_KEYFILE_PATH, _PBS_RECOVERY_ENC_PATH):
|
||||||
return jsonify({'error': 'openssl encryption failed'}), 500
|
return jsonify({'error': 'openssl encryption failed'}), 500
|
||||||
return jsonify({'status': 'ok', 'recovery_path': _PBS_RECOVERY_ENC_PATH})
|
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
|
# 1) Pull the .pxar archive out of the snapshot. PBS stores the
|
||||||
# host backup as `hostcfg.pxar.didx`; restoring it as `hostcfg.pxar`
|
# host backup as `hostcfg.pxar.didx`; restoring it as `hostcfg.pxar`
|
||||||
# writes the file tree under <target>/hostcfg/.
|
# 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')
|
_update(state='restoring', message='Pulling snapshot from PBS')
|
||||||
target_tree = os.path.join(staging, 'tree')
|
target_tree = os.path.join(staging, 'tree')
|
||||||
os.makedirs(target_tree, exist_ok=True)
|
os.makedirs(target_tree, exist_ok=True)
|
||||||
r = subprocess.run(
|
cmd = ['proxmox-backup-client', 'restore',
|
||||||
['proxmox-backup-client', 'restore',
|
'--repository', repo['repository']]
|
||||||
'--repository', repo['repository'],
|
if os.path.isfile(_PBS_KEYFILE_PATH):
|
||||||
snapshot, 'hostcfg.pxar', target_tree],
|
cmd.extend(['--keyfile', _PBS_KEYFILE_PATH])
|
||||||
capture_output=True, text=True, timeout=1800, env=env,
|
cmd.extend([snapshot, 'hostcfg.pxar', target_tree])
|
||||||
)
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800, env=env)
|
||||||
if r.returncode != 0:
|
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
|
return
|
||||||
|
|
||||||
# 2) Pack the restored tree as a .tar.zst — same format the
|
# 2) Pack the restored tree as a .tar.zst — same format the
|
||||||
|
|||||||
Reference in New Issue
Block a user