update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-06-21 22:41:54 +02:00
parent 8e92df5bd7
commit 09a67bf4e6
30 changed files with 1495 additions and 473 deletions

View File

@@ -1 +1 @@
ad6a863fc2e344e5fc5ffeb66b09de8339bfc6b8d3dfcaa8020b4fe89abb4d2e ProxMenux-1.2.2.2-beta.AppImage
6a3aefbea44ba7260ee0a69685504ecc66c3d45ae1ddaf98deb6940975ce8e58 ProxMenux-1.2.2.2-beta.AppImage

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,12 @@ interface ScriptTerminalModalProps {
description: string
scriptName?: string
params?: Record<string, string>
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
// flow so "Press Enter to close" in the bash script actually
// closes the modal without an extra click. Other callers ignore.
onComplete?: () => void
}
export function ScriptTerminalModal({
@@ -58,6 +64,7 @@ export function ScriptTerminalModal({
title,
description,
params = { EXECUTION_MODE: "web" },
onComplete,
}: ScriptTerminalModalProps) {
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
@@ -95,6 +102,13 @@ export function ScriptTerminalModal({
paramsRef.current = params
}, [params])
// Same trick for onComplete — we want the latest callback inside
// the ws.onclose handler without re-running the connection effect.
const onCompleteRef = useRef<(() => void) | undefined>(undefined)
useEffect(() => {
onCompleteRef.current = onComplete
}, [onComplete])
const attemptReconnect = useCallback(() => {
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
return
@@ -195,6 +209,7 @@ const initMessage = {
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
} else {
setIsComplete(true)
onCompleteRef.current?.()
}
}
}
@@ -382,6 +397,7 @@ const initMessage = {
if (!isComplete) {
setIsComplete(true)
onCompleteRef.current?.()
}
}

View File

@@ -78,7 +78,7 @@ from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
from notification_manager import notification_manager # noqa: E402
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
from jwt_middleware import require_auth # noqa: E402
from jwt_middleware import require_auth, require_auth_or_ticket # noqa: E402
import auth_manager # noqa: E402
# -------------------------------------------------------------------
@@ -15826,7 +15826,7 @@ def api_host_backups_remote_archive_export_status(task_id):
@app.route('/api/host-backups/remote-archives/export/<task_id>/download', methods=['GET'])
@require_auth
@require_auth_or_ticket
def api_host_backups_remote_archive_export_download(task_id):
"""Stream the packed export to the browser. Same auth + send_file
pattern as the local archive download. Cleans up the tarball after
@@ -16121,6 +16121,22 @@ def _inspect_compose_json(staging: str) -> dict:
out['files_truncated'] = truncated
out['files_total_count'] = len(files)
# ── Rollback plan ──
# Compare backup state vs current host to surface VMs/LXCs that
# would be removed and components that would be uninstalled by
# a "restore to backup state" operation. Read-only here — the
# actual rollback is executed by apply_cluster_postboot.sh after
# the operator confirms in the Restore flow.
rollback_script = f'{scripts_dir}/compute_rollback_plan.sh'
if os.path.isfile(rollback_script):
try:
r = subprocess.run(['bash', rollback_script, staging],
capture_output=True, text=True, timeout=20)
if r.returncode == 0 and r.stdout.strip():
out['rollback_plan'] = json.loads(r.stdout)
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
pass
# ── Bundle the loose metadata sidecars for quick reference ──
if os.path.isdir(metadata):
meta_view: dict = {}
@@ -16197,6 +16213,166 @@ def api_host_backups_inspect_archive():
pass
# ──────────────────────────────────────────────────────────────
# Restore prepare/cleanup — Monitor → shell TUI handoff
# ──────────────────────────────────────────────────────────────
# The "Restore" button in the Inspect modal goes through TWO steps:
# 1. POST /restore/prepare → backend extracts the snapshot to a
# persistent staging dir under _RESTORE_STAGING_ROOT. Returns
# the staging path so the next step can find it.
# 2. The frontend launches a ScriptTerminalModal pointing at
# restore/monitor_apply.sh with that staging path + selected
# mode/components. monitor_apply.sh sources backup_host.sh and
# calls _rs_run_complete_guided / _rs_run_custom_restore — the
# exact same apply path the TUI uses.
#
# Staging dirs auto-expire: every new prepare() removes any leftover
# from previous runs older than _RESTORE_STAGING_TTL_SECONDS so a
# crashed Monitor session doesn't accumulate gigabytes on /tmp.
_RESTORE_STAGING_ROOT = '/tmp'
_RESTORE_STAGING_PREFIX = 'pmnx-restore-'
_RESTORE_STAGING_TTL_SECONDS = 6 * 3600 # 6 hours
def _restore_staging_gc():
"""Remove stale Monitor restore staging dirs. Best-effort; any
OSError silently skips the entry so a permission glitch on one
item doesn't block the new prepare."""
import time
now = time.time()
try:
entries = os.listdir(_RESTORE_STAGING_ROOT)
except OSError:
return
for entry in entries:
if not entry.startswith(_RESTORE_STAGING_PREFIX):
continue
full = os.path.join(_RESTORE_STAGING_ROOT, entry)
try:
age = now - os.path.getmtime(full)
except OSError:
continue
if age > _RESTORE_STAGING_TTL_SECONDS:
try:
shutil.rmtree(full, ignore_errors=True)
except OSError:
pass
@app.route('/api/host-backups/restore/prepare', methods=['POST'])
@require_auth
def api_host_backups_restore_prepare():
"""Extract a snapshot to a persistent staging directory. The
frontend follows up by launching monitor_apply.sh against that
staging via ScriptTerminalModal. Body is the same shape as
/inspect-archive: {source, repo_name?, snapshot?, path?}.
Returns: {staging_path}."""
payload = request.get_json(silent=True) or {}
source = (payload.get('source') or '').strip()
if source not in ('pbs', 'borg', 'local'):
return jsonify({'error': 'source must be pbs, borg, or local'}), 400
repo_dict: dict = {}
snapshot = ''
if source == 'pbs':
repo_name = (payload.get('repo_name') or '').strip()
snapshot = (payload.get('snapshot') or '').strip()
if not repo_name or not snapshot:
return jsonify({'error': 'repo_name and snapshot are required for pbs'}), 400
for r in _list_pbs_destinations():
if r.get('name') == repo_name:
repo_dict = r
break
if not repo_dict:
return jsonify({'error': f'PBS repo "{repo_name}" not configured'}), 404
elif source == 'borg':
repo_name = (payload.get('repo_name') or '').strip()
snapshot = (payload.get('snapshot') or '').strip()
if not repo_name or not snapshot:
return jsonify({'error': 'repo_name and snapshot are required for borg'}), 400
for r in _list_borg_destinations():
if r.get('name') == repo_name:
repo_dict = r
break
if not repo_dict:
return jsonify({'error': f'Borg repo "{repo_name}" not configured'}), 404
else:
path = (payload.get('path') or '').strip()
if not path or not os.path.isfile(path):
return jsonify({'error': f'local archive not found: {path}'}), 404
repo_dict = {'path': path}
_restore_staging_gc()
import tempfile
staging = tempfile.mkdtemp(prefix=_RESTORE_STAGING_PREFIX, dir=_RESTORE_STAGING_ROOT)
try:
ok, err = _inspect_extract_to_staging(source, repo_dict, snapshot, staging)
if not ok:
shutil.rmtree(staging, ignore_errors=True)
return jsonify({'error': err}), 500
if not _inspect_normalize_layout(staging):
shutil.rmtree(staging, ignore_errors=True)
return jsonify({'error': 'archive layout not recognized — no rootfs/ found'}), 500
# List the real backup paths so the Custom restore checklist
# only offers what's actually present (default profile + the
# operator's extras). Mirrors what _rs_select_component_paths
# in backup_host.sh now does after the paths-not-components
# refactor — perfect backup↔restore parity.
paths_available: list = []
list_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/list_paths.sh'
if os.path.isfile(list_script):
try:
r = subprocess.run(['bash', list_script, staging],
capture_output=True, text=True, timeout=30)
if r.returncode == 0 and r.stdout.strip():
paths_available = json.loads(r.stdout.strip())
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
pass
rollback_plan: dict = {}
rollback_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/compute_rollback_plan.sh'
if os.path.isfile(rollback_script):
try:
r = subprocess.run(['bash', rollback_script, staging],
capture_output=True, text=True, timeout=20)
if r.returncode == 0 and r.stdout.strip():
rollback_plan = json.loads(r.stdout)
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
pass
return jsonify({
'staging_path': staging,
'paths_available': paths_available,
'rollback_plan': rollback_plan,
})
except Exception as e:
shutil.rmtree(staging, ignore_errors=True)
return jsonify({'error': str(e)}), 500
@app.route('/api/host-backups/restore/cleanup', methods=['POST'])
@require_auth
def api_host_backups_restore_cleanup():
"""Explicit cleanup for a staging dir. Called by the UI after the
ScriptTerminalModal closes. Best-effort: the GC in prepare() will
catch anything missed here within _RESTORE_STAGING_TTL_SECONDS."""
payload = request.get_json(silent=True) or {}
staging = (payload.get('staging_path') or '').strip()
# Defense in depth: only allow paths under the staging root with
# the expected prefix. Anything else could be the operator trying
# to coerce the endpoint into rm-ing arbitrary files.
if (not staging
or not staging.startswith(os.path.join(_RESTORE_STAGING_ROOT, _RESTORE_STAGING_PREFIX))
or '..' in staging.split(os.sep)):
return jsonify({'error': 'invalid staging_path'}), 400
try:
shutil.rmtree(staging, ignore_errors=True)
except OSError as e:
return jsonify({'error': str(e)}), 500
return jsonify({'status': 'ok'})
@app.route('/api/host-backups/archives/<path:archive_id>/manifest', methods=['GET'])
@require_auth
def api_host_backups_archive_manifest(archive_id):
@@ -16341,7 +16517,7 @@ def api_host_backups_archive_prepare_restore(archive_id):
@app.route('/api/host-backups/archives/<path:archive_id>/download', methods=['GET'])
@require_auth
@require_auth_or_ticket
def api_host_backups_archive_download(archive_id):
"""Stream the .tar.zst archive back to the operator with a sane
Content-Disposition. send_file handles range requests + streaming

View File

@@ -66,6 +66,47 @@ def require_auth(f):
return decorated_function
def require_auth_or_ticket(f):
"""Like `require_auth` but ALSO accepts a single-use `?ticket=...`
query parameter (same tickets `/api/terminal/ticket` issues for
WebSockets). Use on endpoints that the browser needs to invoke
from an <a download href="..."> tag — anchor tags can't send the
Authorization header, so the caller fetches a ticket first and
appends it to the URL.
Use only for streaming downloads where adding `?ticket=...` is
the only practical way to authenticate; everything else stays
on plain Bearer-token auth."""
@wraps(f)
def decorated_function(*args, **kwargs):
config = load_auth_config()
if not config.get("enabled", False) or config.get("declined", False):
return f(*args, **kwargs)
# First try the Bearer header (fetch from JS uses this).
auth_header = request.headers.get('Authorization')
if auth_header:
parts = auth_header.split()
if len(parts) == 2 and parts[0].lower() == 'bearer':
if verify_token(parts[1]):
return f(*args, **kwargs)
# Fall through to single-use ticket (works for <a download>).
try:
from flask_terminal_routes import _consume_terminal_ticket
if _consume_terminal_ticket(request.args.get('ticket', '')):
return f(*args, **kwargs)
except ImportError:
pass
return jsonify({
"error": "Authentication required",
"message": "Provide a Bearer token in the Authorization header or a fresh ?ticket=... from /api/terminal/ticket"
}), 401
return decorated_function
def require_admin_scope(f):
"""Like `require_auth` but ALSO requires the token's `scope == full_admin`.