MrMasterbay 3e2879a681 security(authz): object-level gates on VMware + PBS write/delete/diagnose (Aikido BOLA)
The read routes already call check_vmware_access / check_pbs_access, but the
mutating ones were guarded only by @require_auth(perms=[...]):
- vmware.py update/delete/diagnose (#469089245/#207/#204): a vmware.config holder
  could take over, delete, or probe another tenant's ESXi server by id.
- pbs.py update/delete (#469089284/#244) and auto-storage (#469089213): a
  pbs.config holder could overwrite/delete another tenant's PBS record, or push
  its stored credentials onto an arbitrary cluster's storage config.
Each now calls check_* first (admins + unlinked servers unaffected); auto-storage
also check_cluster_access on every target cluster. +6 regression tests.
2026-08-10 08:18:08 +02:00

1227 lines
52 KiB
Python

# -*- coding: utf-8 -*-
"""vmware + v2p migration routes - split from monolith dec 2025, NS"""
import logging
import time
import threading
import uuid
from flask import Blueprint, jsonify, request
from pegaprox.constants import *
from pegaprox.globals import *
from pegaprox.models.permissions import *
from pegaprox.core.db import get_db
from pegaprox.utils.auth import require_auth, load_users
from pegaprox.utils.audit import log_audit
# MK 2026-06-04 (CWE-117): mgr.name is from cluster-config (admin-controlled),
# vmware_id from URL. Sanitise both before logging for consistency.
from pegaprox.utils.sanitization import sanitize_log_message as _sl
from pegaprox.utils.rbac import user_can_access_vmware_vm
from pegaprox.api.helpers import check_cluster_access, check_vmware_access
from pegaprox.core.vmware import VMwareManager, load_vmware_servers, save_vmware_server
from pegaprox.core.v2p import V2PMigrationTask, _run_v2p_migration
from pegaprox.background.broadcast import broadcast_resources_loop
bp = Blueprint('vmware', __name__)
# V2P migration tracking
_vmware_migrations = {}
_migration_lock_v2p = threading.Lock()
# =============================================================================
@bp.route('/api/vmware', methods=['GET'])
@require_auth(perms=['vmware.view'])
def list_vmware_servers():
"""List all configured VMware/vCenter servers"""
result = []
for vmware_id, mgr in vmware_managers.items():
result.append(mgr.to_dict())
# Also include disabled servers from DB
try:
db = get_db()
cursor = db.conn.cursor()
cursor.execute("SELECT id, name, host, port, enabled, server_type FROM vmware_servers")
for row in cursor.fetchall():
row_dict = dict(row)
if row_dict['id'] not in vmware_managers:
result.append({
'id': row_dict['id'],
'name': row_dict['name'],
'host': row_dict['host'],
'port': row_dict['port'],
'server_type': row_dict.get('server_type', 'vcenter'),
'enabled': bool(row_dict['enabled']),
'connected': False,
})
except Exception:
pass
return jsonify(result)
@bp.route('/api/vmware', methods=['POST'])
@require_auth(perms=['vmware.config'])
def add_vmware_server():
"""Add a new VMware/vCenter server"""
data = request.json or {}
if not data.get('name') or not data.get('host'):
return jsonify({'error': 'Name and host are required'}), 400
if not data.get('username'):
return jsonify({'error': 'Username is required'}), 400
if not data.get('password'):
return jsonify({'error': 'Password is required'}), 400
vmware_id = str(uuid.uuid4())[:8]
mgr = VMwareManager(vmware_id, data)
if not mgr.connect():
return jsonify({'error': f'Connection failed: {mgr.last_error}'}), 400
save_vmware_server(vmware_id, data)
vmware_managers[vmware_id] = mgr
log_audit(request.session.get('user', 'admin'), 'vmware.added',
f"Added VMware server: {data['name']} ({data['host']}, type={data.get('server_type', 'vcenter')})")
return jsonify({'id': vmware_id, 'message': 'VMware server added successfully', **mgr.to_dict()}), 201
@bp.route('/api/vmware/<vmware_id>', methods=['PUT'])
@require_auth(perms=['vmware.config'])
def update_vmware_server(vmware_id):
"""Update a VMware server config"""
ok, err = check_vmware_access(vmware_id) # NS Aug 2026 (Aikido) — object-level authz on write
if not ok:
return err
data = request.json or {}
if vmware_id not in vmware_managers:
db = get_db()
row = db.conn.cursor().execute("SELECT * FROM vmware_servers WHERE id = ?", (vmware_id,)).fetchone()
if not row:
return jsonify({'error': 'VMware server not found'}), 404
# MK May 2026 (#469 port) — cred-exfil guard. If host changes WHILE the
# password is preserved (came in as ********), don't auto-connect — that
# would ship the saved credential to a potentially attacker-controlled host.
credentials_preserved = False
host_changed = False
if vmware_id in vmware_managers:
old_mgr = vmware_managers[vmware_id]
if (data.get('host') and data.get('host') != old_mgr.host) or \
(data.get('port') and int(data.get('port', 443)) != old_mgr.port):
host_changed = True
if data.get('password') == '********':
# NS Aug 2026 (Aikido pentest) — never persist the preserved password against a CHANGED
# host: the saved row is reused verbatim by diagnose / Test Connection / the boot-time
# auto-connect, any of which would ship the secret to the new (possibly attacker-chosen)
# host. Require a full password whenever the host changes.
if host_changed:
return jsonify({'error': 'Re-enter the password when changing the VMware host.'}), 400
data['password'] = old_mgr.password
credentials_preserved = True
save_vmware_server(vmware_id, data)
mgr = VMwareManager(vmware_id, data)
if data.get('enabled', True):
if host_changed and credentials_preserved:
try:
mgr.connected = False
mgr.last_error = 'Host changed — auto-connect skipped for security (preserved credentials). Use Test Connection manually after verifying the new host.'
except Exception:
pass
logging.warning(f"[VMware:{_sl(getattr(mgr, 'name', vmware_id))}] Skipped auto-connect after host change with preserved credentials (cred-exfil guard)")
else:
mgr.connect()
vmware_managers[vmware_id] = mgr
log_audit(request.session.get('user', 'admin'), 'vmware.updated', f"Updated VMware server: {data.get('name', vmware_id)}")
return jsonify(mgr.to_dict())
@bp.route('/api/vmware/<vmware_id>', methods=['DELETE'])
@require_auth(perms=['vmware.config'])
def delete_vmware_server(vmware_id):
"""Delete a VMware server"""
ok, err = check_vmware_access(vmware_id) # NS Aug 2026 (Aikido) — object-level authz on delete
if not ok:
return err
name = vmware_managers[vmware_id].name if vmware_id in vmware_managers else vmware_id
if vmware_id in vmware_managers:
del vmware_managers[vmware_id]
db = get_db()
db.conn.cursor().execute("DELETE FROM vmware_servers WHERE id = ?", (vmware_id,))
db.conn.commit()
log_audit(request.session.get('user', 'admin'), 'vmware.deleted', f"Deleted VMware server: {name}")
return jsonify({'message': f'VMware server {name} deleted'})
@bp.route('/api/vmware/test-connection', methods=['POST'])
@require_auth(perms=['vmware.config'])
def test_vmware_connection():
"""Test VMware connection with provided credentials"""
data = request.json or {}
if not data.get('host') or not data.get('username') or not data.get('password'):
return jsonify({'error': 'Host, username and password required'}), 400
mgr = VMwareManager('test', data)
if mgr.connect():
return jsonify({'success': True, 'server_info': mgr.server_info, 'api_version': mgr.api_version,
'connection_type': mgr._connection_type})
return jsonify({'success': False, 'error': mgr.last_error}), 400
@bp.route('/api/vmware/<vmware_id>/diagnose', methods=['GET'])
@require_auth(perms=['vmware.config'])
def diagnose_vmware_connection(vmware_id):
"""diagnose connection issues -- compares stored vs. fresh credentials"""
ok, err = check_vmware_access(vmware_id) # NS Aug 2026 (Aikido) — object-level authz on diagnose
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
# Get stored encrypted password from DB
db = get_db()
row = db.conn.cursor().execute(
"SELECT pass_encrypted, username, host, port FROM vmware_servers WHERE id = ?", (vmware_id,)).fetchone()
if not row:
return jsonify({'error': 'Not found in DB'}), 404
row_d = dict(row)
stored_enc = row_d.get('pass_encrypted', '')
# Try decrypt
decrypted = ''
decrypt_ok = False
try:
decrypted = db._decrypt(stored_enc)
decrypt_ok = True
except Exception as e:
decrypted = f'DECRYPT_FAILED: {e}'
# NS: Feb 2026 - SECURITY: no password chars in API response, only boolean indicators
result = {
'vmware_id': vmware_id,
'host': mgr.host,
'port': mgr.port,
'username_in_db': row_d.get('username', ''),
'username_in_mgr': mgr.username,
'password_encrypted_present': bool(stored_enc),
'password_decrypted_ok': decrypt_ok,
'password_matches_mgr': decrypted == mgr.password if decrypt_ok else False,
'mgr_connected': mgr.connected,
'mgr_connection_type': mgr._connection_type,
'mgr_last_error': mgr.last_error,
}
# Try fresh SOAP connection with stored credentials.
# MK 2026-06-04: honour the per-cluster `ssl_verify` flag instead of
# hard-disabling. Default is False because ESXi/vCenter ship with
# self-signed certs in most labs; admins flip it on via cluster settings
# once they've installed a real CA-signed cert + uploaded the CA. When
# opt-out is in effect we log it so the cert posture is observable in
# the audit/SIEM forward.
try:
from pyVim.connect import SmartConnect
import ssl
verify_tls = bool(getattr(mgr, 'ssl_verify', False))
if verify_tls:
ctx = ssl.create_default_context()
else:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
logging.warning(
f"[VMware:{getattr(mgr, 'name', vmware_id)}] fresh-SOAP-test TLS verify DISABLED "
f"(cluster ssl_verify=false). Set ssl_verify=true once a CA-signed cert is in place."
)
si = SmartConnect(host=mgr.host, user=mgr.username, pwd=mgr.password,
port=mgr.port, sslContext=ctx,
disableSslCertValidation=not verify_tls)
if si:
result['fresh_soap_test'] = 'SUCCESS'
from pyVim.connect import Disconnect
Disconnect(si)
else:
result['fresh_soap_test'] = 'FAILED: SmartConnect returned None'
except Exception as e:
err = str(e)
if 'InvalidLogin' in err:
result['fresh_soap_test'] = f'FAILED: InvalidLogin (password wrong or locked)'
else:
result['fresh_soap_test'] = f'FAILED: {err[:150]}'
return jsonify(result)
@bp.route('/api/vmware/<vmware_id>/vms', methods=['GET'])
@require_auth(perms=['vmware.vm.view'])
def get_vmware_vms(vmware_id):
"""List all VMs from vCenter/ESXi"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
result = mgr.get_vms()
if 'error' in result:
if mgr.connect():
result = mgr.get_vms()
if 'error' in result and result.get('status_code') == 400:
mgr._try_soap_fallback()
if mgr._connection_type == 'soap':
result = mgr.get_vms()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>', methods=['GET'])
@require_auth(perms=['vmware.vm.view'])
def get_vmware_vm_detail(vmware_id, vm_id):
"""Get detailed VM info with guest and performance data"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
result = mgr.get_vm(vm_id)
if 'error' in result and mgr.connect():
result = mgr.get_vm(vm_id)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
data = result.get('data', {})
# Guest info
guest = mgr.get_vm_guest_info(vm_id)
if 'error' not in guest:
data['guest_info'] = guest.get('data', {})
# Performance
perf = mgr.get_vm_performance(vm_id)
if 'error' not in perf:
data['performance'] = perf.get('data', {})
return jsonify(data)
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/power/<action>', methods=['POST'])
@require_auth(perms=['vmware.vm.power'])
def vmware_vm_power(vmware_id, vm_id, action):
"""VM power actions: start, stop, suspend, reset"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
if action not in ('start', 'stop', 'suspend', 'reset'):
return jsonify({'error': f'Invalid action: {action}'}), 400
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.power'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
result = mgr.vm_power_action(vm_id, action)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
log_audit(request.session.get('user', 'admin'), f'vmware.vm.{action}',
f"VM power {action} on {vm_id} @ {mgr.name}")
return jsonify({'message': f'VM {action} successful'})
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/snapshots', methods=['GET'])
@require_auth(perms=['vmware.vm.snapshot'])
def get_vmware_snapshots(vmware_id, vm_id):
"""List VM snapshots"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
result = mgr.get_snapshots(vm_id)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/snapshots', methods=['POST'])
@require_auth(perms=['vmware.vm.snapshot'])
def create_vmware_snapshot(vmware_id, vm_id):
"""Create a VM snapshot"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
data = request.json or {}
if not data.get('name'):
return jsonify({'error': 'Snapshot name required'}), 400
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.snapshot'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
result = mgr.create_snapshot(vm_id, data['name'], data.get('description', ''),
data.get('memory', False), data.get('quiesce', True))
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
log_audit(request.session.get('user', 'admin'), 'vmware.snapshot.created',
f"Snapshot '{data['name']}' created for VM {vm_id} @ {mgr.name}")
return jsonify({'message': f'Snapshot created', 'data': result.get('data')}), 201
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/snapshots/<snapshot_id>', methods=['DELETE'])
@require_auth(perms=['vmware.vm.snapshot'])
def delete_vmware_snapshot(vmware_id, vm_id, snapshot_id):
"""Delete a VM snapshot"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.snapshot'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
result = mgr.delete_snapshot(vm_id, snapshot_id)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
log_audit(request.session.get('user', 'admin'), 'vmware.snapshot.deleted',
f"Snapshot {snapshot_id} deleted from VM {vm_id} @ {mgr.name}")
return jsonify({'message': 'Snapshot deleted'})
@bp.route('/api/vmware/<vmware_id>/hosts', methods=['GET'])
@require_auth(perms=['vmware.host.view'])
def get_vmware_hosts(vmware_id):
"""List ESXi hosts"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
result = mgr.get_hosts()
if 'error' in result:
# Reconnect and retry
if mgr.connect():
result = mgr.get_hosts()
# If still failing with 400, try SOAP fallback
if 'error' in result and result.get('status_code') == 400:
mgr._try_soap_fallback()
if mgr._connection_type == 'soap':
result = mgr.get_hosts()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/datastores', methods=['GET'])
@require_auth(perms=['vmware.datastore.view'])
def get_vmware_datastores(vmware_id):
"""List VMware datastores"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
result = mgr.get_datastores()
if 'error' in result:
if mgr.connect():
result = mgr.get_datastores()
if 'error' in result and result.get('status_code') == 400:
mgr._try_soap_fallback()
if mgr._connection_type == 'soap':
result = mgr.get_datastores()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/datastores/<ds_id>', methods=['GET'])
@require_auth(perms=['vmware.datastore.view'])
def get_vmware_datastore_detail(vmware_id, ds_id):
"""Get detailed datastore info"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
result = mgr.get_datastore_detail(ds_id)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', {}))
@bp.route('/api/vmware/<vmware_id>/networks', methods=['GET'])
@require_auth(perms=['vmware.network.view'])
def get_vmware_networks(vmware_id):
"""List VMware networks"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
result = mgr.get_networks()
if 'error' in result:
if mgr.connect():
result = mgr.get_networks()
if 'error' in result and result.get('status_code') == 400:
mgr._try_soap_fallback()
if mgr._connection_type == 'soap':
result = mgr.get_networks()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/clusters', methods=['GET'])
@require_auth(perms=['vmware.view'])
def get_vmware_vcenter_clusters(vmware_id):
"""List vCenter compute clusters with DRS/HA status"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
result = mgr.get_vcenter_clusters_detailed()
if 'error' in result:
# Fallback to basic list
result = mgr.get_vcenter_clusters()
if 'error' in result and result.get('status_code') == 400:
mgr._try_soap_fallback()
if mgr._connection_type == 'soap':
result = mgr.get_vcenter_clusters_detailed()
if 'error' in result:
result = mgr.get_vcenter_clusters()
if 'error' in result:
# For standalone ESXi (no clusters), return empty list instead of error
if result.get('status_code') in (400, 404):
return jsonify([])
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/clusters/<cluster_id>', methods=['GET'])
@require_auth(perms=['vmware.view'])
def get_vmware_cluster_detail(vmware_id, cluster_id):
"""Get cluster detail with DRS/HA config"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
result = mgr.get_cluster_detail(cluster_id)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', {}))
@bp.route('/api/vmware/<vmware_id>/clusters/<cluster_id>/drs', methods=['POST'])
@require_auth(perms=['vmware.cluster.manage'])
def set_vmware_cluster_drs(vmware_id, cluster_id):
"""Toggle DRS on a cluster"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
data = request.json or {}
enabled = data.get('enabled', False)
automation = data.get('automation')
mgr = vmware_managers[vmware_id]
result = mgr.set_cluster_drs(cluster_id, enabled, automation)
if 'error' in result:
return jsonify(result), 500
log_audit(request.session.get('user', 'admin'), 'vmware.cluster.drs',
f"DRS {'enabled' if enabled else 'disabled'} on cluster {cluster_id} @ {mgr.name}")
return jsonify({'message': f"DRS {'enabled' if enabled else 'disabled'}"})
@bp.route('/api/vmware/<vmware_id>/clusters/<cluster_id>/ha', methods=['POST'])
@require_auth(perms=['vmware.cluster.manage'])
def set_vmware_cluster_ha(vmware_id, cluster_id):
"""Toggle HA on a cluster"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
data = request.json or {}
enabled = data.get('enabled', False)
mgr = vmware_managers[vmware_id]
result = mgr.set_cluster_ha(cluster_id, enabled)
if 'error' in result:
return jsonify(result), 500
log_audit(request.session.get('user', 'admin'), 'vmware.cluster.ha',
f"HA {'enabled' if enabled else 'disabled'} on cluster {cluster_id} @ {mgr.name}")
return jsonify({'message': f"HA {'enabled' if enabled else 'disabled'}"})
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/performance', methods=['GET'])
@require_auth(perms=['vmware.vm.view'])
def get_vmware_vm_performance(vmware_id, vm_id):
"""Get VM performance metrics"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
result = mgr.get_vm_performance(vm_id)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', {}))
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/watch', methods=['POST'])
@require_auth(perms=['vmware.vm.view'])
def watch_vmware_vm(vmware_id, vm_id):
"""register interest in a VM -- SSE will push detail data every 5s.
Call again to renew the 120s watch window. POST with empty body."""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if not hasattr(broadcast_resources_loop, '_vmw_watched'):
broadcast_resources_loop._vmw_watched = {}
broadcast_resources_loop._vmw_watched[(vmware_id, vm_id)] = time.time()
return jsonify({'ok': True, 'watching': vm_id, 'ttl': 120})
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/watch', methods=['DELETE'])
@require_auth(perms=['vmware.vm.view'])
def unwatch_vmware_vm(vmware_id, vm_id):
"""Stop watching a VM"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
watched = getattr(broadcast_resources_loop, '_vmw_watched', {})
watched.pop((vmware_id, vm_id), None)
return jsonify({'ok': True})
@bp.route('/api/vmware/<vmware_id>/datacenters', methods=['GET'])
@require_auth(perms=['vmware.view'])
def get_vmware_datacenters(vmware_id):
"""List vCenter datacenters"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
result = mgr.get_datacenters()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/summary', methods=['GET'])
@require_auth(perms=['vmware.view'])
def get_vmware_summary(vmware_id):
"""Get environment summary (VM counts, host counts, health)"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
result = mgr.get_summary()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', {}))
@bp.route('/api/vmware/<vmware_id>/health', methods=['GET'])
@require_auth(perms=['vmware.view'])
def get_vmware_health(vmware_id):
"""Get vCenter appliance health"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
result = mgr.get_appliance_health()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', {}))
@bp.route('/api/vmware/<vmware_id>/folders', methods=['GET'])
@require_auth(perms=['vmware.view'])
def get_vmware_folders(vmware_id):
"""List folders"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
folder_type = request.args.get('type')
result = mgr.get_folders(folder_type)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/resource-pools', methods=['GET'])
@require_auth(perms=['vmware.view'])
def get_vmware_resource_pools(vmware_id):
"""List resource pools"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
result = mgr.get_resource_pools()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/storage-policies', methods=['GET'])
@require_auth(perms=['vmware.view'])
def get_vmware_storage_policies(vmware_id):
"""List storage policies"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
result = mgr.get_storage_policies()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/content-libraries', methods=['GET'])
@require_auth(perms=['vmware.view'])
def get_vmware_content_libraries(vmware_id):
"""List content libraries"""
# NS Jul 2026 (CodeAnt re-scan IDOR) — per-server tenant gate (was role-perm only)
ok, err = check_vmware_access(vmware_id)
if not ok:
return err
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
mgr = vmware_managers[vmware_id]
result = mgr.get_content_libraries()
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
return jsonify(result.get('data', []))
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/console', methods=['POST'])
@require_auth(perms=['vmware.vm.view'])
def get_vmware_console(vmware_id, vm_id):
"""get console ticket -- tries WebMKS, MKS, direct URL"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.view'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
# Security: Verify VM exists on this VMware server before issuing console ticket
# This prevents users from requesting console tickets for arbitrary VM IDs
vm_check = mgr.get_vm(vm_id)
if 'error' in vm_check:
log_audit(request.session.get('user', 'admin'), 'vmware.console.denied',
f"Console access denied for VM {vm_id} @ {mgr.name}: VM not found")
return jsonify({'error': 'VM not found or access denied'}), 404
result = mgr.get_vm_console_ticket(vm_id)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
log_audit(request.session.get('user', 'admin'), 'vmware.console.accessed',
f"Console ticket issued for VM {vm_id} @ {mgr.name}")
return jsonify(result.get('data', {}))
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/config', methods=['PUT'])
@require_auth(perms=['vmware.vm.manage'])
def update_vmware_vm_config(vmware_id, vm_id):
"""Update VM configuration (CPU, RAM, notes, hot-add, etc)"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.manage'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
mgr.ensure_connected()
data = request.json or {}
result = mgr.update_vm_config(vm_id, data)
if 'error' in result:
return jsonify(result), 500
log_audit(request.session.get('user', 'admin'), 'vmware.vm.config',
f"Updated config on VM {vm_id} @ {mgr.name}: {list(data.keys())}")
return jsonify({'message': 'VM configuration updated'})
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/network', methods=['PUT'])
@require_auth(perms=['vmware.vm.manage'])
def update_vmware_vm_network(vmware_id, vm_id):
"""Change VM network adapter"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.manage'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
data = request.json or {}
nic_key = int(data.get('nic_key', 0))
network = data.get('network', '')
if not network:
return jsonify({'error': 'Network name required'}), 400
result = mgr.update_vm_network(vm_id, nic_key, network)
if 'error' in result:
return jsonify(result), 500
log_audit(request.session.get('user', 'admin'), 'vmware.vm.network',
f"Changed network on VM {vm_id} to '{network}' @ {mgr.name}")
return jsonify({'message': f"Network changed to '{network}'"})
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/boot-order', methods=['PUT'])
@require_auth(perms=['vmware.vm.manage'])
def update_vmware_vm_boot_order(vmware_id, vm_id):
"""Change VM boot order"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.manage'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
data = request.json or {}
boot_order = data.get('boot_order', ['disk', 'cdrom', 'net'])
result = mgr.update_vm_boot_order(vm_id, boot_order)
if 'error' in result:
return jsonify(result), 500
return jsonify({'message': 'Boot order updated'})
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/clone', methods=['POST'])
@require_auth(perms=['vmware.vm.migrate'])
def clone_vmware_vm(vmware_id, vm_id):
"""Clone a VM"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
data = request.json or {}
if not data.get('name'):
return jsonify({'error': 'Clone name is required'}), 400
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.migrate'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
result = mgr.clone_vm(vm_id, data['name'], data.get('folder'), data.get('resource_pool'), data.get('datastore'))
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
log_audit(request.session.get('user', 'admin'), 'vmware.vm.cloned',
f"Cloned VM {vm_id} as '{data['name']}' @ {mgr.name}")
return jsonify({'message': f"VM cloned as '{data['name']}'", 'data': result.get('data')}), 201
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>', methods=['DELETE'])
@require_auth(perms=['vmware.vm.power'])
def delete_vmware_vm(vmware_id, vm_id):
"""Delete a VM (must be powered off)"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.power'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
result = mgr.delete_vm(vm_id)
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
log_audit(request.session.get('user', 'admin'), 'vmware.vm.deleted',
f"Deleted VM {vm_id} @ {mgr.name}")
return jsonify({'message': 'VM deleted'})
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/rename', methods=['POST'])
@require_auth(perms=['vmware.vm.power'])
def rename_vmware_vm(vmware_id, vm_id):
"""Rename a VM"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
data = request.json or {}
if not data.get('name'):
return jsonify({'error': 'New name is required'}), 400
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.power'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
result = mgr.rename_vm(vm_id, data['name'])
if 'error' in result:
return jsonify(result), result.get('status_code', 500)
log_audit(request.session.get('user', 'admin'), 'vmware.vm.renamed',
f"Renamed VM {vm_id} to '{data['name']}' @ {mgr.name}")
return jsonify({'message': f"VM renamed to '{data['name']}'"})
# ===========================================================================
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/migration-plan', methods=['GET'])
@require_auth(perms=['vmware.vm.migrate'])
def get_vmware_migration_plan(vmware_id, vm_id):
"""Analyze source VM and return migration plan with available Proxmox targets.
Also detects ESXi host and datastore for SSHFS access."""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.migrate'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
mgr = vmware_managers[vmware_id]
# NS Aug 2026 — refresh a stale ESXi REST/CIS session before the read, exactly like the
# VM-list/detail routes do (get_vmware_vms:270, single-VM GET:295). Without it, a server
# that's been up long enough for the ESXi session to expire returns "VM not found" here and
# the migration wizard dies at the planning step even though the VM plainly exists.
mgr.ensure_connected()
result = mgr.get_vm_disks_for_export(vm_id)
if 'error' in result:
return jsonify(result), 400
vm_data = result['data']
# Available Proxmox targets — only clusters the caller may reach (don't leak others' topology)
targets = []
for cid, cmgr in cluster_managers.items():
if cmgr.is_connected:
allowed, _ = check_cluster_access(cid)
if not allowed:
continue
nodes = list(cmgr.nodes.keys()) if cmgr.nodes else []
node_storages = {}
for n in nodes:
try:
sr = cmgr._api_get(f"https://{cmgr.host}:{cmgr.api_port}/api2/json/nodes/{n}/storage")
if sr.status_code == 200:
node_storages[n] = [s['storage'] for s in sr.json().get('data', [])
if s.get('active') and 'images' in s.get('content', '')]
except:
node_storages[n] = []
targets.append({
'cluster_id': cid, 'cluster_name': cmgr.config.name,
'nodes': nodes, 'storages': node_storages
})
return jsonify({
'source': vm_data,
'targets': targets,
'esxi_host': mgr.host,
'esxi_user': 'root',
'estimated_downtime_seconds': max(10, int(vm_data.get('total_disk_gb', 10) * 0.3)),
'requirements': [
'SSH must be enabled on ESXi host',
'sshfs must be installed on target Proxmox node (apt install sshfs)',
'ESXi root password is required for SSHFS access',
'Sufficient temp space on Proxmox node for disk conversion',
],
'method': 'SSHFS + qm importdisk (works on VMFS 5, VMFS 6, vSAN, NFS)',
})
@bp.route('/api/vmware/<vmware_id>/vms/<vm_id>/migrate', methods=['POST'])
@require_auth(perms=['vmware.vm.migrate'])
def start_vmware_migration(vmware_id, vm_id):
"""Start near-zero-downtime migration from VMware to Proxmox.
Required body:
- target_cluster, target_node, target_storage
- esxi_password: Root password for ESXi SSH access
Optional:
- esxi_host: ESXi host IP (default: from VMware server config)
- esxi_user: SSH username (default: root)
- esxi_datastore: Datastore name (auto-detected if not set)
- esxi_vm_dir: VM directory name on datastore (default: VM name)
- network_bridge, start_after, remove_source
- wait_for_confirmation (#562): hold before the final switchover and wait for an
explicit POST .../confirm-cutover (or .../cancel-cutover). Default false.
- confirmation_timeout: seconds to wait at that gate before auto-aborting (default 86400).
"""
if vmware_id not in vmware_managers:
return jsonify({'error': 'VMware server not found'}), 404
# Security fix: Check VM-level authorization
from pegaprox.utils.auth import load_users
users = load_users()
user = users.get(request.session.get('user', ''), {})
user['username'] = request.session.get('user', '')
if not user_can_access_vmware_vm(user, vmware_id, vm_id, 'vmware.vm.migrate'):
return jsonify({'error': 'Permission denied: You do not have access to this VM'}), 403
data = request.json or {}
for field in ('target_cluster', 'target_node', 'target_storage'):
if not data.get(field):
return jsonify({'error': f'{field} is required'}), 400
# MK May 2026 (#481 port) — target_storage flows into `pvesm` calls on the
# PVE node. Validate at the api boundary before the shell touches it.
from pegaprox.utils.sanitization import validate_storage_name, validate_esxi_path_component
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
# NS 2026-06-05 (audit C-2/M-2): esxi_datastore / esxi_vm_dir are user-supplied
# and get interpolated into root shell commands on the PVE node. Empty = let
# the task auto-detect from the VM config; non-empty must be a safe single
# component name (no slashes, no shell metacharacters). Reject at the door.
for _f in ('esxi_datastore', 'esxi_vm_dir'):
_v = (data.get(_f) or '').strip()
if _v and not validate_esxi_path_component(_v):
return jsonify({'error': f'Invalid {_f}: only letters, digits, space and ._()+- are allowed (single name, no path).'}), 400
# #598 — aio_mode is interpolated into the root `qm set` / conf line that
# attaches the migrated disk. V2PMigrationTask.__init__ already whitelists it,
# but reject a bad value at the door too (matches the boundary-validation
# style above); empty = unset = PVE default.
_aio = (data.get('aio_mode') or '').strip().lower()
if _aio and _aio not in ('threads', 'native', 'io_uring'):
return jsonify({'error': 'Invalid aio_mode: must be threads, native or io_uring.'}), 400
if not data.get('esxi_password'):
return jsonify({'error': 'esxi_password is required for SSHFS-based migration'}), 400
if data['target_cluster'] not in cluster_managers:
return jsonify({'error': 'Target cluster not found'}), 404
# gate the migration target on cluster access
allowed, err_response = check_cluster_access(data['target_cluster'])
if not allowed:
return err_response
mgr = vmware_managers[vmware_id]
# NS Aug 2026 — same stale-session guard as the migration-plan + VM-list routes: a long-lived
# server whose ESXi session has expired would otherwise read "VM not found" here and the
# migration would abort before it even started.
mgr.ensure_connected()
vm_detail = mgr.get_vm(vm_id)
vm_name = vm_detail.get('data', {}).get('name', vm_id) if 'data' in vm_detail else vm_id
# NS: pass all NICs from VMware to migration task so multi-NIC + MAC works
if 'selected_nics' not in data and 'data' in vm_detail:
nics = vm_detail['data'].get('nics', [])
if nics:
data['selected_nics'] = nics
mid = str(uuid.uuid4())[:8]
task = V2PMigrationTask(mid, vmware_id, vm_id, data['target_cluster'],
data['target_node'], data['target_storage'], vm_name, data)
with _migration_lock_v2p:
_vmware_migrations[mid] = task
thread = threading.Thread(target=_run_v2p_migration, args=(task,), daemon=True)
thread.start()
log_audit(request.session.get('user', 'admin'), 'vmware.migration.started',
f"V2P migration: {vm_name} @ {data.get('esxi_host', mgr.host)} -> "
f"{data['target_cluster']}/{data['target_node']}/{data['target_storage']}")
return jsonify({
'migration_id': mid,
'message': f'Migration started for {vm_name}',
'task': task.to_dict(),
}), 202
def _migration_reachable(t):
# NS Jul 2026 (CodeAnt IDOR) — a caller may see a migration only if they can reach one of the
# clusters it touches (source or target). Tasks with no determinable cluster are shown.
cids = [c for c in (getattr(t, 'target_cluster', None), getattr(t, 'source_cluster', None)) if c]
return (not cids) or any(check_cluster_access(c)[0] for c in cids)
# NS Aug 2026 (#654) — the route + auth decorators were stuck on the _migration_reachable helper
# above (a copy-paste slip when the IDOR helper was inserted between them and this handler), so
# GET /api/vmware/migrations called the helper with no args -> TypeError 500 and ESXi migration
# blew up immediately, while the real handler below was never routed. Put the decorators back here.
@bp.route('/api/vmware/migrations', methods=['GET'])
@require_auth(perms=['vmware.vm.migrate'])
def list_vmware_migrations():
"""List all active and recent migrations"""
return jsonify([t.to_dict() for t in _vmware_migrations.values() if _migration_reachable(t)])
@bp.route('/api/vmware/migrations/<mid>', methods=['GET'])
@require_auth(perms=['vmware.vm.migrate'])
def get_vmware_migration_status(mid):
"""Get detailed status of a specific migration"""
if mid not in _vmware_migrations:
return jsonify({'error': 'Migration not found'}), 404
if not _migration_reachable(_vmware_migrations[mid]):
return jsonify({'error': 'Migration not found'}), 404
return jsonify(_vmware_migrations[mid].to_dict())
@bp.route('/api/vmware/migrations/<mid>/confirm-cutover', methods=['POST'])
@require_auth(perms=['vmware.vm.migrate'])
def confirm_vmware_cutover(mid):
"""#562 — commit the final switchover for a migration that is holding at the
optional pre-cutover confirmation gate. Only valid while the task is parked in
'awaiting_confirmation'; the migration thread picks the flag up within ~2s."""
if mid not in _vmware_migrations:
return jsonify({'error': 'Migration not found'}), 404
task = _vmware_migrations[mid]
if not _migration_reachable(task):
return jsonify({'error': 'Migration not found'}), 404
if getattr(task, 'phase', None) != 'awaiting_confirmation':
return jsonify({'error': 'Migration is not waiting for cutover confirmation',
'phase': getattr(task, 'phase', None)}), 409
task._cutover_confirmed = True
log_audit(request.session.get('user', 'admin'), 'vmware.migration.cutover_confirmed',
f"V2P cutover confirmed for {getattr(task, 'vm_name', mid)} (migration {mid})")
return jsonify({'message': 'Cutover confirmed — switchover proceeding', 'task': task.to_dict()})
@bp.route('/api/vmware/migrations/<mid>/cancel-cutover', methods=['POST'])
@require_auth(perms=['vmware.vm.migrate'])
def cancel_vmware_cutover(mid):
"""#562 — abort a migration that is holding at the confirmation gate. The source
VM is left running (it was never suspended); the migration thread tears down the
staging mount + migration snapshot and marks the run 'cancelled'. Only valid while
the task is parked in 'awaiting_confirmation'."""
if mid not in _vmware_migrations:
return jsonify({'error': 'Migration not found'}), 404
task = _vmware_migrations[mid]
if not _migration_reachable(task):
return jsonify({'error': 'Migration not found'}), 404
if getattr(task, 'phase', None) != 'awaiting_confirmation':
return jsonify({'error': 'Migration is not waiting for cutover confirmation',
'phase': getattr(task, 'phase', None)}), 409
task._cutover_cancelled = True
log_audit(request.session.get('user', 'admin'), 'vmware.migration.cutover_cancelled',
f"V2P cutover cancelled for {getattr(task, 'vm_name', mid)} (migration {mid}) — source left running")
return jsonify({'message': 'Cutover cancelled — source VM left running', 'task': task.to_dict()})
# End VMware API endpoints
# ============================================================================