update 1.2.2.3 beta

This commit is contained in:
MacRimi
2026-07-06 00:30:47 +02:00
parent e07c83f4a9
commit 8df8a77bf1
5 changed files with 534 additions and 13 deletions

View File

@@ -1 +1 @@
7eb39d1bb3aed0750f32708fab66bf5c2c95a47da5722b9bd351b34c7c31b3ee ProxMenux-1.2.2.3-beta.AppImage
ab9f30de3517bf2aa0746691e230186e647fb68ad57a298837c7c871700e0c4c ProxMenux-1.2.2.3-beta.AppImage

View File

@@ -333,6 +333,190 @@ const formatRunAt = (iso: string | null) => {
}
}
// ── PBS keyfile management (shared between CreateJob + ManualBackup) ──
//
// Rendered inside the encryption section when a keyfile is already
// installed. Replaces the older static info box that pointed to a
// non-existent "Settings → Host backup" panel: the operator now has
// three concrete actions (change stored keyfile passphrase, replace
// keyfile, remove keyfile) with destructive-confirmation modals that
// call the /api/host-backups/pbs-encryption/* endpoints and mutate
// pbsRecoveryStatus so the parent dialog re-renders on success.
//
// `onReplaced` is called after a successful remove/replace-remove so
// the parent can reset its local pbsEncryptMode from "existing" back
// to "new" and let the Generate/Import radio unfold naturally.
function KeyfileManageInline({
reuseText,
onReplaced,
mutateStatus,
}: {
reuseText: string
onReplaced: () => void
mutateStatus: () => Promise<unknown>
}) {
const [action, setAction] = useState<null | "passphrase" | "replace" | "remove">(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [pass1, setPass1] = useState("")
const [pass2, setPass2] = useState("")
const [backupFirst, setBackupFirst] = useState(true)
const close = () => {
setAction(null)
setBusy(false)
setErr(null)
setPass1("")
setPass2("")
setBackupFirst(true)
}
const runPassphrase = async () => {
if (pass1 !== pass2) {
setErr("Passphrases do not match.")
return
}
setBusy(true)
setErr(null)
try {
await fetchApi("/api/host-backups/pbs-encryption/passphrase", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_passphrase: pass1 }),
})
await mutateStatus()
close()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setBusy(false)
}
}
const runRemove = async (mode: "remove" | "replace") => {
setBusy(true)
setErr(null)
try {
await fetchApi("/api/host-backups/pbs-encryption/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ backup_before_remove: backupFirst }),
})
await mutateStatus()
if (mode === "replace") onReplaced()
close()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setBusy(false)
}
}
return (
<div className="space-y-2">
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div>{reuseText}</div>
</div>
<div className="flex flex-wrap gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px]"
onClick={() => setAction("passphrase")}
>
Update stored passphrase
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px]"
onClick={() => setAction("replace")}
>
Replace keyfile
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px] !text-red-400 border-red-500/40 hover:bg-red-500/10"
onClick={() => setAction("remove")}
>
Remove keyfile
</Button>
</div>
<Dialog open={action === "passphrase"} onOpenChange={(v) => !v && close()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Update stored keyfile passphrase</DialogTitle>
<DialogDescription>
This only rotates the passphrase ProxMenux uses to unlock the keyfile at backup time (stored in <code className="font-mono">/usr/local/share/proxmenux/pbs-key.pass</code>). It does <strong>not</strong> re-encrypt the keyfile itself.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="mpass1" className="text-xs">New passphrase</Label>
<Input id="mpass1" type="password" value={pass1} onChange={(e) => setPass1(e.target.value)} placeholder="Leave blank to remove the stored passphrase" className="font-mono h-9" />
</div>
{pass1 && (
<div>
<Label htmlFor="mpass2" className="text-xs">Confirm new passphrase</Label>
<Input id="mpass2" type="password" value={pass2} onChange={(e) => setPass2(e.target.value)} className="font-mono h-9" />
</div>
)}
{err && (
<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">{err}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={close}>Cancel</Button>
<Button onClick={runPassphrase} disabled={busy || (!!pass1 && pass1 !== pass2)}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={action === "replace" || action === "remove"} onOpenChange={(v) => !v && close()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-400" />
{action === "replace" ? "Replace keyfile" : "Remove keyfile"}
</DialogTitle>
<DialogDescription>
Backups already stored on PBS were encrypted with the current keyfile. After this action:
</DialogDescription>
</DialogHeader>
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-1 pl-2">
<li>New backups will use {action === "replace" ? "the newly generated / imported keyfile" : "no encryption on this host until a new keyfile is set up"}.</li>
<li>Downloading pre-existing encrypted backups from this host <strong className="text-red-400">will fail</strong> unless you keep a copy of the current key.</li>
<li>Existing recovery blobs on PBS stay intact they still recover the old key with its original passphrase.</li>
</ul>
<label className="flex items-start gap-2 cursor-pointer text-xs">
<Checkbox checked={backupFirst} onCheckedChange={(v) => setBackupFirst(!!v)} className="mt-0.5" />
<span>Save a copy of the current keyfile + passphrase + recovery blob under <code className="font-mono">/root/pbs-key.old-&lt;timestamp&gt;.*</code> before {action === "replace" ? "replacing" : "removing"}.</span>
</label>
{err && (
<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">{err}</div>
)}
<DialogFooter>
<Button variant="outline" onClick={close} disabled={busy}>Cancel</Button>
<Button
onClick={() => runRemove(action === "replace" ? "replace" : "remove")}
disabled={busy}
className="!bg-red-500 hover:!bg-red-600 !text-white"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : (action === "replace" ? "Remove current — set up new" : "Remove")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
export function HostBackup() {
const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>(
"/api/host-backups/jobs",
@@ -3058,12 +3242,11 @@ function CreateJobDialog({
</label>
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div>
An encryption key is already installed on this host it will be reused for this job. To replace it, first import a new one from Settings Host backup or remove the existing keyfile.
</div>
</div>
<KeyfileManageInline
reuseText="An encryption key is already installed on this host — it will be reused for this job. Use the buttons below to change the stored passphrase, replace the key with a new one, or remove it entirely."
onReplaced={() => setPbsEncryptMode("new")}
mutateStatus={mutatePbsRecovery}
/>
)}
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
@@ -3966,12 +4149,11 @@ function ManualBackupDialog({
</label>
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div>
An encryption key is already installed on this host it will be reused for this backup.
</div>
</div>
<KeyfileManageInline
reuseText="An encryption key is already installed on this host — it will be reused for this backup. Use the buttons below to change the stored passphrase, replace the key with a new one, or remove it entirely."
onReplaced={() => setPbsEncryptMode("new")}
mutateStatus={mutatePbsRecovery}
/>
)}
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (

View File

@@ -13842,6 +13842,153 @@ def _pbs_recovery_decrypt(passphrase: str, in_path: str, out_path: str) -> bool:
return True
@app.route('/api/host-backups/pbs-encryption/keyfile-info', methods=['GET'])
@require_auth
def api_pbs_encryption_keyfile_info():
"""Snapshot of the installed keyfile for the "Manage keyfile" panel.
Extends /pbs-recovery/status with metadata that only makes sense
when a keyfile is actually installed (installed_at, kdf hint).
Returns 200 with `installed: false` when no keyfile is present so
the panel can render a neutral empty state without special-casing
404s in the caller."""
installed = os.path.isfile(_PBS_KEYFILE_PATH)
if not installed:
return jsonify({
'installed': False,
'path': _PBS_KEYFILE_PATH,
})
installed_at = ''
try:
installed_at = time.strftime(
'%Y-%m-%dT%H:%M:%SZ',
time.gmtime(os.path.getmtime(_PBS_KEYFILE_PATH)),
)
except OSError:
pass
# Best-effort KDF hint from the JSON keyfile body itself. `key info`
# would be the authoritative source but refuses scrypt keyfiles
# without their passphrase, so parsing the JSON manually gives us
# a hint even for imported scrypt keys the operator brought their
# own passphrase for.
kdf_hint = 'unknown'
try:
with open(_PBS_KEYFILE_PATH, 'r') as f:
raw = f.read(4096)
body = json.loads(raw)
kdf_raw = body.get('kdf')
if isinstance(kdf_raw, str):
kdf_hint = kdf_raw.lower()
elif isinstance(kdf_raw, dict):
# scrypt keyfiles carry `"kdf": {"Scrypt": {...}}`.
kdf_hint = next(iter(kdf_raw)).lower() if kdf_raw else 'unknown'
except (OSError, json.JSONDecodeError, ValueError, StopIteration):
pass
return jsonify({
'installed': True,
'path': _PBS_KEYFILE_PATH,
'fingerprint': _pbs_keyfile_fingerprint(),
'kdf_hint': kdf_hint,
'installed_at': installed_at,
'has_keyfile_passphrase': (
os.path.isfile(_PBS_KEYFILE_PASS_PATH)
and os.path.getsize(_PBS_KEYFILE_PASS_PATH) > 0
),
'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH),
})
@app.route('/api/host-backups/pbs-encryption/remove', methods=['POST'])
@require_auth
def api_pbs_encryption_remove():
"""Remove the installed keyfile + its paired local artifacts
(passphrase file, recovery blob). Optionally backs the three up to
/root under a timestamped prefix so the operator can still decrypt
or rebuild them later. Never touches the recovery blob(s) already
uploaded to PBS — those stay recoverable with their original
passphrase and are the safe way to reach any pre-existing backup
encrypted with the removed key.
Body: `{backup_before_remove: bool}` (default true)
"""
payload = request.get_json(silent=True) or {}
do_backup = bool(payload.get('backup_before_remove', True))
if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({'error': 'no keyfile installed'}), 404
backed_up = []
if do_backup:
ts = time.strftime('%Y%m%d_%H%M%S', time.localtime())
for src, dst_suffix in (
(_PBS_KEYFILE_PATH, '.conf'),
(_PBS_KEYFILE_PASS_PATH, '.pass'),
(_PBS_RECOVERY_ENC_PATH, '.recovery.enc'),
):
if not os.path.isfile(src):
continue
dst = f'/root/pbs-key.old-{ts}{dst_suffix}'
try:
with open(src, 'rb') as fin, open(os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), 'wb') as fout:
fout.write(fin.read())
backed_up.append(dst)
except OSError as e:
return jsonify({
'error': f'backup to /root failed for {src}: {e}',
'partial_backups': backed_up,
}), 500
# Wipe order: recovery blob first so a mid-flight failure leaves us
# in a clean, backup-still-decryptable-with-blob state.
removed = []
for path in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH, _PBS_KEYFILE_PATH):
try:
os.remove(path)
removed.append(path)
except FileNotFoundError:
pass
except OSError as e:
return jsonify({
'error': f'remove failed for {path}: {e}',
'backed_up': backed_up,
'removed': removed,
}), 500
return jsonify({
'status': 'ok',
'backed_up': backed_up,
'removed': removed,
})
@app.route('/api/host-backups/pbs-encryption/passphrase', methods=['POST'])
@require_auth
def api_pbs_encryption_passphrase():
"""Update ONLY the stored keyfile passphrase (pbs-key.pass) without
touching the keyfile itself. Useful when the operator rotates the
passphrase on their own PBS setup and needs ProxMenux to catch up.
Body: `{new_passphrase}`. Empty value wipes the file — the runner
then treats the keyfile as kdf=none.
"""
payload = request.get_json(silent=True) or {}
new_pass = payload.get('new_passphrase') or ''
if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({'error': 'no keyfile installed'}), 404
if not new_pass:
try:
os.remove(_PBS_KEYFILE_PASS_PATH)
except FileNotFoundError:
pass
except OSError as e:
return jsonify({'error': f'cannot remove passphrase file: {e}'}), 500
return jsonify({'status': 'ok', 'has_keyfile_passphrase': False})
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(new_pass)
except OSError as e:
return jsonify({'error': f'cannot write passphrase file: {e}'}), 500
return jsonify({'status': 'ok', 'has_keyfile_passphrase': True})
@app.route('/api/host-backups/pbs-recovery/status', methods=['GET'])
@require_auth
def api_pbs_recovery_status():