update 1.2.2.3 beta

This commit is contained in:
MacRimi
2026-07-05 23:58:58 +02:00
parent fd1aeb1ead
commit 487ab04a14
8 changed files with 212 additions and 82 deletions

View File

@@ -1 +1 @@
1678cb55256ffcfb62f451b631aa174c014b36319c857b7d066cde8d0431c7f1 ProxMenux-1.2.2.3-beta.AppImage
7eb39d1bb3aed0750f32708fab66bf5c2c95a47da5722b9bd351b34c7c31b3ee ProxMenux-1.2.2.3-beta.AppImage

View File

@@ -2046,6 +2046,7 @@ function CreateJobDialog({
// File picked in the "existing" mode. Kept in a ref-like state so a
// stale reference doesn't linger across modal opens.
const [pbsImportFile, setPbsImportFile] = useState<File | null>(null)
const [pbsImportKeyfilePass, setPbsImportKeyfilePass] = useState<string>("")
const [pbsImportBusy, setPbsImportBusy] = useState<boolean>(false)
// Legacy alias for the many recovery / gating checks below — a truthy
// value means "the operator wants an encrypted backup" regardless of
@@ -2066,7 +2067,7 @@ function CreateJobDialog({
// present, the passphrase input becomes optional ("leave blank to
// keep saved"). Refreshed after a successful setup call.
const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{
has_keyfile: boolean; has_recovery: boolean
has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean
}>(
open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null,
fetcher,
@@ -2246,6 +2247,7 @@ function CreateJobDialog({
setPbsBackupId("")
setPbsEncryptMode("none")
setPbsImportFile(null)
setPbsImportKeyfilePass("")
setPbsImportBusy(false)
setPbsRecoveryPass("")
setPbsRecoveryPass2("")
@@ -2385,7 +2387,10 @@ function CreateJobDialog({
await fetchApi("/api/host-backups/pbs-encryption/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content_b64 }),
body: JSON.stringify({
content_b64,
keyfile_passphrase: pbsImportKeyfilePass,
}),
})
mutatePbsRecovery()
} catch (e) {
@@ -3093,21 +3098,40 @@ function CreateJobDialog({
</div>
</label>
{pbsEncryptMode === "existing" && (
<div className="space-y-1.5 pt-1">
<Label htmlFor="pbsImportFile" className="text-[11px] font-medium">
Keyfile to import
</Label>
<Input
id="pbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
disabled={pbsImportBusy}
className="h-8 text-[11px]"
/>
<p className="text-[10px] text-muted-foreground">
Validated with <code className="font-mono">proxmox-backup-client key info</code> before install. On validation failure the job is not created and no keyfile is written.
</p>
<div className="space-y-2 pt-1">
<div className="space-y-1.5">
<Label htmlFor="pbsImportFile" className="text-[11px] font-medium">
Keyfile to import
</Label>
<Input
id="pbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
disabled={pbsImportBusy}
className="h-8 text-[11px]"
/>
<p className="text-[10px] text-muted-foreground">
ProxMenux does not validate the file any keyfile you accept as valid on your PBS is accepted here. If it later fails at backup time, the tool's error tells you why.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="pbsImportKeyfilePass" className="text-[11px] font-medium">
Keyfile passphrase (optional)
</Label>
<Input
id="pbsImportKeyfilePass"
type="password"
value={pbsImportKeyfilePass}
onChange={(e) => setPbsImportKeyfilePass(e.target.value)}
disabled={pbsImportBusy}
placeholder="Leave blank if the keyfile has no passphrase"
className="h-8 text-[11px] font-mono"
/>
<p className="text-[10px] text-muted-foreground">
If the keyfile was generated with <code className="font-mono">proxmox-backup-client key create --kdf scrypt</code> (the tool's default) it needs the original passphrase to be unlocked at backup time. Leave blank for <code className="font-mono">--kdf none</code> keyfiles. Stored at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.pass</code> (chmod 600).
</p>
</div>
</div>
)}
</div>
@@ -3512,6 +3536,7 @@ function ManualBackupDialog({
// block — the three modes and the import flow are identical here.
const [pbsEncryptMode, setPbsEncryptMode] = useState<"none" | "new" | "existing">("none")
const [pbsImportFile, setPbsImportFile] = useState<File | null>(null)
const [pbsImportKeyfilePass, setPbsImportKeyfilePass] = useState<string>("")
const [pbsImportBusy, setPbsImportBusy] = useState<boolean>(false)
const pbsEncrypt = pbsEncryptMode !== "none"
const [pbsRecoveryPass, setPbsRecoveryPass] = useState<string>("")
@@ -3525,7 +3550,7 @@ function ManualBackupDialog({
// present, the passphrase input becomes optional ("leave blank to
// keep saved"). Refreshed after a successful setup call.
const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{
has_keyfile: boolean; has_recovery: boolean
has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean
}>(
open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null,
fetcher,
@@ -3578,6 +3603,7 @@ function ManualBackupDialog({
setPbsBackupId("")
setPbsEncryptMode("none")
setPbsImportFile(null)
setPbsImportKeyfilePass("")
setPbsImportBusy(false)
setPbsRecoveryPass("")
setPbsRecoveryPass2("")
@@ -3657,7 +3683,10 @@ function ManualBackupDialog({
await fetchApi("/api/host-backups/pbs-encryption/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content_b64 }),
body: JSON.stringify({
content_b64,
keyfile_passphrase: pbsImportKeyfilePass,
}),
})
mutatePbsRecovery()
} catch (e) {
@@ -3977,21 +4006,40 @@ function ManualBackupDialog({
</div>
</label>
{pbsEncryptMode === "existing" && (
<div className="space-y-1.5 pt-1">
<Label htmlFor="manualPbsImportFile" className="text-[11px] font-medium">
Keyfile to import
</Label>
<Input
id="manualPbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
disabled={pbsImportBusy}
className="h-8 text-[11px]"
/>
<p className="text-[10px] text-muted-foreground">
Validated with <code className="font-mono">proxmox-backup-client key info</code> before install.
</p>
<div className="space-y-2 pt-1">
<div className="space-y-1.5">
<Label htmlFor="manualPbsImportFile" className="text-[11px] font-medium">
Keyfile to import
</Label>
<Input
id="manualPbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
disabled={pbsImportBusy}
className="h-8 text-[11px]"
/>
<p className="text-[10px] text-muted-foreground">
ProxMenux does not validate the file any keyfile accepted by your PBS is accepted here.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="manualPbsImportKeyfilePass" className="text-[11px] font-medium">
Keyfile passphrase (optional)
</Label>
<Input
id="manualPbsImportKeyfilePass"
type="password"
value={pbsImportKeyfilePass}
onChange={(e) => setPbsImportKeyfilePass(e.target.value)}
disabled={pbsImportBusy}
placeholder="Leave blank if the keyfile has no passphrase"
className="h-8 text-[11px] font-mono"
/>
<p className="text-[10px] text-muted-foreground">
Only needed for keyfiles created with <code className="font-mono">--kdf scrypt</code> (the default of <code className="font-mono">proxmox-backup-client key create</code>). Stored at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.pass</code> (chmod 600) and reused by every encrypted job on this host.
</p>
</div>
</div>
)}
</div>
@@ -4354,7 +4402,7 @@ function DestinationsSection({
// exists. Drives the "Recover keyfile" banner.
const hasPbs = (destinations?.pbs?.length ?? 0) > 0
const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{
has_keyfile: boolean; has_recovery: boolean
has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean
}>(hasPbs ? "/api/host-backups/pbs-recovery/status" : null, fetcher)
const { data: pbsRecoveryAvailable } = useSWR<{
snapshots: Array<{ repo_name: string; repo_repository: string; backup_id: string; source_host: string; backup_time: number; snapshot: string }>

View File

@@ -13738,6 +13738,14 @@ _PBS_KEYFILE_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.conf'
# shell's hb_pbs_setup_recovery flow byte-for-byte (AES-256-CBC +
# PBKDF2 600k iterations).
_PBS_RECOVERY_ENC_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.recovery.enc'
# Passphrase used to unlock the keyfile itself when it was created with
# `--kdf scrypt`. Written by the "Import existing keyfile" flow when
# the operator provides one; empty file (or missing) means the keyfile
# is kdf=none and no passphrase is needed. Read at backup time by the
# runner and exported as PBS_ENCRYPTION_PASSWORD so
# proxmox-backup-client can decrypt the key without prompting.
# Same trust boundary as the keyfile itself: chmod 600, next to it.
_PBS_KEYFILE_PASS_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.pass'
def _pbs_keyfile_get_or_create(create_if_missing: bool = True) -> str:
@@ -13843,6 +13851,13 @@ def api_pbs_recovery_status():
return jsonify({
'has_keyfile': os.path.isfile(_PBS_KEYFILE_PATH),
'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH),
# True only when a non-empty passphrase file exists next to the
# keyfile. Lets the UI skip re-prompting the operator for the
# scrypt-unlock passphrase during subsequent job creations.
'has_keyfile_passphrase': (
os.path.isfile(_PBS_KEYFILE_PASS_PATH)
and os.path.getsize(_PBS_KEYFILE_PASS_PATH) > 0
),
'keyfile_path': _PBS_KEYFILE_PATH,
'recovery_path': _PBS_RECOVERY_ENC_PATH,
# Fingerprint of the installed keyfile — empty when no keyfile
@@ -14041,12 +14056,18 @@ def api_pbs_encryption_import():
# 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.
# a valid JSON document and downstream tools refuse it with a
# cryptic parse error.
if content.startswith(b'\xef\xbb\xbf'):
content = content[3:]
# Optional passphrase for a scrypt-encoded keyfile. Blank means the
# keyfile is kdf=none. We do NOT verify that the passphrase actually
# unlocks the keyfile — that would require the operator to be
# present and the flow to be interactive. If it's wrong, backups
# will fail loudly with proxmox-backup-client's own error message.
keyfile_pass = payload.get('keyfile_passphrase') or ''
import tempfile
tmp_fd, tmp_path = tempfile.mkstemp(prefix='pbs-key.import.', dir=_BACKUP_STATE_DIR)
try:
@@ -14057,37 +14078,26 @@ def api_pbs_encryption_import():
except OSError as e:
return jsonify({'error': f'cannot write tmp keyfile: {e}'}), 500
try:
r = subprocess.run(
['proxmox-backup-client', 'key', 'info', '--output-format', 'json', tmp_path],
capture_output=True, text=True, timeout=10,
)
except (subprocess.TimeoutExpired, OSError) as e:
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
# No content validation. The operator brings their own key —
# any KDF, any format, any origin. If it later doesn't work
# with proxmox-backup-client the failure will surface at
# backup time with a clear error. This is a deliberate
# trust-the-operator design: keeping `key info` validation
# here rejected legitimate scrypt-encoded keyfiles that could
# only be inspected with their original passphrase, which is
# exactly the case ProxMenux can't provide in a non-interactive
# flow.
# The old recovery blob was encrypted with the old key — a new
# blob has to be built by re-running /pbs-recovery/setup once
# the operator confirms this import.
try:
os.remove(_PBS_RECOVERY_ENC_PATH)
except FileNotFoundError:
pass
except OSError:
pass
for stale in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH):
try:
os.remove(stale)
except FileNotFoundError:
pass
except OSError:
pass
try:
os.replace(tmp_path, _PBS_KEYFILE_PATH)
@@ -14095,9 +14105,22 @@ def api_pbs_encryption_import():
except OSError as e:
return jsonify({'error': f'cannot promote keyfile: {e}'}), 500
# Persist the keyfile passphrase alongside the key, chmod 600.
# Same trust boundary as the keyfile itself: root-only. Empty
# value → don't write anything (blank passphrase == kdf=none).
if keyfile_pass:
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(keyfile_pass)
except OSError as e:
return jsonify({'error': f'cannot store keyfile passphrase: {e}'}), 500
return jsonify({
'status': 'ok',
'keyfile_path': _PBS_KEYFILE_PATH,
'keyfile_pass_stored': bool(keyfile_pass),
'recovery_reset': True,
})
finally: