mirror of
https://github.com/PegaProx/project-pegaprox.git
synced 2026-08-12 15:27:47 +08:00
The cluster-reach check (check_cluster_access) grants access via the pool/VM-ACL fallback and expects a downstream per-VM gate — these 5 routes lacked it: - clusters.py cancel_task (#469089252): parse the task's VMID from the UPID and require user_can_access_vm(vm.stop) — no cross-pool/tenant task cancellation. - storage.py create/update backup job (#469089226): authorize every submitted VMID (vm.backup); cluster-wide (all=1) / pool jobs are admin-only. - static_files.py vms-without-pool (#469089182): filter to accessible VMs. - search.py cluster tags (#469089237): count only accessible VMs' tags. - xhm.py migration detail/list (#469089253): require source-VM access, matching the plan/start gate. Admins pass user_can_access_vm unchanged. +5 regression tests.
228 lines
8.9 KiB
Python
228 lines
8.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Cross-Hypervisor Migration API - LW Mar 2026
|
|
Endpoints for Proxmox <-> XCP-ng <-> ESXi migration.
|
|
"""
|
|
|
|
import threading
|
|
import uuid
|
|
from flask import Blueprint, jsonify, request
|
|
|
|
from pegaprox.globals import cluster_managers, _xhm_migrations
|
|
from pegaprox.utils.auth import require_auth, load_users, build_authz_user
|
|
from pegaprox.utils.audit import log_audit
|
|
from pegaprox.utils.rbac import user_can_access_vm
|
|
from pegaprox.api.helpers import check_cluster_access
|
|
from pegaprox.core.xhm import (
|
|
XHMigrationTask, plan_xcpng_to_pve, plan_pve_to_xcpng,
|
|
_run_xcpng_to_pve, _run_pve_to_xcpng,
|
|
plan_esxi_to_pve, plan_esxi_to_xcpng,
|
|
_run_esxi_to_pve, _run_esxi_to_xcpng,
|
|
)
|
|
|
|
bp = Blueprint('xhm', __name__)
|
|
|
|
_xhm_lock = threading.Lock()
|
|
|
|
|
|
@bp.route('/api/xhm/plan', methods=['GET'])
|
|
@require_auth(perms=['vm.migrate'])
|
|
def xhm_plan():
|
|
"""Get migration plan - analyzes source VM and lists available targets."""
|
|
source_cluster = request.args.get('source_cluster', '')
|
|
source_vmid = request.args.get('source_vmid', '')
|
|
target_cluster = request.args.get('target_cluster', '')
|
|
direction = request.args.get('direction', '')
|
|
|
|
if not source_cluster or not source_vmid or not target_cluster:
|
|
return jsonify({'error': 'source_cluster, source_vmid, and target_cluster are required'}), 400
|
|
|
|
# Authorization: check source cluster access
|
|
ok, err = check_cluster_access(source_cluster)
|
|
if not ok:
|
|
return err
|
|
|
|
# Authorization: check target cluster access
|
|
ok, err = check_cluster_access(target_cluster)
|
|
if not ok:
|
|
return err
|
|
|
|
# Authorization: check source VM access
|
|
user = build_authz_user(request.session['user'], request.session)
|
|
try:
|
|
vmid_int = int(source_vmid)
|
|
except (ValueError, TypeError):
|
|
return jsonify({'error': 'Invalid source_vmid'}), 400
|
|
|
|
if not user_can_access_vm(user, source_cluster, vmid_int, 'vm.migrate'):
|
|
return jsonify({'error': 'Access denied to source VM'}), 403
|
|
|
|
# auto-detect direction from cluster types
|
|
src_mgr = cluster_managers.get(source_cluster)
|
|
tgt_mgr = cluster_managers.get(target_cluster)
|
|
if not src_mgr:
|
|
return jsonify({'error': 'Source cluster not found'}), 404
|
|
if not tgt_mgr:
|
|
return jsonify({'error': 'Target cluster not found'}), 404
|
|
|
|
src_type = getattr(src_mgr, 'cluster_type', 'proxmox')
|
|
tgt_type = getattr(tgt_mgr, 'cluster_type', 'proxmox')
|
|
|
|
if src_type == tgt_type:
|
|
return jsonify({'error': f'Both clusters are {src_type} - use native migration instead'}), 400
|
|
|
|
# route to correct plan function based on cluster types
|
|
if src_type == 'esxi' and tgt_type == 'proxmox':
|
|
result = plan_esxi_to_pve(source_cluster, source_vmid, target_cluster)
|
|
elif src_type == 'esxi' and tgt_type == 'xcpng':
|
|
result = plan_esxi_to_xcpng(source_cluster, source_vmid, target_cluster)
|
|
elif src_type == 'xcpng':
|
|
result = plan_xcpng_to_pve(source_cluster, source_vmid, target_cluster)
|
|
elif tgt_type == 'xcpng':
|
|
source_node = request.args.get('source_node', '')
|
|
if not source_node:
|
|
return jsonify({'error': 'source_node required for Proxmox source'}), 400
|
|
result = plan_pve_to_xcpng(source_cluster, source_node, source_vmid, target_cluster)
|
|
else:
|
|
return jsonify({'error': f'Unsupported migration: {src_type} -> {tgt_type}'}), 400
|
|
|
|
if 'error' in result:
|
|
return jsonify(result), 400
|
|
return jsonify(result)
|
|
|
|
|
|
@bp.route('/api/xhm/migrate', methods=['POST'])
|
|
@require_auth(perms=['vm.migrate'])
|
|
def xhm_start():
|
|
"""Start cross-hypervisor migration."""
|
|
data = request.json or {}
|
|
required = ['source_cluster', 'source_vmid', 'target_cluster', 'target_storage']
|
|
for f in required:
|
|
if not data.get(f):
|
|
return jsonify({'error': f'{f} is required'}), 400
|
|
|
|
# MK May 2026 (#481 port) — target_storage is embedded in pvesm alloc cmds
|
|
# in core/xhm.py. Validate at api boundary.
|
|
from pegaprox.utils.sanitization import validate_storage_name
|
|
if not validate_storage_name(data['target_storage']):
|
|
return jsonify({'error': 'Invalid target_storage name. Must be alphanumeric with hyphens, underscores, or dots only.'}), 400
|
|
|
|
# Authorization: check source cluster access
|
|
ok, err = check_cluster_access(data['source_cluster'])
|
|
if not ok:
|
|
return err
|
|
|
|
# Authorization: check target cluster access
|
|
ok, err = check_cluster_access(data['target_cluster'])
|
|
if not ok:
|
|
return err
|
|
|
|
# Authorization: check source VM access
|
|
user = build_authz_user(request.session['user'], request.session)
|
|
try:
|
|
vmid_int = int(data['source_vmid'])
|
|
except (ValueError, TypeError):
|
|
return jsonify({'error': 'Invalid source_vmid'}), 400
|
|
|
|
if not user_can_access_vm(user, data['source_cluster'], vmid_int, 'vm.migrate'):
|
|
return jsonify({'error': 'Access denied to source VM'}), 403
|
|
|
|
src_mgr = cluster_managers.get(data['source_cluster'])
|
|
tgt_mgr = cluster_managers.get(data['target_cluster'])
|
|
if not src_mgr:
|
|
return jsonify({'error': 'Source cluster not found'}), 404
|
|
if not tgt_mgr:
|
|
return jsonify({'error': 'Target cluster not found'}), 404
|
|
|
|
src_type = getattr(src_mgr, 'cluster_type', 'proxmox')
|
|
tgt_type = getattr(tgt_mgr, 'cluster_type', 'proxmox')
|
|
|
|
if src_type == 'esxi' and tgt_type == 'proxmox':
|
|
direction = 'esxi_to_pve'
|
|
elif src_type == 'esxi' and tgt_type == 'xcpng':
|
|
direction = 'esxi_to_xcpng'
|
|
elif src_type == 'xcpng' and tgt_type != 'xcpng':
|
|
direction = 'xcpng_to_pve'
|
|
elif src_type != 'xcpng' and tgt_type == 'xcpng':
|
|
direction = 'pve_to_xcpng'
|
|
else:
|
|
return jsonify({'error': 'Invalid cluster combination for cross-hypervisor migration'}), 400
|
|
|
|
if direction in ('xcpng_to_pve', 'esxi_to_pve') and not data.get('target_node'):
|
|
return jsonify({'error': 'target_node is required for migration to Proxmox'}), 400
|
|
|
|
mid = str(uuid.uuid4())[:8]
|
|
task = XHMigrationTask(
|
|
mid=mid,
|
|
direction=direction,
|
|
source_cluster=data['source_cluster'],
|
|
source_node=data.get('source_node', ''),
|
|
source_vmid=data['source_vmid'],
|
|
target_cluster=data['target_cluster'],
|
|
target_node=data['target_node'],
|
|
target_storage=data['target_storage'],
|
|
vm_name=data.get('vm_name', ''),
|
|
config=data,
|
|
)
|
|
|
|
with _xhm_lock:
|
|
_xhm_migrations[mid] = task
|
|
|
|
_runners = {
|
|
'xcpng_to_pve': _run_xcpng_to_pve,
|
|
'pve_to_xcpng': _run_pve_to_xcpng,
|
|
'esxi_to_pve': _run_esxi_to_pve,
|
|
'esxi_to_xcpng': _run_esxi_to_xcpng,
|
|
}
|
|
runner = _runners.get(direction)
|
|
if not runner:
|
|
return jsonify({'error': f'No runner for direction {direction}'}), 400
|
|
t = threading.Thread(target=runner, args=(task,), daemon=True)
|
|
t.start()
|
|
|
|
user = request.session.get('user', 'admin') if hasattr(request, 'session') else 'admin'
|
|
log_audit(user, 'xhm.migration.started',
|
|
f"XHM {direction}: {data.get('vm_name', data['source_vmid'])} -> "
|
|
f"{data['target_cluster']}/{data['target_node']}")
|
|
|
|
return jsonify({
|
|
'migration_id': mid,
|
|
'message': f'Migration started ({direction})',
|
|
'task': task.to_dict(),
|
|
}), 202
|
|
|
|
|
|
def _xhm_reachable(t):
|
|
# NS Jul 2026 (CodeAnt IDOR) — show a migration only if the caller reaches one of its clusters.
|
|
from pegaprox.api.helpers import check_cluster_access
|
|
cids = [c for c in (getattr(t, 'target_cluster', None), getattr(t, 'source_cluster', None)) if c]
|
|
if cids and not any(check_cluster_access(c)[0] for c in cids):
|
|
return False
|
|
# NS Aug 2026 (Aikido #469089253) — and only if the caller can access the source VM itself,
|
|
# matching the plan/start gate; cluster reach alone leaked other VMs' migration records.
|
|
svmid, scluster = getattr(t, 'source_vmid', None), getattr(t, 'source_cluster', None)
|
|
if svmid and scluster:
|
|
try:
|
|
_u = build_authz_user(request.session.get('user', ''), request.session)
|
|
return user_can_access_vm(_u, scluster, int(svmid), 'vm.migrate')
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
# NS Aug 2026 (#654) — same slip as the vmware list route: decorators were on _xhm_reachable
|
|
# instead of this handler, so GET /api/xhm/migrations 500'd with a missing-arg TypeError.
|
|
@bp.route('/api/xhm/migrations', methods=['GET'])
|
|
@require_auth(perms=['vm.migrate'])
|
|
def xhm_list():
|
|
return jsonify([t.to_dict() for t in _xhm_migrations.values() if _xhm_reachable(t)])
|
|
|
|
|
|
@bp.route('/api/xhm/migrations/<mid>', methods=['GET'])
|
|
@require_auth(perms=['vm.migrate'])
|
|
def xhm_detail(mid):
|
|
if mid not in _xhm_migrations:
|
|
return jsonify({'error': 'Migration not found'}), 404
|
|
if not _xhm_reachable(_xhm_migrations[mid]):
|
|
return jsonify({'error': 'Migration not found'}), 404
|
|
return jsonify(_xhm_migrations[mid].to_dict())
|