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 (
+ {/* 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"
+ />
+
+
+ setAddingDestType("local")}
+ title="Save another local destination"
+ >
+
+ Add another
+
+
+ )}
- 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"
+ />
+
+
+ setAddingDestType("local")}
+ title="Save another local destination"
+ >
+
+ Add another
+
+
+ )}
+
+ 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"}).
-
+ 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. */}
profile:{profileMode}
@@ -3349,6 +3676,7 @@ function ManualBackupDialog({
{submitting ? (
@@ -3396,15 +3724,23 @@ function ManualBackupDialog({
// which backend a given destination targets — only the color, the
// label and the action set.
type UnifiedDest =
- | { id: string; kind: "local"; path: string; source: "default" | "custom"; removable: boolean }
+ | { id: string; kind: "local"; path: string; source: "default" | "custom"; removable: boolean; jobsUsing: string[] }
| {
id: string; kind: "pbs"; name: string; repository: string;
source: "proxmox" | "manual"; fingerprint?: string; removable: boolean
+ jobsUsing: string[]
}
| {
id: string; kind: "borg"; name: string; repository: string;
isSsh: boolean; ssh?: { user: string; host: string; remotePath: string };
- sshKeyPath?: string; removable: boolean
+ sshKeyPath?: string;
+ // The destination's encryption mode + whether a passphrase is
+ // saved server-side; used to render a Lock / Unlock badge so the
+ // operator sees at a glance which Borg repos are encrypted.
+ encryptMode?: "none" | "repokey" | "keyfile" | "authenticated"
+ hasPassphrase?: boolean
+ removable: boolean
+ jobsUsing: string[]
}
interface CapacityInfo {
@@ -3433,9 +3769,53 @@ function DestinationsSection({
onChanged: () => void
}) {
const [configuring, setConfiguring] = useState(false)
+ const [editingDest, setEditingDest] = useState(null)
+ const [confirmingDest, setConfirmingDest] = useState(null)
const [busyKey, setBusyKey] = useState(null)
const [error, setError] = useState(null)
+ // Used by the Delete-destination confirm to tell the operator how
+ // many backups already live on this target (they're kept after the
+ // destination + jobs cascade, so the warning is informational).
+ const { data: localArchivesResp } = useSWR<{ archives: BackupArchive[] }>(
+ "/api/host-backups/archives",
+ fetcher,
+ { refreshInterval: 60_000, revalidateOnFocus: false },
+ )
+ const { data: remoteArchivesResp } = useSWR(
+ "/api/host-backups/remote-archives",
+ fetcher,
+ { refreshInterval: 60_000, revalidateOnFocus: false },
+ )
+ // Recovery state — only needed when at least one PBS destination
+ // exists. Drives the "Recover keyfile" banner.
+ const hasPbs = (destinations?.pbs?.length ?? 0) > 0
+ const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{
+ has_keyfile: boolean; has_recovery: boolean
+ }>(hasPbs ? "/api/host-backups/pbs-recovery/status" : null, fetcher)
+ const { data: pbsRecoveryAvailable } = useSWR<{
+ snapshots: Array<{ repo_name: string; repo_repository: string; backup_id: string; source_host: string; backup_time: number; snapshot: string }>
+ errors: Array<{ repo_name: string; error: string }>
+ }>(
+ hasPbs && pbsRecoveryStatus && !pbsRecoveryStatus.has_keyfile
+ ? "/api/host-backups/pbs-recovery/available"
+ : null,
+ fetcher,
+ )
+ const [recoveringKeyfile, setRecoveringKeyfile] = useState(false)
+ const backupsCountFor = (it: UnifiedDest): number => {
+ if (it.kind === "local") {
+ const root = it.path.replace(/\/+$/, "") + "/"
+ return (localArchivesResp?.archives || []).filter(
+ (a) => (a.path || "").startsWith(root),
+ ).length
+ }
+ const name = it.kind === "pbs" ? it.name : it.kind === "borg" ? it.name : ""
+ return (remoteArchivesResp?.snapshots || []).filter(
+ (s) => s.backend === it.kind && s.repo_name === name,
+ ).length
+ }
+
// Flatten PBS + Borg + Local into one list. Order: locals first
// (default always on top), then PBS, then Borg — matches the
// mental model of "what's already on this machine" before any
@@ -3449,6 +3829,7 @@ function DestinationsSection({
path: e.path,
source: e.source as "default" | "custom",
removable: e.removable,
+ jobsUsing: e.jobs_using || [],
})
}
for (const r of destinations?.pbs || []) {
@@ -3460,6 +3841,7 @@ function DestinationsSection({
source: r.source as "proxmox" | "manual",
fingerprint: r.fingerprint || undefined,
removable: r.source === "manual",
+ jobsUsing: r.jobs_using || [],
})
}
for (const r of destinations?.borg || []) {
@@ -3472,7 +3854,10 @@ function DestinationsSection({
isSsh: !!ssh,
ssh: ssh ?? undefined,
sshKeyPath: (r as { ssh_key_path?: string }).ssh_key_path,
+ encryptMode: r.encrypt_mode,
+ hasPassphrase: r.has_passphrase,
removable: true,
+ jobsUsing: r.jobs_using || [],
})
}
return out
@@ -3512,11 +3897,11 @@ function DestinationsSection({
(capacityResp?.results || []).map((r) => [r.id, r] as const),
)
- async function removePbs(name: string) {
+ async function removePbs(name: string, force = false) {
setBusyKey(`pbs:${name}`)
setError(null)
try {
- await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}`, { method: "DELETE" })
+ await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
@@ -3524,11 +3909,11 @@ function DestinationsSection({
setBusyKey(null)
}
}
- async function removeBorg(name: string) {
+ async function removeBorg(name: string, force = false) {
setBusyKey(`borg:${name}`)
setError(null)
try {
- await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}`, { method: "DELETE" })
+ await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
@@ -3536,12 +3921,14 @@ function DestinationsSection({
setBusyKey(null)
}
}
- async function removeLocal(path: string) {
+ async function removeLocal(path: string, force = false) {
setBusyKey(`local:${path}`)
setError(null)
try {
+ const params = new URLSearchParams({ path })
+ if (force) params.set("force", "true")
await fetchApi(
- `/api/host-backups/destinations/local?path=${encodeURIComponent(path)}`,
+ `/api/host-backups/destinations/local?${params.toString()}`,
{ method: "DELETE" },
)
onChanged()
@@ -3551,6 +3938,15 @@ function DestinationsSection({
setBusyKey(null)
}
}
+ async function confirmDeleteDest() {
+ const it = confirmingDest
+ if (!it) return
+ const force = it.jobsUsing.length > 0
+ setConfirmingDest(null)
+ if (it.kind === "pbs") return removePbs(it.name, force)
+ if (it.kind === "borg") return removeBorg(it.name, force)
+ if (it.kind === "local") return removeLocal(it.path, force)
+ }
// Detach a USB drive — only the filesystem is unmounted; the path
// stays in local-target.conf so re-plugging the same disk picks it
// up again without re-adding the destination.
@@ -3573,13 +3969,10 @@ function DestinationsSection({
return (
-
-
- Pre-configure backup destinations so wizards and manual backups can pick from them without re-typing credentials each time. Each entry is colored by backend: PBS purple, Local blue, Borg fuchsia.
-
+
setConfiguring(true)}
>
@@ -3591,6 +3984,31 @@ function DestinationsSection({
{error}
)}
+ {/* 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 && (
+ setRecoveringKeyfile(true)}
+ className="w-full flex items-start gap-2 px-3 py-2.5 rounded-md border border-emerald-500/40 bg-emerald-500/5 hover:bg-emerald-500/10 transition-colors text-left"
+ title="A recovery copy of the PBS keyfile was found on this PBS server"
+ >
+
+
+
+ PBS keyfile is missing — recover it from PBS
+
+
+ An encrypted recovery copy of the keyfile is available ({pbsRecoveryAvailable!.snapshots.length} snapshot{pbsRecoveryAvailable!.snapshots.length === 1 ? "" : "s"}). Click to rebuild the keyfile using your recovery passphrase.
+
+
+ Recover →
+
+ )}
{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 (
+
+ )
+ })()}
- {/* 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. */}
+
+ {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 && (
-
- {unmountBusy ? : }
- Unmount
-
- )}
- {item.removable ? (
-
- {busy ? : }
- Remove
-
- ) : (
-
- {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. */}
+
) : (
<>
@@ -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. */}
+
+ ⚠ 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.
)}
{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 (
+ <>
+
+ >
+ )
+}
+
+// ──────────────────────────────────────────────────────────────
+// 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 (
+
+ )
+}
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
}