From 57e936785d399e74d346611be7288767229c81df Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 20 Jun 2026 21:59:19 +0200 Subject: [PATCH] update 1.2.2.2 beta --- AppImage/components/host-backup.tsx | 1655 ++++++++++++++--- AppImage/scripts/flask_server.py | 628 ++++++- .../backup_restore/run_scheduled_backup.sh | 183 +- 3 files changed, 2182 insertions(+), 284 deletions(-) diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index e84b8c4b..12a9679f 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -35,6 +35,7 @@ import { FileText, Power, RefreshCw, + Lock, } from "lucide-react" import { fetchApi, getApiUrl } from "../lib/api-config" import { formatStorage } from "../lib/utils" @@ -55,6 +56,10 @@ interface BackupJob { manual: boolean // MANUAL_RUN=1 — one-shot, no schedule, won't re-fire last_status: string | null next_run: string | null + // True when this job ships encrypted backups — PBS_KEYFILE set for + // PBS, or BORG_ENCRYPT_MODE != "none" for Borg. Drives the lock + // badge in the row + the toggle's initial state in Edit. + encrypted?: boolean } // Sprint H — remote backups (PBS + Borg). Listing is cheap (metadata @@ -74,6 +79,10 @@ interface RemoteSnapshot { protected: boolean files: Array<{ filename: string; size: number }> fingerprint: string | null + // Whether THIS snapshot is encrypted. For PBS it's derived from + // any file having crypt-mode != "none"; for Borg it follows the + // target's encrypt_mode. + encrypted?: boolean borg_id?: string // Borg-only fields when available borg_start_iso?: string } @@ -254,6 +263,17 @@ const humanizeOnCalendar = (raw: string | null | undefined): string => { return `${s} (systemd OnCalendar)` } +// A job is "running" when its .status file has RUN_AT (runner started) +// but no RESULT yet (runner hasn't written the verdict). Lets the UI +// flag in-progress runs both inline (badge in the row) and from the +// Manual backups card so an operator who closes the dialog can re-open +// the live stream. +function isJobRunning(j: BackupJob | null | undefined): boolean { + if (!j?.last_status) return false + const s = parseJobStatus(j.last_status) + return !!(s?.runAt && !s?.result) +} + // last_status is the raw RUN_AT=..., RESULT=..., LOG_FILE=... blob the // scheduler runner persists. We only care about RESULT + RUN_AT for the // UI; everything else is noise the operator doesn't need on a list view. @@ -325,6 +345,7 @@ export function HostBackup() { const [creatingJob, setCreatingJob] = useState(false) const [editingJobId, setEditingJobId] = useState(null) const [viewingJobId, setViewingJobId] = useState(null) + const [watchingManualId, setWatchingManualId] = useState(null) const [runningManual, setRunningManual] = useState(false) async function confirmDeleteJob() { @@ -431,11 +452,18 @@ export function HostBackup() { )} {jobsResp.jobs.filter((j) => !j.manual).map((j) => { const status = parseJobStatus(j.last_status) - const statusBadge = status?.result === "ok" - ? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-400" } - : status?.result - ? { label: status.result, cls: "bg-red-500/10 border-red-500/40 text-red-400" } - : null + const running = isJobRunning(j) + // While running we override the ok/failed pill with a + // running spinner — same way the modal does. Clicking + // the row re-opens JobDetailModal which auto-detects + // the in-progress state and resumes streaming. + const statusBadge = running + ? { label: "running", cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" } + : status?.result === "ok" + ? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-400" } + : status?.result + ? { label: status.result, cls: "bg-red-500/10 border-red-500/40 text-red-400" } + : null const lastRunWhen = formatRunAt(status?.runAt ?? null) return ( - - -

- Manual backups run once and stop — no schedule, no retention. Use this when you want a one-off copy without committing to a recurring job. The resulting archive shows up in Available Archives below (for local backends; PBS / Borg manual backups go to their respective storage). -

+ {/* In-progress manual jobs. If the operator closed the + ManualBackupDialog before the runner finished, this + banner is the way back into the live log — clicking + opens the JobDetailModal which automatically resumes + the streaming view for any in-progress job. */} + {jobsResp?.jobs + ?.filter((j) => j.manual && isJobRunning(j)) + .map((j) => ( + + ))}
@@ -640,6 +698,14 @@ export function HostBackup() { {u.source} + {u.remote?.encrypted && ( + + + + )} {formatMtime(u.created_at)} @@ -712,13 +778,23 @@ export function HostBackup() { setRunningManual(false)} - onLaunched={() => { + onLaunched={(jobId) => { + // Hand off straight to the live-progress modal so the + // operator never has to chase the "View progress" banner + // after pressing Run. Mutate jobs first so the new manual + // entry is in cache when the watch modal opens. setRunningManual(false) mutateJobs() - // Refresh archives a few seconds after launch so the new - // tar.zst (if it's a local backend) appears in the list - // without waiting for the next 30s SWR tick. - setTimeout(() => mutateArchives(), 5000) + setWatchingManualId(jobId) + // Refresh BOTH archive lists a few seconds after launch so + // the new backup shows up regardless of backend: local + // archives are on disk locally, PBS / Borg snapshots are + // pulled from `remote-archives`. Without the second mutate + // a Borg manual stayed invisible until the next 60s tick. + setTimeout(() => { + mutateArchives() + mutateRemoteArchives() + }, 5000) }} /> @@ -737,7 +813,29 @@ export function HostBackup() { setJobToDelete(job) } }} - onChanged={() => mutateJobs()} + onChanged={() => { + mutateJobs() + mutateArchives() + mutateRemoteArchives() + }} + /> + + {/* Manual jobs use their own modal — no Schedule/Retention/ + Profile, no Edit/Run/Disable. Only the live log; the modal + is closed with the dialog's own X (no extra footer). */} + setWatchingManualId(null)} + onChanged={() => { + // The watch modal fires onChanged when a tracked run + // finishes (last_result transitions from null → ok/failed). + // Refresh jobs (status badge), local archives (new tar.zst) + // and remote archives (Borg/PBS snapshots) so the operator + // sees the new backup without waiting for the next tick. + mutateJobs() + mutateArchives() + mutateRemoteArchives() + }} /> {/* ── Delete confirmation ────────────────────────────── */} @@ -1665,23 +1763,36 @@ interface PveVzdumpJob { enabled: boolean } +// All Destination shapes carry `jobs_using`: list of job_ids that +// currently depend on the destination. Surfaced in the Delete- +// destination confirm flow so the operator sees the cascade impact +// before agreeing. + interface PbsRepo { name: string repository: string fingerprint: string | null source: "proxmox" | "manual" + jobs_using?: string[] } interface BorgRepo { name: string repository: string ssh_key_path?: string + // Encryption + saved-passphrase metadata. Newer backends ship these; + // older deployments without the fields default to "repokey" (the + // shell installer's historical default) and unknown-passphrase. + encrypt_mode?: "none" | "repokey" | "keyfile" | "authenticated" + has_passphrase?: boolean + jobs_using?: string[] } interface LocalTargetEntry { path: string source: "default" | "custom" removable: boolean + jobs_using?: string[] } interface LocalTarget { @@ -1719,6 +1830,7 @@ interface JobDetail { pbs_backup_id: string | null pbs_fingerprint: string | null has_pbs_password: boolean + pbs_encrypt: boolean local_dest_dir: string | null local_archive_ext: string | null borg_repo: string | null @@ -1771,6 +1883,25 @@ function CreateJobDialog({ // Backend-specific fields const [pbsRepository, setPbsRepository] = useState("") const [pbsBackupId, setPbsBackupId] = useState("") + // Client-side PBS encryption — sent as `pbs_encrypt` in the payload. + // Backend resolves the shared keyfile + injects PBS_KEYFILE into + // the job .env so the runner adds `--keyfile` to the backup call. + const [pbsEncrypt, setPbsEncrypt] = useState(false) + // Optional recovery passphrase. When set, the backend encrypts the + // keyfile with openssl and the runner uploads that blob to PBS with + // every backup so the keyfile can be rebuilt on a fresh host with + // only the passphrase. Mirrors hb_pbs_setup_recovery from the shell. + const [pbsRecoveryPass, setPbsRecoveryPass] = useState("") + const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState("") + // Whether the host already has an escrow blob configured. When + // 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 + }>( + open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null, + fetcher, + ) const [localDestDir, setLocalDestDir] = useState("") const [borgRepoSelected, setBorgRepoSelected] = useState("") const [borgPassphrase, setBorgPassphrase] = useState("") @@ -1845,6 +1976,7 @@ function CreateJobDialog({ if (r.keep_yearly !== undefined) setKeepYearly(String(r.keep_yearly)) if (jobDetail.pbs_repository) setPbsRepository(jobDetail.pbs_repository) if (jobDetail.pbs_backup_id) setPbsBackupId(jobDetail.pbs_backup_id) + setPbsEncrypt(!!jobDetail.pbs_encrypt) if (jobDetail.local_dest_dir) setLocalDestDir(jobDetail.local_dest_dir) if (jobDetail.borg_repo) setBorgRepoSelected(jobDetail.borg_repo) if (jobDetail.borg_encrypt_mode) { @@ -1935,6 +2067,9 @@ function CreateJobDialog({ setKeepYearly("0") setPbsRepository("") setPbsBackupId("") + setPbsEncrypt(false) + setPbsRecoveryPass("") + setPbsRecoveryPass2("") setLocalDestDir("") setBorgRepoSelected("") setBorgPassphrase("") @@ -2019,7 +2154,7 @@ function CreateJobDialog({ ? !!pbsRepository : backend === "local" ? true - : /* borg */ !!borgRepoSelected && (borgEncryptMode === "none" || !!borgPassphrase) + : /* borg */ !!borgRepoSelected const canSubmit = canAdvanceFrom1 && canAdvanceFrom2 && canAdvanceFrom3 && canAdvanceFrom4 && backendValid @@ -2029,6 +2164,28 @@ function CreateJobDialog({ if (mode === "attach" && !selectedPveJob) return setSubmitting(true) setError(null) + // Configure the PBS recovery escrow BEFORE creating the job so the + // very first backup the runner triggers can already upload the + // blob. Only fires when the operator typed a passphrase pair. + if (backend === "pbs" && pbsEncrypt && pbsRecoveryPass) { + if (pbsRecoveryPass !== pbsRecoveryPass2) { + setError("Recovery passphrases don't match.") + setSubmitting(false) + return + } + try { + await fetchApi("/api/host-backups/pbs-recovery/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase: pbsRecoveryPass }), + }) + mutatePbsRecovery() + } catch (e) { + setError(`Recovery setup failed: ${e instanceof Error ? e.message : String(e)}`) + setSubmitting(false) + return + } + } try { const body: Record = { id: jobId, @@ -2059,12 +2216,16 @@ function CreateJobDialog({ body.pbs_password = "" if (pbsBackupId) body.pbs_backup_id = pbsBackupId if (selectedPbs?.fingerprint) body.pbs_fingerprint = selectedPbs.fingerprint + body.pbs_encrypt = pbsEncrypt } else if (backend === "local") { if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim() } else if (backend === "borg") { + // Passphrase + encrypt_mode are inherited from the destination + // (the runner reads borg-pass-.txt + the 4th field of + // borg-targets.txt). Only send overrides when the operator + // really typed a new one in an edit flow. body.borg_repo = borgRepoSelected - body.borg_passphrase = borgPassphrase - body.borg_encrypt_mode = borgEncryptMode + if (isEdit && borgPassphrase) body.borg_passphrase = borgPassphrase } // fetchApi returns the parsed JSON and throws (with the backend's // error message in `error.message` when the body was JSON) on any @@ -2557,9 +2718,6 @@ function CreateJobDialog({ {r.name} — {r.repository} - {r.source === "proxmox" && ( - proxmox - )} ))} @@ -2605,6 +2763,62 @@ function CreateJobDialog({ PBS organises backups into named groups, each with its own retention. Leave blank to use the automatic default for this host (recommended).

+ {/* PBS client-side encryption — same flow as the + shell wizard. A shared keyfile under /usr/local/ + share/proxmenux/pbs-key.conf is generated on + first use and reused by every encrypted job. */} +
+ +

+ Adds --keyfile to proxmox-backup-client backup. Encryption happens before upload so chunks land on the PBS server already ciphered. The keyfile lives at /usr/local/share/proxmenux/pbs-key.conf (chmod 0600) and is shared across every encrypted PBS job on this host. +

+ {pbsEncrypt && ( +
+
+ + Recovery passphrase (strongly recommended) +
+

+ With a recovery passphrase, an encrypted copy of the keyfile is uploaded to PBS with every backup. If you lose this host, you can recover the keyfile on a fresh install with just the passphrase. Without it, losing the local keyfile means the encrypted backups become unrecoverable forever. + {pbsRecoveryStatus?.has_recovery && " A recovery blob is already configured — leave the fields blank to keep it."} +

+
+ + setPbsRecoveryPass(e.target.value)} + placeholder={pbsRecoveryStatus?.has_recovery ? "(unchanged — type to replace)" : "Long random string — write it down somewhere safe"} + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPbsRecoveryPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( +

Passphrases don't match.

+ )} +
+
+ )} +
)} {backend === "local" && ( @@ -2625,6 +2839,39 @@ function CreateJobDialog({ ) : ( <> + {(destResp?.local?.entries?.length ?? 0) > 0 && ( +
+ + +
+ )}

- Where the tar.zst archive is written. - {" "} - Leave blank to use the configured local target - {" "} - ({destResp?.local?.effective || "/var/lib/vz/dump"} - {destResp?.local?.configured ? "" : " — default"}). + Pick a configured destination above, or type a custom directory path. Empty input falls back to {destResp?.local?.effective || "/var/lib/vz/dump"}.

)} @@ -2691,43 +2933,19 @@ function CreateJobDialog({ )} -
- - -
- {borgEncryptMode !== "none" && ( -
- - setBorgPassphrase(e.target.value)} - className="font-mono mt-1" - placeholder={isEdit && jobDetail?.has_borg_passphrase - ? "(unchanged — type to replace)" - : "Passphrase used to decrypt repo at restore time"} - /> -

- {isEdit && jobDetail?.has_borg_passphrase - ? "Leave blank to keep the current passphrase." - : "Stored in the .env (mode 0600). If the repo is new it will be initialised with this passphrase."} + {/* Encryption + passphrase aren't asked here — they + live on the borg destination. The runner reads + the saved sidecar at run time. */} + {(() => { + const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected) + if (!dest) return null + const mode = dest.encrypt_mode || "repokey" + return ( +

+ Encryption + passphrase are taken from the destination ({mode === "none" ? "no encryption" : mode}). Edit the destination if you need to change them.

-
- )} + ) + })()} )} @@ -2905,7 +3123,10 @@ function ManualBackupDialog({ }: { open: boolean onClose: () => void - onLaunched: () => void + // The job_id of the just-launched manual run. The caller uses it to + // transition straight into the live-progress modal so the operator + // never has to chase a banner after pressing Run. + onLaunched: (jobId: string) => void }) { const [step, setStep] = useState<1 | 2>(1) const [backend, setBackend] = useState<"pbs" | "local" | "borg">("local") @@ -2914,6 +3135,18 @@ function ManualBackupDialog({ const [pbsRepository, setPbsRepository] = useState("") const [pbsBackupId, setPbsBackupId] = useState("") + const [pbsEncrypt, setPbsEncrypt] = useState(false) + const [pbsRecoveryPass, setPbsRecoveryPass] = useState("") + const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState("") + // Whether the host already has an escrow blob configured. When + // 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 + }>( + open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null, + fetcher, + ) const [localDestDir, setLocalDestDir] = useState("") const [borgRepoSelected, setBorgRepoSelected] = useState("") const [borgPassphrase, setBorgPassphrase] = useState("") @@ -2960,6 +3193,9 @@ function ManualBackupDialog({ setCustomPaths(new Set()) setPbsRepository("") setPbsBackupId("") + setPbsEncrypt(false) + setPbsRecoveryPass("") + setPbsRecoveryPass2("") setLocalDestDir("") setBorgRepoSelected("") setBorgPassphrase("") @@ -2994,13 +3230,37 @@ function ManualBackupDialog({ ? !!pbsRepository : backend === "local" ? true - : !!borgRepoSelected && (borgEncryptMode === "none" || !!borgPassphrase) + // Borg destination carries its own passphrase + encryption. + // No need to gate on local state — the wizard no longer asks. + : !!borgRepoSelected const canSubmit = canAdvanceFrom1 && backendValid async function handleRun() { if (!canSubmit) return setSubmitting(true) setError(null) + // Same pre-step as CreateJob: configure the PBS recovery escrow + // before the run so the runner's post-backup upload of the blob + // has something to push. + if (backend === "pbs" && pbsEncrypt && pbsRecoveryPass) { + if (pbsRecoveryPass !== pbsRecoveryPass2) { + setError("Recovery passphrases don't match.") + setSubmitting(false) + return + } + try { + await fetchApi("/api/host-backups/pbs-recovery/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase: pbsRecoveryPass }), + }) + mutatePbsRecovery() + } catch (e) { + setError(`Recovery setup failed: ${e instanceof Error ? e.message : String(e)}`) + setSubmitting(false) + return + } + } try { const body: Record = { backend, @@ -3014,19 +3274,23 @@ function ManualBackupDialog({ body.pbs_password = "" if (pbsBackupId) body.pbs_backup_id = pbsBackupId if (selectedPbs?.fingerprint) body.pbs_fingerprint = selectedPbs.fingerprint + body.pbs_encrypt = pbsEncrypt } else if (backend === "local") { if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim() } else if (backend === "borg") { + // Passphrase + encrypt_mode are inherited from the destination + // (borg-pass-.txt sidecar + 4th field of borg-targets.txt). body.borg_repo = borgRepoSelected - body.borg_passphrase = borgPassphrase - body.borg_encrypt_mode = borgEncryptMode } - await fetchApi("/api/host-backups/manual-run", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - onLaunched() + const resp = await fetchApi<{ status: string; job_id: string }>( + "/api/host-backups/manual-run", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ) + onLaunched(resp.job_id) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { @@ -3194,11 +3458,99 @@ function ManualBackupDialog({ className="font-mono mt-1" /> +
+ +

+ Uses the shared keyfile at /usr/local/share/proxmenux/pbs-key.conf (auto-generated on first use). +

+ {pbsEncrypt && ( +
+
+ + Recovery passphrase (strongly recommended) +
+

+ With a recovery passphrase an encrypted copy of the keyfile is uploaded to PBS on every backup. Lets you recover it on a fresh host with just the passphrase. + {pbsRecoveryStatus?.has_recovery && " A recovery blob is already configured — leave blank to keep it."} +

+
+ + setPbsRecoveryPass(e.target.value)} + placeholder={pbsRecoveryStatus?.has_recovery ? "(unchanged — type to replace)" : "Long random string — write it down somewhere safe"} + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPbsRecoveryPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( +

Passphrases don't match.

+ )} +
+
+ )} +
)} {backend === "local" && (
- + + {(destResp?.local?.entries?.length ?? 0) > 0 && ( +
+ + +
+ )} +

+ Pick one of the configured local destinations, or type a custom directory path below. Empty input falls back to the selected destination above. +

-

- Where the tar.zst archive is written. - {" "}Leave blank to use the configured local target - {" "}({destResp?.local?.effective || "/var/lib/vz/dump"}). -

)} {backend === "borg" && ( @@ -3260,43 +3607,23 @@ function ManualBackupDialog({ )} -
- - -
- {borgEncryptMode !== "none" && ( -
- - setBorgPassphrase(e.target.value)} - className="font-mono mt-1" - placeholder="Passphrase used to decrypt repo at restore time" - /> -
- )} + {(() => { + const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected) + if (!dest) return null + const mode = dest.encrypt_mode || "repokey" + return ( +

+ Encryption + passphrase are taken from the destination ({mode === "none" ? "no encryption" : mode}). Edit the destination if you need to change them. +

+ ) + })()} )} {/* Summary — mirrors the styling of the JobDetailModal. */}
Summary
-
+
Backend: {backend} @@ -3305,7 +3632,7 @@ function ManualBackupDialog({ manual / one-shot
-
+
profile: {profileMode} @@ -3349,6 +3676,7 @@ function ManualBackupDialog({
)} + {/* Recovery banner: only when there's no local keyfile AND at + least one escrow blob was found on a configured PBS. Click + opens a passphrase dialog and the keyfile is rebuilt from + the blob. Equivalent to hb_pbs_try_keyfile_recovery in the + shell — what runs after a fresh PVE install when the + operator points the new host at the same PBS. */} + {hasPbs && pbsRecoveryStatus && !pbsRecoveryStatus.has_keyfile && (pbsRecoveryAvailable?.snapshots?.length ?? 0) > 0 && ( + + )}
{items.map((it) => { const cap = capByEdid.get(it.id) @@ -3598,7 +4016,16 @@ function DestinationsSection({ (it.kind === "pbs" && busyKey === `pbs:${it.name}`) || (it.kind === "borg" && busyKey === `borg:${it.name}`) || (it.kind === "local" && busyKey === `local:${it.path}`) - const unmountBusy = it.kind === "local" && busyKey === `umount:${it.path}` + // Borg local-mode repos sit on top of a path — the same + // /mnt/proxmenux-backup-disk-* USB mountpoint a Local target + // would use. Expose Unmount for them too so the operator + // doesn't have to remember which destination type is on the + // disk they want to detach. + const borgLocalUsbPath = it.kind === "borg" && !it.isSsh && cap?.is_usb + ? it.repository + : null + const umountTarget = it.kind === "local" ? it.path : borgLocalUsbPath + const unmountBusy = !!umountTarget && busyKey === `umount:${umountTarget}` return ( { - if (it.kind === "pbs") return removePbs(it.name) - if (it.kind === "borg") return removeBorg(it.name) - if (it.kind === "local") return removeLocal(it.path) - }} + onDelete={() => setConfirmingDest(it)} onUnmount={ - it.kind === "local" && cap?.is_usb - ? () => unmountUsb(it.path) + umountTarget && cap?.is_usb + ? () => unmountUsb(umountTarget) : undefined } + onEdit={ + // Only PBS-manual and Borg are editable from the UI. + // PBS-proxmox is managed by Datacenter → Storage and + // local destinations have no fields beyond the path. + it.kind === "pbs" && it.source === "manual" + ? () => { + const [user, rest] = it.repository.includes("@") + ? [it.repository.split("@").slice(0, -1).join("@"), it.repository.split("@").pop() || ""] + : ["root@pam", it.repository] + const [server, datastore] = rest.includes(":") + ? [rest.split(":").slice(0, -1).join(":"), rest.split(":").pop() || ""] + : [rest, ""] + setEditingDest({ + kind: "pbs", + name: it.name, + username: user, + server, + datastore, + fingerprint: it.fingerprint, + has_password: true, + }) + } + : it.kind === "borg" + ? () => setEditingDest({ + kind: "borg", + name: it.name, + repository: it.repository, + ssh_key_path: it.sshKeyPath, + encrypt_mode: (destinations?.borg?.find((b) => b.name === it.name)?.encrypt_mode) || "repokey", + has_passphrase: !!destinations?.borg?.find((b) => b.name === it.name)?.has_passphrase, + }) + : undefined + } /> ) })} @@ -3628,6 +4084,96 @@ function DestinationsSection({ onChanged() }} /> + {/* Edit-an-existing-destination dialog. Uses the same form as + Add, just hydrated with the saved values and with `name` + locked. The POST endpoint is upsert-by-name on the backend + so no separate route is needed. */} + setEditingDest(null)} + onSaved={() => { + setEditingDest(null) + onChanged() + }} + /> + setRecoveringKeyfile(false)} + onRecovered={() => { + setRecoveringKeyfile(false) + mutatePbsRecovery() + }} + /> + {/* Delete-destination confirm. Always opens (uniform UX) but + the body adapts: a plain "Remove?" when no jobs/backups + depend on the destination, or a warning summarizing the + cascade (jobs go, backups stay) when there's something at + stake. The destination + its sidecars are removed; backups + on disk / on the remote stay untouched so the operator can + decide what to do with them later. */} + {confirmingDest && (() => { + const it = confirmingDest + const jobs = it.jobsUsing + const backups = backupsCountFor(it) + const headline = + it.kind === "local" ? it.path + : it.kind === "pbs" ? `${it.name} — ${it.repository}` + : `${it.name} — ${it.repository}` + return ( + { if (!v) setConfirmingDest(null) }}> + + + + 0 ? "text-amber-500" : "text-red-500"}`} /> + Remove destination + + + {jobs.length === 0 && backups === 0 + ? "Removes the destination from the list. Nothing else depends on it." + : "The destination + its saved credentials will be removed. Backups already stored stay where they are — you decide what to do with them later."} + + +
+ {headline} +
+ {(jobs.length > 0 || backups > 0) && ( +
+ {jobs.length > 0 && ( +
+
+ {jobs.length} job{jobs.length === 1 ? "" : "s"} will be deleted (cascade): +
+
+ {jobs.map((j) => ( + + {j} + + ))} +
+
+ )} + {backups > 0 && ( +
+ {backups} backup{backups === 1 ? "" : "s"} already at this destination — they are kept. +
+ )} +
+ )} +
+ + +
+
+
+ ) + })()}
) } @@ -3644,6 +4190,7 @@ function DestinationRow({ unmountBusy, onDelete, onUnmount, + onEdit, }: { item: UnifiedDest capacity?: CapacityInfo @@ -3651,6 +4198,7 @@ function DestinationRow({ unmountBusy: boolean onDelete: () => void onUnmount?: () => void + onEdit?: () => void }) { const accent = item.kind === "pbs" ? "bg-purple-500/10 text-purple-400 border-purple-500/20" @@ -3673,7 +4221,10 @@ function DestinationRow({ const sourceLabel = item.kind === "local" ? (item.source === "default" ? "built-in default" : "manually added") : item.kind === "pbs" ? (item.source === "proxmox" ? "Datacenter → Storage" : "manually added") - : item.isSsh ? "remote (SSH)" : "local path" + // Borg targets are always operator-configured (no Datacenter + // auto-discovery path), so they all share the "manually added" + // wording for visual consistency with the Local custom row. + : "manually added" const pct = capacity?.total && capacity.available !== undefined ? Math.min(100, Math.round(((capacity.total - capacity.available) / capacity.total) * 100)) @@ -3681,66 +4232,118 @@ function DestinationRow({ return (
- {/* Top row: icon + headline + all badges, then actions on the right */} + {/* Top row split in two: a wrappable left side (icon + headline + + badges) and a fixed right side (Unmount + Remove). The right + side never falls to a new line even if `headline` is long, + because the outer container is a non-wrapping flex. */}
-
- -

{headline}

- - {item.kind} - - {item.kind === "pbs" && item.source === "proxmox" && ( - - proxmox +
+
+ +

{headline}

+ + {item.kind} - )} {item.kind === "local" && item.source === "default" && ( default )} - {item.kind === "borg" && ( - - {item.isSsh ? "ssh" : "local"} - - )} - {isUsb && ( + {item.kind === "borg" ? ( + // For Borg the sub-type is one of three (mutually exclusive): + // ssh → remote repo + // usb → local repo whose path lives on a USB mount + // local → local repo on an internal disk / regular dir + // Previously we showed BORG + LOCAL + USB which read like + // three orthogonal facets and confused the badge with the + // Local destination kind. Collapsing to a single sub-type + // badge avoids the overlap. + <> + {item.isSsh ? ( + + ssh + + ) : isUsb ? ( + + + USB + + ) : ( + + local + + )} + {/* Encryption indicator — icon-only chip for encrypted + repos. Plaintext repos get no chip (visually quiet + for the common case). The tooltip carries the mode + + saved-passphrase state for hover detail. */} + {item.encryptMode && item.encryptMode !== "none" && ( + + {/* Icon sized to roughly the text-[10px] line-box of + the neighbouring text badges (BORG, USB, …) so the + pill itself ends up the same height — matching + visual weight without adding a label. */} + + + )} + + ) : isUsb && ( + // PBS / Local destinations: USB badge is additive (the kind + // badge alone doesn't say where the path lives). USB )} - {/* Spacer pushes actions to the right end of the row */} -
- {onUnmount && ( - - )} - {item.removable ? ( - - ) : ( - - {item.kind === "local" ? "built-in" : "managed by PVE"} - - )} +
+ {/* Action chips — always pinned to the right, never wrap. + The outer flex container has no flex-wrap so even a very + long headline keeps these two buttons in place. Chip + style (square border + tinted bg) makes them read as + actionable rather than decorative icons. */} +
+ {onEdit && ( + + )} + {onUnmount && ( + + )} + {item.removable ? ( + + ) : ( + + {item.kind === "local" ? "built-in" : "managed by PVE"} + + )} +
{subline && (

@@ -3877,12 +4480,34 @@ function ConfigureDestinationWizard({ // One dialog handles all three Add flows — the form is small enough // that branching by type keeps it cleaner than three separate components. +// Pre-fill payload for editing an existing destination. `name` stays +// fixed (it's the key the sidecar / state files are scoped to); +// everything else can be re-typed. PBS password and Borg passphrase +// can be left blank to keep the saved one. +interface EditingDest { + kind: "pbs" | "borg" + name: string + // PBS fields + server?: string + datastore?: string + username?: string + fingerprint?: string + has_password?: boolean + // Borg fields + repository?: string + ssh_key_path?: string + encrypt_mode?: "none" | "repokey" | "keyfile" | "authenticated" + has_passphrase?: boolean +} + function AddDestinationDialog({ type, + editing, onClose, onSaved, }: { type: "pbs" | "borg" | "local" | null + editing?: EditingDest | null onClose: () => void // The repository string of the just-saved destination, so callers // can auto-select it in a dropdown (the wizard does this when an @@ -3907,42 +4532,119 @@ function AddDestinationDialog({ const [borgSshKeyPath, setBorgSshKeyPath] = useState("/root/.ssh/proxmenux_borg") const [generatedKey, setGeneratedKey] = useState<{ public_key: string; authorized_keys_line: string } | null>(null) const [generatingKey, setGeneratingKey] = useState(false) + // Borg encryption + passphrase live on the destination, not on each + // job. Two-mode UI: encrypted (repokey, the default) or none. Other + // borg modes (keyfile / authenticated) stay supported by the backend + // for legacy shell-created configs. + const [borgEncryptionEnabled, setBorgEncryptionEnabled] = useState(true) + const [borgPassphrase, setBorgPassphraseLocal] = useState("") + const [borgPassphrase2, setBorgPassphrase2] = useState("") // Local field const [localPath, setLocalPath] = useState("") const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) - useEffect(() => { - if (type === null) { - setName("") - setServer("") - setDatastore("") - setUsername("root@pam") - setPassword("") - setFingerprint("") - setBorgRepo("") - setBorgMode("local") - setBorgSshUser("borg") - setBorgSshHost("") - setBorgSshRemotePath("") - setBorgSshKeyPath("/root/.ssh/proxmenux_borg") - setGeneratedKey(null) - setGeneratingKey(false) - setLocalPath("") - setError(null) - setSubmitting(false) + // Read the current destinations so the UsbPicker can hide any USB + // mountpoint already in use as a Local or Borg-local destination — + // no point offering the same disk twice. + const { data: existingDest } = useSWR( + type !== null ? "/api/host-backups/destinations" : null, + fetcher, + ) + const usedPaths: string[] = (() => { + const out: string[] = [] + for (const e of existingDest?.local?.entries || []) { + if (e.source === "custom") out.push(e.path) } - }, [type]) + for (const r of existingDest?.borg || []) { + // Borg local-mode: the `repository` IS the path. SSH repos + // start with `ssh://` and are excluded automatically. + if (!r.repository.startsWith("ssh://")) out.push(r.repository) + } + return out + })() + // Reset every time the dialog OPENS — not just on close. If we're + // opening in edit mode, hydrate the form from the saved destination + // so the operator only has to change what they want. Name stays + // read-only in edit mode (it's the key for sidecars / state files). + useEffect(() => { + setError(null) + setSubmitting(false) + setGeneratedKey(null) + setGeneratingKey(false) + setPassword("") + setBorgPassphraseLocal("") + setBorgPassphrase2("") + if (editing && editing.kind === "pbs") { + setName(editing.name) + setServer(editing.server || "") + setDatastore(editing.datastore || "") + setUsername(editing.username || "root@pam") + setFingerprint(editing.fingerprint || "") + setBorgRepo(""); setBorgMode("local"); setBorgSshUser("borg") + setBorgSshHost(""); setBorgSshRemotePath("") + setBorgSshKeyPath("/root/.ssh/proxmenux_borg") + setBorgEncryptionEnabled(true); setLocalPath("") + return + } + if (editing && editing.kind === "borg") { + const repo = editing.repository || "" + const ssh = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/) + setName(editing.name) + if (ssh) { + setBorgMode("ssh") + setBorgSshUser(ssh[1]) + setBorgSshHost(ssh[2]) + setBorgSshRemotePath(`/${ssh[3]}`) + setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg") + setBorgRepo("") + } else { + setBorgMode("local") + setBorgRepo(repo) + setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshRemotePath("") + setBorgSshKeyPath("/root/.ssh/proxmenux_borg") + } + const mode = editing.encrypt_mode || "repokey" + setBorgEncryptionEnabled(mode !== "none") + setServer(""); setDatastore(""); setUsername("root@pam"); setFingerprint("") + setLocalPath("") + return + } + // Add-new path: full reset. + setName("") + setServer("") + setDatastore("") + setUsername("root@pam") + setFingerprint("") + setBorgRepo("") + setBorgMode("local") + setBorgSshUser("borg") + setBorgSshHost("") + setBorgSshRemotePath("") + setBorgSshKeyPath("/root/.ssh/proxmenux_borg") + setBorgEncryptionEnabled(true) + setLocalPath("") + }, [type, editing]) + + const isEditing = !!editing const nameValid = /^[a-zA-Z0-9_-]+$/.test(name) - const borgValid = + const borgPathValid = borgMode === "local" ? !!borgRepo.trim() : !!(borgSshUser.trim() && borgSshHost.trim() && borgSshRemotePath.trim()) + // In edit mode the saved passphrase / password is reused when the + // operator leaves the inputs blank, so validation relaxes. + const borgPassphraseValid = + !borgEncryptionEnabled + || (isEditing && !borgPassphrase && editing?.has_passphrase) + || (borgPassphrase.length > 0 && borgPassphrase === borgPassphrase2) + const borgValid = borgPathValid && borgPassphraseValid const canSubmit = type === "pbs" - ? nameValid && server.trim() && datastore.trim() && password + ? nameValid && server.trim() && datastore.trim() && + (!!password || (isEditing && !!editing?.has_password)) : type === "borg" ? nameValid && borgValid : type === "local" @@ -3993,7 +4695,14 @@ function AddDestinationDialog({ }) savedRepo = resp?.repository } else if (type === "borg") { - const body: Record = { name, mode: borgMode } + const body: Record = { + name, + mode: borgMode, + encrypt_mode: borgEncryptionEnabled ? "repokey" : "none", + } + if (borgEncryptionEnabled) { + body.passphrase = borgPassphrase + } if (borgMode === "local") { body.repo = borgRepo.trim() } else { @@ -4028,8 +4737,12 @@ function AddDestinationDialog({ - - Add {type === "pbs" ? "PBS" : type === "borg" ? "Borg" : "local"} destination + {isEditing ? ( + + ) : ( + + )} + {isEditing ? "Edit" : "Add"} {type === "pbs" ? "PBS" : type === "borg" ? "Borg" : "local"} destination

@@ -4037,8 +4750,10 @@ function AddDestinationDialog({ <>
- setName(e.target.value)} className="font-mono mt-1" placeholder="my-pbs" /> -

A short identifier. Letters, digits, _ or -.

+ setName(e.target.value)} className="font-mono mt-1" placeholder="my-pbs" disabled={isEditing} /> +

+ {isEditing ? "Name is the key for saved credentials and can't be changed." : "A short identifier. Letters, digits, _ or -."} +

@@ -4055,7 +4770,17 @@ function AddDestinationDialog({
- setPassword(e.target.value)} className="font-mono mt-1" /> + setPassword(e.target.value)} + className="font-mono mt-1" + placeholder={isEditing && editing?.has_password ? "(unchanged — type to replace)" : ""} + /> + {isEditing && editing?.has_password && ( +

Leave blank to keep the saved password.

+ )}
@@ -4068,7 +4793,10 @@ function AddDestinationDialog({ <>
- setName(e.target.value)} className="font-mono mt-1" placeholder="usb-borg or remote-borg" /> + setName(e.target.value)} className="font-mono mt-1" placeholder="usb-borg or remote-borg" disabled={isEditing} /> + {isEditing && ( +

Name is the key for the saved passphrase and can't be changed.

+ )}
@@ -4102,7 +4830,7 @@ function AddDestinationDialog({ Absolute path on this host. Pick a USB drive below to auto-fill, or type the path manually.

- setBorgRepo(p)} /> + setBorgRepo(p)} excludePaths={usedPaths} />
) : ( <> @@ -4165,6 +4893,71 @@ function AddDestinationDialog({
)} + + {/* Encryption + passphrase live on the destination, not on + each job. Two-mode UI: encrypted (repokey, the borg + recommended default) or none. The other borg modes + (keyfile, authenticated) are still honored by the + backend for legacy shell-created configs but not + exposed here. */} +
+ +
+ + +
+ {borgEncryptionEnabled && ( +
+
+ + setBorgPassphraseLocal(e.target.value)} + placeholder={isEditing && editing?.has_passphrase + ? "(unchanged — type to replace)" + : "Long random string — store it somewhere safe"} + className="font-mono mt-1" + /> + {isEditing && editing?.has_passphrase && ( +

Leave blank to keep the saved passphrase.

+ )} +
+
+ + setBorgPassphrase2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1" + /> + {borgPassphrase && borgPassphrase2 && borgPassphrase !== borgPassphrase2 && ( +

Passphrases don't match.

+ )} +
+

+ ⚠ Without this passphrase the repo cannot be decrypted — losing it means losing every backup. The passphrase is saved server-side at borg-pass-{name || "<name>"}.txt (chmod 0600) so jobs against this destination can use it transparently. +

+
+ )} +
)} {type === "local" && ( @@ -4176,7 +4969,7 @@ function AddDestinationDialog({ Where scheduled / manual local backups write the tar.zst archive. Pick a USB drive below to auto-fill, or type the path manually.

- setLocalPath(p)} /> + setLocalPath(p)} excludePaths={usedPaths} />
)} {error && ( @@ -4203,13 +4996,30 @@ function AddDestinationDialog({ // so the operator can mount + format USB media without leaving the // "configure destination" flow. // ────────────────────────────────────────────────────────────── -function UsbPicker({ onPick }: { onPick: (path: string) => void }) { +function UsbPicker({ + onPick, + excludePaths = [], +}: { + onPick: (path: string) => void + // Hide a USB drive ONLY when an existing destination already targets + // its mountpoint root verbatim. Subdirectory uses don't claim the + // whole disk — both Local and Borg can share the same USB by + // pointing at different subdirs (`/mnt/usbX/borgbackup` for Borg, + // `/mnt/usbX/local-dump` for Local). The previous prefix-based + // filter was too greedy and made it impossible to add a second + // destination type once any one of them landed on the disk. + excludePaths?: string[] +}) { const { data, mutate, isLoading } = useSWR<{ drives: UsbDrive[] }>( "/api/host-backups/usb-drives", fetcher, { refreshInterval: 0 }, ) - const drives = data?.drives ?? [] + const normalized = new Set((excludePaths || []).map((p) => p.replace(/\/+$/, ""))) + const drives = (data?.drives ?? []).filter((d) => { + const mp = (d.path_or_device || "").replace(/\/+$/, "") + return !mp || !normalized.has(mp) + }) const [busyKey, setBusyKey] = useState(null) const [error, setError] = useState(null) const [formatTarget, setFormatTarget] = useState(null) @@ -4549,16 +5359,15 @@ function ExtraPathsSection() { )}
- +
))}
@@ -4939,6 +5748,22 @@ function JobDetailModal({ } }, [detail, running, runBaseline, onChanged]) + // When the modal opens onto a job that's already running (last_run_at + // set but last_result still null), jump straight into streaming mode + // so the operator can resume watching from anywhere — including the + // "Manual backup in progress" banner on the Manual backups card and + // a click on a scheduled job row that's currently mid-run. + useEffect(() => { + if (!open || running || !detail) return + if (detail.last_run_at && !detail.last_result) { + // Baseline = empty so the exit condition still fires (any + // non-empty last_run_at + a non-null last_result will trip it). + setRunBaseline("") + setRunBaselineLogPath(detail.last_log_path ?? null) + setRunning(true) + } + }, [open, running, detail]) + // Reset on close. useEffect(() => { if (!open) { @@ -5298,3 +6123,381 @@ function JobDetailModal({ ) } + +// ────────────────────────────────────────────────────────────── +// ManualJobWatchModal +// ────────────────────────────────────────────────────────────── +// Sibling of JobDetailModal but tailored to manual / one-shot jobs. +// A manual job has no schedule, no retention, no profile picker — and +// once it has run it can't be re-run or edited (it's a frozen +// snapshot of one trigger). So the modal collapses to: +// - the live log when the job is in flight +// - the destination details (only the fields that matter for the +// picked backend) +// - Close + Delete actions (Edit / Run / Disable don't apply) +// ────────────────────────────────────────────────────────────── +function ManualJobWatchModal({ + jobId, + onClose, + onChanged, +}: { + jobId: string | null + onClose: () => void + onChanged: () => void +}) { + const open = jobId !== null + const [running, setRunning] = useState(false) + const [runBaselineLogPath, setRunBaselineLogPath] = useState(null) + const [actionError, setActionError] = useState(null) + + const { data: detail, mutate: refetch, isLoading } = useSWR( + open ? `/api/host-backups/jobs/${encodeURIComponent(jobId!)}` : null, + fetcher, + { refreshInterval: running ? 2000 : 0 }, + ) + const { data: liveLog } = useSWR<{ content: string; log_path: string | null; size: number }>( + open && running ? `/api/host-backups/jobs/${encodeURIComponent(jobId!)}/log` : null, + fetcher, + { refreshInterval: 1500 }, + ) + const liveLogRef = useRef(null) + useEffect(() => { + if (running && liveLogRef.current) { + liveLogRef.current.scrollTop = liveLogRef.current.scrollHeight + } + }, [liveLog, running]) + + // Auto-detect in-progress runs (RUN_AT set but RESULT not yet + // persisted) and enter streaming mode on open. + useEffect(() => { + if (!open || running || !detail) return + if (detail.last_run_at && !detail.last_result) { + setRunBaselineLogPath(detail.last_log_path ?? null) + setRunning(true) + } + }, [open, running, detail]) + + // Exit streaming when both RUN_AT and RESULT are set. + useEffect(() => { + if (!running || !detail) return + if (detail.last_run_at && detail.last_result) { + setRunning(false) + setRunBaselineLogPath(null) + onChanged() + } + }, [detail, running, onChanged]) + + useEffect(() => { + if (!open) { + setRunning(false) + setRunBaselineLogPath(null) + setActionError(null) + } + }, [open]) + + const resultBadge = running + ? { label: "running", cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" } + : detail?.last_result === "ok" + ? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-300" } + : detail?.last_result + ? { label: detail.last_result, cls: "bg-red-500/10 border-red-500/40 text-red-300" } + : null + const lastRunWhen = formatRunAt(detail?.last_run_at ?? null) + const destAccent = + detail?.method === "pbs" ? "text-purple-400" + : detail?.method === "borg" ? "text-fuchsia-400" + : "text-blue-400" + + return ( + <> + { if (!v) onClose() }}> + + + + + {detail?.id ?? jobId} + {detail && ( + <> + + {detail.method} + + + manual / one-shot + + + )} + + + One-shot backup — captured at the time of the trigger. Cannot be re-run or edited. + + + + {actionError && ( +
+ {actionError} +
+ )} + + {isLoading || !detail ? ( +
+ + Loading job… +
+ ) : ( + +
+ {/* Last run + live log */} +
+

+ Last run +

+
+ {resultBadge ? ( + + {running && } + {resultBadge.label} + + ) : ( + never run + )} + {lastRunWhen && {lastRunWhen}} +
+ {running ? ( + (() => { + const sameOldLog = + !!liveLog?.log_path && + !!runBaselineLogPath && + liveLog.log_path === runBaselineLogPath + const showContent = liveLog?.content && !sameOldLog + return ( +
+
+{showContent ? liveLog!.content : "Waiting for runner to start…"}
+                          
+
+ + + {showContent ? `live · ${formatBytes(liveLog?.size ?? 0)}` : "starting…"} + + {showContent && liveLog?.log_path && ( + + {liveLog.log_path} + + )} +
+
+ ) + })() + ) : detail.last_log_tail && detail.last_log_tail.length > 0 ? ( +
+
+{detail.last_log_tail.join("\n")}
+                      
+
+ tail · {detail.last_log_size > 0 ? formatBytes(detail.last_log_size) : "—"} +
+
+ ) : null} +
+ + {/* Destination — only the fields that apply to the + picked backend. No Schedule/Retention/Profile here + because manual jobs don't carry those. */} +
+

+ Destination +

+ {detail.method === "pbs" && ( + <> + {detail.pbs_repository && ( + } label="repository" value={detail.pbs_repository} mono /> + )} + {detail.pbs_backup_id && ( + } label="backup-id" value={detail.pbs_backup_id} mono /> + )} + + )} + {detail.method === "borg" && ( + <> + {detail.borg_repo && ( + } label="repo" value={detail.borg_repo} mono /> + )} + } label="encryption" value={detail.borg_encrypt_mode} mono /> + + )} + {detail.method === "local" && detail.local_dest_dir && ( + } label="dir" value={detail.local_dest_dir} mono /> + )} +
+
+
+ )} + +
+
+ + ) +} + +// ────────────────────────────────────────────────────────────── +// PbsKeyfileRecoveryDialog +// ────────────────────────────────────────────────────────────── +// Drops the missing PBS keyfile back onto disk by pulling the escrow +// blob from a `proxmenux-keyrecovery-` snapshot and decrypting +// it with the operator's recovery passphrase. Mirrors the shell's +// hb_pbs_try_keyfile_recovery flow. Triggered from the banner in +// DestinationsSection when the local keyfile is missing but at least +// one escrow snapshot is present on a configured PBS. +// ────────────────────────────────────────────────────────────── +function PbsKeyfileRecoveryDialog({ + open, + snapshots, + onClose, + onRecovered, +}: { + open: boolean + snapshots: Array<{ repo_name: string; repo_repository: string; backup_id: string; source_host: string; backup_time: number; snapshot: string }> + onClose: () => void + onRecovered: () => void +}) { + const [selectedKey, setSelectedKey] = useState("") + const [passphrase, setPassphrase] = useState("") + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!open) { + setSelectedKey("") + setPassphrase("") + setBusy(false) + setError(null) + return + } + if (snapshots.length >= 1 && !selectedKey) { + setSelectedKey(`${snapshots[0].repo_name}::${snapshots[0].snapshot}`) + } + }, [open, snapshots]) + + const selected = snapshots.find((s) => `${s.repo_name}::${s.snapshot}` === selectedKey) || null + + async function handleRecover() { + if (!selected || !passphrase) return + setBusy(true) + setError(null) + try { + await fetchApi("/api/host-backups/pbs-recovery/restore", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repo_name: selected.repo_name, + snapshot: selected.snapshot, + passphrase, + }), + }) + onRecovered() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + return ( + { if (!v && !busy) onClose() }}> + + + + + Recover PBS keyfile + + + The encrypted keyfile blob is downloaded from PBS and decrypted with your recovery passphrase. The resulting key is written to /usr/local/share/proxmenux/pbs-key.conf. + + + + {snapshots.length > 1 && ( +
+ + +

+ Pick the source host whose passphrase you remember — each install uploads its own escrow. +

+
+ )} + + {selected && ( +
+
+ Source host: + {selected.source_host} +
+
+ PBS: + {selected.repo_repository} +
+
+ )} + +
+ + setPassphrase(e.target.value)} + placeholder="The passphrase set when the keyfile was created" + className="font-mono mt-1" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && passphrase && !busy) handleRecover() + }} + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ) +} diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 5451146a..bfd38d76 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -12503,6 +12503,14 @@ def _backup_job_summary(env_file: str) -> dict: 'manual': job.get('MANUAL_RUN') == '1', 'last_status': last_status, 'next_run': next_run, + # `encrypted` is a per-job flag — for PBS it's "has a keyfile + # assigned", for Borg "the destination's encryption is on". + # Used by the UI to render the lock badge consistently. + 'encrypted': ( + bool(job.get('PBS_KEYFILE')) if backend == 'pbs' + else (job.get('BORG_ENCRYPT_MODE') or 'none') != 'none' if backend == 'borg' + else False + ), } @@ -12862,13 +12870,22 @@ def _list_pbs_destinations() -> list: seen.add((name, repo)) except OSError: pass + # Annotate each repo with the jobs that currently depend on it so + # the UI can warn before deleting a destination that's in use. + for r in repos: + r['jobs_using'] = _jobs_using_pbs(r['repository']) return repos def _list_borg_destinations() -> list: - """borg-targets.txt has 3 pipe-separated fields per line: - name|repo|ssh_key — split on 2 pipes so the SSH key never ends up - appended to the repository path.""" + """borg-targets.txt has 4 pipe-separated fields per line: + name|repo|ssh_key|encrypt_mode + + Legacy lines from the shell installer only carry the first three + fields — those targets default to `repokey` (what the shell wizard + has always written). `has_passphrase` reports whether a sibling + `borg-pass-.txt` exists, so the UI can skip the passphrase + prompt when the credential is already saved server-side.""" targets: list = [] cfg = f'{_BACKUP_STATE_DIR}/borg-targets.txt' if not os.path.exists(cfg): @@ -12879,16 +12896,154 @@ def _list_borg_destinations() -> list: line = raw.strip() if not line or '|' not in line: continue - parts = line.split('|', 2) + parts = line.split('|', 3) name = parts[0] repo = parts[1] if len(parts) > 1 else '' ssh_key = parts[2] if len(parts) > 2 else '' - targets.append({'name': name, 'repository': repo, 'ssh_key': ssh_key}) + encrypt_mode = (parts[3] if len(parts) > 3 else '').strip() or 'repokey' + pass_file = f'{_BACKUP_STATE_DIR}/borg-pass-{name}.txt' + has_passphrase = os.path.isfile(pass_file) + targets.append({ + 'name': name, + 'repository': repo, + 'ssh_key': ssh_key, + 'ssh_key_path': ssh_key, + 'encrypt_mode': encrypt_mode, + 'has_passphrase': has_passphrase, + 'jobs_using': _jobs_using_borg(repo), + }) except OSError: pass return targets +def _resolve_borg_passphrase(name: str) -> str: + """Return the passphrase saved for borg destination `name`, or ''.""" + if not name: + return '' + pf = f'{_BACKUP_STATE_DIR}/borg-pass-{name}.txt' + if not os.path.isfile(pf): + return '' + try: + with open(pf) as f: + return f.read().strip() + except OSError: + return '' + + +def _delete_job_internal(job_id: str) -> None: + """Tear down a backup job — used both by the individual DELETE + endpoint and by the cascade when removing a destination. Drops + .env / .paths / systemd unit + timer (with disable + reload), + plus all of the job's logs and status sidecar. Idempotent: + missing files are silently skipped.""" + if not _JOB_ID_RE.match(job_id): + return + env_file = f'{_BACKUP_JOBS_DIR}/{job_id}.env' + if not os.path.exists(env_file): + return + try: + job = _parse_job_env(env_file) + except OSError: + job = {} + is_attached = bool(job.get('PVE_STORAGE')) + paths_file = f'{_BACKUP_JOBS_DIR}/{job_id}.paths' + service_file = f'/etc/systemd/system/proxmenux-backup-{job_id}.service' + timer_file = f'/etc/systemd/system/proxmenux-backup-{job_id}.timer' + if not is_attached: + try: + subprocess.run( + ['systemctl', '--now', 'disable', f'proxmenux-backup-{job_id}.timer'], + capture_output=True, timeout=10, + ) + except (subprocess.TimeoutExpired, OSError): + pass + for f in (env_file, paths_file, service_file, timer_file): + try: + if os.path.exists(f): + os.remove(f) + except OSError: + pass + if not is_attached: + try: + subprocess.run(['systemctl', 'daemon-reload'], capture_output=True, timeout=10) + except (subprocess.TimeoutExpired, OSError): + pass + import glob as _glob + for log_f in _glob.glob(f'{_BACKUP_LOG_DIR}/{job_id}-*.log') + \ + _glob.glob(f'{_BACKUP_LOG_DIR}/{job_id}-last.status'): + try: + os.remove(log_f) + except OSError: + pass + + +def _jobs_using_pbs(repo: str) -> list: + """Return the list of job_ids whose .env points at this PBS + repository. Used by the DELETE-destination confirm flow so the + operator sees what gets cascade-removed.""" + if not repo or not os.path.isdir(_BACKUP_JOBS_DIR): + return [] + out: list = [] + for fname in os.listdir(_BACKUP_JOBS_DIR): + if not fname.endswith('.env'): + continue + try: + env = _parse_job_env(f'{_BACKUP_JOBS_DIR}/{fname}') + except OSError: + continue + if env.get('BACKEND') == 'pbs' and env.get('PBS_REPOSITORY') == repo: + out.append(fname[:-4]) + return out + + +def _jobs_using_borg(repo: str) -> list: + """Same as _jobs_using_pbs but for Borg destinations.""" + if not repo or not os.path.isdir(_BACKUP_JOBS_DIR): + return [] + out: list = [] + for fname in os.listdir(_BACKUP_JOBS_DIR): + if not fname.endswith('.env'): + continue + try: + env = _parse_job_env(f'{_BACKUP_JOBS_DIR}/{fname}') + except OSError: + continue + if env.get('BACKEND') == 'borg' and env.get('BORG_REPO') == repo: + out.append(fname[:-4]) + return out + + +def _jobs_using_local(path: str) -> list: + """Jobs whose LOCAL_DEST_DIR is this local destination path.""" + if not path or not os.path.isdir(_BACKUP_JOBS_DIR): + return [] + out: list = [] + target = path.rstrip('/') + for fname in os.listdir(_BACKUP_JOBS_DIR): + if not fname.endswith('.env'): + continue + try: + env = _parse_job_env(f'{_BACKUP_JOBS_DIR}/{fname}') + except OSError: + continue + if env.get('BACKEND') == 'local' and (env.get('LOCAL_DEST_DIR') or '').rstrip('/') == target: + out.append(fname[:-4]) + return out + + +def _resolve_borg_destination_for_repo(repo: str) -> dict | None: + """Find the borg destination dict (from _list_borg_destinations) + whose `repository` matches. Used to inherit passphrase + encryption + from the destination when a job/manual-run doesn't provide them.""" + if not repo: + return None + for d in _list_borg_destinations(): + if d.get('repository') == repo: + return d + return None + + _LOCAL_DEFAULT_PATH = '/var/lib/vz/dump' @@ -12920,11 +13075,13 @@ def _read_local_target_paths() -> list: def _list_local_targets() -> list: """One entry per local destination the operator can target. The /var/lib/vz/dump default is always present and not removable; any - custom path stacked by the operator gets source=custom + removable.""" + custom path stacked by the operator gets source=custom + removable. + Each entry carries `jobs_using` so the UI can warn before delete.""" entries: list = [{ 'path': _LOCAL_DEFAULT_PATH, 'source': 'default', 'removable': False, + 'jobs_using': _jobs_using_local(_LOCAL_DEFAULT_PATH), }] for p in _read_local_target_paths(): if p == _LOCAL_DEFAULT_PATH: @@ -12934,6 +13091,7 @@ def _list_local_targets() -> list: 'path': p, 'source': 'custom', 'removable': True, + 'jobs_using': _jobs_using_local(p), }) return entries @@ -13047,6 +13205,272 @@ def _capacity_borg_ssh(host: str, user: str, remote_path: str, key_path: str = ' return {'total': total, 'used': used, 'available': avail, 'remote': True} +# Shared PBS encryption keyfile — mirrors the shell flow's single +# `$HB_STATE_DIR/pbs-key.conf`. One host-wide keyfile reused across +# every encrypted PBS backup. chmod 0600 is the protection (no KDF +# passphrase, matching `proxmox-backup-client key create --kdf none`). +_PBS_KEYFILE_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.conf' +# Optional escrow: an openssl-encrypted copy of the keyfile that the +# runner uploads to every PBS backup. Lets the operator recover the +# keyfile on a fresh host using just the passphrase. Mirrors the +# 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' + + +def _pbs_keyfile_get_or_create(create_if_missing: bool = True) -> str: + """Return the path to the shared PBS keyfile, generating it on + demand if requested. Returns "" when no keyfile exists and the + caller didn't ask to create one.""" + if os.path.isfile(_PBS_KEYFILE_PATH): + return _PBS_KEYFILE_PATH + if not create_if_missing: + return '' + try: + os.makedirs(_BACKUP_STATE_DIR, exist_ok=True) + r = subprocess.run( + ['proxmox-backup-client', 'key', 'create', '--kdf', 'none', _PBS_KEYFILE_PATH], + capture_output=True, text=True, timeout=15, + ) + if r.returncode != 0 or not os.path.isfile(_PBS_KEYFILE_PATH): + return '' + os.chmod(_PBS_KEYFILE_PATH, 0o600) + return _PBS_KEYFILE_PATH + except (OSError, subprocess.TimeoutExpired): + return '' + + +def _pbs_recovery_encrypt(passphrase: str, in_path: str, out_path: str) -> bool: + """openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt < passphrase. + Mirrors hb_pbs_encrypt_recovery in the shell so a blob produced + here can be decrypted by the shell's restore flow, and vice versa.""" + if not os.path.isfile(in_path) or not passphrase: + return False + try: + r = subprocess.run( + ['openssl', 'enc', '-aes-256-cbc', '-pbkdf2', '-iter', '600000', + '-salt', '-in', in_path, '-out', out_path, '-pass', 'stdin'], + input=passphrase, text=True, + capture_output=True, timeout=30, + ) + except (subprocess.TimeoutExpired, OSError): + return False + if r.returncode != 0 or not os.path.isfile(out_path): + return False + try: + os.chmod(out_path, 0o600) + except OSError: + pass + return True + + +def _pbs_recovery_decrypt(passphrase: str, in_path: str, out_path: str) -> bool: + """Inverse of _pbs_recovery_encrypt. Used by the restore flow when + the operator has lost the local keyfile and wants to pull it back + from a PBS escrow snapshot.""" + if not os.path.isfile(in_path) or not passphrase: + return False + try: + r = subprocess.run( + ['openssl', 'enc', '-d', '-aes-256-cbc', '-pbkdf2', '-iter', '600000', + '-in', in_path, '-out', out_path, '-pass', 'stdin'], + input=passphrase, text=True, + capture_output=True, timeout=30, + ) + except (subprocess.TimeoutExpired, OSError): + return False + if r.returncode != 0 or not os.path.isfile(out_path): + return False + try: + os.chmod(out_path, 0o600) + except OSError: + pass + return True + + +@app.route('/api/host-backups/pbs-recovery/status', methods=['GET']) +@require_auth +def api_pbs_recovery_status(): + """Whether the host has a PBS keyfile + whether an escrow blob is + saved alongside it. Used by the wizard to decide whether to show + the 'set recovery passphrase' input.""" + return jsonify({ + 'has_keyfile': os.path.isfile(_PBS_KEYFILE_PATH), + 'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH), + 'keyfile_path': _PBS_KEYFILE_PATH, + 'recovery_path': _PBS_RECOVERY_ENC_PATH, + }) + + +@app.route('/api/host-backups/pbs-recovery/setup', methods=['POST']) +@require_auth +def api_pbs_recovery_setup(): + """Create the escrow blob from the host keyfile, encrypted with + the operator's recovery passphrase. Body: {passphrase}. Idempotent + — re-running it with a new passphrase replaces the escrow. + + The blob is uploaded to PBS by the runner after every successful + encrypted backup (see _sb_run_pbs / hb_pbs_upload_recovery_blob), + so the operator can rebuild the keyfile from any host with just + the passphrase.""" + payload = request.get_json(silent=True) or {} + passphrase = payload.get('passphrase') or '' + if not passphrase: + 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'): + return jsonify({'error': 'openssl is not installed; cannot create recovery blob'}), 500 + if not _pbs_recovery_encrypt(passphrase, _PBS_KEYFILE_PATH, _PBS_RECOVERY_ENC_PATH): + return jsonify({'error': 'openssl encryption failed'}), 500 + return jsonify({'status': 'ok', 'recovery_path': _PBS_RECOVERY_ENC_PATH}) + + +@app.route('/api/host-backups/pbs-recovery/available', methods=['GET']) +@require_auth +def api_pbs_recovery_available(): + """List `host/proxmenux-keyrecovery-*` snapshots across every + configured PBS repository. The restore flow uses this when the + local keyfile is missing — the operator picks a snapshot, types + the recovery passphrase, and the keyfile is rebuilt from the + decrypted blob. Returns the freshest snapshot per backup-id + + repo so multi-host environments don't bury each other.""" + out: list = [] + errors: list = [] + for repo in _list_pbs_destinations(): + pwd = _resolve_pbs_password(repo['name']) + if not pwd: + errors.append({'repo_name': repo['name'], 'error': 'no password available'}) + continue + env = dict(os.environ) + env['PBS_PASSWORD'] = pwd + fp = _resolve_pbs_fingerprint(repo['name']) + if fp: + env['PBS_FINGERPRINT'] = fp + try: + r = subprocess.run( + ['proxmox-backup-client', 'snapshot', 'list', + '--repository', repo['repository'], '--output-format', 'json'], + capture_output=True, text=True, timeout=30, env=env, + ) + except (subprocess.TimeoutExpired, OSError) as e: + errors.append({'repo_name': repo['name'], 'error': str(e)}) + continue + if r.returncode != 0: + errors.append({'repo_name': repo['name'], 'error': (r.stderr.strip() or r.stdout.strip())[:200]}) + continue + try: + items = json.loads(r.stdout) + except (json.JSONDecodeError, ValueError): + continue + # Group by backup-id (per source host) and keep the newest. + latest: dict = {} + for it in items: + if it.get('backup-type') != 'host': + continue + bid = it.get('backup-id') or '' + if not bid.startswith('proxmenux-keyrecovery-'): + continue + t = it.get('backup-time', 0) + if bid not in latest or t > latest[bid]['backup_time']: + latest[bid] = { + 'repo_name': repo['name'], + 'repo_repository': repo['repository'], + 'backup_id': bid, + 'source_host': bid[len('proxmenux-keyrecovery-'):], + 'backup_time': t, + 'snapshot': f"host/{bid}/{t}", + } + out.extend(latest.values()) + out.sort(key=lambda x: x['backup_time'], reverse=True) + return jsonify({'snapshots': out, 'errors': errors}) + + +@app.route('/api/host-backups/pbs-recovery/restore', methods=['POST']) +@require_auth +def api_pbs_recovery_restore(): + """Download a keyrecovery blob, decrypt with the operator's + passphrase, write the resulting key to /usr/local/share/proxmenux/ + pbs-key.conf (chmod 0600). Body: {repo_name, snapshot, passphrase}. + Symmetric with the shell's hb_pbs_try_keyfile_recovery.""" + payload = request.get_json(silent=True) or {} + repo_name = (payload.get('repo_name') or '').strip() + snapshot = (payload.get('snapshot') or '').strip() + passphrase = payload.get('passphrase') or '' + if not repo_name or not snapshot or not passphrase: + return jsonify({'error': 'repo_name, snapshot and passphrase are required'}), 400 + if not shutil.which('openssl'): + return jsonify({'error': 'openssl is not installed'}), 500 + # Resolve the PBS repository entry + repo = None + for r in _list_pbs_destinations(): + if r.get('name') == repo_name: + repo = r + break + if not repo: + return jsonify({'error': f'PBS repository "{repo_name}" not found'}), 404 + pwd = _resolve_pbs_password(repo_name) + if not pwd: + return jsonify({'error': f'no password available for PBS "{repo_name}"'}), 400 + env = dict(os.environ) + env['PBS_PASSWORD'] = pwd + fp = _resolve_pbs_fingerprint(repo_name) + if fp: + env['PBS_FINGERPRINT'] = fp + # Download the blob to a temp file (NOT --keyfile; the blob is + # openssl-encrypted, not chunk-encrypted with PBS keyfile). + import tempfile + tmp_dir = tempfile.mkdtemp(prefix='pmnx_keyrec_') + blob_path = os.path.join(tmp_dir, 'keyrecovery.enc') + try: + r = subprocess.run( + ['proxmox-backup-client', 'restore', snapshot, + 'keyrecovery.conf', blob_path, + '--repository', repo['repository']], + capture_output=True, text=True, timeout=60, env=env, + ) + if r.returncode != 0: + return jsonify({'error': f'PBS restore failed: {(r.stderr.strip() or r.stdout.strip())[:200]}'}), 500 + if not os.path.isfile(blob_path): + return jsonify({'error': 'PBS restore did not produce a blob'}), 500 + # Decrypt to a temp file first; only rename onto the real + # keyfile path once decryption succeeds, so a failed attempt + # never overwrites a working keyfile. + tmp_key = os.path.join(tmp_dir, 'pbs-key.conf') + if not _pbs_recovery_decrypt(passphrase, blob_path, tmp_key): + return jsonify({'error': 'decryption failed — wrong passphrase, or the blob is corrupt'}), 400 + try: + os.makedirs(_BACKUP_STATE_DIR, exist_ok=True) + shutil.move(tmp_key, _PBS_KEYFILE_PATH) + os.chmod(_PBS_KEYFILE_PATH, 0o600) + except OSError as e: + return jsonify({'error': f'failed to persist keyfile: {e}'}), 500 + # Also re-create the local recovery blob so the operator + # doesn't immediately end up in the same state if they wipe + # this host again — the existing passphrase is reused. + _pbs_recovery_encrypt(passphrase, _PBS_KEYFILE_PATH, _PBS_RECOVERY_ENC_PATH) + return jsonify({'status': 'ok', 'keyfile_path': _PBS_KEYFILE_PATH}) + finally: + try: + shutil.rmtree(tmp_dir, ignore_errors=True) + except OSError: + pass + + +@app.route('/api/host-backups/pbs-recovery/setup', methods=['DELETE']) +@require_auth +def api_pbs_recovery_teardown(): + """Drop the local escrow blob. Doesn't touch the copies already + uploaded to PBS — those stay until the operator prunes them on + the server side.""" + if os.path.isfile(_PBS_RECOVERY_ENC_PATH): + try: + os.remove(_PBS_RECOVERY_ENC_PATH) + except OSError as e: + return jsonify({'error': str(e)}), 500 + return jsonify({'status': 'ok'}) + + def _resolve_pbs_password(repo_name: str) -> str: """Return the PBS password for `repo_name`, looking in both the proxmenux manual sidecar (`pbs-pass-.txt`) and Proxmox's @@ -13471,11 +13895,21 @@ def api_host_backups_job_create(): import socket pbs_backup_id = f'hostcfg-{socket.gethostname()}' pbs_fingerprint = (payload.get('pbs_fingerprint') or '').strip() + # Client-side encryption: when the operator toggled "encrypt" + # we generate the host-wide keyfile (if missing) and pin it + # in the .env. The runner sees PBS_KEYFILE and adds --keyfile + # to the backup invocation. + pbs_encrypt = bool(payload.get('pbs_encrypt')) lines.append(f'PBS_REPOSITORY={pbs_repo}') lines.append(f'PBS_PASSWORD={pbs_pass}') lines.append(f'PBS_BACKUP_ID={pbs_backup_id}') if pbs_fingerprint: lines.append(f'PBS_FINGERPRINT={pbs_fingerprint}') + if pbs_encrypt: + kf = _pbs_keyfile_get_or_create(True) + if not kf: + return jsonify({'error': 'failed to create PBS encryption keyfile (is proxmox-backup-client installed?)'}), 500 + lines.append(f'PBS_KEYFILE={kf}') elif backend == 'local': explicit = (payload.get('local_dest_dir') or '').strip() if explicit: @@ -13510,15 +13944,27 @@ def api_host_backups_job_create(): lines.append(f'LOCAL_ARCHIVE_EXT={archive_ext}') elif backend == 'borg': # Borg is timer-only — the attached check earlier rejected this combo. + # Passphrase + encrypt_mode now live on the destination: when the + # operator picks a saved borg destination, we inherit both from it + # instead of asking again per-job. The body can still override + # (typed-in passphrase / explicit encrypt_mode) for the case where + # the destination doesn't have those saved yet. borg_repo = (payload.get('borg_repo') or '').strip() - borg_pass = payload.get('borg_passphrase') or '' - borg_encrypt = (payload.get('borg_encrypt_mode') or 'none').strip().lower() if not borg_repo: return jsonify({'error': 'borg_repo is required'}), 400 + dest = _resolve_borg_destination_for_repo(borg_repo) + borg_pass = payload.get('borg_passphrase') or '' + if not borg_pass and dest: + borg_pass = _resolve_borg_passphrase(dest.get('name') or '') + borg_encrypt_raw = payload.get('borg_encrypt_mode') + if borg_encrypt_raw is None or borg_encrypt_raw == '': + borg_encrypt = (dest.get('encrypt_mode') if dest else None) or 'none' + else: + borg_encrypt = borg_encrypt_raw.strip().lower() if borg_encrypt not in ('none', 'repokey', 'keyfile', 'authenticated'): return jsonify({'error': 'borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated'}), 400 if borg_encrypt != 'none' and not borg_pass: - return jsonify({'error': 'borg_passphrase is required when encryption is enabled'}), 400 + return jsonify({'error': 'borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)'}), 400 lines.append(f'BORG_REPO={borg_repo}') lines.append(f'BORG_PASSPHRASE={borg_pass}') lines.append(f'BORG_ENCRYPT_MODE={borg_encrypt}') @@ -13650,6 +14096,7 @@ def _backup_job_detail(env_file: str) -> dict: 'pbs_backup_id': raw.get('PBS_BACKUP_ID') or None, 'pbs_fingerprint': raw.get('PBS_FINGERPRINT') or None, 'has_pbs_password': bool(raw.get('PBS_PASSWORD')), + 'pbs_encrypt': bool(raw.get('PBS_KEYFILE')), 'local_dest_dir': raw.get('LOCAL_DEST_DIR') or None, 'local_archive_ext': raw.get('LOCAL_ARCHIVE_EXT') or None, 'borg_repo': raw.get('BORG_REPO') or None, @@ -13903,9 +14350,25 @@ def api_host_backups_job_update(job_id): import socket pbs_backup_id = f'hostcfg-{socket.gethostname()}' pbs_fingerprint = (payload.get('pbs_fingerprint') or existing.get('PBS_FINGERPRINT') or '').strip() + # Encryption: payload `pbs_encrypt` is the new state. When the + # operator toggles it ON we persist the keyfile path; OFF + # simply omits PBS_KEYFILE from the rewritten .env. We never + # delete the host-wide keyfile here — other jobs may depend on + # it. The `pbs_encrypt` key is required to be passed + # explicitly on edit so an absent field is treated as "keep + # the current state". + if 'pbs_encrypt' in payload: + pbs_encrypt = bool(payload.get('pbs_encrypt')) + else: + pbs_encrypt = bool(existing.get('PBS_KEYFILE')) lines.append(f'PBS_REPOSITORY={pbs_repo}') lines.append(f'PBS_PASSWORD={pbs_pass}') lines.append(f'PBS_BACKUP_ID={pbs_backup_id}') + if pbs_encrypt: + kf = _pbs_keyfile_get_or_create(True) + if not kf: + return jsonify({'error': 'failed to create PBS encryption keyfile'}), 500 + lines.append(f'PBS_KEYFILE={kf}') if pbs_fingerprint: lines.append(f'PBS_FINGERPRINT={pbs_fingerprint}') elif backend == 'local': @@ -13921,7 +14384,12 @@ def api_host_backups_job_update(job_id): borg_repo = (payload.get('borg_repo') or existing.get('BORG_REPO') or '').strip() if not borg_repo: return jsonify({'error': 'borg_repo is required'}), 400 - borg_encrypt = (payload.get('borg_encrypt_mode') or existing.get('BORG_ENCRYPT_MODE') or 'none').strip().lower() + dest = _resolve_borg_destination_for_repo(borg_repo) + # Encryption mode: payload > existing .env > destination > "none". + borg_encrypt = (payload.get('borg_encrypt_mode') + or existing.get('BORG_ENCRYPT_MODE') + or (dest.get('encrypt_mode') if dest else None) + or 'none').strip().lower() if borg_encrypt not in ('none', 'repokey', 'keyfile', 'authenticated'): return jsonify({'error': 'borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated'}), 400 new_pass = payload.get('borg_passphrase') @@ -13929,8 +14397,10 @@ def api_host_backups_job_update(job_id): borg_pass = existing.get('BORG_PASSPHRASE', '') else: borg_pass = new_pass + if not borg_pass and dest: + borg_pass = _resolve_borg_passphrase(dest.get('name') or '') if borg_encrypt != 'none' and not borg_pass: - return jsonify({'error': 'borg_passphrase is required when encryption is enabled'}), 400 + return jsonify({'error': 'borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)'}), 400 lines.append(f'BORG_REPO={borg_repo}') lines.append(f'BORG_PASSPHRASE={borg_pass}') lines.append(f'BORG_ENCRYPT_MODE={borg_encrypt}') @@ -14069,11 +14539,17 @@ def api_host_backups_manual_run(): import socket pbs_backup_id = f'hostcfg-{socket.gethostname()}-manual' pbs_fingerprint = (payload.get('pbs_fingerprint') or '').strip() + pbs_encrypt = bool(payload.get('pbs_encrypt')) lines.append(f'PBS_REPOSITORY={pbs_repo}') lines.append(f'PBS_PASSWORD={pbs_pass}') lines.append(f'PBS_BACKUP_ID={pbs_backup_id}') if pbs_fingerprint: lines.append(f'PBS_FINGERPRINT={pbs_fingerprint}') + if pbs_encrypt: + kf = _pbs_keyfile_get_or_create(True) + if not kf: + return jsonify({'error': 'failed to create PBS encryption keyfile'}), 500 + lines.append(f'PBS_KEYFILE={kf}') elif backend == 'local': explicit = (payload.get('local_dest_dir') or '').strip() dest_dir = explicit.rstrip('/') if explicit else _get_local_target()['effective'] @@ -14084,12 +14560,19 @@ def api_host_backups_manual_run(): borg_repo = (payload.get('borg_repo') or '').strip() if not borg_repo: return jsonify({'error': 'borg_repo is required'}), 400 - borg_encrypt = (payload.get('borg_encrypt_mode') or 'none').strip().lower() + dest = _resolve_borg_destination_for_repo(borg_repo) + borg_encrypt_raw = payload.get('borg_encrypt_mode') + if borg_encrypt_raw is None or borg_encrypt_raw == '': + borg_encrypt = (dest.get('encrypt_mode') if dest else None) or 'none' + else: + borg_encrypt = borg_encrypt_raw.strip().lower() if borg_encrypt not in ('none', 'repokey', 'keyfile', 'authenticated'): return jsonify({'error': 'borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated'}), 400 borg_pass = payload.get('borg_passphrase') or '' + if not borg_pass and dest: + borg_pass = _resolve_borg_passphrase(dest.get('name') or '') if borg_encrypt != 'none' and not borg_pass: - return jsonify({'error': 'borg_passphrase is required when encryption is enabled'}), 400 + return jsonify({'error': 'borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)'}), 400 lines.append(f'BORG_REPO={borg_repo}') lines.append(f'BORG_PASSPHRASE={borg_pass}') lines.append(f'BORG_ENCRYPT_MODE={borg_encrypt}') @@ -14207,6 +14690,12 @@ def api_host_backups_dest_local_clear(): existing = _read_local_target_paths() if target not in existing: return jsonify({'error': 'path not found'}), 404 + force = (request.args.get('force') or '').lower() in ('1', 'true', 'yes') + cascaded: list = [] + if force: + for jid in _jobs_using_local(target): + _delete_job_internal(jid) + cascaded.append(jid) remaining = [p for p in existing if p != target] try: if remaining: @@ -14218,7 +14707,7 @@ def api_host_backups_dest_local_clear(): os.remove(cfg) except OSError as e: return jsonify({'error': str(e)}), 500 - return jsonify({'status': 'ok', 'removed': target}) + return jsonify({'status': 'ok', 'removed': target, 'jobs_removed': cascaded}) @app.route('/api/host-backups/destinations/borg', methods=['POST']) @@ -14254,6 +14743,32 @@ def api_host_backups_dest_borg_add(): else: return jsonify({'error': 'mode must be "local" or "ssh"'}), 400 + # Encryption + passphrase live on the DESTINATION, not on each job. + # `encrypt_mode` is one of: none, repokey, keyfile, authenticated + # (the UI exposes only `none`/`repokey`; the others stay supported + # so existing shell-created configs aren't downgraded). The body + # may carry `passphrase`; if set and non-empty we persist it next + # to the config in `borg-pass-.txt` (chmod 0600). + encrypt_mode = (payload.get('encrypt_mode') or 'repokey').strip().lower() + if encrypt_mode not in ('none', 'repokey', 'keyfile', 'authenticated'): + return jsonify({'error': 'encrypt_mode must be one of: none, repokey, keyfile, authenticated'}), 400 + passphrase = payload.get('passphrase') or '' + # POST is upsert-by-name: in edit mode we keep the saved passphrase + # when the caller doesn't send a new one. Only required when there + # is no saved one AND encryption is enabled. + existing_pp = '' + pass_file_existing = f'{_BACKUP_STATE_DIR}/borg-pass-{name}.txt' + if os.path.isfile(pass_file_existing): + try: + with open(pass_file_existing) as f: + existing_pp = f.read().strip() + except OSError: + pass + if encrypt_mode != 'none' and not passphrase and not existing_pp: + return jsonify({'error': 'passphrase is required when encryption is enabled'}), 400 + if encrypt_mode != 'none' and not passphrase: + passphrase = existing_pp + cfg = f'{_BACKUP_STATE_DIR}/borg-targets.txt' try: os.makedirs(_BACKUP_STATE_DIR, exist_ok=True) @@ -14264,14 +14779,26 @@ def api_host_backups_dest_borg_add(): if ln.startswith(f'{name}|'): continue existing_lines.append(ln.rstrip('\n')) - existing_lines.append(f'{name}|{repo}|{ssh_key}') + existing_lines.append(f'{name}|{repo}|{ssh_key}|{encrypt_mode}') with open(cfg, 'w') as f: for ln in existing_lines: f.write(ln + '\n') os.chmod(cfg, 0o600) + # Persist or remove the passphrase sidecar, depending on mode. + pass_file = f'{_BACKUP_STATE_DIR}/borg-pass-{name}.txt' + if encrypt_mode == 'none': + if os.path.exists(pass_file): + try: + os.remove(pass_file) + except OSError: + pass + elif passphrase: + with open(pass_file, 'w') as f: + f.write(passphrase) + os.chmod(pass_file, 0o600) except OSError as e: return jsonify({'error': f'failed to write borg-targets.txt: {e}'}), 500 - return jsonify({'status': 'ok', 'name': name, 'repo': repo}) + return jsonify({'status': 'ok', 'name': name, 'repo': repo, 'encrypt_mode': encrypt_mode}) @app.route('/api/host-backups/destinations/borg/', methods=['DELETE']) @@ -14282,6 +14809,15 @@ def api_host_backups_dest_borg_remove(name): safe = _safe_dest_name(name) if not safe: return jsonify({'error': 'invalid name'}), 400 + force = (request.args.get('force') or '').lower() in ('1', 'true', 'yes') + cascaded: list = [] + if force: + for d in _list_borg_destinations(): + if d.get('name') == safe: + for jid in _jobs_using_borg(d.get('repository') or ''): + _delete_job_internal(jid) + cascaded.append(jid) + break cfg = f'{_BACKUP_STATE_DIR}/borg-targets.txt' if os.path.exists(cfg): try: @@ -14301,7 +14837,7 @@ def api_host_backups_dest_borg_remove(name): os.remove(f) except OSError: pass - return jsonify({'status': 'ok', 'name': safe}) + return jsonify({'status': 'ok', 'name': safe, 'jobs_removed': cascaded}) @app.route('/api/host-backups/destinations/pbs', methods=['POST']) @@ -14323,8 +14859,22 @@ def api_host_backups_dest_pbs_add(): fingerprint = (payload.get('fingerprint') or '').strip() if not server or not datastore: return jsonify({'error': 'server and datastore are required'}), 400 - if not password: + # POST is upsert-by-name: if `name` already exists and password + # is omitted, keep the saved one (the edit-destination flow uses + # this to update server/datastore/fingerprint without forcing + # the operator to re-type the password). + existing_pw = '' + pass_file = f'{_BACKUP_STATE_DIR}/pbs-pass-{name}.txt' + if os.path.isfile(pass_file): + try: + with open(pass_file) as f: + existing_pw = f.read() + except OSError: + pass + if not password and not existing_pw: return jsonify({'error': 'password is required'}), 400 + if not password: + password = existing_pw repo = f'{username}@{server}:{datastore}' cfg_line = f'{name}|{repo}' @@ -14371,10 +14921,26 @@ def api_host_backups_dest_pbs_add(): def api_host_backups_dest_pbs_remove(name): """Remove a manual PBS config by name. Auto-discovered PBS storages from /etc/pve/storage.cfg are NOT removable from here (PVE owns - those — the operator manages them under Datacenter → Storage).""" + those — the operator manages them under Datacenter → Storage). + + When `?force=true`, any jobs whose .env points at this PBS + repository are removed too (cascade). Backups on the PBS side + are NEVER touched — only the local job definitions go.""" safe = _safe_dest_name(name) if not safe: return jsonify({'error': 'invalid name'}), 400 + force = (request.args.get('force') or '').lower() in ('1', 'true', 'yes') + cascaded: list = [] + if force: + # Find the repo string for this name first, then look up the + # jobs that depend on it. We do this BEFORE deleting the + # config so the lookup still works. + for d in _list_pbs_destinations(): + if d.get('name') == safe: + for jid in _jobs_using_pbs(d.get('repository') or ''): + _delete_job_internal(jid) + cascaded.append(jid) + break cfg = f'{_BACKUP_STATE_DIR}/pbs-manual-configs.txt' if not os.path.exists(cfg): return jsonify({'error': 'no manual PBS configs file'}), 404 @@ -14396,7 +14962,7 @@ def api_host_backups_dest_pbs_remove(name): os.remove(f) except OSError: pass - return jsonify({'status': 'ok', 'name': safe}) + return jsonify({'status': 'ok', 'name': safe, 'jobs_removed': cascaded}) # ── Sprint D.2.c: SSH key generation for Borg remotes ────────────── @@ -14784,11 +15350,25 @@ def _list_pbs_snapshots_for_repo(repo: dict, timeout: int = 15) -> tuple[list, s out: list = [] for it in items: backup_type = it.get('backup-type', '') + # The host-backup tab lists ONLY backups taken with the + # ProxMenux host-backup flow (`backup-type: host`). PBS + # repositories often also carry vzdump-style VM/LXC snapshots + # (`vm` / `ct`) — those belong to the Virtual Machines / LXC + # views, not here. + if backup_type != 'host': + continue backup_id = it.get('backup-id', '') backup_time = it.get('backup-time', 0) # The canonical snapshot identifier the client accepts back — # exactly what we'd pass to `proxmox-backup-client restore`. snapshot = f"{backup_type}/{backup_id}/{backup_time}" + # A snapshot is "encrypted" when ANY of its files reports a + # crypt-mode other than `none`. Used to render the lock badge. + files = it.get('files', []) or [] + encrypted = any( + (f.get('crypt-mode') or 'none') != 'none' + for f in files if isinstance(f, dict) + ) out.append({ 'backend': 'pbs', 'repo_name': repo['name'], @@ -14800,8 +15380,9 @@ def _list_pbs_snapshots_for_repo(repo: dict, timeout: int = 15) -> tuple[list, s 'size_bytes': it.get('size', 0), 'owner': it.get('owner'), 'protected': it.get('protected', False), - 'files': it.get('files', []), + 'files': files, 'fingerprint': it.get('fingerprint'), + 'encrypted': encrypted, }) return out, None @@ -14925,6 +15506,11 @@ def _list_borg_archives_for_target(target: dict, timeout: int = 30) -> tuple[lis 'fingerprint': None, 'borg_id': arc.get('id'), 'borg_start_iso': start_iso, + # Encrypted iff the destination's repo mode is not "none". + # Borg archives inherit the encryption of the repo they + # live in (cannot mix per-archive), so the target's flag + # is authoritative. + 'encrypted': (target.get('encrypt_mode') or 'repokey') != 'none', }) return out, None diff --git a/scripts/backup_restore/run_scheduled_backup.sh b/scripts/backup_restore/run_scheduled_backup.sh index 8d2497ef..2161cf4c 100644 --- a/scripts/backup_restore/run_scheduled_backup.sh +++ b/scripts/backup_restore/run_scheduled_backup.sh @@ -99,32 +99,102 @@ _sb_run_local() { return 0 } +# Look up a borg destination's passphrase from the proxmenux state +# directory. The shell convention is `borg-pass-.txt` next to +# `borg-targets.txt`; we resolve the `` by scanning targets for +# the one whose repository matches the runtime BORG_REPO. +_sb_borg_resolve_password() { + local repo="$1" + local cfg="${HB_STATE_DIR:-/usr/local/share/proxmenux}/borg-targets.txt" + [[ -f "$cfg" && -n "$repo" ]] || return 1 + local found_name="" line name target_repo + while IFS='|' read -r name target_repo _; do + [[ -z "$name" || -z "$target_repo" ]] && continue + if [[ "$target_repo" == "$repo" ]]; then + found_name="$name" + break + fi + done < "$cfg" + [[ -z "$found_name" ]] && return 1 + local pf="${HB_STATE_DIR:-/usr/local/share/proxmenux}/borg-pass-${found_name}.txt" + [[ -r "$pf" ]] || return 1 + cat "$pf" +} + _sb_run_borg() { local stage_root="$1" local archive_name="$2" - local borg_bin repo passphrase + local borg_bin repo passphrase encrypt_mode - borg_bin=$(hb_ensure_borg) || return 1 + borg_bin=$(hb_ensure_borg) || { echo "borg binary not available"; return 1; } repo="${BORG_REPO:-}" passphrase="${BORG_PASSPHRASE:-}" - [[ -z "$repo" || -z "$passphrase" ]] && return 1 + encrypt_mode="${BORG_ENCRYPT_MODE:-none}" + if [[ -z "$repo" ]]; then + echo "BORG_REPO not configured" + return 1 + fi + # Empty passphrase + encrypted mode → look it up from the saved + # destination sidecar (the canonical place for borg credentials, + # same pattern as PBS). Jobs created against a destination that + # has its passphrase stored no longer need to duplicate it in the + # job .env. + if [[ -z "$passphrase" && "$encrypt_mode" != "none" ]]; then + passphrase=$(_sb_borg_resolve_password "$repo" 2>/dev/null || true) + fi + if [[ "$encrypt_mode" != "none" && -z "$passphrase" ]]; then + echo "BORG_PASSPHRASE is required when encryption is enabled (mode=$encrypt_mode)" + echo " → save the passphrase on the destination or set it in the job .env" + return 1 + fi # Re-export the credentials so child processes (borg, ssh) inherit # them. `source ` only assigns to the current shell — without # an explicit re-export, child `borg` calls drop back to ssh defaults - # and a remote repo silently auth-fails with no log trail (since the - # call is also `>/dev/null 2>&1`). + # and a remote repo silently auth-fails with no log trail. export BORG_PASSPHRASE="$passphrase" [[ -n "${BORG_RSH:-}" ]] && export BORG_RSH export BORG_RELOCATED_REPO_ACCESS_IS_OK=yes export BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK=yes - if ! hb_borg_init_if_needed "$borg_bin" "$repo" "${BORG_ENCRYPT_MODE:-none}" >/dev/null 2>&1; then + # Probe the repo first when working with a local path. Three cases + # the operator hits regularly: + # - path is a valid borg repo + creds match → `borg list` works + # - path is a valid borg repo + creds DON'T match → explicit + # "credentials don't match" error (a wrong passphrase saved on + # the destination, typically) + # - path exists, has files, but is NOT a borg repo → explicit + # "path is not empty" error so the operator points at a fresh + # subdirectory instead of hitting borg's confusing "There is + # already something at " message + if [[ "$repo" != ssh://* && -d "$repo" ]]; then + if ! "$borg_bin" list "$repo" /dev/null 2>&1; then + local repo_config="$repo/config" + if [[ -f "$repo_config" ]] && grep -qE '^\[repository\]' "$repo_config" 2>/dev/null; then + echo "Borg repository at ${repo} exists but the configured credentials don't match it." + echo " → the saved passphrase or encryption mode for this destination is wrong." + return 1 + fi + # Not a repo. If the directory isn't empty, init will fail with + # "There is already something at ". Detect and explain. + if find "$repo" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then + echo "Path ${repo} is not a borg repository AND is not empty." + echo " → Borg refuses to initialize a repo where there are other files." + echo " → Point the destination at a fresh empty subdirectory (e.g. ${repo}/borgbackup)." + return 1 + fi + fi + fi + + # Send stderr to stdout so the caller's `>>$log_file 2>&1` captures + # borg's diagnostics. The previous `>/dev/null 2>&1` silenced every + # error and made "Job finished with errors" impossible to debug. + if ! hb_borg_init_if_needed "$borg_bin" "$repo" "$encrypt_mode" 2>&1; then return 1 fi (cd "$stage_root" && "$borg_bin" create --stats \ - "${repo}::${archive_name}" rootfs metadata) >/dev/null 2>&1 || return 1 + "${repo}::${archive_name}" rootfs metadata) 2>&1 || return 1 "$borg_bin" prune -v --list "$repo" \ ${KEEP_LAST:+--keep-last "$KEEP_LAST"} \ @@ -133,7 +203,7 @@ _sb_run_borg() { ${KEEP_WEEKLY:+--keep-weekly "$KEEP_WEEKLY"} \ ${KEEP_MONTHLY:+--keep-monthly "$KEEP_MONTHLY"} \ ${KEEP_YEARLY:+--keep-yearly "$KEEP_YEARLY"} \ - >/dev/null 2>&1 || true + 2>&1 || true echo "BORG_ARCHIVE=${archive_name}" return 0 @@ -145,39 +215,56 @@ _sb_run_borg() { # fallback when the job .env carries an empty PBS_PASSWORD — happens # when the operator picks a PVE-managed PBS in the wizard (the .pw # file is the canonical credential location for those). +# +# Implemented in pure bash because the previous awk version relied on +# `match($0, regex, arr)` which is a gawk extension and silently no-ops +# on mawk / busybox awk — leaving the password unresolved and breaking +# every manual / scheduled PBS run against a PVE-managed storage. _sb_pbs_resolve_password() { local repo="$1" local cfg=/etc/pve/storage.cfg - [[ -f "$cfg" ]] || return 1 - awk -v target="$repo" ' - /^[a-z]+:[[:space:]]+/ { - if (prev_type == "pbs" && prev_name != "" && server != "" && datastore != "") { - u = (username != "" ? username : "root@pam") - built = u "@" server ":" datastore - if (built == target) { print prev_name; exit 0 } - } - match($0, /^([a-z]+):[[:space:]]+(.+)$/, m) - prev_type = m[1]; prev_name = m[2] - server=""; datastore=""; username="" - next - } - /^[[:space:]]+server[[:space:]]+/ { sub(/^[[:space:]]+server[[:space:]]+/, ""); server=$0; next } - /^[[:space:]]+datastore[[:space:]]+/ { sub(/^[[:space:]]+datastore[[:space:]]+/, ""); datastore=$0; next } - /^[[:space:]]+username[[:space:]]+/ { sub(/^[[:space:]]+username[[:space:]]+/, ""); username=$0; next } - END { - if (prev_type == "pbs" && prev_name != "" && server != "" && datastore != "") { - u = (username != "" ? username : "root@pam") - built = u "@" server ":" datastore - if (built == target) print prev_name - } - } - ' "$cfg" | head -n1 | { - read -r name || true - [[ -z "$name" ]] && return 1 - local pw_file="/etc/pve/priv/storage/${name}.pw" - [[ -r "$pw_file" ]] || return 1 - cat "$pw_file" + [[ -f "$cfg" && -n "$repo" ]] || return 1 + + local current_type="" current_name="" server="" datastore="" username="" + local found_name="" + + _try_match() { + [[ "$current_type" == "pbs" && -n "$current_name" && -n "$server" && -n "$datastore" ]] || return + local u="${username:-root@pam}" + local built="${u}@${server}:${datastore}" + if [[ "$built" == "$repo" ]]; then + found_name="$current_name" + fi } + + local line key val + while IFS= read -r line; do + if [[ "$line" =~ ^([a-z]+):[[:space:]]+(.+)$ ]]; then + _try_match + [[ -n "$found_name" ]] && break + current_type="${BASH_REMATCH[1]}" + current_name="${BASH_REMATCH[2]}" + server=""; datastore=""; username="" + continue + fi + if [[ "$line" =~ ^[[:space:]]+([a-zA-Z_]+)[[:space:]]+(.+)$ ]]; then + key="${BASH_REMATCH[1]}" + val="${BASH_REMATCH[2]}" + case "$key" in + server) server="$val" ;; + datastore) datastore="$val" ;; + username) username="$val" ;; + esac + fi + done < "$cfg" + # Final block may not have triggered the start-of-next-block check. + [[ -z "$found_name" ]] && _try_match + unset -f _try_match 2>/dev/null + + [[ -z "$found_name" ]] && return 1 + local pw_file="/etc/pve/priv/storage/${found_name}.pw" + [[ -r "$pw_file" ]] || return 1 + cat "$pw_file" } _sb_run_pbs() { @@ -223,6 +310,28 @@ _sb_run_pbs() { ${KEEP_YEARLY:+--keep-yearly "$KEEP_YEARLY"} \ 2>&1 || true + # Upload the keyfile recovery blob alongside the main backup so the + # operator can rebuild the keyfile on a fresh host. Only fires when: + # - this backup was encrypted (PBS_KEYFILE set), AND + # - the operator configured a recovery passphrase + # (pbs-key.recovery.enc exists, created by the UI's + # /pbs-recovery/setup endpoint or shell hb_pbs_setup_recovery). + # The blob is already passphrase-encrypted by openssl, so we do NOT + # pass --keyfile here: PBS stores it as a plain blob retrievable + # without the keyfile, which is the whole point of the escrow. + local recovery_enc="/usr/local/share/proxmenux/pbs-key.recovery.enc" + if [[ -n "${PBS_KEYFILE:-}" && -f "$recovery_enc" ]]; then + env PBS_PASSWORD="$PBS_PASSWORD" \ + PBS_FINGERPRINT="${PBS_FINGERPRINT:-}" \ + proxmox-backup-client backup \ + "keyrecovery.conf:${recovery_enc}" \ + --repository "$PBS_REPOSITORY" \ + --backup-type host \ + --backup-id "proxmenux-keyrecovery-$(hostname)" \ + --backup-time "$epoch" \ + 2>&1 || true + fi + echo "PBS_SNAPSHOT=host/${backup_id}/${epoch}" return 0 }