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
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1678cb55256ffcfb62f451b631aa174c014b36319c857b7d066cde8d0431c7f1 ProxMenux-1.2.2.3-beta.AppImage
7eb39d1bb3aed0750f32708fab66bf5c2c95a47da5722b9bd351b34c7c31b3ee ProxMenux-1.2.2.3-beta.AppImage
+83 -35
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 }>
+53 -30
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: