mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-27 02:48:24 +00:00
update 1.2.2.3 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
7eb39d1bb3aed0750f32708fab66bf5c2c95a47da5722b9bd351b34c7c31b3ee ProxMenux-1.2.2.3-beta.AppImage
|
ab9f30de3517bf2aa0746691e230186e647fb68ad57a298837c7c871700e0c4c ProxMenux-1.2.2.3-beta.AppImage
|
||||||
|
|||||||
@@ -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-<timestamp>.*</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() {
|
export function HostBackup() {
|
||||||
const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>(
|
const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>(
|
||||||
"/api/host-backups/jobs",
|
"/api/host-backups/jobs",
|
||||||
@@ -3058,12 +3242,11 @@ function CreateJobDialog({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
|
{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">
|
<KeyfileManageInline
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
|
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."
|
||||||
<div>
|
onReplaced={() => setPbsEncryptMode("new")}
|
||||||
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.
|
mutateStatus={mutatePbsRecovery}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
|
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
|
||||||
@@ -3966,12 +4149,11 @@ function ManualBackupDialog({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
|
{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">
|
<KeyfileManageInline
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
|
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."
|
||||||
<div>
|
onReplaced={() => setPbsEncryptMode("new")}
|
||||||
An encryption key is already installed on this host — it will be reused for this backup.
|
mutateStatus={mutatePbsRecovery}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
|
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
|
||||||
|
|||||||
@@ -13842,6 +13842,153 @@ def _pbs_recovery_decrypt(passphrase: str, in_path: str, out_path: str) -> bool:
|
|||||||
return True
|
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'])
|
@app.route('/api/host-backups/pbs-recovery/status', methods=['GET'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_pbs_recovery_status():
|
def api_pbs_recovery_status():
|
||||||
|
|||||||
@@ -652,6 +652,7 @@ _bk_manage_destinations() {
|
|||||||
1 "$(translate "Proxmox Backup Server (PBS) destinations")" \
|
1 "$(translate "Proxmox Backup Server (PBS) destinations")" \
|
||||||
2 "$(translate "Borg repositories")" \
|
2 "$(translate "Borg repositories")" \
|
||||||
3 "$(translate "Local archive targets (paths + USB mount/unmount)")" \
|
3 "$(translate "Local archive targets (paths + USB mount/unmount)")" \
|
||||||
|
4 "$(translate "PBS encryption keyfile (show / replace / remove)")" \
|
||||||
0 "$(translate "Return")" \
|
0 "$(translate "Return")" \
|
||||||
3>&1 1>&2 2>&3) || break
|
3>&1 1>&2 2>&3) || break
|
||||||
|
|
||||||
@@ -666,11 +667,202 @@ _bk_manage_destinations() {
|
|||||||
3)
|
3)
|
||||||
_bk_manage_local_destinations
|
_bk_manage_local_destinations
|
||||||
;;
|
;;
|
||||||
|
4)
|
||||||
|
_bk_manage_pbs_encryption_keyfile
|
||||||
|
;;
|
||||||
0) break ;;
|
0) break ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Backup the current keyfile + its paired files to /root under a
|
||||||
|
# timestamped prefix. Returns the timestamp on success (empty on
|
||||||
|
# failure). Never overwrites existing files. Used before any
|
||||||
|
# destructive change (replace / remove) so the operator can still
|
||||||
|
# decrypt pre-existing backups if they need to.
|
||||||
|
_bk_pbs_backup_keyfile_to_root() {
|
||||||
|
local ts key_file="$HB_STATE_DIR/pbs-key.conf"
|
||||||
|
local pass_file="$HB_STATE_DIR/pbs-key.pass"
|
||||||
|
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
|
||||||
|
[[ -f "$key_file" ]] || { echo ""; return 1; }
|
||||||
|
ts=$(date +%Y%m%d_%H%M%S)
|
||||||
|
umask 077
|
||||||
|
cp -f "$key_file" "/root/pbs-key.old-${ts}.conf" 2>/dev/null || { echo ""; return 1; }
|
||||||
|
chmod 600 "/root/pbs-key.old-${ts}.conf" 2>/dev/null || true
|
||||||
|
if [[ -f "$pass_file" ]]; then
|
||||||
|
cp -f "$pass_file" "/root/pbs-key.old-${ts}.pass" 2>/dev/null || true
|
||||||
|
chmod 600 "/root/pbs-key.old-${ts}.pass" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -f "$recovery_enc" ]]; then
|
||||||
|
cp -f "$recovery_enc" "/root/pbs-key.old-${ts}.recovery.enc" 2>/dev/null || true
|
||||||
|
chmod 600 "/root/pbs-key.old-${ts}.recovery.enc" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "$ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wipe local keyfile + pass + recovery. Never touches PBS.
|
||||||
|
_bk_pbs_wipe_local_keyfile() {
|
||||||
|
rm -f "$HB_STATE_DIR/pbs-key.conf" \
|
||||||
|
"$HB_STATE_DIR/pbs-key.pass" \
|
||||||
|
"$HB_STATE_DIR/pbs-key.recovery.enc" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dedicated management submenu for the PBS encryption keyfile.
|
||||||
|
# Available as option 4 of "Configure backup destinations". All
|
||||||
|
# destructive actions offer a backup-to-/root step first and require
|
||||||
|
# an explicit confirmation before touching disk.
|
||||||
|
_bk_manage_pbs_encryption_keyfile() {
|
||||||
|
local key_file="$HB_STATE_DIR/pbs-key.conf"
|
||||||
|
local pass_file="$HB_STATE_DIR/pbs-key.pass"
|
||||||
|
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
|
||||||
|
while true; do
|
||||||
|
local installed=0
|
||||||
|
[[ -s "$key_file" ]] && installed=1
|
||||||
|
|
||||||
|
local preview=""
|
||||||
|
if (( installed )); then
|
||||||
|
local fp="" kdf_hint="unknown" installed_at=""
|
||||||
|
if command -v proxmox-backup-client >/dev/null 2>&1; then
|
||||||
|
fp=$(proxmox-backup-client key info --output-format json "$key_file" 2>/dev/null \
|
||||||
|
| grep -oE '"fingerprint"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||||
|
| head -1 | sed 's/.*"\([^"]*\)".*/\1/')
|
||||||
|
fi
|
||||||
|
if grep -q '"kdf":[[:space:]]*"None"' "$key_file" 2>/dev/null; then
|
||||||
|
kdf_hint="none"
|
||||||
|
elif grep -q '"Scrypt"' "$key_file" 2>/dev/null; then
|
||||||
|
kdf_hint="scrypt"
|
||||||
|
fi
|
||||||
|
installed_at=$(date -r "$key_file" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "")
|
||||||
|
preview="$(hb_translate "Installed:") \Zb\Z2$(hb_translate "yes")\Zn"$'\n'
|
||||||
|
preview+="$(hb_translate "Path:") $key_file"$'\n'
|
||||||
|
[[ -n "$installed_at" ]] && preview+="$(hb_translate "Installed at:") $installed_at"$'\n'
|
||||||
|
preview+="$(hb_translate "KDF:") $kdf_hint"$'\n'
|
||||||
|
[[ -n "$fp" ]] && preview+="$(hb_translate "Fingerprint:") $fp"$'\n'
|
||||||
|
preview+="$(hb_translate "Keyfile passphrase stored:") "
|
||||||
|
[[ -s "$pass_file" ]] && preview+="\Zb\Z2$(hb_translate "yes")\Zn"$'\n' || preview+="\Zb$(hb_translate "no")\Zn"$'\n'
|
||||||
|
preview+="$(hb_translate "Recovery escrow present:") "
|
||||||
|
[[ -s "$recovery_enc" ]] && preview+="\Zb\Z2$(hb_translate "yes")\Zn"$'\n' || preview+="\Zb$(hb_translate "no")\Zn"$'\n'
|
||||||
|
else
|
||||||
|
preview="$(hb_translate "No keyfile installed on this host.")"$'\n\n'
|
||||||
|
preview+="$(hb_translate "PBS encrypted backups won't work until one is generated or imported. Both actions happen during job creation the first time you tick 'Encrypt backups'.")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local choice
|
||||||
|
if (( installed )); then
|
||||||
|
choice=$(dialog --backtitle "ProxMenux" --colors \
|
||||||
|
--title "$(translate "Manage PBS encryption keyfile")" \
|
||||||
|
--menu "\n${preview}\n" \
|
||||||
|
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||||
|
"info" "$(hb_translate "Show detailed keyfile info")" \
|
||||||
|
"pass" "$(hb_translate "Update stored keyfile passphrase")" \
|
||||||
|
"replace" "$(hb_translate "Replace keyfile (generate new or import)")" \
|
||||||
|
"remove" "$(hb_translate "Remove keyfile from this host")" \
|
||||||
|
"back" "$(hb_translate "← Return")" \
|
||||||
|
3>&1 1>&2 2>&3) || break
|
||||||
|
else
|
||||||
|
choice=$(dialog --backtitle "ProxMenux" --colors \
|
||||||
|
--title "$(translate "Manage PBS encryption keyfile")" \
|
||||||
|
--menu "\n${preview}\n" \
|
||||||
|
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||||
|
"back" "$(hb_translate "← Return")" \
|
||||||
|
3>&1 1>&2 2>&3) || break
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$choice" in
|
||||||
|
info)
|
||||||
|
# Full `key info` output (may need passphrase for scrypt).
|
||||||
|
local info_out
|
||||||
|
info_out=$(proxmox-backup-client key info "$key_file" 2>&1)
|
||||||
|
dialog --backtitle "ProxMenux" \
|
||||||
|
--title "$(hb_translate "Keyfile info")" \
|
||||||
|
--msgbox "$info_out" 20 80
|
||||||
|
;;
|
||||||
|
pass)
|
||||||
|
local new_pass new_pass2
|
||||||
|
while true; do
|
||||||
|
new_pass=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Update keyfile passphrase")" \
|
||||||
|
--insecure --passwordbox "$(hb_translate "New passphrase used to unlock the keyfile (leave blank to remove the stored passphrase — for kdf=none keyfiles):")" \
|
||||||
|
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || { new_pass=""; break; }
|
||||||
|
if [[ -z "$new_pass" ]]; then
|
||||||
|
new_pass2=""
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
new_pass2=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Update keyfile passphrase")" \
|
||||||
|
--insecure --passwordbox "$(hb_translate "Confirm new passphrase:")" \
|
||||||
|
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || { new_pass=""; break; }
|
||||||
|
[[ "$new_pass" == "$new_pass2" ]] && break
|
||||||
|
dialog --backtitle "ProxMenux" \
|
||||||
|
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
|
||||||
|
done
|
||||||
|
if [[ -n "$new_pass" ]]; then
|
||||||
|
umask 077
|
||||||
|
printf '%s' "$new_pass" > "$pass_file"
|
||||||
|
chmod 600 "$pass_file" 2>/dev/null || true
|
||||||
|
dialog --backtitle "ProxMenux" \
|
||||||
|
--msgbox "$(hb_translate "Stored keyfile passphrase updated.")" 8 60
|
||||||
|
else
|
||||||
|
rm -f "$pass_file" 2>/dev/null
|
||||||
|
dialog --backtitle "ProxMenux" \
|
||||||
|
--msgbox "$(hb_translate "Stored keyfile passphrase removed.")" 8 60
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
replace|remove)
|
||||||
|
local action_title="$(hb_translate "Replace keyfile")"
|
||||||
|
[[ "$choice" == "remove" ]] && action_title="$(hb_translate "Remove keyfile")"
|
||||||
|
# Destructive warning + confirmation
|
||||||
|
local warn_body
|
||||||
|
warn_body="\Zb\Z1$(hb_translate "This is a destructive action")\Zn"$'\n\n'
|
||||||
|
warn_body+="$(hb_translate "Backups already stored on PBS were encrypted with the current keyfile. After this action:")"$'\n'
|
||||||
|
warn_body+=" • $(hb_translate "New backups will use the new (or no) keyfile.")"$'\n'
|
||||||
|
warn_body+=" • $(hb_translate "Downloading pre-existing encrypted backups from this host will fail unless you keep a copy of the current key.")"$'\n'
|
||||||
|
warn_body+=" • $(hb_translate "Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase.")"$'\n\n'
|
||||||
|
warn_body+="$(hb_translate "Continue?")"
|
||||||
|
if ! dialog --backtitle "ProxMenux" --colors --yesno "$warn_body" 16 80; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
local do_backup=1
|
||||||
|
if ! dialog --backtitle "ProxMenux" \
|
||||||
|
--title "$(hb_translate "Backup current key")" \
|
||||||
|
--yesno "$(hb_translate "Save a copy of the current keyfile + passphrase + recovery blob under /root/pbs-key.old-<timestamp>.* before ${action_title,,}?")" \
|
||||||
|
10 78; then
|
||||||
|
do_backup=0
|
||||||
|
fi
|
||||||
|
local ts=""
|
||||||
|
if (( do_backup )); then
|
||||||
|
ts=$(_bk_pbs_backup_keyfile_to_root)
|
||||||
|
if [[ -z "$ts" ]]; then
|
||||||
|
dialog --backtitle "ProxMenux" \
|
||||||
|
--msgbox "$(hb_translate "Backup to /root failed. Aborting.")" 8 60
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
_bk_pbs_wipe_local_keyfile
|
||||||
|
if [[ "$choice" == "replace" ]]; then
|
||||||
|
# Delegate to the yes/no + generate/import flow.
|
||||||
|
# HB_ASK_ENCRYPT_INSTRUCTIONS is unused here, we
|
||||||
|
# directly call the two helpers.
|
||||||
|
local rmenu
|
||||||
|
rmenu=$(dialog --backtitle "ProxMenux" \
|
||||||
|
--title "$(hb_translate "Replace keyfile")" \
|
||||||
|
--menu "$(hb_translate "How should the new keyfile be set up?")" \
|
||||||
|
10 78 2 \
|
||||||
|
"new" "$(hb_translate "Generate a new keyfile (per host — safest isolation)")" \
|
||||||
|
"import" "$(hb_translate "Import an existing keyfile from a path (shared across hosts)")" \
|
||||||
|
3>&1 1>&2 2>&3) || rmenu=""
|
||||||
|
case "$rmenu" in
|
||||||
|
new) _hb_pbs_create_new_keyfile || true ;;
|
||||||
|
import) _hb_pbs_import_dialog || true ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
local done_msg="$action_title $(hb_translate "completed.")"
|
||||||
|
[[ -n "$ts" ]] && done_msg+=$'\n\n'"$(hb_translate "Backup saved with prefix:") /root/pbs-key.old-${ts}"
|
||||||
|
dialog --backtitle "ProxMenux" --msgbox "$done_msg" 10 78
|
||||||
|
;;
|
||||||
|
back|"") break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
_bk_manage_extra_paths() {
|
_bk_manage_extra_paths() {
|
||||||
while true; do
|
while true; do
|
||||||
local -a paths=()
|
local -a paths=()
|
||||||
|
|||||||
Reference in New Issue
Block a user