mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-05 15:26:22 +00:00
Update 1.2.2.3 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
7b7cb13e8f2d4044626663d8187018908705e480e81f510ee46f6df7918fef84 ProxMenux-1.2.2.3-beta.AppImage
|
6da865786f93c94359fd4428876a953f2706d752bd28120510ef5330f1d420aa ProxMenux-1.2.2.3-beta.AppImage
|
||||||
|
|||||||
+877
-297
File diff suppressed because it is too large
Load Diff
@@ -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})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 $?
|
||||||
;;
|
;;
|
||||||
|
|||||||
Reference in New Issue
Block a user