Update 1.2.2.3 beta

This commit is contained in:
MacRimi
2026-07-08 12:18:52 +02:00
parent 4789371f4d
commit 341681a3dc
6 changed files with 1795 additions and 465 deletions
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7b7cb13e8f2d4044626663d8187018908705e480e81f510ee46f6df7918fef84 ProxMenux-1.2.2.3-beta.AppImage
6da865786f93c94359fd4428876a953f2706d752bd28120510ef5330f1d420aa ProxMenux-1.2.2.3-beta.AppImage
File diff suppressed because it is too large Load Diff
+467 -79
View File
@@ -46,6 +46,7 @@ import socket
import sqlite3
import subprocess
import sys
import tempfile
import time
import threading
import urllib.parse
@@ -13746,6 +13747,177 @@ _PBS_RECOVERY_ENC_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.recovery.enc'
# proxmox-backup-client can decrypt the key without prompting.
# Same trust boundary as the keyfile itself: chmod 600, next to it.
_PBS_KEYFILE_PASS_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.pass'
# Escrow-mode state file: one line, 'none'|'local'|'full'. Mirrors the
# shell's $HB_STATE_DIR/pbs-key.mode. Missing file = legacy install =
# 'full' (the pre-3-mode behaviour) so upgrades are silent.
_PBS_ESCROW_MODE_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.mode'
_PBS_ESCROW_MODES = ('none', 'local', 'full')
# PVE stores per-storage encryption keyfiles here when the operator
# enables `encryption-key` on a PBS storage entry. Same JSON keyfile
# format as `proxmox-backup-client key create --kdf none` — usable
# verbatim with --keyfile. Reusing it avoids duplicating a secret
# across the PVE config and ProxMenux state.
_PVE_STORAGE_KEY_DIR = '/etc/pve/priv/storage'
_PVE_STORAGE_CFG = '/etc/pve/storage.cfg'
def _pbs_read_escrow_mode() -> str:
"""Read the current escrow mode ('none'|'local'|'full'). Falls back
to 'full' for legacy installs where the mode file doesn't exist —
that matches the pre-feature behaviour."""
try:
with open(_PBS_ESCROW_MODE_PATH, 'r') as f:
v = f.read(32).strip()
if v in _PBS_ESCROW_MODES:
return v
except OSError:
pass
return 'full'
def _pbs_write_escrow_mode(mode: str) -> bool:
if mode not in _PBS_ESCROW_MODES:
return False
try:
os.makedirs(_BACKUP_STATE_DIR, exist_ok=True)
fd = os.open(
_PBS_ESCROW_MODE_PATH,
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
0o600,
)
with os.fdopen(fd, 'w') as f:
f.write(mode + '\n')
except OSError:
return False
return True
def _pbs_discover_pve_keyfiles() -> list:
"""List PVE-managed PBS storage entries that carry an encryption
keyfile at /etc/pve/priv/storage/<NAME>.enc. Returns
[{name, server, datastore, path}] for each matching entry. Empty
list on any read error this is best-effort discovery.
"""
if not (os.path.isfile(_PVE_STORAGE_CFG) and os.path.isdir(_PVE_STORAGE_KEY_DIR)):
return []
entries = []
current = None
try:
with open(_PVE_STORAGE_CFG, 'r') as f:
for raw_line in f:
line = raw_line.rstrip('\n')
if not line.strip():
continue
# Non-indented block start: "<type>: <name>"
if not line[0].isspace():
if current and current.get('type') == 'pbs':
entries.append(current)
stripped = line.strip()
if ':' in stripped:
typ, _, name = stripped.partition(':')
name = name.strip()
current = {'type': typ.strip(), 'name': name}
else:
current = None
continue
# Indented body line: " key value"
if current is None:
continue
parts = line.strip().split(None, 1)
if len(parts) == 2:
current[parts[0]] = parts[1]
if current and current.get('type') == 'pbs':
entries.append(current)
except OSError:
return []
result = []
for e in entries:
name = e.get('name') or ''
if not name:
continue
kf = os.path.join(_PVE_STORAGE_KEY_DIR, f'{name}.enc')
if not os.path.isfile(kf):
continue
result.append({
'name': name,
'server': e.get('server', ''),
'datastore': e.get('datastore', ''),
'path': kf,
})
return result
def _pbs_pve_keyfile_for_repository(repository: str) -> dict:
"""Given a PBS repository string 'user@realm@host:datastore', return
the matching PVE-storage keyfile entry (dict) or {} if none matches.
"""
if not repository or ':' not in repository or '@' not in repository:
return {}
# Split repo on last '@' and last ':' — user/realm may contain '@'
# in the token form.
host_and_ds = repository.rsplit('@', 1)[-1]
host, _, datastore = host_and_ds.rpartition(':')
if not host or not datastore:
return {}
for e in _pbs_discover_pve_keyfiles():
if e.get('server') == host and e.get('datastore') == datastore:
return e
return {}
def _pbs_do_finalize_recovery(passphrase: str, mode: str) -> tuple:
"""Wrap the on-disk keyfile with the operator's passphrase (writes
_PBS_RECOVERY_ENC_PATH) and persist the escrow mode. Called by
both import and set-mode flows.
- mode 'full' or 'local' envelope written + mode file updated.
- mode 'none' should never reach here the caller must skip the
call entirely. Guarded by an assert for safety.
Every artefact ProxMenux writes lives under _BACKUP_STATE_DIR
(usually /usr/local/share/proxmenux/). No copies leak into /root/
the operator uses the Monitor's Download button when they want
the keyfile offsite. Returns (ok, '') the second slot is kept
for API compatibility with older callers that expected an offsite
path there.
"""
assert mode in ('local', 'full'), 'finalize_recovery called with invalid mode'
if not shutil.which('openssl'):
return False, ''
if not os.path.isfile(_PBS_KEYFILE_PATH):
return False, ''
if not _pbs_recovery_encrypt(passphrase, _PBS_KEYFILE_PATH, _PBS_RECOVERY_ENC_PATH):
return False, ''
_pbs_write_escrow_mode(mode)
return True, ''
def _pbs_install_pve_keyfile(src_path: str) -> bool:
"""Copy a PVE-storage keyfile into the ProxMenux canonical location.
Atomic: writes to a temp path in the same directory, chmods, then
renames. Returns True on success."""
if not (src_path and os.path.isfile(src_path)):
return False
try:
os.makedirs(_BACKUP_STATE_DIR, exist_ok=True)
fd, tmp = tempfile.mkstemp(
prefix='pbs-key.', suffix='.pve', dir=_BACKUP_STATE_DIR,
)
try:
with os.fdopen(fd, 'wb') as fout, open(src_path, 'rb') as fin:
fout.write(fin.read())
os.chmod(tmp, 0o600)
os.replace(tmp, _PBS_KEYFILE_PATH)
except OSError:
try:
os.remove(tmp)
except OSError:
pass
return False
except OSError:
return False
return True
def _pbs_keyfile_get_or_create(create_if_missing: bool = True) -> str:
@@ -13905,6 +14077,7 @@ def api_pbs_encryption_keyfile_info():
and os.path.getsize(_PBS_KEYFILE_PASS_PATH) > 0
),
'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH),
'escrow_mode': _pbs_read_escrow_mode(),
})
@@ -13919,38 +14092,26 @@ def api_pbs_encryption_remove():
passphrase and are the safe way to reach any pre-existing backup
encrypted with the removed key.
Body: `{backup_before_remove: bool}` (default true)
Deleting is destructive and unconditional the operator is
expected to click Download first if they want a copy. ProxMenux
never scatters files under /root/ any more.
"""
payload = request.get_json(silent=True) or {}
do_backup = bool(payload.get('backup_before_remove', True))
if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({'error': 'no keyfile installed'}), 404
backed_up = []
if do_backup:
ts = time.strftime('%Y%m%d_%H%M%S', time.localtime())
for src, dst_suffix in (
(_PBS_KEYFILE_PATH, '.conf'),
(_PBS_KEYFILE_PASS_PATH, '.pass'),
(_PBS_RECOVERY_ENC_PATH, '.recovery.enc'),
):
if not os.path.isfile(src):
continue
dst = f'/root/pbs-key.old-{ts}{dst_suffix}'
try:
with open(src, 'rb') as fin, open(os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), 'wb') as fout:
fout.write(fin.read())
backed_up.append(dst)
except OSError as e:
return jsonify({
'error': f'backup to /root failed for {src}: {e}',
'partial_backups': backed_up,
}), 500
backed_up: list = []
# Wipe order: recovery blob first so a mid-flight failure leaves us
# in a clean, backup-still-decryptable-with-blob state.
# in a clean, backup-still-decryptable-with-blob state. The escrow
# mode file goes last because it's the smallest side-effect and
# missing = 'full' (legacy default), which is a safe fallback.
removed = []
for path in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH, _PBS_KEYFILE_PATH):
for path in (
_PBS_RECOVERY_ENC_PATH,
_PBS_KEYFILE_PASS_PATH,
_PBS_KEYFILE_PATH,
_PBS_ESCROW_MODE_PATH,
):
try:
os.remove(path)
removed.append(path)
@@ -14023,6 +14184,10 @@ def api_pbs_recovery_status():
# manifest's expected fingerprint after a download error
# ("missing key — manifest was created with key XX:XX:...").
'keyfile_fingerprint': _pbs_keyfile_fingerprint(),
# Current escrow model: 'none' | 'local' | 'full'. Missing on
# legacy hosts (pre-3-mode feature) — the reader falls back to
# 'full' silently, matching the pre-feature behaviour.
'escrow_mode': _pbs_read_escrow_mode(),
})
@@ -14174,35 +14339,41 @@ def api_host_backups_restore_log():
@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.
"""Install a PBS keyfile at the canonical shared path and set the
escrow mode in one atomic step.
Body: ``{"content_b64": "<base64 of the keyfile bytes>"}``
Body (all fields optional except as noted):
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.
source: "content" (default) | "pve-storage"
content_b64: <base64 bytes> # source=content
pve_storage_name: "<storage-name>" # source=pve-storage
keyfile_passphrase: <scrypt kdf> # source=content only
escrow_mode: "none" | "local" | "full" # default "full"
escrow_passphrase: <str> # required unless escrow_mode='none'
Behaviour by escrow_mode:
- none: keyfile installed, no envelope, no /root copy, no upload
- local: envelope written + copied to /root, no PBS upload
- full: envelope written + copied to /root, runner uploads to PBS
Any prior recovery blob at the canonical path is dropped it was
encrypted for the old key. The escrow mode file is updated to
reflect the new choice; downstream runs (both shell and runner)
read it back.
"""
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
source = (payload.get('source') or 'content').strip().lower()
if source not in ('content', 'pve-storage', 'generate', 'path'):
return jsonify({'error': "source must be 'content', 'pve-storage', 'generate' or 'path'"}), 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
escrow_mode = (payload.get('escrow_mode') or 'full').strip().lower()
if escrow_mode not in _PBS_ESCROW_MODES:
return jsonify({'error': f'escrow_mode must be one of {_PBS_ESCROW_MODES}'}), 400
escrow_pass = payload.get('escrow_passphrase') or ''
if escrow_mode != 'none' and not escrow_pass:
return jsonify({'error': 'escrow_passphrase is required when escrow_mode is not "none"'}), 400
if escrow_mode != 'none' and not shutil.which('openssl'):
return jsonify({'error': 'openssl is not installed; cannot build the recovery envelope'}), 500
if not shutil.which('proxmox-backup-client'):
return jsonify({'error': 'proxmox-backup-client is not installed on this host'}), 500
@@ -14212,21 +14383,115 @@ def api_pbs_encryption_import():
except OSError as e:
return jsonify({'error': f'cannot create state directory: {e}'}), 500
# Strip a leading UTF-8 BOM (b'\xef\xbb\xbf') if the operator's
# editor added one — the byte order mark makes the file no longer
# a valid JSON document and downstream tools refuse it with a
# cryptic parse error.
if content.startswith(b'\xef\xbb\xbf'):
content = content[3:]
# ── Resolve the keyfile bytes based on source ──────────────────
keyfile_pass = ''
if source == 'generate':
# Fresh keyfile generated server-side via proxmox-backup-client.
# We short-circuit the "load bytes → atomic install" pipeline
# below because the tool already writes the file at the
# canonical path.
stale_recovery = _PBS_RECOVERY_ENC_PATH
try:
os.remove(stale_recovery)
except FileNotFoundError:
pass
except OSError:
pass
# Wipe any zero-byte residue so the "already installed" branch
# in _pbs_keyfile_get_or_create doesn't misfire.
try:
if os.path.isfile(_PBS_KEYFILE_PATH) and os.path.getsize(_PBS_KEYFILE_PATH) == 0:
os.remove(_PBS_KEYFILE_PATH)
except OSError:
pass
# If a keyfile is already present the operator must remove it
# first — silent replacement here would drop backups they still
# need to decrypt.
if os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({
'error': "a keyfile is already installed at "
f"{_PBS_KEYFILE_PATH}. Remove it first via /pbs-encryption/remove.",
}), 409
if not _pbs_keyfile_get_or_create(True):
return jsonify({'error': 'failed to generate the encryption keyfile'}), 500
keyfile_pass = ''
offsite_copy = ''
if escrow_mode == 'none':
_pbs_write_escrow_mode('none')
else:
ok, offsite_copy = _pbs_do_finalize_recovery(escrow_pass, escrow_mode)
if not ok:
return jsonify({'error': 'openssl encryption of the recovery envelope failed'}), 500
return jsonify({
'status': 'ok',
'source': 'generate',
'keyfile_path': _PBS_KEYFILE_PATH,
'keyfile_pass_stored': False,
'escrow_mode': escrow_mode,
'recovery_ready': escrow_mode != 'none',
'offsite_copy': offsite_copy,
})
# Optional passphrase for a scrypt-encoded keyfile. Blank means the
# keyfile is kdf=none. We do NOT verify that the passphrase actually
# unlocks the keyfile — that would require the operator to be
# present and the flow to be interactive. If it's wrong, backups
# will fail loudly with proxmox-backup-client's own error message.
keyfile_pass = payload.get('keyfile_passphrase') or ''
if source == 'path':
# Read a keyfile from an absolute path on this host. Mirrors
# the shell wizard's "import from path" branch — the operator
# dropped the file somewhere on disk (via scp, USB, etc.) and
# types the path. Same size cap + BOM strip as source=content.
src_path = (payload.get('keyfile_path') or '').strip()
if not src_path:
return jsonify({'error': "keyfile_path is required when source is 'path'"}), 400
if not os.path.isabs(src_path):
return jsonify({'error': 'keyfile_path must be an absolute path'}), 400
if not os.path.isfile(src_path):
return jsonify({'error': f'no file at {src_path}'}), 404
try:
with open(src_path, 'rb') as f:
content = f.read(8192 + 1)
except OSError as e:
return jsonify({'error': f'cannot read {src_path}: {e}'}), 500
if not content or len(content) > 8192:
return jsonify({'error': 'keyfile size out of expected range (1..8192 bytes)'}), 400
if content.startswith(b'\xef\xbb\xbf'):
content = content[3:]
keyfile_pass = '' # keyfile passphrase never used by this branch
import tempfile
elif source == 'content':
content_b64 = payload.get('content_b64') or ''
if not content_b64:
return jsonify({'error': "content_b64 is required when source is 'content'"}), 400
import base64
try:
content = base64.b64decode(content_b64, validate=True)
except Exception:
return jsonify({'error': 'content_b64 is not valid base64'}), 400
if not content or len(content) > 8192:
return jsonify({'error': 'keyfile size out of expected range (1..8192 bytes)'}), 400
# Strip a leading UTF-8 BOM if the operator's editor added one.
if content.startswith(b'\xef\xbb\xbf'):
content = content[3:]
# Optional keyfile passphrase (scrypt KDF).
keyfile_pass = payload.get('keyfile_passphrase') or ''
else:
# source == 'pve-storage'
pve_name = (payload.get('pve_storage_name') or '').strip()
if not pve_name:
return jsonify({'error': "pve_storage_name is required when source is 'pve-storage'"}), 400
pve_path = os.path.join(_PVE_STORAGE_KEY_DIR, f'{pve_name}.enc')
if not os.path.isfile(pve_path):
return jsonify({
'error': f'no PVE-managed keyfile at {pve_path}',
}), 404
try:
with open(pve_path, 'rb') as f:
content = f.read(8192 + 1)
except OSError as e:
return jsonify({'error': f'cannot read PVE keyfile: {e}'}), 500
if not content or len(content) > 8192:
return jsonify({'error': 'PVE keyfile out of expected size range (1..8192 bytes)'}), 400
# PVE-managed keyfiles are always --kdf none by construction.
keyfile_pass = ''
# ── Atomic install to _PBS_KEYFILE_PATH ────────────────────────
tmp_fd, tmp_path = tempfile.mkstemp(prefix='pbs-key.import.', dir=_BACKUP_STATE_DIR)
try:
try:
@@ -14236,19 +14501,8 @@ def api_pbs_encryption_import():
except OSError as e:
return jsonify({'error': f'cannot write tmp keyfile: {e}'}), 500
# No content validation. The operator brings their own key —
# any KDF, any format, any origin. If it later doesn't work
# with proxmox-backup-client the failure will surface at
# backup time with a clear error. This is a deliberate
# trust-the-operator design: keeping `key info` validation
# here rejected legitimate scrypt-encoded keyfiles that could
# only be inspected with their original passphrase, which is
# exactly the case ProxMenux can't provide in a non-interactive
# flow.
# 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.
# The old recovery blob was encrypted with the old key — drop it.
# The paired passphrase file goes too; the new source drives it.
for stale in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH):
try:
os.remove(stale)
@@ -14263,9 +14517,8 @@ def api_pbs_encryption_import():
except OSError as e:
return jsonify({'error': f'cannot promote keyfile: {e}'}), 500
# Persist the keyfile passphrase alongside the key, chmod 600.
# Same trust boundary as the keyfile itself: root-only. Empty
# value → don't write anything (blank passphrase == kdf=none).
# Persist the keyfile passphrase (if any). Same trust boundary
# as the keyfile itself.
if keyfile_pass:
try:
fd = os.open(_PBS_KEYFILE_PASS_PATH,
@@ -14275,11 +14528,23 @@ def api_pbs_encryption_import():
except OSError as e:
return jsonify({'error': f'cannot store keyfile passphrase: {e}'}), 500
# ── Finalize per escrow mode ───────────────────────────────
offsite_copy = ''
if escrow_mode == 'none':
_pbs_write_escrow_mode('none')
else:
ok, offsite_copy = _pbs_do_finalize_recovery(escrow_pass, escrow_mode)
if not ok:
return jsonify({'error': 'openssl encryption of the recovery envelope failed'}), 500
return jsonify({
'status': 'ok',
'source': source,
'keyfile_path': _PBS_KEYFILE_PATH,
'keyfile_pass_stored': bool(keyfile_pass),
'recovery_reset': True,
'escrow_mode': escrow_mode,
'recovery_ready': escrow_mode != 'none',
'offsite_copy': offsite_copy,
})
finally:
try:
@@ -14289,6 +14554,124 @@ def api_pbs_encryption_import():
pass
@app.route('/api/host-backups/pbs-encryption/download-keyfile', methods=['GET'])
@require_auth
def api_pbs_encryption_download_keyfile():
"""Send the installed PBS keyfile as a downloadable attachment.
This is how the operator gets an offsite copy without exposing
the file over shell they hit Download, save it somewhere safe
(USB, password manager, another host), and can later re-import
it on a reinstalled host via the same Import flow. Returns 404
when no keyfile is installed."""
if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({'error': 'no keyfile installed'}), 404
filename = f'pbs-key.conf'
try:
# send_file handles the streaming + Content-Disposition header;
# forcing as_attachment makes the browser show a save dialog
# instead of rendering the JSON inline.
return send_file(
_PBS_KEYFILE_PATH,
mimetype='application/octet-stream',
as_attachment=True,
download_name=filename,
)
except OSError as e:
return jsonify({'error': f'cannot read keyfile: {e}'}), 500
@app.route('/api/host-backups/pbs-encryption/discover-pve-keyfiles', methods=['GET'])
@require_auth
def api_pbs_discover_pve_keyfiles():
"""List PBS storage entries in /etc/pve/storage.cfg that carry an
encryption keyfile at /etc/pve/priv/storage/<NAME>.enc. When a
`repository` query param is provided, the entry (if any) whose
server + datastore match that repository is flagged with
`matches_repository: true` so the frontend can pre-select it as
the recommended import source.
Response:
{
entries: [
{name, server, datastore, path, matches_repository (bool)}
]
}
"""
repo = (request.args.get('repository') or '').strip()
matched = _pbs_pve_keyfile_for_repository(repo) if repo else {}
matched_path = matched.get('path', '') if matched else ''
entries = []
for e in _pbs_discover_pve_keyfiles():
entries.append({
**e,
'matches_repository': (bool(matched_path) and e['path'] == matched_path),
})
return jsonify({'entries': entries})
@app.route('/api/host-backups/pbs-encryption/set-escrow-mode', methods=['POST'])
@require_auth
def api_pbs_set_escrow_mode():
"""Change the escrow mode for an already-installed keyfile without
touching the keyfile itself.
Body:
escrow_mode: "none" | "local" | "full"
escrow_passphrase: <str> # required when going to local/full
# or when current mode is 'none'
Transitions:
any none: drops the local envelope + /root offsite copy;
writes mode='none'. Uploaded PBS envelopes are NOT
touched (they stay recoverable with their original
passphrase).
any local/full: encrypts a fresh envelope from the current
keyfile with the provided passphrase. When
going from 'none' the passphrase is mandatory;
when just switching between local/full it is
also required because we always rebuild the
envelope so both modes stay consistent.
"""
if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({'error': 'no keyfile installed'}), 404
payload = request.get_json(silent=True) or {}
new_mode = (payload.get('escrow_mode') or '').strip().lower()
if new_mode not in _PBS_ESCROW_MODES:
return jsonify({'error': f'escrow_mode must be one of {_PBS_ESCROW_MODES}'}), 400
if new_mode == 'none':
# Wipe local envelope + best-effort /root copies. Uploaded PBS
# blobs stay — the operator may still recover the old key via
# its old passphrase using the keyrecovery snapshot group.
for p in (_PBS_RECOVERY_ENC_PATH,):
try:
os.remove(p)
except FileNotFoundError:
pass
except OSError as e:
return jsonify({'error': f'cannot remove {p}: {e}'}), 500
_pbs_write_escrow_mode('none')
return jsonify({
'status': 'ok',
'escrow_mode': 'none',
'recovery_ready': False,
})
# new_mode is 'local' or 'full' → rebuild the envelope from scratch.
passphrase = payload.get('escrow_passphrase') or ''
if not passphrase:
return jsonify({'error': 'escrow_passphrase is required for local/full modes'}), 400
ok, offsite = _pbs_do_finalize_recovery(passphrase, new_mode)
if not ok:
return jsonify({'error': 'openssl encryption of the recovery envelope failed'}), 500
return jsonify({
'status': 'ok',
'escrow_mode': new_mode,
'recovery_ready': True,
'offsite_copy': offsite,
})
@app.route('/api/host-backups/pbs-recovery/setup', methods=['POST'])
@require_auth
def api_pbs_recovery_setup():
@@ -14326,6 +14709,11 @@ def api_pbs_recovery_setup():
if not _pbs_recovery_encrypt(passphrase, _PBS_KEYFILE_PATH, _PBS_RECOVERY_ENC_PATH):
return jsonify({'error': 'openssl encryption failed'}), 500
# Legacy setup implies the operator wants the full escrow behaviour
# (envelope uploaded to PBS by the runner). New callers should use
# /pbs-encryption/set-escrow-mode with an explicit mode, but this
# endpoint remains for compatibility with older Monitor builds.
_pbs_write_escrow_mode('full')
return jsonify({'status': 'ok', 'recovery_path': _PBS_RECOVERY_ENC_PATH})
+5 -4
View File
@@ -1195,17 +1195,18 @@ _rs_extract_pbs() {
# error rather than the raw log.
local extra_hint=""
if grep -qiE 'encryption key|unable to (load|read) key|no key (file|found)|decrypt|failed to decrypt' "$log_file" 2>/dev/null; then
extra_hint=$'\n\n'"$(translate "This backup is encrypted but no keyfile is available on this host.")"
extra_hint=$'\n\n'"$(translate "This backup is encrypted.")"
if [[ -f "$HB_STATE_DIR/pbs-key.conf" ]]; then
extra_hint+=$'\n\n'"$(translate "A keyfile is present but doesn't match the one used to create the backup. Make sure you have the correct keyfile from the source host.")"
extra_hint+=$'\n\n'"$(translate "A keyfile is present at:")"$'\n'" $HB_STATE_DIR/pbs-key.conf"$'\n'"$(translate "but it does not match the one used to create the backup. Replace it with the correct keyfile from the source host and retry.")"
else
extra_hint+=$'\n\n'"$(translate "No keyfile recovery copy was found in PBS for this backup — it was created before the recovery feature existed. The encrypted content cannot be recovered.")"
extra_hint+=$'\n\n'"$(translate "The automatic recovery chain (PVE storage key, local envelope in /root, PBS keyrecovery group) did not find a usable keyfile for this snapshot.")"
extra_hint+=$'\n\n'"$(translate "Copy your keyfile to:")"$'\n'" $HB_STATE_DIR/pbs-key.conf"$'\n'"$(translate "and run Restore again — or pick an unencrypted backup.")"
fi
fi
dialog --backtitle "ProxMenux" --title "$(translate "PBS extraction failed")" \
--msgbox "$(translate "Could not extract from PBS.")"$'\n\n'"$(translate "Backup:") $snapshot"$'\n'"$(translate "Archive:") $archive$extra_hint" \
16 78
20 78
hb_show_log "$log_file" "$(translate "PBS restore error log")"
return 1
}
+445 -84
View File
@@ -903,6 +903,184 @@ hb_pbs_decrypt_recovery() {
-in "$1" -out "$2" -pass stdin 2>/dev/null
}
# ==========================================================
# ESCROW MODE — data model for the keyfile recovery strategy
# ==========================================================
# A single state file at $HB_STATE_DIR/pbs-key.mode records how this
# host wants its PBS keyfile protected against loss. Three values:
# none — keyfile lives only here. No local envelope, no upload.
# If host is lost without an offsite copy → data is gone.
# local — an openssl-wrapped envelope of the keyfile is written to
# /root and $HB_STATE_DIR. Not uploaded to PBS. Operator
# moves the /root copy offsite.
# full — same envelope AND uploaded to PBS as -keyrecovery group.
# Fully self-recoverable, at the cost of putting the
# wrapped keyfile on the same server as the encrypted data.
# Missing file = pre-existing install from before this feature. Treat
# as 'full' to preserve behaviour. Only backup_host.sh / the runner
# needs to read this at runtime; the dialog paths write it.
_hb_pbs_read_escrow_mode() {
local f="$HB_STATE_DIR/pbs-key.mode"
if [[ -s "$f" ]]; then
local v
v=$(head -c 32 "$f" | tr -d '[:space:]')
case "$v" in
none|local|full) printf '%s' "$v"; return 0 ;;
esac
fi
printf 'full'
}
_hb_pbs_write_escrow_mode() {
local mode="$1"
case "$mode" in
none|local|full) : ;;
*) return 1 ;;
esac
mkdir -p "$HB_STATE_DIR" 2>/dev/null || true
umask 077
printf '%s\n' "$mode" > "$HB_STATE_DIR/pbs-key.mode"
chmod 600 "$HB_STATE_DIR/pbs-key.mode" 2>/dev/null || true
return 0
}
# ==========================================================
# PVE STORAGE KEYFILE DISCOVERY
# ==========================================================
# When the operator has already added a PBS storage in Proxmox VE
# and enabled encryption on it (`pvesm set NAME --encryption-key
# autogen` or via the storage editor), PVE writes the keyfile to
# /etc/pve/priv/storage/<NAME>.enc. That file IS a valid raw PBS
# keyfile (same JSON format as `proxmox-backup-client key create
# --kdf none`); it can be reused verbatim with --keyfile.
# ProxMenux surfaces those keyfiles as reuse candidates in the
# encryption dialog, so users don't have to type absolute paths or
# duplicate secrets across two configs.
# Emit one line per PVE-managed PBS storage that has a .enc keyfile:
# name|server|datastore|keyfile-path
_hb_pbs_discover_pve_keyfiles() {
local cfg=/etc/pve/storage.cfg
local dir=/etc/pve/priv/storage
[[ -r "$cfg" && -d "$dir" ]] || return 0
# Parse storage.cfg PBS entries into name+server+datastore triples.
# A single awk pass builds the triples; a shell loop then checks
# each for a matching .enc keyfile and emits only those that
# actually exist.
awk '
function emit(){ printf "%s|%s|%s\n", name, server, datastore }
/^pbs:[[:space:]]+/ { if (name) emit(); name=$2; server=""; datastore=""; next }
/^[a-z]+:[[:space:]]+/ { if (name) { emit(); name="" } }
/^[[:space:]]+server[[:space:]]+/ { server=$2 }
/^[[:space:]]+datastore[[:space:]]+/ { datastore=$2 }
END { if (name) emit() }
' "$cfg" 2>/dev/null | while IFS='|' read -r name server datastore; do
local kf="$dir/$name.enc"
[[ -s "$kf" ]] || continue
printf '%s|%s|%s|%s\n' "$name" "$server" "$datastore" "$kf"
done
}
# Given HB_PBS_REPOSITORY (form user@realm@host:datastore), find the
# PVE-storage keyfile whose server + datastore match. Emits the path
# on stdout, exits non-zero if no match.
_hb_pbs_pve_keyfile_for_current_repo() {
[[ -n "${HB_PBS_REPOSITORY:-}" ]] || return 1
local repo_host repo_datastore
repo_host="${HB_PBS_REPOSITORY##*@}"
repo_host="${repo_host%:*}"
repo_datastore="${HB_PBS_REPOSITORY##*:}"
local match
match=$(_hb_pbs_discover_pve_keyfiles | awk -F'|' \
-v h="$repo_host" -v d="$repo_datastore" \
'$2==h && $3==d {print $4; exit}')
[[ -n "$match" ]] || return 1
printf '%s' "$match"
}
# Copy a PVE-storage keyfile into the ProxMenux canonical location.
# Same trust boundary as hb_pbs_import_keyfile — no validation, just
# atomic install + chmod 600.
_hb_pbs_install_pve_keyfile() {
local src="$1"
local dst="$HB_STATE_DIR/pbs-key.conf"
[[ -s "$src" && -r "$src" ]] || return 1
mkdir -p "$HB_STATE_DIR" 2>/dev/null || true
local tmp
tmp=$(mktemp "${dst}.pve.XXXXXX") || return 1
if ! cp -f "$src" "$tmp"; then rm -f "$tmp"; return 1; fi
chmod 600 "$tmp"
mv -f "$tmp" "$dst" || { rm -f "$tmp"; return 1; }
return 0
}
# ==========================================================
# ESCROW MODE — dialog
# ==========================================================
# Simple yes/no: "Upload the encryption key to PBS?" — mirrors the
# operator's mental model. Two outcomes only, no middle ground:
# Yes → mode='full': ProxMenux writes a passphrase-wrapped copy of
# the keyfile to /root and uploads it to PBS as the
# `-keyrecovery` group so a reinstalled host can recover the
# key with just the passphrase.
# No → mode='none': ProxMenux keeps the keyfile ONLY at the
# canonical local path. The operator is shown the location
# and told to make their own offsite copy — losing it means
# every encrypted backup on PBS is unreadable.
# The 'local' mode (envelope in /root but not uploaded) is still a
# valid backend value; it is just not surfaced to the operator here.
#
# Sets HB_PBS_ESCROW_RESULT to 'full' or 'none' on success; returns
# non-zero on cancel. We use a global variable instead of stdout
# capture because `if dialog --yesno ...; then` inside a `$( )`
# command substitution flushes the terminal at subshell exit and
# leaves the very next dialog invocation needing an extra Enter to
# render — invisible bug in new keyfile flow. Callers set
# their own `local mode=$HB_PBS_ESCROW_RESULT` right after.
_hb_pbs_prompt_escrow_mode() {
# Optional first arg: source path of an EXISTING keyfile the
# operator is about to reuse (either from PVE storage or from an
# absolute path they just typed). When set, the dialog:
# • shows the source path so the operator sees where the key
# already lives on this host,
# • defaults to "No, keep local only" — the operator already
# has this keyfile elsewhere, so a PBS escrow copy is usually
# redundant. They can still hit Yes.
# When empty (fresh generate), defaults to "Yes, upload" — the
# safer path when no other copy of the key exists yet.
local existing_source="${1:-}"
HB_PBS_ESCROW_RESULT=""
local msg
msg="$(hb_translate "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?")"$'\n\n'
if [[ -n "$existing_source" ]]; then
msg+="$(hb_translate "Existing key is at:")"$'\n'" $existing_source"$'\n\n'
fi
msg+="$(hb_translate "Yes: set a recovery passphrase now; the encrypted key envelope is uploaded with every backup.")"$'\n\n'
msg+="$(hb_translate "No: the key stays only at")"$'\n'" $HB_STATE_DIR/pbs-key.conf"$'\n'
msg+="$(hb_translate "Copy that file offsite yourself, or download it from the Monitor.")"
local -a dialog_args=(
--backtitle "ProxMenux"
--title "$(hb_translate "Upload key to PBS?")"
--yes-label "$(hb_translate "Yes, upload")"
--no-label "$(hb_translate "No, keep local only")"
--defaultno
)
dialog_args+=(--yesno "$msg" 18 78)
dialog "${dialog_args[@]}"
# 0 = Yes button, 1 = No button, anything else (ESC / cancel) →
# abort the whole encryption setup so the operator can retry.
case $? in
0) HB_PBS_ESCROW_RESULT='full'; return 0 ;;
1) HB_PBS_ESCROW_RESULT='none'; return 0 ;;
*) return 1 ;;
esac
}
# Import a user-provided PBS encryption keyfile at the canonical
# ProxMenux path. Used by the "shared keyfile across hosts" flow
# where the operator generated one master key manually and wants
@@ -953,13 +1131,17 @@ hb_pbs_import_keyfile() {
return 0
}
# Prompt for a recovery passphrase pair; on OK returns the passphrase on
# stdout. Cancel or empty input returns non-zero WITHOUT creating any
# file. The caller decides when — and whether — to persist a keyfile
# and its recovery blob after this. Splitting the prompt out of the
# side-effectful phase means "user cancels passphrase" leaves the disk
# in exactly the state it was before the encryption flow started.
# Prompt for a recovery passphrase pair. Sets HB_PBS_PASS_RESULT on
# success; returns non-zero on cancel or when openssl is missing. The
# caller decides when — and whether — to persist a keyfile after this,
# so a cancel here leaves the disk exactly as it was.
#
# Uses a global variable instead of stdout capture — see the note on
# _hb_pbs_prompt_escrow_mode. Command-substitution around dialogs
# leaves the terminal in a state where the next dialog needs an extra
# Enter to render.
_hb_pbs_prompt_recovery_pass() {
HB_PBS_PASS_RESULT=""
if ! command -v openssl >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery setup failed")" \
--msgbox "$(hb_translate "openssl is not installed — cannot create recovery copy. Install openssl and retry.")" 9 70
@@ -978,7 +1160,8 @@ _hb_pbs_prompt_recovery_pass() {
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
done
printf '%s' "$pass1"
HB_PBS_PASS_RESULT="$pass1"
return 0
}
# Encrypt the on-disk keyfile with the supplied passphrase, drop an
@@ -986,8 +1169,15 @@ _hb_pbs_prompt_recovery_pass() {
# Precondition: the keyfile exists at $HB_STATE_DIR/pbs-key.conf.
# Returns non-zero only on openssl failure (rare). The caller is
# responsible for undoing whatever it just did if this fails.
#
# Args:
# $1 = passphrase
# $2 = escrow mode ('local' or 'full'); the message text branches
# on this. Never called with 'none' — the top-level flow skips
# finalize entirely for that mode.
_hb_pbs_finalize_recovery() {
local pass="$1"
local mode="${2:-full}"
local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
@@ -998,44 +1188,43 @@ _hb_pbs_finalize_recovery() {
fi
chmod 600 "$recovery_enc"
# Drop an easy-export copy in /root so the operator can scp/USB
# it offsite without spelunking through HB_STATE_DIR.
local export_copy="/root/pbs-key.recovery-$(hostname)-$(date +%Y%m%d).enc"
if cp "$recovery_enc" "$export_copy" 2>/dev/null; then
chmod 600 "$export_copy"
else
export_copy=""
fi
local success_msg
success_msg="$(hb_translate "Recovery configured.")"$'\n\n'
success_msg+="$(hb_translate "Every PBS backup from now on will also upload the encrypted recovery copy to PBS — automatically, no extra steps from you.")"$'\n\n'
success_msg+="$(hb_translate "If you lose this host: install ProxMenux on a fresh PVE host, point it at the same PBS, and the restore flow will offer to recover the keyfile using your passphrase.")"
if [[ -n "$export_copy" ]]; then
success_msg+=$'\n\n'"$(hb_translate "Offsite copy (optional):") $export_copy"
fi
dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery ready")" \
--msgbox "$success_msg" 18 80
# No extra "Recovery ready" msgbox — the operator's next action is
# the backup itself. The status of the encryption setup is visible
# in the Monitor and in the summary the backup emits when it
# starts running.
return 0
}
# Public wrapper preserved for external callers that already have a
# keyfile on disk and just want the recovery blob generated. Combines
# the prompt + finalize phases.
# the prompt + finalize phases. Defaults to 'full' when called without
# a mode argument (preserves legacy caller behaviour); when the caller
# passes a mode it is forwarded to _hb_pbs_finalize_recovery so the
# success message matches.
hb_pbs_setup_recovery() {
local mode="${1:-full}"
local pass
pass=$(_hb_pbs_prompt_recovery_pass) || return 1
_hb_pbs_finalize_recovery "$pass"
if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then
return 1
fi
_hb_pbs_write_escrow_mode "$mode"
}
# Upload the local recovery .enc to PBS as a separate snapshot
# group. Called from _bk_pbs after the main backup succeeds.
# Skips silently if no recovery copy is present. Returns 0 on
# success or skip, 1 on upload failure.
# Short-circuits when escrow mode is not 'full' — the recovery
# envelope may still exist locally in 'local' mode, but the whole
# point of that mode is not putting it on PBS. Skips silently if no
# recovery copy is present. Returns 0 on success or skip, 1 on
# upload failure.
hb_pbs_upload_recovery_blob() {
local epoch="$1"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
[[ ! -f "$recovery_enc" ]] && return 0
local mode
mode=$(_hb_pbs_read_escrow_mode)
[[ "$mode" != "full" ]] && return 0
# `proxmox-backup-client backup` only accepts archive types
# `pxar` / `img` / `conf` / `log` as the source spec — `.blob`
@@ -1058,16 +1247,77 @@ hb_pbs_upload_recovery_blob() {
>/dev/null 2>&1
}
# On a fresh install with no local keyfile, try to recover it
# from PBS. Returns 0 if the keyfile was successfully restored
# to $1, 1 if no recovery is possible or the user cancelled.
# On a fresh install with no local keyfile, try to recover it.
# Attempts several sources in order of decreasing convenience for
# Manual-import fallback: prompt the operator for the absolute path
# of the keyfile on this host. Copies it verbatim to the canonical
# path. Called from hb_pbs_try_keyfile_recovery when both auto-paths
# (PVE storage keyfile, PBS keyrecovery group) have been exhausted.
# Returns 0 on successful install, 1 on cancel or missing file.
_hb_pbs_manual_keyfile_import() {
local target_keyfile="$1"
local src
src=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Import encryption keyfile")" \
--inputbox "$(hb_translate "No PBS keyfile is installed on this host and no automatic recovery was possible.")"$'\n\n'"$(hb_translate "Absolute path to your PBS keyfile:")"$'\n\n'"$(hb_translate "The file is copied to")"$'\n'"$target_keyfile" \
16 78 "" 3>&1 1>&2 2>&3) || return 1
src="$(echo "$src" | xargs)"
[[ -z "$src" ]] && return 1
if [[ ! -s "$src" || ! -r "$src" ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "The file does not exist, is empty or is not readable.")"$'\n\n'"$(hb_translate "Path:") $src" \
10 78
return 1
fi
mkdir -p "$(dirname "$target_keyfile")"
if ! cp -f "$src" "$target_keyfile"; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "Could not copy the keyfile into place.")" 9 70
return 1
fi
chmod 600 "$target_keyfile"
return 0
}
# On a fresh install with no local keyfile, try to recover it.
# Attempts sources in order and stops at the first one that works:
#
# 1. PVE-storage keyfile matching HB_PBS_REPOSITORY
# (/etc/pve/priv/storage/<name>.enc) — silent copy.
# 2. PBS `-keyrecovery` snapshot group — prompts for passphrase.
# 3. Manual import from an absolute path on this host — prompted
# when the two auto-paths above find nothing or the operator
# declines them.
#
# Returns 0 if the keyfile was successfully restored to $1,
# 1 if no recovery is possible or the user cancelled.
hb_pbs_try_keyfile_recovery() {
local target_keyfile="$1"
# ── Step 1: PVE-storage keyfile matching the current repository ──
# If the operator has already configured PBS in PVE with an
# encryption-key, we can use that file verbatim — no passphrase
# prompt, no PBS round-trip. This is the classic "why do I need
# to import? it is right there" case.
local pve_key_path=""
if [[ -n "${HB_PBS_REPOSITORY:-}" ]]; then
pve_key_path=$(_hb_pbs_pve_keyfile_for_current_repo 2>/dev/null || true)
fi
if [[ -n "$pve_key_path" && -s "$pve_key_path" ]]; then
mkdir -p "$(dirname "$target_keyfile")"
if cp -f "$pve_key_path" "$target_keyfile" 2>/dev/null; then
chmod 600 "$target_keyfile"
dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovered")" \
--msgbox "$(hb_translate "Reused the encryption key from the PVE storage entry.")"$'\n\n'"$(hb_translate "Source:") $pve_key_path"$'\n'"$(hb_translate "Installed at:") $target_keyfile"$'\n\n'"$(hb_translate "Restore can now proceed.")" \
14 78
return 0
fi
fi
if ! command -v openssl >/dev/null 2>&1; then
return 1 # silently — main path will surface a clearer error
fi
# ── Step 2: PBS -keyrecovery snapshot group (fall back) ────────
# Discover keyrecovery groups in PBS. Two patterns are matched:
# - hostcfg-<host>-keyrecovery (current naming — groups
# adjacent to the main hostcfg-<host>)
@@ -1092,8 +1342,11 @@ hb_pbs_try_keyfile_recovery() {
)
if [[ ${#recovery_entries[@]} -eq 0 ]]; then
return 1 # no recovery available — main flow will fail later on
# the actual decrypt, with a clear message
# No auto-recovery available — fall through to the manual
# import prompt. Nothing has been written to disk yet so a
# cancel here just aborts cleanly.
_hb_pbs_manual_keyfile_import "$target_keyfile"
return $?
fi
# Pick the recovery group (auto if one, ask if many)
@@ -1129,9 +1382,13 @@ hb_pbs_try_keyfile_recovery() {
iso=$(date -u -d "@$picked_epoch" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "$picked_epoch")
local recovery_snapshot="host/${picked_id}/${iso}"
dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovery available")" \
if ! dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovery available")" \
--yesno "$(hb_translate "Local keyfile is missing but a recovery copy was found in PBS.")"$'\n\n'"$(hb_translate "Backup:") $recovery_snapshot"$'\n\n'"$(hb_translate "Recover the keyfile using your recovery passphrase?")" \
13 78 || return 1
13 78; then
# Operator declined PBS recovery → offer the manual import path.
_hb_pbs_manual_keyfile_import "$target_keyfile"
return $?
fi
# Download the blob once; we may retry passphrase entry without
# re-fetching it.
@@ -1180,26 +1437,36 @@ hb_pbs_try_keyfile_recovery() {
# Internal helper: create a fresh PBS keyfile at the canonical path
# and its paired recovery blob. The passphrase is prompted BEFORE the
# keyfile hits disk — so if the operator cancels at the passphrase
# prompt, nothing is created and nothing needs cleaning up. The only
# time we ever have to remove a keyfile here is if openssl fails to
# encrypt the recovery blob (rare); that's a real error, not a cancel.
# and (unless mode='none') its paired recovery envelope. Prompts the
# operator for the escrow mode first — that decision drives whether
# a passphrase is asked, whether openssl is run, and what message
# closes the flow. Cancel at any dialog before the keyfile is
# actually created leaves the disk untouched.
_hb_pbs_create_new_keyfile() {
local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
# 1. Collect the recovery passphrase. Cancel here → zero side effect.
local pass
pass=$(_hb_pbs_prompt_recovery_pass) || return 1
# 1. Pick the escrow mode. Cancel here → zero side effect.
_hb_pbs_prompt_escrow_mode || return 1
local mode="$HB_PBS_ESCROW_RESULT"
# 2. Create the keyfile. Point of no return: any failure past here
# leaves us with state to clean up.
msg_info "$(hb_translate "Creating PBS encryption key...")"
# 2. Collect the recovery passphrase — only when we're going to
# encrypt an envelope. Cancel at the passphrase prompt → zero
# side effect.
local pass=""
if [[ "$mode" != "none" ]]; then
_hb_pbs_prompt_recovery_pass || return 1
pass="$HB_PBS_PASS_RESULT"
fi
# 3. Create the keyfile. Point of no return: any failure past here
# leaves us with state to clean up. No msg_info/msg_ok in the
# dialog chain — those interleave with the dialogs' terminal
# state and cause the operator to have to press Enter twice.
mkdir -p "$HB_STATE_DIR"
local create_stderr
local create_stderr create_rc
create_stderr=$(proxmox-backup-client key create --kdf none "$key_file" </dev/null 2>&1 >/dev/null)
local create_rc=$?
create_rc=$?
if [[ $create_rc -ne 0 || ! -s "$key_file" ]]; then
# Sweep any zero-byte residue and surface the real error.
[[ -f "$key_file" && ! -s "$key_file" ]] && rm -f "$key_file" 2>/dev/null
@@ -1214,24 +1481,29 @@ _hb_pbs_create_new_keyfile() {
return 1
fi
chmod 600 "$key_file"
msg_ok "$(hb_translate "Encryption key created:") $key_file"
# 3. Encrypt the recovery blob with the already-collected passphrase.
# Only an openssl-level failure lands us in the wipe branch.
if ! _hb_pbs_finalize_recovery "$pass"; then
rm -f "$key_file" "$recovery_enc" 2>/dev/null
HB_PBS_KEYFILE_OPT=""
return 1
# 4. Finalize per mode. On mode='none' we skip finalize entirely —
# no envelope, no msgbox. The operator was already told in the
# upload prompt where the keyfile lives; the backup summary
# reprints "Encryption: Enabled" when the run starts.
if [[ "$mode" != "none" ]]; then
if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then
rm -f "$key_file" "$recovery_enc" 2>/dev/null
HB_PBS_KEYFILE_OPT=""
return 1
fi
fi
_hb_pbs_write_escrow_mode "$mode"
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
return 0
}
# Internal helper: prompt for the source path, validate WITHOUT
# copying, ask the recovery passphrase, and only then install the
# keyfile at the canonical path. Cancel at any dialog before the
# install step leaves the disk untouched — no "phantom" keyfile.
# copying, ask the escrow mode (and, if not 'none', the recovery
# passphrase), then install the keyfile at the canonical path.
# Cancel at any dialog before the install step leaves the disk
# untouched — no "phantom" keyfile.
_hb_pbs_import_dialog() {
local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
@@ -1280,22 +1552,29 @@ _hb_pbs_import_dialog() {
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
done
# 4. Collect the recovery passphrase. Cancel here → zero side effect
# (the source file is still where it was; we haven't copied yet).
local pass
pass=$(_hb_pbs_prompt_recovery_pass) || return 1
# 4. Pick the escrow mode. Cancel → zero side effect.
_hb_pbs_prompt_escrow_mode "$src" || return 1
local mode="$HB_PBS_ESCROW_RESULT"
# 5. Install the keyfile. Point of no return.
# hb_pbs_import_keyfile just copies + chmods; content is not inspected.
# 5. Collect the recovery passphrase — only when we plan to write
# an envelope. Cancel → zero side effect.
local pass=""
if [[ "$mode" != "none" ]]; then
_hb_pbs_prompt_recovery_pass || return 1
pass="$HB_PBS_PASS_RESULT"
fi
# 6. Install the keyfile. Point of no return. No msg_ok before the
# next dialog — the msg_ line breaks dialog terminal state and
# forces the operator to press Enter twice on the next one.
if ! hb_pbs_import_keyfile "$src"; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "Could not copy the keyfile into place. Check permissions on:") $HB_STATE_DIR" \
10 78
return 1
fi
msg_ok "$(hb_translate "Imported existing encryption key:") $key_file"
# 6. Persist the keyfile passphrase (if any) next to the keyfile.
# 7. Persist the keyfile passphrase (if any) next to the keyfile.
# Same trust boundary — chmod 600, root-only. Empty passphrase
# means no file: the runner treats absent file as kdf=none.
# Also export HB_PBS_ENC_PASS so _bk_pbs (called right after
@@ -1312,30 +1591,97 @@ _hb_pbs_import_dialog() {
HB_PBS_ENC_PASS=""
fi
# 7. Encrypt the recovery blob with the recovery passphrase.
# openssl failure is the only path that has to undo the install.
if ! _hb_pbs_finalize_recovery "$pass"; then
rm -f "$key_file" "$recovery_enc" "$keyfile_pass_file" 2>/dev/null
HB_PBS_KEYFILE_OPT=""
# 8. Finalize per mode. mode='none' skips finalize (no envelope,
# no msgbox) — the operator was already told in the upload
# prompt where the keyfile lives.
if [[ "$mode" != "none" ]]; then
if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then
rm -f "$key_file" "$recovery_enc" "$keyfile_pass_file" 2>/dev/null
HB_PBS_KEYFILE_OPT=""
return 1
fi
fi
_hb_pbs_write_escrow_mode "$mode"
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
return 0
}
# Internal helper: reuse a keyfile already managed by PVE at
# /etc/pve/priv/storage/<NAME>.enc. Same mode-aware finalize flow as
# create + import, but without a passphrase prompt for the keyfile
# itself (PVE-managed encryption-key entries are always --kdf none).
_hb_pbs_reuse_pve_keyfile() {
local pve_name="$1"
local pve_path="$2"
local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
[[ -s "$pve_path" ]] || return 1
# 1. Pick the escrow mode. Pass the PVE source path so the dialog
# tells the operator where the reused key already lives.
_hb_pbs_prompt_escrow_mode "$pve_path" || return 1
local mode="$HB_PBS_ESCROW_RESULT"
# 2. Recovery passphrase (only if we plan to write an envelope).
local pass=""
if [[ "$mode" != "none" ]]; then
_hb_pbs_prompt_recovery_pass || return 1
pass="$HB_PBS_PASS_RESULT"
fi
# 3. Install the PVE keyfile at the canonical path. No msg_ok
# before the next dialog.
if ! _hb_pbs_install_pve_keyfile "$pve_path"; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "Could not copy the PVE keyfile into place. Check permissions on:") $HB_STATE_DIR" \
10 78
return 1
fi
# 4. PVE-managed keys never carry a per-key passphrase (they are
# always --kdf none). Wipe any stale pbs-key.pass so the runner
# does not try to feed a passphrase to a keyfile that has none.
rm -f "$HB_STATE_DIR/pbs-key.pass" 2>/dev/null || true
HB_PBS_ENC_PASS=""
# 5. Finalize per mode. mode='none' skips finalize (no envelope,
# no msgbox).
if [[ "$mode" != "none" ]]; then
if ! _hb_pbs_finalize_recovery "$pass" "$mode"; then
rm -f "$key_file" "$recovery_enc" 2>/dev/null
HB_PBS_KEYFILE_OPT=""
return 1
fi
fi
_hb_pbs_write_escrow_mode "$mode"
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
return 0
}
hb_ask_pbs_encryption() {
# Two-step flow:
# Flow (spec by the operator):
# 1. Yes/No: "Do you want to encrypt this backup?"
# No → plain backup, no keyfile touched.
# Yes → step 2.
# 2a. Keyfile already exists on this host → use it silently.
# 2b. No keyfile yet → menu with 2 options:
# new → generate + confirm passphrase (legacy flow).
# import → prompt path, validate, copy into place.
# cancel → ABORT the whole backup (operator asked for
# encryption but didn't pick a method; we don't
# silently downgrade to plain).
# Subsequent scheduled or manual backups never re-ask for
# the setup — that is what "already configured" means.
# 2b. No keyfile yet → 2-option menu:
# new → generate a fresh keyfile
# existing → try to auto-detect a PVE-managed keyfile
# matching the current PBS repository;
# if found, use it silently; if not, prompt
# for the absolute path.
# 3. After the keyfile is in place, ask Yes/No:
# "Upload an encrypted copy of the key to PBS?"
# Yes → passphrase → envelope written + will be uploaded
# by the runner (mode='full').
# No → operator is shown the local path and told to make
# their own offsite copy (mode='none').
#
# A single-run helper leftover from a cancelled/failed create can
# leave a zero-byte keyfile behind; we wipe it before deciding so
# the "keyfile exists" branch never trips on an empty file.
@@ -1373,21 +1719,36 @@ hb_ask_pbs_encryption() {
return 0
fi
# ── Step 2b: no keyfile → let the operator generate or import
# ── Step 2b: source menu — 2 options ─────────────────────
local choice
choice=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption key")" \
--default-item "new" \
--menu "$(hb_translate "No encryption key is stored on this host. Choose how to set one up:")" \
12 78 2 \
"new" "$(hb_translate "Generate a new keyfile (one per host — safest isolation)")" \
"import" "$(hb_translate "Import an existing keyfile from a path (shared across hosts)")" \
3>&1 1>&2 2>&3) || return 1 # Cancel → abort backup.
"new" "$(hb_translate "Generate a new keyfile")" \
"existing" "$(hb_translate "Use an existing keyfile")" \
3>&1 1>&2 2>&3) || return 1
case "$choice" in
new)
_hb_pbs_create_new_keyfile
return $?
;;
import)
existing)
# Auto-detect: is there a PVE-managed keyfile for this repo?
# If yes, use it silently (matches operator ask: "if there
# is one at the default path, auto-detect it"). If not,
# fall through to the manual-path dialog.
local pve_kf=""
if [[ -n "${HB_PBS_REPOSITORY:-}" ]]; then
pve_kf=$(_hb_pbs_pve_keyfile_for_current_repo 2>/dev/null || true)
fi
if [[ -n "$pve_kf" && -s "$pve_kf" ]]; then
local pve_name
pve_name="$(basename "$pve_kf" .enc)"
_hb_pbs_reuse_pve_keyfile "$pve_name" "$pve_kf"
return $?
fi
_hb_pbs_import_dialog
return $?
;;