mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
update 1.2.2.2 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
||||
33e64043c50e9d90c1297019219c08d5196ab8181f0409e77f1dce9c42b0e73e ProxMenux-1.2.2.2-beta.AppImage
|
||||
08b669193097449f8330ad430066574bf0e99f477aea97172e6b845ad4ee648c ProxMenux-1.2.2.2-beta.AppImage
|
||||
|
||||
@@ -810,17 +810,9 @@ entities:
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**.
|
||||
This project is licensed under the **GNU General Public License, version 3 (GPL-3.0)**.
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format
|
||||
- Adapt — remix, transform, and build upon the material
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made
|
||||
- NonCommercial — You may not use the material for commercial purposes
|
||||
|
||||
For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/).
|
||||
You are free to use, study, share and modify the software under the terms of the licence. Any distributed derivative work must be licensed under the same terms and include the full source code — see the [full licence text](https://www.gnu.org/licenses/gpl-3.0.html) or the [`LICENSE`](https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) file at the repository root.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1972,7 +1972,11 @@ interface JobDetail {
|
||||
pbs_backup_id: string | null
|
||||
pbs_fingerprint: string | null
|
||||
has_pbs_password: boolean
|
||||
// Legacy — kept for backend responses that only track the on/off
|
||||
// toggle. Newer builds also send pbs_encrypt_mode ("none" | "new"
|
||||
// | "existing"); loadFromJobDetail prefers that when present.
|
||||
pbs_encrypt: boolean
|
||||
pbs_encrypt_mode?: "none" | "new" | "existing"
|
||||
local_dest_dir: string | null
|
||||
local_archive_ext: string | null
|
||||
borg_repo: string | null
|
||||
@@ -2025,10 +2029,21 @@ function CreateJobDialog({
|
||||
// Backend-specific fields
|
||||
const [pbsRepository, setPbsRepository] = useState<string>("")
|
||||
const [pbsBackupId, setPbsBackupId] = useState<string>("")
|
||||
// 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<boolean>(false)
|
||||
// Client-side PBS encryption mode. Three modes match the shell wizard:
|
||||
// - "none" — plain backup, no --keyfile
|
||||
// - "new" — backend generates a fresh per-host keyfile (default)
|
||||
// - "existing" — operator uploads their own keyfile (shared across
|
||||
// hosts). The upload happens via a dedicated endpoint
|
||||
// BEFORE the job create call — see handleCreate below.
|
||||
const [pbsEncryptMode, setPbsEncryptMode] = useState<"none" | "new" | "existing">("none")
|
||||
// File picked in the "existing" mode. Kept in a ref-like state so a
|
||||
// stale reference doesn't linger across modal opens.
|
||||
const [pbsImportFile, setPbsImportFile] = useState<File | null>(null)
|
||||
const [pbsImportBusy, setPbsImportBusy] = useState<boolean>(false)
|
||||
// Legacy alias for the many recovery / gating checks below — a truthy
|
||||
// value means "the operator wants an encrypted backup" regardless of
|
||||
// whether that keyfile came from a fresh generate or from an import.
|
||||
const pbsEncrypt = pbsEncryptMode !== "none"
|
||||
// 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
|
||||
@@ -2123,7 +2138,15 @@ 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)
|
||||
// Prefer the new pbs_encrypt_mode string when the backend sends it;
|
||||
// fall back to the legacy bool for older `.env` layouts that only
|
||||
// remember the on/off toggle.
|
||||
const jd = jobDetail as unknown as { pbs_encrypt_mode?: string; pbs_encrypt?: boolean }
|
||||
if (jd.pbs_encrypt_mode === "new" || jd.pbs_encrypt_mode === "existing" || jd.pbs_encrypt_mode === "none") {
|
||||
setPbsEncryptMode(jd.pbs_encrypt_mode)
|
||||
} else {
|
||||
setPbsEncryptMode(jd.pbs_encrypt ? "new" : "none")
|
||||
}
|
||||
if (jobDetail.local_dest_dir) setLocalDestDir(jobDetail.local_dest_dir)
|
||||
if (jobDetail.borg_repo) setBorgRepoSelected(jobDetail.borg_repo)
|
||||
if (jobDetail.borg_encrypt_mode) {
|
||||
@@ -2214,7 +2237,9 @@ function CreateJobDialog({
|
||||
setKeepYearly("0")
|
||||
setPbsRepository("")
|
||||
setPbsBackupId("")
|
||||
setPbsEncrypt(false)
|
||||
setPbsEncryptMode("none")
|
||||
setPbsImportFile(null)
|
||||
setPbsImportBusy(false)
|
||||
setPbsRecoveryPass("")
|
||||
setPbsRecoveryPass2("")
|
||||
setPbsRecoveryChange(false)
|
||||
@@ -2322,6 +2347,39 @@ function CreateJobDialog({
|
||||
if (mode === "attach" && !selectedPveJob) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
// "Import existing" mode: push the user-supplied keyfile to the
|
||||
// canonical shared path BEFORE anything else. This mirrors the
|
||||
// shell wizard where hb_pbs_import_keyfile runs before recovery
|
||||
// setup and before the job .env is written. If the upload fails
|
||||
// we bail loudly instead of silently downgrading to "generate new".
|
||||
if (backend === "pbs" && pbsEncryptMode === "existing") {
|
||||
if (!pbsImportFile) {
|
||||
setError("Pick a keyfile to import.")
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
setPbsImportBusy(true)
|
||||
try {
|
||||
const buf = new Uint8Array(await pbsImportFile.arrayBuffer())
|
||||
// btoa on binary requires a String.fromCharCode round-trip.
|
||||
// The keyfile is small (~200 bytes), so no perf concern.
|
||||
let bin = ""
|
||||
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i])
|
||||
const content_b64 = btoa(bin)
|
||||
await fetchApi("/api/host-backups/pbs-encryption/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content_b64 }),
|
||||
})
|
||||
mutatePbsRecovery()
|
||||
} catch (e) {
|
||||
setError(`Keyfile import failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
setPbsImportBusy(false)
|
||||
}
|
||||
// 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.
|
||||
@@ -2374,7 +2432,7 @@ function CreateJobDialog({
|
||||
body.pbs_password = ""
|
||||
if (pbsBackupId) body.pbs_backup_id = pbsBackupId
|
||||
if (selectedPbs?.fingerprint) body.pbs_fingerprint = selectedPbs.fingerprint
|
||||
body.pbs_encrypt = pbsEncrypt
|
||||
body.pbs_encrypt_mode = pbsEncryptMode
|
||||
} else if (backend === "local") {
|
||||
if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim()
|
||||
} else if (backend === "borg") {
|
||||
@@ -2938,23 +2996,50 @@ function CreateJobDialog({
|
||||
</p>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
shell wizard. The keyfile can be freshly generated
|
||||
per host or imported from an existing one the
|
||||
operator uses across every host. */}
|
||||
<div className="pt-2 border-t border-border space-y-3">
|
||||
<Label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={pbsEncrypt}
|
||||
onCheckedChange={(v) => setPbsEncrypt(!!v)}
|
||||
/>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label className="inline-flex items-center gap-1.5">
|
||||
<Lock className="h-3.5 w-3.5 text-emerald-400" />
|
||||
Encrypt backups (client-side keyfile)
|
||||
</span>
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground pl-7">
|
||||
Adds <code className="font-mono">--keyfile</code> to <code className="font-mono">proxmox-backup-client backup</code>. Encryption happens before upload so chunks land on the PBS server already ciphered. The keyfile lives at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) and is shared across every encrypted PBS job on this host.
|
||||
</p>
|
||||
</Label>
|
||||
<Select
|
||||
value={pbsEncryptMode}
|
||||
onValueChange={(v) => setPbsEncryptMode(v as "none" | "new" | "existing")}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No encryption</SelectItem>
|
||||
<SelectItem value="new">Generate a new keyfile (per host — safest isolation)</SelectItem>
|
||||
<SelectItem value="existing">Import an existing keyfile (shared across hosts)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Adds <code className="font-mono">--keyfile</code> to <code className="font-mono">proxmox-backup-client backup</code>. Encryption happens before upload so chunks land on PBS already ciphered. The keyfile is installed at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) and reused by every encrypted PBS job on this host.
|
||||
</p>
|
||||
</div>
|
||||
{pbsEncryptMode === "existing" && (
|
||||
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
|
||||
<Label htmlFor="pbsImportFile" className="text-[11px] font-medium">
|
||||
Keyfile to import
|
||||
</Label>
|
||||
<Input
|
||||
id="pbsImportFile"
|
||||
type="file"
|
||||
accept=".conf,.key,application/json,text/plain"
|
||||
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
|
||||
disabled={pbsImportBusy}
|
||||
className="h-8 text-[11px]"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
The file is validated with <code className="font-mono">proxmox-backup-client key info</code> before being installed. If validation fails the job is not created and the existing keyfile (if any) stays in place.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{pbsEncrypt && (
|
||||
<div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
|
||||
<div className="text-[11px] font-medium text-foreground flex items-center gap-1.5">
|
||||
@@ -3350,7 +3435,12 @@ function ManualBackupDialog({
|
||||
|
||||
const [pbsRepository, setPbsRepository] = useState<string>("")
|
||||
const [pbsBackupId, setPbsBackupId] = useState<string>("")
|
||||
const [pbsEncrypt, setPbsEncrypt] = useState<boolean>(false)
|
||||
// Encryption mode. See CreateJobDialog above for the full comment
|
||||
// block — the three modes and the import flow are identical here.
|
||||
const [pbsEncryptMode, setPbsEncryptMode] = useState<"none" | "new" | "existing">("none")
|
||||
const [pbsImportFile, setPbsImportFile] = useState<File | null>(null)
|
||||
const [pbsImportBusy, setPbsImportBusy] = useState<boolean>(false)
|
||||
const pbsEncrypt = pbsEncryptMode !== "none"
|
||||
const [pbsRecoveryPass, setPbsRecoveryPass] = useState<string>("")
|
||||
const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState<string>("")
|
||||
// Operator opted to replace an already-configured recovery escrow.
|
||||
@@ -3413,7 +3503,9 @@ function ManualBackupDialog({
|
||||
setCustomPaths(new Set())
|
||||
setPbsRepository("")
|
||||
setPbsBackupId("")
|
||||
setPbsEncrypt(false)
|
||||
setPbsEncryptMode("none")
|
||||
setPbsImportFile(null)
|
||||
setPbsImportBusy(false)
|
||||
setPbsRecoveryPass("")
|
||||
setPbsRecoveryPass2("")
|
||||
setPbsRecoveryChange(false)
|
||||
@@ -3470,6 +3562,35 @@ function ManualBackupDialog({
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
// "Import existing" mode: same shape as CreateJobDialog — push the
|
||||
// keyfile to the canonical path before anything else, bail loudly
|
||||
// if validation fails so we don't silently fall back to "new".
|
||||
if (backend === "pbs" && pbsEncryptMode === "existing") {
|
||||
if (!pbsImportFile) {
|
||||
setError("Pick a keyfile to import.")
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
setPbsImportBusy(true)
|
||||
try {
|
||||
const buf = new Uint8Array(await pbsImportFile.arrayBuffer())
|
||||
let bin = ""
|
||||
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i])
|
||||
const content_b64 = btoa(bin)
|
||||
await fetchApi("/api/host-backups/pbs-encryption/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content_b64 }),
|
||||
})
|
||||
mutatePbsRecovery()
|
||||
} catch (e) {
|
||||
setError(`Keyfile import failed: ${e instanceof Error ? e.message : String(e)}`)
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
setPbsImportBusy(false)
|
||||
}
|
||||
// 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.
|
||||
@@ -3505,7 +3626,7 @@ function ManualBackupDialog({
|
||||
body.pbs_password = ""
|
||||
if (pbsBackupId) body.pbs_backup_id = pbsBackupId
|
||||
if (selectedPbs?.fingerprint) body.pbs_fingerprint = selectedPbs.fingerprint
|
||||
body.pbs_encrypt = pbsEncrypt
|
||||
body.pbs_encrypt_mode = pbsEncryptMode
|
||||
} else if (backend === "local") {
|
||||
if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim()
|
||||
} else if (backend === "borg") {
|
||||
@@ -3706,19 +3827,46 @@ function ManualBackupDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-border space-y-3">
|
||||
<Label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={pbsEncrypt}
|
||||
onCheckedChange={(v) => setPbsEncrypt(!!v)}
|
||||
/>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<div className="space-y-2">
|
||||
<Label className="inline-flex items-center gap-1.5">
|
||||
<Lock className="h-3.5 w-3.5 text-emerald-400" />
|
||||
Encrypt this backup (client-side keyfile)
|
||||
</span>
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground pl-7">
|
||||
Uses the shared keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (auto-generated on first use).
|
||||
</p>
|
||||
</Label>
|
||||
<Select
|
||||
value={pbsEncryptMode}
|
||||
onValueChange={(v) => setPbsEncryptMode(v as "none" | "new" | "existing")}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No encryption</SelectItem>
|
||||
<SelectItem value="new">Generate a new keyfile (per host — safest isolation)</SelectItem>
|
||||
<SelectItem value="existing">Import an existing keyfile (shared across hosts)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Uses the shared keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>.
|
||||
</p>
|
||||
</div>
|
||||
{pbsEncryptMode === "existing" && (
|
||||
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
|
||||
<Label htmlFor="manualPbsImportFile" className="text-[11px] font-medium">
|
||||
Keyfile to import
|
||||
</Label>
|
||||
<Input
|
||||
id="manualPbsImportFile"
|
||||
type="file"
|
||||
accept=".conf,.key,application/json,text/plain"
|
||||
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
|
||||
disabled={pbsImportBusy}
|
||||
className="h-8 text-[11px]"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Validated with <code className="font-mono">proxmox-backup-client key info</code> before install.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{pbsEncrypt && (
|
||||
<div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
|
||||
<div className="text-[11px] font-medium text-foreground flex items-center gap-1.5">
|
||||
|
||||
@@ -13803,6 +13803,99 @@ def api_pbs_recovery_status():
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/host-backups/pbs-encryption/import', methods=['POST'])
|
||||
@require_auth
|
||||
def api_pbs_encryption_import():
|
||||
"""Install a user-provided PBS keyfile at the canonical shared path.
|
||||
|
||||
Body: ``{"content_b64": "<base64 of the keyfile bytes>"}``
|
||||
|
||||
Use case: operators who generated their own PBS master key and want
|
||||
to share it across every host (single recovery blob covers all,
|
||||
cross-host restore is possible). The keyfile is written to a tmp
|
||||
path first and validated with ``proxmox-backup-client key info``;
|
||||
only if the tool accepts it as a real PBS keyfile does it get
|
||||
promoted to the canonical ``_PBS_KEYFILE_PATH``. Any prior
|
||||
keyfile + recovery blob at that location are replaced atomically —
|
||||
the old blob was encrypted for the old key and would no longer be
|
||||
decryptable anyway.
|
||||
"""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
content_b64 = payload.get('content_b64') or ''
|
||||
if not content_b64:
|
||||
return jsonify({'error': 'content_b64 is required'}), 400
|
||||
|
||||
import base64
|
||||
try:
|
||||
content = base64.b64decode(content_b64, validate=True)
|
||||
except Exception:
|
||||
return jsonify({'error': 'content_b64 is not valid base64'}), 400
|
||||
|
||||
# PBS keyfiles are compact JSON blobs (~200 bytes). Cap at 8 KB so
|
||||
# a malformed upload can't fill the state dir with garbage.
|
||||
if not content or len(content) > 8192:
|
||||
return jsonify({'error': 'keyfile size out of expected range (1..8192 bytes)'}), 400
|
||||
|
||||
if not shutil.which('proxmox-backup-client'):
|
||||
return jsonify({'error': 'proxmox-backup-client is not installed on this host'}), 500
|
||||
|
||||
try:
|
||||
os.makedirs(_BACKUP_STATE_DIR, exist_ok=True)
|
||||
except OSError as e:
|
||||
return jsonify({'error': f'cannot create state directory: {e}'}), 500
|
||||
|
||||
import tempfile
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(prefix='pbs-key.import.', dir=_BACKUP_STATE_DIR)
|
||||
try:
|
||||
try:
|
||||
with os.fdopen(tmp_fd, 'wb') as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp_path, 0o600)
|
||||
except OSError as e:
|
||||
return jsonify({'error': f'cannot write tmp keyfile: {e}'}), 500
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['proxmox-backup-client', 'key', 'info', '--output-format', 'json', tmp_path],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
return jsonify({'error': f'validation failed: {type(e).__name__}'}), 500
|
||||
|
||||
if r.returncode != 0:
|
||||
return jsonify({
|
||||
'error': 'proxmox-backup-client did not recognise this file as a valid PBS keyfile',
|
||||
}), 400
|
||||
|
||||
# The old recovery blob was encrypted with the old key — a new
|
||||
# blob has to be built by re-running /pbs-recovery/setup once
|
||||
# the operator confirms this import.
|
||||
try:
|
||||
os.remove(_PBS_RECOVERY_ENC_PATH)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.replace(tmp_path, _PBS_KEYFILE_PATH)
|
||||
os.chmod(_PBS_KEYFILE_PATH, 0o600)
|
||||
except OSError as e:
|
||||
return jsonify({'error': f'cannot promote keyfile: {e}'}), 500
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'keyfile_path': _PBS_KEYFILE_PATH,
|
||||
'recovery_reset': True,
|
||||
})
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_path) and tmp_path != _PBS_KEYFILE_PATH:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@app.route('/api/host-backups/pbs-recovery/setup', methods=['POST'])
|
||||
@require_auth
|
||||
def api_pbs_recovery_setup():
|
||||
@@ -14428,21 +14521,47 @@ 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'))
|
||||
# Client-side encryption. Three modes:
|
||||
# - "none" — no --keyfile flag, plain backup
|
||||
# - "new" — generate a fresh per-host keyfile (default,
|
||||
# matches legacy `pbs_encrypt: true`)
|
||||
# - "existing" — reuse whatever keyfile is already installed
|
||||
# at _PBS_KEYFILE_PATH (typical after the
|
||||
# operator uploaded their own via
|
||||
# /api/host-backups/pbs-encryption/import)
|
||||
# Legacy compat: a truthy `pbs_encrypt` bool with no
|
||||
# `pbs_encrypt_mode` maps to "new".
|
||||
pbs_encrypt_mode = (payload.get('pbs_encrypt_mode') or '').strip().lower()
|
||||
if not pbs_encrypt_mode:
|
||||
pbs_encrypt_mode = 'new' if bool(payload.get('pbs_encrypt')) else 'none'
|
||||
if pbs_encrypt_mode not in ('none', 'new', 'existing'):
|
||||
return jsonify({'error': "pbs_encrypt_mode must be 'none', 'new' or 'existing'"}), 400
|
||||
|
||||
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:
|
||||
|
||||
if pbs_encrypt_mode == 'new':
|
||||
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 pbs_encrypt_mode == 'existing':
|
||||
# The keyfile must already be present — either from a
|
||||
# previous run or from a fresh import via the dedicated
|
||||
# endpoint. Fail loudly rather than silently generating
|
||||
# a new one that would leave the user's own key unused.
|
||||
if not os.path.isfile(_PBS_KEYFILE_PATH):
|
||||
return jsonify({
|
||||
'error': (
|
||||
"pbs_encrypt_mode='existing' but no keyfile is installed at "
|
||||
f"{_PBS_KEYFILE_PATH} — import one first with "
|
||||
"POST /api/host-backups/pbs-encryption/import"
|
||||
),
|
||||
}), 400
|
||||
lines.append(f'PBS_KEYFILE={_PBS_KEYFILE_PATH}')
|
||||
elif backend == 'local':
|
||||
explicit = (payload.get('local_dest_dir') or '').strip()
|
||||
if explicit:
|
||||
@@ -14630,6 +14749,14 @@ def _backup_job_detail(env_file: str) -> dict:
|
||||
'pbs_fingerprint': raw.get('PBS_FINGERPRINT') or None,
|
||||
'has_pbs_password': bool(raw.get('PBS_PASSWORD')),
|
||||
'pbs_encrypt': bool(raw.get('PBS_KEYFILE')),
|
||||
# New: mode used the last time the job was created / edited.
|
||||
# The .env only stores PBS_KEYFILE=<path>, so we can't tell
|
||||
# "new" from "existing" after the fact — the edit flow just
|
||||
# sees "encrypted" and defaults to "new" if the operator
|
||||
# decides to reconfigure. Sending "new" whenever a keyfile is
|
||||
# present matches the legacy pbs_encrypt bool: an edit that
|
||||
# doesn't touch the mode dropdown produces the same .env.
|
||||
'pbs_encrypt_mode': ('new' if raw.get('PBS_KEYFILE') else 'none'),
|
||||
'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,
|
||||
|
||||
Reference in New Issue
Block a user