update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-07-05 09:30:13 +02:00
parent 790c8d2fd4
commit 29bca610a0
9 changed files with 549 additions and 138 deletions

View File

@@ -1 +1 @@
33e64043c50e9d90c1297019219c08d5196ab8181f0409e77f1dce9c42b0e73e ProxMenux-1.2.2.2-beta.AppImage
08b669193097449f8330ad430066574bf0e99f477aea97172e6b845ad4ee648c ProxMenux-1.2.2.2-beta.AppImage

View File

@@ -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.

View File

@@ -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">

View File

@@ -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,