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 sqlite3
import subprocess import subprocess
import sys import sys
import tempfile
import time import time
import threading import threading
import urllib.parse 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. # proxmox-backup-client can decrypt the key without prompting.
# Same trust boundary as the keyfile itself: chmod 600, next to it. # Same trust boundary as the keyfile itself: chmod 600, next to it.
_PBS_KEYFILE_PASS_PATH = f'{_BACKUP_STATE_DIR}/pbs-key.pass' _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: 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 and os.path.getsize(_PBS_KEYFILE_PASS_PATH) > 0
), ),
'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH), '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 passphrase and are the safe way to reach any pre-existing backup
encrypted with the removed key. 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): if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({'error': 'no keyfile installed'}), 404 return jsonify({'error': 'no keyfile installed'}), 404
backed_up = [] backed_up: list = []
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
# Wipe order: recovery blob first so a mid-flight failure leaves us # 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 = [] 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: try:
os.remove(path) os.remove(path)
removed.append(path) removed.append(path)
@@ -14023,6 +14184,10 @@ def api_pbs_recovery_status():
# manifest's expected fingerprint after a download error # manifest's expected fingerprint after a download error
# ("missing key — manifest was created with key XX:XX:..."). # ("missing key — manifest was created with key XX:XX:...").
'keyfile_fingerprint': _pbs_keyfile_fingerprint(), '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']) @app.route('/api/host-backups/pbs-encryption/import', methods=['POST'])
@require_auth @require_auth
def api_pbs_encryption_import(): 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 source: "content" (default) | "pve-storage"
to share it across every host (single recovery blob covers all, content_b64: <base64 bytes> # source=content
cross-host restore is possible). The keyfile is written to a tmp pve_storage_name: "<storage-name>" # source=pve-storage
path first and validated with ``proxmox-backup-client key info``; keyfile_passphrase: <scrypt kdf> # source=content only
only if the tool accepts it as a real PBS keyfile does it get escrow_mode: "none" | "local" | "full" # default "full"
promoted to the canonical ``_PBS_KEYFILE_PATH``. Any prior escrow_passphrase: <str> # required unless escrow_mode='none'
keyfile + recovery blob at that location are replaced atomically
the old blob was encrypted for the old key and would no longer be Behaviour by escrow_mode:
decryptable anyway. - 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 {} payload = request.get_json(silent=True) or {}
content_b64 = payload.get('content_b64') or '' source = (payload.get('source') or 'content').strip().lower()
if not content_b64: if source not in ('content', 'pve-storage', 'generate', 'path'):
return jsonify({'error': 'content_b64 is required'}), 400 return jsonify({'error': "source must be 'content', 'pve-storage', 'generate' or 'path'"}), 400
import base64 escrow_mode = (payload.get('escrow_mode') or 'full').strip().lower()
try: if escrow_mode not in _PBS_ESCROW_MODES:
content = base64.b64decode(content_b64, validate=True) return jsonify({'error': f'escrow_mode must be one of {_PBS_ESCROW_MODES}'}), 400
except Exception: escrow_pass = payload.get('escrow_passphrase') or ''
return jsonify({'error': 'content_b64 is not valid base64'}), 400 if escrow_mode != 'none' and not escrow_pass:
return jsonify({'error': 'escrow_passphrase is required when escrow_mode is not "none"'}), 400
# PBS keyfiles are compact JSON blobs (~200 bytes). Cap at 8 KB so if escrow_mode != 'none' and not shutil.which('openssl'):
# a malformed upload can't fill the state dir with garbage. return jsonify({'error': 'openssl is not installed; cannot build the recovery envelope'}), 500
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'): if not shutil.which('proxmox-backup-client'):
return jsonify({'error': 'proxmox-backup-client is not installed on this host'}), 500 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: except OSError as e:
return jsonify({'error': f'cannot create state directory: {e}'}), 500 return jsonify({'error': f'cannot create state directory: {e}'}), 500
# Strip a leading UTF-8 BOM (b'\xef\xbb\xbf') if the operator's # ── Resolve the keyfile bytes based on source ──────────────────
# editor added one — the byte order mark makes the file no longer keyfile_pass = ''
# a valid JSON document and downstream tools refuse it with a if source == 'generate':
# cryptic parse error. # Fresh keyfile generated server-side via proxmox-backup-client.
if content.startswith(b'\xef\xbb\xbf'): # We short-circuit the "load bytes → atomic install" pipeline
content = content[3:] # 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 if source == 'path':
# keyfile is kdf=none. We do NOT verify that the passphrase actually # Read a keyfile from an absolute path on this host. Mirrors
# unlocks the keyfile — that would require the operator to be # the shell wizard's "import from path" branch — the operator
# present and the flow to be interactive. If it's wrong, backups # dropped the file somewhere on disk (via scp, USB, etc.) and
# will fail loudly with proxmox-backup-client's own error message. # types the path. Same size cap + BOM strip as source=content.
keyfile_pass = payload.get('keyfile_passphrase') or '' 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) tmp_fd, tmp_path = tempfile.mkstemp(prefix='pbs-key.import.', dir=_BACKUP_STATE_DIR)
try: try:
try: try:
@@ -14236,19 +14501,8 @@ def api_pbs_encryption_import():
except OSError as e: except OSError as e:
return jsonify({'error': f'cannot write tmp keyfile: {e}'}), 500 return jsonify({'error': f'cannot write tmp keyfile: {e}'}), 500
# No content validation. The operator brings their own key — # The old recovery blob was encrypted with the old key — drop it.
# any KDF, any format, any origin. If it later doesn't work # The paired passphrase file goes too; the new source drives it.
# 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.
for stale in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH): for stale in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH):
try: try:
os.remove(stale) os.remove(stale)
@@ -14263,9 +14517,8 @@ def api_pbs_encryption_import():
except OSError as e: except OSError as e:
return jsonify({'error': f'cannot promote keyfile: {e}'}), 500 return jsonify({'error': f'cannot promote keyfile: {e}'}), 500
# Persist the keyfile passphrase alongside the key, chmod 600. # Persist the keyfile passphrase (if any). Same trust boundary
# Same trust boundary as the keyfile itself: root-only. Empty # as the keyfile itself.
# value → don't write anything (blank passphrase == kdf=none).
if keyfile_pass: if keyfile_pass:
try: try:
fd = os.open(_PBS_KEYFILE_PASS_PATH, fd = os.open(_PBS_KEYFILE_PASS_PATH,
@@ -14275,11 +14528,23 @@ def api_pbs_encryption_import():
except OSError as e: except OSError as e:
return jsonify({'error': f'cannot store keyfile passphrase: {e}'}), 500 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({ return jsonify({
'status': 'ok', 'status': 'ok',
'source': source,
'keyfile_path': _PBS_KEYFILE_PATH, 'keyfile_path': _PBS_KEYFILE_PATH,
'keyfile_pass_stored': bool(keyfile_pass), 'keyfile_pass_stored': bool(keyfile_pass),
'recovery_reset': True, 'escrow_mode': escrow_mode,
'recovery_ready': escrow_mode != 'none',
'offsite_copy': offsite_copy,
}) })
finally: finally:
try: try:
@@ -14289,6 +14554,124 @@ def api_pbs_encryption_import():
pass 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']) @app.route('/api/host-backups/pbs-recovery/setup', methods=['POST'])
@require_auth @require_auth
def api_pbs_recovery_setup(): 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): if not _pbs_recovery_encrypt(passphrase, _PBS_KEYFILE_PATH, _PBS_RECOVERY_ENC_PATH):
return jsonify({'error': 'openssl encryption failed'}), 500 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}) 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. # error rather than the raw log.
local extra_hint="" 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 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 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 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
fi fi
dialog --backtitle "ProxMenux" --title "$(translate "PBS extraction failed")" \ 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" \ --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")" hb_show_log "$log_file" "$(translate "PBS restore error log")"
return 1 return 1
} }
+445 -84
View File
@@ -903,6 +903,184 @@ hb_pbs_decrypt_recovery() {
-in "$1" -out "$2" -pass stdin 2>/dev/null -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 # Import a user-provided PBS encryption keyfile at the canonical
# ProxMenux path. Used by the "shared keyfile across hosts" flow # ProxMenux path. Used by the "shared keyfile across hosts" flow
# where the operator generated one master key manually and wants # where the operator generated one master key manually and wants
@@ -953,13 +1131,17 @@ hb_pbs_import_keyfile() {
return 0 return 0
} }
# Prompt for a recovery passphrase pair; on OK returns the passphrase on # Prompt for a recovery passphrase pair. Sets HB_PBS_PASS_RESULT on
# stdout. Cancel or empty input returns non-zero WITHOUT creating any # success; returns non-zero on cancel or when openssl is missing. The
# file. The caller decides when — and whether — to persist a keyfile # caller decides when — and whether — to persist a keyfile after this,
# and its recovery blob after this. Splitting the prompt out of the # so a cancel here leaves the disk exactly as it was.
# side-effectful phase means "user cancels passphrase" leaves the disk #
# in exactly the state it was before the encryption flow started. # 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_prompt_recovery_pass() {
HB_PBS_PASS_RESULT=""
if ! command -v openssl >/dev/null 2>&1; then if ! command -v openssl >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery setup failed")" \ 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 --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" \ dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50 --msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
done done
printf '%s' "$pass1" HB_PBS_PASS_RESULT="$pass1"
return 0
} }
# Encrypt the on-disk keyfile with the supplied passphrase, drop an # 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. # Precondition: the keyfile exists at $HB_STATE_DIR/pbs-key.conf.
# Returns non-zero only on openssl failure (rare). The caller is # Returns non-zero only on openssl failure (rare). The caller is
# responsible for undoing whatever it just did if this fails. # 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() { _hb_pbs_finalize_recovery() {
local pass="$1" local pass="$1"
local mode="${2:-full}"
local key_file="$HB_STATE_DIR/pbs-key.conf" local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
@@ -998,44 +1188,43 @@ _hb_pbs_finalize_recovery() {
fi fi
chmod 600 "$recovery_enc" chmod 600 "$recovery_enc"
# Drop an easy-export copy in /root so the operator can scp/USB # No extra "Recovery ready" msgbox — the operator's next action is
# it offsite without spelunking through HB_STATE_DIR. # the backup itself. The status of the encryption setup is visible
local export_copy="/root/pbs-key.recovery-$(hostname)-$(date +%Y%m%d).enc" # in the Monitor and in the summary the backup emits when it
if cp "$recovery_enc" "$export_copy" 2>/dev/null; then # starts running.
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
return 0 return 0
} }
# Public wrapper preserved for external callers that already have a # Public wrapper preserved for external callers that already have a
# keyfile on disk and just want the recovery blob generated. Combines # 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() { hb_pbs_setup_recovery() {
local mode="${1:-full}"
local pass local pass
pass=$(_hb_pbs_prompt_recovery_pass) || return 1 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 # Upload the local recovery .enc to PBS as a separate snapshot
# group. Called from _bk_pbs after the main backup succeeds. # group. Called from _bk_pbs after the main backup succeeds.
# Skips silently if no recovery copy is present. Returns 0 on # Short-circuits when escrow mode is not 'full' — the recovery
# success or skip, 1 on upload failure. # 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() { hb_pbs_upload_recovery_blob() {
local epoch="$1" local epoch="$1"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
[[ ! -f "$recovery_enc" ]] && return 0 [[ ! -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 # `proxmox-backup-client backup` only accepts archive types
# `pxar` / `img` / `conf` / `log` as the source spec — `.blob` # `pxar` / `img` / `conf` / `log` as the source spec — `.blob`
@@ -1058,16 +1247,77 @@ hb_pbs_upload_recovery_blob() {
>/dev/null 2>&1 >/dev/null 2>&1
} }
# On a fresh install with no local keyfile, try to recover it # On a fresh install with no local keyfile, try to recover it.
# from PBS. Returns 0 if the keyfile was successfully restored # Attempts several sources in order of decreasing convenience for
# to $1, 1 if no recovery is possible or the user cancelled. # 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() { hb_pbs_try_keyfile_recovery() {
local target_keyfile="$1" 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 if ! command -v openssl >/dev/null 2>&1; then
return 1 # silently — main path will surface a clearer error return 1 # silently — main path will surface a clearer error
fi fi
# ── Step 2: PBS -keyrecovery snapshot group (fall back) ────────
# Discover keyrecovery groups in PBS. Two patterns are matched: # Discover keyrecovery groups in PBS. Two patterns are matched:
# - hostcfg-<host>-keyrecovery (current naming — groups # - hostcfg-<host>-keyrecovery (current naming — groups
# adjacent to the main hostcfg-<host>) # adjacent to the main hostcfg-<host>)
@@ -1092,8 +1342,11 @@ hb_pbs_try_keyfile_recovery() {
) )
if [[ ${#recovery_entries[@]} -eq 0 ]]; then if [[ ${#recovery_entries[@]} -eq 0 ]]; then
return 1 # no recovery available — main flow will fail later on # No auto-recovery available — fall through to the manual
# the actual decrypt, with a clear message # 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 fi
# Pick the recovery group (auto if one, ask if many) # 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") 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}" 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?")" \ --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 # Download the blob once; we may retry passphrase entry without
# re-fetching it. # re-fetching it.
@@ -1180,26 +1437,36 @@ hb_pbs_try_keyfile_recovery() {
# Internal helper: create a fresh PBS keyfile at the canonical path # Internal helper: create a fresh PBS keyfile at the canonical path
# and its paired recovery blob. The passphrase is prompted BEFORE the # and (unless mode='none') its paired recovery envelope. Prompts the
# keyfile hits disk — so if the operator cancels at the passphrase # operator for the escrow mode first — that decision drives whether
# prompt, nothing is created and nothing needs cleaning up. The only # a passphrase is asked, whether openssl is run, and what message
# time we ever have to remove a keyfile here is if openssl fails to # closes the flow. Cancel at any dialog before the keyfile is
# encrypt the recovery blob (rare); that's a real error, not a cancel. # actually created leaves the disk untouched.
_hb_pbs_create_new_keyfile() { _hb_pbs_create_new_keyfile() {
local key_file="$HB_STATE_DIR/pbs-key.conf" local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
# 1. Collect the recovery passphrase. Cancel here → zero side effect. # 1. Pick the escrow mode. Cancel here → zero side effect.
local pass _hb_pbs_prompt_escrow_mode || return 1
pass=$(_hb_pbs_prompt_recovery_pass) || return 1 local mode="$HB_PBS_ESCROW_RESULT"
# 2. Create the keyfile. Point of no return: any failure past here # 2. Collect the recovery passphrase — only when we're going to
# leaves us with state to clean up. # encrypt an envelope. Cancel at the passphrase prompt → zero
msg_info "$(hb_translate "Creating PBS encryption key...")" # 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" 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) 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 if [[ $create_rc -ne 0 || ! -s "$key_file" ]]; then
# Sweep any zero-byte residue and surface the real error. # Sweep any zero-byte residue and surface the real error.
[[ -f "$key_file" && ! -s "$key_file" ]] && rm -f "$key_file" 2>/dev/null [[ -f "$key_file" && ! -s "$key_file" ]] && rm -f "$key_file" 2>/dev/null
@@ -1214,24 +1481,29 @@ _hb_pbs_create_new_keyfile() {
return 1 return 1
fi fi
chmod 600 "$key_file" chmod 600 "$key_file"
msg_ok "$(hb_translate "Encryption key created:") $key_file"
# 3. Encrypt the recovery blob with the already-collected passphrase. # 4. Finalize per mode. On mode='none' we skip finalize entirely —
# Only an openssl-level failure lands us in the wipe branch. # no envelope, no msgbox. The operator was already told in the
if ! _hb_pbs_finalize_recovery "$pass"; then # upload prompt where the keyfile lives; the backup summary
rm -f "$key_file" "$recovery_enc" 2>/dev/null # reprints "Encryption: Enabled" when the run starts.
HB_PBS_KEYFILE_OPT="" if [[ "$mode" != "none" ]]; then
return 1 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 fi
_hb_pbs_write_escrow_mode "$mode"
HB_PBS_KEYFILE_OPT="--keyfile $key_file" HB_PBS_KEYFILE_OPT="--keyfile $key_file"
return 0 return 0
} }
# Internal helper: prompt for the source path, validate WITHOUT # Internal helper: prompt for the source path, validate WITHOUT
# copying, ask the recovery passphrase, and only then install the # copying, ask the escrow mode (and, if not 'none', the recovery
# keyfile at the canonical path. Cancel at any dialog before the # passphrase), then install the keyfile at the canonical path.
# install step leaves the disk untouched — no "phantom" keyfile. # Cancel at any dialog before the install step leaves the disk
# untouched — no "phantom" keyfile.
_hb_pbs_import_dialog() { _hb_pbs_import_dialog() {
local key_file="$HB_STATE_DIR/pbs-key.conf" local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" 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 --msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
done done
# 4. Collect the recovery passphrase. Cancel here → zero side effect # 4. Pick the escrow mode. Cancel → zero side effect.
# (the source file is still where it was; we haven't copied yet). _hb_pbs_prompt_escrow_mode "$src" || return 1
local pass local mode="$HB_PBS_ESCROW_RESULT"
pass=$(_hb_pbs_prompt_recovery_pass) || return 1
# 5. Install the keyfile. Point of no return. # 5. Collect the recovery passphrase — only when we plan to write
# hb_pbs_import_keyfile just copies + chmods; content is not inspected. # 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 if ! hb_pbs_import_keyfile "$src"; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \ dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
--msgbox "$(hb_translate "Could not copy the keyfile into place. Check permissions on:") $HB_STATE_DIR" \ --msgbox "$(hb_translate "Could not copy the keyfile into place. Check permissions on:") $HB_STATE_DIR" \
10 78 10 78
return 1 return 1
fi 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 # Same trust boundary — chmod 600, root-only. Empty passphrase
# means no file: the runner treats absent file as kdf=none. # means no file: the runner treats absent file as kdf=none.
# Also export HB_PBS_ENC_PASS so _bk_pbs (called right after # Also export HB_PBS_ENC_PASS so _bk_pbs (called right after
@@ -1312,30 +1591,97 @@ _hb_pbs_import_dialog() {
HB_PBS_ENC_PASS="" HB_PBS_ENC_PASS=""
fi fi
# 7. Encrypt the recovery blob with the recovery passphrase. # 8. Finalize per mode. mode='none' skips finalize (no envelope,
# openssl failure is the only path that has to undo the install. # no msgbox) — the operator was already told in the upload
if ! _hb_pbs_finalize_recovery "$pass"; then # prompt where the keyfile lives.
rm -f "$key_file" "$recovery_enc" "$keyfile_pass_file" 2>/dev/null if [[ "$mode" != "none" ]]; then
HB_PBS_KEYFILE_OPT="" 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 return 1
fi 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" HB_PBS_KEYFILE_OPT="--keyfile $key_file"
return 0 return 0
} }
hb_ask_pbs_encryption() { hb_ask_pbs_encryption() {
# Two-step flow: # Flow (spec by the operator):
# 1. Yes/No: "Do you want to encrypt this backup?" # 1. Yes/No: "Do you want to encrypt this backup?"
# No → plain backup, no keyfile touched. # No → plain backup, no keyfile touched.
# Yes → step 2. # Yes → step 2.
# 2a. Keyfile already exists on this host → use it silently. # 2a. Keyfile already exists on this host → use it silently.
# 2b. No keyfile yet → menu with 2 options: # Subsequent scheduled or manual backups never re-ask for
# new → generate + confirm passphrase (legacy flow). # the setup — that is what "already configured" means.
# import → prompt path, validate, copy into place. # 2b. No keyfile yet → 2-option menu:
# cancel → ABORT the whole backup (operator asked for # new → generate a fresh keyfile
# encryption but didn't pick a method; we don't # existing → try to auto-detect a PVE-managed keyfile
# silently downgrade to plain). # 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 # A single-run helper leftover from a cancelled/failed create can
# leave a zero-byte keyfile behind; we wipe it before deciding so # leave a zero-byte keyfile behind; we wipe it before deciding so
# the "keyfile exists" branch never trips on an empty file. # the "keyfile exists" branch never trips on an empty file.
@@ -1373,21 +1719,36 @@ hb_ask_pbs_encryption() {
return 0 return 0
fi fi
# ── Step 2b: no keyfile → let the operator generate or import # ── Step 2b: source menu — 2 options ─────────────────────
local choice local choice
choice=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption key")" \ 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:")" \ --menu "$(hb_translate "No encryption key is stored on this host. Choose how to set one up:")" \
12 78 2 \ 12 78 2 \
"new" "$(hb_translate "Generate a new keyfile (one per host — safest isolation)")" \ "new" "$(hb_translate "Generate a new keyfile")" \
"import" "$(hb_translate "Import an existing keyfile from a path (shared across hosts)")" \ "existing" "$(hb_translate "Use an existing keyfile")" \
3>&1 1>&2 2>&3) || return 1 # Cancel → abort backup. 3>&1 1>&2 2>&3) || return 1
case "$choice" in case "$choice" in
new) new)
_hb_pbs_create_new_keyfile _hb_pbs_create_new_keyfile
return $? 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 _hb_pbs_import_dialog
return $? return $?
;; ;;