update 1.2.2.3 beta

This commit is contained in:
MacRimi
2026-07-06 00:30:47 +02:00
parent e07c83f4a9
commit 8df8a77bf1
5 changed files with 534 additions and 13 deletions

View File

@@ -13842,6 +13842,153 @@ def _pbs_recovery_decrypt(passphrase: str, in_path: str, out_path: str) -> bool:
return True
@app.route('/api/host-backups/pbs-encryption/keyfile-info', methods=['GET'])
@require_auth
def api_pbs_encryption_keyfile_info():
"""Snapshot of the installed keyfile for the "Manage keyfile" panel.
Extends /pbs-recovery/status with metadata that only makes sense
when a keyfile is actually installed (installed_at, kdf hint).
Returns 200 with `installed: false` when no keyfile is present so
the panel can render a neutral empty state without special-casing
404s in the caller."""
installed = os.path.isfile(_PBS_KEYFILE_PATH)
if not installed:
return jsonify({
'installed': False,
'path': _PBS_KEYFILE_PATH,
})
installed_at = ''
try:
installed_at = time.strftime(
'%Y-%m-%dT%H:%M:%SZ',
time.gmtime(os.path.getmtime(_PBS_KEYFILE_PATH)),
)
except OSError:
pass
# Best-effort KDF hint from the JSON keyfile body itself. `key info`
# would be the authoritative source but refuses scrypt keyfiles
# without their passphrase, so parsing the JSON manually gives us
# a hint even for imported scrypt keys the operator brought their
# own passphrase for.
kdf_hint = 'unknown'
try:
with open(_PBS_KEYFILE_PATH, 'r') as f:
raw = f.read(4096)
body = json.loads(raw)
kdf_raw = body.get('kdf')
if isinstance(kdf_raw, str):
kdf_hint = kdf_raw.lower()
elif isinstance(kdf_raw, dict):
# scrypt keyfiles carry `"kdf": {"Scrypt": {...}}`.
kdf_hint = next(iter(kdf_raw)).lower() if kdf_raw else 'unknown'
except (OSError, json.JSONDecodeError, ValueError, StopIteration):
pass
return jsonify({
'installed': True,
'path': _PBS_KEYFILE_PATH,
'fingerprint': _pbs_keyfile_fingerprint(),
'kdf_hint': kdf_hint,
'installed_at': installed_at,
'has_keyfile_passphrase': (
os.path.isfile(_PBS_KEYFILE_PASS_PATH)
and os.path.getsize(_PBS_KEYFILE_PASS_PATH) > 0
),
'has_recovery': os.path.isfile(_PBS_RECOVERY_ENC_PATH),
})
@app.route('/api/host-backups/pbs-encryption/remove', methods=['POST'])
@require_auth
def api_pbs_encryption_remove():
"""Remove the installed keyfile + its paired local artifacts
(passphrase file, recovery blob). Optionally backs the three up to
/root under a timestamped prefix so the operator can still decrypt
or rebuild them later. Never touches the recovery blob(s) already
uploaded to PBS — those stay recoverable with their original
passphrase and are the safe way to reach any pre-existing backup
encrypted with the removed key.
Body: `{backup_before_remove: bool}` (default true)
"""
payload = request.get_json(silent=True) or {}
do_backup = bool(payload.get('backup_before_remove', True))
if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({'error': 'no keyfile installed'}), 404
backed_up = []
if do_backup:
ts = time.strftime('%Y%m%d_%H%M%S', time.localtime())
for src, dst_suffix in (
(_PBS_KEYFILE_PATH, '.conf'),
(_PBS_KEYFILE_PASS_PATH, '.pass'),
(_PBS_RECOVERY_ENC_PATH, '.recovery.enc'),
):
if not os.path.isfile(src):
continue
dst = f'/root/pbs-key.old-{ts}{dst_suffix}'
try:
with open(src, 'rb') as fin, open(os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), 'wb') as fout:
fout.write(fin.read())
backed_up.append(dst)
except OSError as e:
return jsonify({
'error': f'backup to /root failed for {src}: {e}',
'partial_backups': backed_up,
}), 500
# Wipe order: recovery blob first so a mid-flight failure leaves us
# in a clean, backup-still-decryptable-with-blob state.
removed = []
for path in (_PBS_RECOVERY_ENC_PATH, _PBS_KEYFILE_PASS_PATH, _PBS_KEYFILE_PATH):
try:
os.remove(path)
removed.append(path)
except FileNotFoundError:
pass
except OSError as e:
return jsonify({
'error': f'remove failed for {path}: {e}',
'backed_up': backed_up,
'removed': removed,
}), 500
return jsonify({
'status': 'ok',
'backed_up': backed_up,
'removed': removed,
})
@app.route('/api/host-backups/pbs-encryption/passphrase', methods=['POST'])
@require_auth
def api_pbs_encryption_passphrase():
"""Update ONLY the stored keyfile passphrase (pbs-key.pass) without
touching the keyfile itself. Useful when the operator rotates the
passphrase on their own PBS setup and needs ProxMenux to catch up.
Body: `{new_passphrase}`. Empty value wipes the file — the runner
then treats the keyfile as kdf=none.
"""
payload = request.get_json(silent=True) or {}
new_pass = payload.get('new_passphrase') or ''
if not os.path.isfile(_PBS_KEYFILE_PATH):
return jsonify({'error': 'no keyfile installed'}), 404
if not new_pass:
try:
os.remove(_PBS_KEYFILE_PASS_PATH)
except FileNotFoundError:
pass
except OSError as e:
return jsonify({'error': f'cannot remove passphrase file: {e}'}), 500
return jsonify({'status': 'ok', 'has_keyfile_passphrase': False})
try:
fd = os.open(_PBS_KEYFILE_PASS_PATH,
os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as f:
f.write(new_pass)
except OSError as e:
return jsonify({'error': f'cannot write passphrase file: {e}'}), 500
return jsonify({'status': 'ok', 'has_keyfile_passphrase': True})
@app.route('/api/host-backups/pbs-recovery/status', methods=['GET'])
@require_auth
def api_pbs_recovery_status():