Add openvixdisklib as an open NBD replacement for VMware VDDK.

VDDK is no longer publicly distributed, so this library reverse-engineers
the vSphere NFC path and exposes ConnectEx, Open, Read, and Write without
the proprietary SDK.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Lucian Petrut
2026-09-07 11:41:01 +00:00
co-authored by Cursor
commit 4ddf0c015d
31 changed files with 4089 additions and 0 deletions
View File
+371
View File
@@ -0,0 +1,371 @@
# Copyright 2026 Cloudbase Solutions Srl
# All Rights Reserved.
"""VDDK-compatible vSphere NFC authentication.
VixDiskLib_ConnectEx / Open authenticate in two stages:
1. SOAP login to vCenter (or ESXi) and an internal NfcService call that
returns a one-time vim.HostServiceTicket.
2. A TLS session to the ESXi authd daemon on TCP 902, completed with the
ticket's sessionId and service name.
pyVim / pyVmomi are used for every public VIM operation (login, inventory,
HostServiceTicket). NfcService is not in the public WSDL, so it is registered
with pyVmomi's type system and invoked through the same SOAP stub.
"""
from __future__ import annotations
import hashlib
import socket
import ssl
from typing import Optional
from pyVim.connect import Disconnect, SmartConnect
from pyVmomi import vim
from pyVmomi.VmomiSupport import CreateManagedType, F_OPTIONAL, GetVmodlType
NFC_SERVICE_MOID = "nfcService"
AUTHD_DEFAULT_PORT = 902
_NFC_TYPES_REGISTERED = False
def _ssl_client_context(verify: bool = True) -> ssl.SSLContext:
"""Return a client TLS context built with public ``ssl`` APIs."""
context = ssl.create_default_context()
if not verify:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def _register_nfc_types() -> None:
"""Register internal vim.NfcService methods on the pyVmomi type map."""
global _NFC_TYPES_REGISTERED
if _NFC_TYPES_REGISTERED:
return
try:
GetVmodlType("vim.NfcService")
_NFC_TYPES_REGISTERED = True
return
except Exception:
pass
CreateManagedType(
"vim.NfcService",
"NfcService",
"vmodl.ManagedObject",
"vim.version.version1",
[],
[
("getVmFiles", "NfcGetVmFiles", "vim.version.version1",
(("vm", "vim.VirtualMachine", "vim.version.version1", 0, None),),
(0, "vim.HostServiceTicket", "vim.HostServiceTicket"), None, None),
("randomAccessOpen", "NfcRandomAccessOpenDisk",
"vim.version.version1",
(("vm", "vim.VirtualMachine", "vim.version.version1", 0, None),
("diskDeviceKey", "int", "vim.version.version1", 0, None),
("hostForAccess", "vim.HostSystem", "vim.version.version1",
F_OPTIONAL, None),),
(0, "vim.HostServiceTicket", "vim.HostServiceTicket"), None, None),
("randomAccessOpenReadonly", "NfcRandomAccessOpenReadonly",
"vim.version.version1",
(("vm", "vim.VirtualMachine", "vim.version.version1", 0, None),
("diskDeviceKey", "int", "vim.version.version1", 0, None),
("hostForAccess", "vim.HostSystem", "vim.version.version1",
F_OPTIONAL, None),),
(0, "vim.HostServiceTicket", "vim.HostServiceTicket"), None, None),
("getServerNfcLibVersion", "NfcGetServerNfcLibVersion",
"vim.version.version1",
(("hostForAccess", "vim.HostSystem", "vim.version.version1",
0, None),),
(0, "int", "int"), None, None),
],
)
_NFC_TYPES_REGISTERED = True
def nfc_service(si: vim.ServiceInstance) -> vim.NfcService:
"""Return the vCenter/ESXi NfcService managed object on ``si``'s SOAP stub.
Args:
si: An authenticated ServiceInstance from pyVim.connect.SmartConnect.
"""
_register_nfc_types()
nfc_cls = GetVmodlType("vim.NfcService")
return nfc_cls(NFC_SERVICE_MOID, si._stub)
def connect_vim(
host: str,
username: str,
password: str,
port: int = 443,
thumbprint: Optional[str] = None,
allow_untrusted: bool = False) -> vim.ServiceInstance:
"""Login to vCenter or ESXi using pyVim.connect.SmartConnect.
Args:
host: vCenter or ESXi hostname/IP.
username: VIM user name.
password: VIM password.
port: HTTPS port, usually 443.
thumbprint: Optional SHA-1 SSL thumbprint of the management endpoint.
allow_untrusted: If True, skip certificate validation.
"""
ssl_context = None
if allow_untrusted:
ssl_context = _ssl_client_context(verify=False)
return SmartConnect(
host=host,
user=username,
pwd=password,
port=port,
thumbprint=thumbprint,
sslContext=ssl_context,
disableSslCertValidation=allow_untrusted)
def _virtual_disk_key(vm: vim.VirtualMachine, disk_path: str) -> int:
"""Return the VirtualDisk device key whose backing path is ``disk_path``."""
for device in vm.config.hardware.device:
if isinstance(device, vim.vm.device.VirtualDisk):
backing = getattr(device, "backing", None)
file_name = getattr(backing, "fileName", None)
if file_name == disk_path:
return device.key
raise ValueError(
f"VMDK path {disk_path!r} is not attached to {vm._moId}")
def get_nfc_ticket(
si: vim.ServiceInstance,
vm: vim.VirtualMachine,
disk_device_key: Optional[int] = None,
host_for_access: Optional[vim.HostSystem] = None,
read_only: bool = True,
disk_path: Optional[str] = None) -> vim.HostServiceTicket:
"""Return a one-time NFC HostServiceTicket for ``vm``.
Matches VDDK: ``NfcGetVmFiles`` when only the VM is known (read-only),
``NfcRandomAccessOpenReadonly`` / ``NfcRandomAccessOpenDisk`` when a
virtual disk device key (or datastore path) is supplied.
Args:
si: Authenticated ServiceInstance.
vm: Target virtual machine.
disk_device_key: Optional VirtualDisk.device key (for example 2000).
host_for_access: Host that should serve NFC; defaults to the VM's host.
read_only: When False, request a writable ticket (needs a disk).
disk_path: Datastore path used to resolve ``disk_device_key``.
"""
nfc = nfc_service(si)
if read_only and disk_device_key is None and disk_path is None:
return nfc.GetVmFiles(vm)
if disk_device_key is None:
if disk_path is None:
raise ValueError(
"writable NFC tickets need disk_path or disk_device_key")
disk_device_key = _virtual_disk_key(vm, disk_path)
if host_for_access is None:
host_for_access = vm.runtime.host
if read_only:
return nfc.RandomAccessOpenReadonly(
vm, disk_device_key, host_for_access)
return nfc.RandomAccessOpen(vm, disk_device_key, host_for_access)
def _format_thumbprint(digest: bytes) -> str:
return ":".join(f"{byte:02X}" for byte in digest)
def _sha1_thumbprint(der_cert: bytes) -> str:
return _format_thumbprint(hashlib.sha1(der_cert).digest())
def _normalize_thumbprint(thumbprint: str) -> str:
return thumbprint.replace(":", "").replace(" ", "").upper()
def get_ssl_cert_thumbprint(
host: str,
port: int = 443,
digest_algorithm: str = "sha1",
ssl_context: Optional[ssl.SSLContext] = None,
timeout: float = 30.0) -> str:
"""Return the TLS certificate thumbprint of ``host``:``port``.
Reads the peer certificate in DER form and hashes it with ``hashlib``.
The result is colon-separated uppercase hex (for example
``A5:AF:7D:…``), matching VDDK / pyVmomi SHA-1 thumbprints.
Args:
host: Hostname or IP of the TLS server.
port: TLS port, usually 443.
digest_algorithm: Hash name accepted by ``hashlib.new``. Default
``sha1`` is the format VDDK and pyVmomi expect.
ssl_context: Optional SSL context. When omitted, a default client
context is used with hostname checks and certificate
validation disabled so a self-signed management certificate
can still be read.
timeout: Connect timeout in seconds.
"""
if ssl_context is None:
ssl_context = _ssl_client_context(verify=False)
with socket.create_connection((host, port), timeout=timeout) as sock:
with ssl_context.wrap_socket(
sock, server_hostname=host) as ssock:
cert = ssock.getpeercert(binary_form=True)
if not cert:
raise ConnectionError(
f"no peer certificate from {host}:{port}")
return _format_thumbprint(
hashlib.new(digest_algorithm, cert).digest())
def _readline(sock: socket.socket) -> str:
buf = b""
while not buf.endswith(b"\n"):
chunk = sock.recv(1)
if not chunk:
raise ConnectionError("authd connection closed")
buf += chunk
if len(buf) > 4096:
raise ConnectionError("oversized authd response")
return buf.decode("ascii", "replace").rstrip("\r\n")
def _expect_code(line: str, code: str, what: str) -> str:
if not line.startswith(code):
raise ConnectionError(f"authd {what} failed: {line}")
return line[len(code):].lstrip()
def connect_authd(
ticket: vim.HostServiceTicket,
allow_untrusted: bool = False,
timeout: float = 30.0) -> ssl.SSLSocket:
"""Complete the ESXi authd handshake using an NFC HostServiceTicket.
Wire sequence captured from VDDK against authd on TCP 902:
1. Read the plaintext 220 banner, then wrap the socket with TLS.
2. SESSION <sessionId>
3. BANNER
4. THUMBPRINT_SHA2 PlainText (NFC data stays on this TLS socket)
5. PROXY <ticket.service> (vpxa-nfc when connecting via vCenter)
Args:
ticket: One-time ticket from get_nfc_ticket().
allow_untrusted: If False, require the peer SHA-1 thumbprint to match
ticket.sslThumbprint.
timeout: Socket timeout in seconds.
"""
host = ticket.host
port = ticket.port or AUTHD_DEFAULT_PORT
raw = socket.create_connection((host, port), timeout=timeout)
try:
banner = _readline(raw)
if not banner.startswith("220"):
raise ConnectionError(f"unexpected authd banner: {banner}")
ssl_context = _ssl_client_context(verify=False)
ssock = ssl_context.wrap_socket(raw, server_hostname=host)
except Exception:
raw.close()
raise
try:
if not allow_untrusted and ticket.sslThumbprint:
peer = _sha1_thumbprint(ssock.getpeercert(True))
if _normalize_thumbprint(peer) != _normalize_thumbprint(
ticket.sslThumbprint):
raise ConnectionError(
f"ESXi SSL thumbprint mismatch: got {peer}, "
f"expected {ticket.sslThumbprint}")
ssock.sendall(f"SESSION {ticket.sessionId}\r\n".encode("ascii"))
# Trailing space is part of the BANNER command token used by authd.
ssock.sendall(b"BANNER \r\n")
_expect_code(_readline(ssock), "220", "BANNER")
ssock.sendall(b"THUMBPRINT_SHA2 PlainText\r\n")
_expect_code(_readline(ssock), "200", "THUMBPRINT_SHA2")
service = ticket.service or "vpxa-nfc"
ssock.sendall(f"PROXY {service}\r\n".encode("ascii"))
_expect_code(_readline(ssock), "200", "PROXY")
return ssock
except Exception:
ssock.close()
raise
class NfcAuthSession:
"""Authenticated VIM session plus an authd/NFC TLS socket."""
def __init__(
self,
si: vim.ServiceInstance,
ticket: vim.HostServiceTicket,
authd_sock: ssl.SSLSocket) -> None:
self.si = si
self.ticket = ticket
self.authd_sock = authd_sock
def close(self) -> None:
"""Close the authd socket and logout of the VIM session."""
try:
self.authd_sock.close()
finally:
Disconnect(self.si)
def __enter__(self) -> "NfcAuthSession":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
def authenticate(
host: str,
username: str,
password: str,
vm_moref: str,
port: int = 443,
thumbprint: Optional[str] = None,
allow_untrusted: bool = False,
disk_device_key: Optional[int] = None,
disk_path: Optional[str] = None,
read_only: bool = True) -> NfcAuthSession:
"""Login to vSphere and complete NFC authd authentication for a VM.
Args:
host: vCenter or ESXi hostname/IP.
username: VIM user name.
password: VIM password.
vm_moref: Virtual machine managed object id (for example ``vm-13098``).
port: HTTPS port for VIM, usually 443.
thumbprint: Optional SHA-1 thumbprint of the management endpoint.
allow_untrusted: Skip TLS certificate checks when True.
disk_device_key: Optional VirtualDisk device key; when omitted with
``read_only``, the VDDK ``NfcGetVmFiles`` ticket is used.
disk_path: Datastore path used to resolve ``disk_device_key``.
read_only: When False, request a writable ``NfcRandomAccessOpenDisk``
ticket.
"""
si = connect_vim(
host, username, password, port=port,
thumbprint=thumbprint, allow_untrusted=allow_untrusted)
try:
vm = vim.VirtualMachine(vm_moref, si._stub)
ticket = get_nfc_ticket(
si, vm, disk_device_key=disk_device_key,
disk_path=disk_path, read_only=read_only)
authd_sock = connect_authd(
ticket, allow_untrusted=allow_untrusted)
except Exception:
Disconnect(si)
raise
return NfcAuthSession(si, ticket, authd_sock)
+425
View File
@@ -0,0 +1,425 @@
# Copyright 2026 Cloudbase Solutions Srl
# All Rights Reserved.
"""VDDK-compatible NFC disk open, sector read, and sector write.
After ``nfc_auth.connect_authd`` returns ``200 Connect``, VDDK stops using
``SSL_write`` on the authd socket. ``THUMBPRINT_SHA2 PlainText`` means the
NFC binary protocol runs as raw TCP on that same file descriptor
(``useSSL=0``). This module dups that fd and speaks:
1. Classic 264-byte NFC messages (handshake, version, connection data,
AIO session open).
2. NFC AIO frames (16-byte header plus payload) to open a VMDK and read
or write sectors.
pyVmomi is not involved here; the ticket and TLS authd handshake already
happened in ``nfc_auth``.
"""
from __future__ import annotations
import os
import socket
import ssl
import struct
from openvixdisklib.nfc_auth import NfcAuthSession
NFC_MSG_SIZE = 264
NFC_AIO_MAGIC = 0xA100DA7A
NFC_AIO_HDR_SIZE = 16
NFC_SECTOR_SIZE = 512
NFC_PROTOCOL_VERSION = 11
# Max data bytes in one AIO IO reply fragment (NfcAioInitSession buffer).
NFC_AIO_BUFFER_SIZE = 65536
# Classic NFC message types observed on the wire (uint32 at offset 0).
NFC_MSG_SESSION_COMPLETE = 4
NFC_MSG_SESSION_PARAMS = 33
NFC_MSG_SESSION_PARAMS_REPLY = 36
NFC_MSG_HANDSHAKE = 43
NFC_MSG_VERSION = 51
NFC_MSG_AIO_SESSION_OPEN = 52
NFC_MSG_CONNECTION_DATA = 54
NFC_MSG_SESSION_FEATURES = 55
# SessionParams / feature bits from VDDK logs (interruption | switch).
NFC_SESSION_FEATURE_INTERRUPTION_SWITCH = 3
# AIO message types (NfcAioSendMessage "type = N").
NFC_AIO_MSG_ERROR = 1
NFC_AIO_MSG_OPEN_SESSION = 2
NFC_AIO_MSG_CLOSE_SESSION = 3
NFC_AIO_MSG_OPEN_FILE = 4
NFC_AIO_MSG_CLOSE_FILE = 5
NFC_AIO_MSG_IO = 7
NFC_AIO_MSG_SET_SOCK_OPTS = 9
NFC_AIO_MSG_DDB_GET = 11
NFC_AIO_MSG_SET_RES_POOL = 22
# Open-file body: file type NFC_DISK. 0x1e is what VDDK sends for
# VIXDISKLIB_FLAG_OPEN_READ_ONLY; writable opens clear bit 0x04 (0x1a).
NFC_DISK = 2
NFC_OPEN_FLAGS_READ_ONLY = 0x1E
NFC_OPEN_FLAGS_READ_WRITE = 0x1A
NFC_AIO_IO_WRITE = 0
NFC_AIO_IO_READ = 1
class NfcProtocolError(ConnectionError):
"""Raised when an NFC message is malformed or reports failure."""
def takeover_authd_socket(ssock: ssl.SSLSocket) -> socket.socket:
"""Return a raw socket on the authd TCP connection.
VDDK writes NFC with ``write(SSL_get_fd(ssl), ...)`` after PROXY, so
those bytes are not TLS records. Duping the fd lets Python do the
same without ``SSLSocket.send`` re-encrypting, and without
``SSL_shutdown``.
Args:
ssock: The TLS socket from ``nfc_auth.connect_authd``.
"""
timeout = ssock.gettimeout()
raw = socket.socket(
family=ssock.family,
type=ssock.type,
proto=ssock.proto,
fileno=os.dup(ssock.fileno()))
raw.settimeout(timeout)
return raw
def _recvn(sock: socket.socket, size: int) -> bytes:
buf = bytearray()
while len(buf) < size:
chunk = sock.recv(size - len(buf))
if not chunk:
raise NfcProtocolError(
f"NFC connection closed, needed {size} bytes, got {len(buf)}")
buf.extend(chunk)
return bytes(buf)
def _send_nfc_msg(
sock: socket.socket, msg_type: int, body: bytes = b"") -> None:
if len(body) > NFC_MSG_SIZE - 4:
raise ValueError("NFC classic message body too large")
frame = struct.pack("<I", msg_type) + body
sock.sendall(frame.ljust(NFC_MSG_SIZE, b"\x00"))
def _recv_nfc_msg(sock: socket.socket) -> tuple[int, bytes]:
frame = _recvn(sock, NFC_MSG_SIZE)
msg_type = struct.unpack_from("<I", frame)[0]
return msg_type, frame[4:]
def _pack_aio_hdr(msg_type: int, payload_size: int, op_id: int) -> bytes:
return struct.pack(
"<IIII", NFC_AIO_MAGIC, msg_type, payload_size, op_id)
def _unpack_aio_hdr(hdr: bytes) -> tuple[int, int, int]:
magic, msg_type, size, op_id = struct.unpack_from("<IIII", hdr)
if magic != NFC_AIO_MAGIC:
raise NfcProtocolError(
f"AIO header magic mismatch: 0x{magic:x}, "
f"expected 0x{NFC_AIO_MAGIC:x}")
if msg_type == NFC_AIO_MSG_ERROR:
raise NfcProtocolError(f"AIO error opId={op_id} size={size}")
return msg_type, size, op_id
class NfcDisk:
"""An NFC AIO session with one VMDK opened for I/O."""
def __init__(
self,
sock: socket.socket,
path: str,
handle: int,
sector_size: int) -> None:
"""Wrap an AIO session that already has ``path`` open.
Args:
sock: Raw NFC socket after handshake.
path: Datastore path that was opened.
handle: Server file handle from OPEN_FILE.
sector_size: Sector size from the OPEN_FILE reply.
"""
self._sock = sock
self._op_id = 0
self.path = path
self.handle = handle
self.sector_size = sector_size
self._closed = False
def _next_op_id(self) -> int:
op_id = self._op_id
self._op_id += 1
return op_id
def _aio_roundtrip(
self,
msg_type: int,
payload: bytes,
extra: bytes = b"",
extra_recv: int = 0) -> bytes:
"""Send one AIO request and return the reply payload (+ extra)."""
op_id = self._next_op_id()
self._sock.sendall(
_pack_aio_hdr(msg_type, len(payload), op_id) + payload)
if extra:
self._sock.sendall(extra)
rhdr = _recvn(self._sock, NFC_AIO_HDR_SIZE)
magic, rtype, rsize, rop = struct.unpack_from("<IIII", rhdr)
if magic != NFC_AIO_MAGIC:
raise NfcProtocolError(
f"AIO header magic mismatch: 0x{magic:x}, "
f"expected 0x{NFC_AIO_MAGIC:x}")
body = _recvn(self._sock, rsize) if rsize else b""
if rtype == NFC_AIO_MSG_ERROR:
raise NfcProtocolError(
f"AIO error opId={rop} size={rsize} {body.hex()}")
if rtype != msg_type or rop != op_id:
raise NfcProtocolError(
f"AIO reply type={rtype} opId={rop}, "
f"expected type={msg_type} opId={op_id}")
if extra_recv:
body += _recvn(self._sock, extra_recv)
return body
def read(self, start_sector: int, num_sectors: int = 1) -> bytes:
"""Read ``num_sectors`` starting at ``start_sector``.
Matches ``VixDiskLib_Read``: one ``NFC_AIO_MSG_IO`` request in
byte units. If the length exceeds the AIO buffer (64 KiB) the
server replies with several same-``opId`` fragments.
Args:
start_sector: Sector offset from the start of the disk.
num_sectors: Number of sectors to read.
"""
if num_sectors < 1:
raise ValueError("num_sectors must be at least 1")
length = num_sectors * self.sector_size
offset = start_sector * self.sector_size
payload = struct.pack(
"<QQQQIII",
self.handle,
NFC_AIO_IO_READ,
offset,
length,
length,
length,
0)
op_id = self._next_op_id()
self._sock.sendall(
_pack_aio_hdr(NFC_AIO_MSG_IO, len(payload), op_id) + payload)
data = bytearray()
while len(data) < length:
rhdr = _recvn(self._sock, NFC_AIO_HDR_SIZE)
rtype, rsize, rop = _unpack_aio_hdr(rhdr)
if rtype != NFC_AIO_MSG_IO or rop != op_id:
raise NfcProtocolError(
f"AIO IO reply type={rtype} opId={rop}, "
f"expected type={NFC_AIO_MSG_IO} opId={op_id}")
body = _recvn(self._sock, rsize)
if rsize < 36:
raise NfcProtocolError(
f"AIO IO reply payload too short: {rsize}")
chunk_len = struct.unpack_from("<I", body, 32)[0]
remaining = length - len(data)
if chunk_len == 0 or chunk_len > remaining:
raise NfcProtocolError(
f"AIO IO chunk length {chunk_len} invalid, "
f"remaining {remaining}")
data.extend(_recvn(self._sock, chunk_len))
return bytes(data)
def write(
self,
start_sector: int,
num_sectors: int,
data: bytes) -> None:
"""Write ``num_sectors`` starting at ``start_sector``.
Matches ``VixDiskLib_Write``: one ``NFC_AIO_MSG_IO`` request per
chunk in byte units, with sector bytes sent after the 44-byte
payload. Chunks larger than the AIO buffer (64 KiB) are split.
Args:
start_sector: Sector offset from the start of the disk.
num_sectors: Number of sectors to write.
data: Bytes to write; length must be ``num_sectors * sector_size``.
"""
if num_sectors < 1:
raise ValueError("num_sectors must be at least 1")
length = num_sectors * self.sector_size
if len(data) != length:
raise ValueError(
f"write data is {len(data)} bytes, need {length}")
max_sectors = NFC_AIO_BUFFER_SIZE // self.sector_size
offset_sectors = start_sector
remaining = data
while remaining:
n_sectors = min(len(remaining) // self.sector_size, max_sectors)
chunk = remaining[:n_sectors * self.sector_size]
self._write_once(offset_sectors, n_sectors, chunk)
offset_sectors += n_sectors
remaining = remaining[n_sectors * self.sector_size:]
def _write_once(
self,
start_sector: int,
num_sectors: int,
data: bytes) -> None:
length = num_sectors * self.sector_size
offset = start_sector * self.sector_size
payload = struct.pack(
"<QQQQIII",
self.handle,
NFC_AIO_IO_WRITE,
offset,
length,
length,
length,
0)
self._aio_roundtrip(NFC_AIO_MSG_IO, payload, extra=data)
def close(self) -> None:
"""Close the VMDK, the AIO session, and the classic NFC session."""
if self._closed:
return
self._closed = True
try:
self._aio_roundtrip(
NFC_AIO_MSG_CLOSE_FILE, struct.pack("<Q", self.handle))
self._aio_roundtrip(
NFC_AIO_MSG_CLOSE_SESSION, struct.pack("<I", 0))
_send_nfc_msg(self._sock, NFC_MSG_SESSION_COMPLETE)
finally:
try:
self._sock.close()
except OSError:
pass
def __enter__(self) -> "NfcDisk":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
def _handshake(
sock: socket.socket,
client_name: str,
op_id: str,
version: int) -> None:
"""Run the classic NFC session handshake used by VDDK NBD."""
_send_nfc_msg(sock, NFC_MSG_HANDSHAKE, b"PlainText")
_send_nfc_msg(sock, NFC_MSG_SESSION_PARAMS)
reply_type, _ = _recv_nfc_msg(sock)
if reply_type != NFC_MSG_SESSION_PARAMS_REPLY:
raise NfcProtocolError(
f"expected session-params reply {NFC_MSG_SESSION_PARAMS_REPLY}, "
f"got {reply_type}")
_send_nfc_msg(sock, NFC_MSG_VERSION, struct.pack("<I", version))
reply_type, body = _recv_nfc_msg(sock)
if reply_type != NFC_MSG_VERSION:
raise NfcProtocolError(
f"expected version reply {NFC_MSG_VERSION}, got {reply_type}")
remote_version = struct.unpack_from("<I", body)[0]
if remote_version < 3:
raise NfcProtocolError(
f"NFC server version {remote_version} is too old for AIO")
name_b = client_name.encode("ascii")
op_b = op_id.encode("ascii")
_send_nfc_msg(
sock, NFC_MSG_CONNECTION_DATA,
struct.pack("<II", len(name_b), len(op_b)))
sock.sendall(name_b)
sock.sendall(op_b)
_send_nfc_msg(
sock, NFC_MSG_SESSION_FEATURES,
struct.pack("<I", NFC_SESSION_FEATURE_INTERRUPTION_SWITCH))
_send_nfc_msg(sock, NFC_MSG_AIO_SESSION_OPEN)
reply_type, _ = _recv_nfc_msg(sock)
if reply_type != NFC_MSG_AIO_SESSION_OPEN:
raise NfcProtocolError(
f"expected AIO session-open reply "
f"{NFC_MSG_AIO_SESSION_OPEN}, got {reply_type}")
def _aio_prepare(disk: NfcDisk) -> None:
disk._aio_roundtrip(
NFC_AIO_MSG_OPEN_SESSION, bytes(16))
disk._aio_roundtrip(
NFC_AIO_MSG_SET_SOCK_OPTS, bytes(12))
disk._aio_roundtrip(
NFC_AIO_MSG_SET_RES_POOL, struct.pack("<I", 1))
def _parse_open_reply(body: bytes) -> tuple[int, int]:
if len(body) < 40:
raise NfcProtocolError(f"OPEN_FILE reply too short: {len(body)}")
handle, file_type, _flags = struct.unpack_from("<QII", body, 8)
sector_size = struct.unpack_from("<I", body, 36)[0]
if file_type != NFC_DISK:
raise NfcProtocolError(
f"opened file type {file_type}, expected NFC_DISK")
if sector_size == 0:
sector_size = NFC_SECTOR_SIZE
return handle, sector_size
def open_disk(
session: NfcAuthSession,
disk_path: str,
client_name: str = "vddk",
op_id: str = "nbdmode",
version: int = NFC_PROTOCOL_VERSION,
read_only: bool = True) -> NfcDisk:
"""Open ``disk_path`` over the authenticated authd socket.
Matches VDDK ``VixDiskLib_Open`` of a datastore path after the NFC
ticket and authd PROXY handshake: session init, AIO open, then
``NFC_AIO_MSG_OPEN_FILE`` with type ``NFC_DISK``.
Args:
session: Result of ``nfc_auth.authenticate``.
disk_path: Datastore path, for example
``[datastore0] vm/vm.vmdk``.
client_name: NFC client name; VDDK sends ``vddk``.
op_id: NFC operation id; VDDK NBD sends ``nbdmode``.
version: Client NFC protocol version (lab ESXi answered 11).
read_only: When True, open with VDDK's read-only NFC flags.
"""
sock = takeover_authd_socket(session.authd_sock)
try:
_handshake(sock, client_name, op_id, version)
disk = NfcDisk(sock, disk_path, handle=0, sector_size=NFC_SECTOR_SIZE)
_aio_prepare(disk)
path_b = disk_path.encode("utf-8")
open_flags = (
NFC_OPEN_FLAGS_READ_ONLY if read_only
else NFC_OPEN_FLAGS_READ_WRITE)
open_body = struct.pack(
"<IIIIII",
len(path_b), 0, 0, 0, NFC_DISK, open_flags)
open_body = open_body.ljust(60, b"\x00")
reply = disk._aio_roundtrip(
NFC_AIO_MSG_OPEN_FILE, open_body, extra=path_b)
handle, sector_size = _parse_open_reply(reply)
disk.handle = handle
disk.sector_size = sector_size
return disk
except Exception:
sock.close()
raise
+333
View File
@@ -0,0 +1,333 @@
# Copyright 2026 Cloudbase Solutions Srl
# All Rights Reserved.
"""Drop-in replacement for ``tests.integration.vixdisklib`` that does not
use VDDK.
Callers can switch with::
from openvixdisklib import openvixdisklib as vixdisklib
``VixDiskLibHandle.connect`` / ``open`` / ``read`` match the VDDK wrapper
in ``tests/integration/vixdisklib.py``. VIM login uses pyVmomi; NFC ticket,
authd, and disk I/O use ``nfc_auth`` and ``nfc_open``.
"""
from __future__ import annotations
import contextlib
import ctypes
import logging
import os
from typing import Iterator, Optional, Union
from pyVim.connect import Disconnect
from pyVmomi import vim
from openvixdisklib import nfc_auth
from openvixdisklib import nfc_open
LOG = logging.getLogger(__name__)
VIXDISKLIB_VERSION_MAJOR = 8
VIXDISKLIB_VERSION_MINOR = 0
VIXDISKLIB_SECTOR_SIZE = 512
VIXDISKLIB_CRED_UID = 1
VIXDISKLIB_FLAG_OPEN_UNBUFFERED = 1
VIXDISKLIB_FLAG_OPEN_SINGLE_LINK = 2
VIXDISKLIB_FLAG_OPEN_READ_ONLY = 4
VIXDISKLIB_FLAG_OPEN_COMPRESSION_ZLIB = 16
VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ = 32
VIXDISKLIB_FLAG_OPEN_COMPRESSION_SKIPZ = 64
_COMPRESSION_FLAGS = (
VIXDISKLIB_FLAG_OPEN_COMPRESSION_ZLIB
| VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ
| VIXDISKLIB_FLAG_OPEN_COMPRESSION_SKIPZ)
VIX_SUPPORTED_COMPATIBILITY_MODES = [
"6.0", "6.5", "6.7", "7.0", "8.0"]
def get_buffer(size: int):
"""Return a ctypes buffer of ``size`` bytes, as the VDDK wrapper did."""
return ctypes.create_string_buffer(size)
def _parse_vm_moref(vmx_spec: Optional[str]) -> str:
if not vmx_spec:
raise ValueError(
"vmx_spec is required (for example 'moref=vm-13098')")
if "=" in vmx_spec:
kind, value = vmx_spec.split("=", 1)
if kind.lower() != "moref" or not value:
raise ValueError(f"unsupported vmx_spec: {vmx_spec}")
return value
return vmx_spec
def _require_nbd(transport_modes: Optional[str]) -> None:
if transport_modes is None:
return
modes = [m for m in transport_modes.split(":") if m]
if "nbd" not in modes:
raise NotImplementedError(
f"only nbd transport is supported, got {transport_modes!r}")
class _Connection:
"""VIM session plus the VM moref needed to issue an NFC ticket at Open."""
def __init__(
self,
si: vim.ServiceInstance,
vm_moref: str,
snapshot_ref: Optional[str],
thumbprint: Optional[str],
allow_untrusted: bool,
read_only: bool) -> None:
self.si = si
self.vm_moref = vm_moref
self.snapshot_ref = snapshot_ref
self.thumbprint = thumbprint
self.allow_untrusted = allow_untrusted
self.read_only = read_only
class _DiskHandle:
"""Opened NFC disk plus the authd TLS socket it was taken from."""
def __init__(
self,
disk: nfc_open.NfcDisk,
authd_sock) -> None:
self.disk = disk
self.authd_sock = authd_sock
class VixDiskLibHandle:
"""VDDK-compatible handle backed by pyVmomi and the NFC replacement."""
def __init__(
self,
config_path: Optional[str] = None,
vixdisklib_compatibility_version: Optional[str] = None) -> None:
"""Accept the VDDK wrapper constructor; no native library is loaded.
Args:
config_path: Ignored. VDDK used this for logging plugins.
vixdisklib_compatibility_version: Optional ``major.minor`` string
such as ``8.0``. Validated for form only.
"""
del config_path
target_versions = VIX_SUPPORTED_COMPATIBILITY_MODES
if vixdisklib_compatibility_version:
target_versions = [vixdisklib_compatibility_version]
LOG.debug("vixDiskLib versions targeted: %s", target_versions)
version_used = None
for version in reversed(target_versions):
try:
major_ver, minor_ver = version.split(".")
int(major_ver)
int(minor_ver)
except ValueError as ex:
raise ValueError(
"Unsupported vixDiskLib version format '%s'. vixDiskLib "
"compatibility mode must be of the form "
"'$major.$minor'" % version) from ex
version_used = version
break
if not version_used:
raise Exception(
"Could not initialize vixDiskLib with any of the following "
"versions: %s" % target_versions)
LOG.info(
"Successfully initialized vixDiskLib with target version '%s'",
version_used)
@classmethod
def get_vix_disklib_name(cls) -> str:
"""Return the native library name; this replacement does not load it."""
if os.name == "nt":
return "vixDiskLib.dll"
return "libvixDiskLib.so"
def get_transport_modes(self) -> list[str]:
"""Return the transport modes this replacement implements."""
return ["nbd"]
def get_transport_mode(self, disk_handle: _DiskHandle) -> str:
"""Return the transport used for ``disk_handle``."""
del disk_handle
return "nbd"
@contextlib.contextmanager
def connect(
self,
server_name: str,
thumbprint: Optional[str],
username: str,
password: str,
vmx_spec: Optional[str] = None,
snapshot_ref: Optional[str] = None,
read_only: bool = True,
transport_modes: Optional[str] = None,
port: int = 443,
allow_untrusted: bool = False) -> Iterator[_Connection]:
"""Login to vCenter/ESXi. Matches ``VixDiskLib_ConnectEx``.
The NFC ticket and authd handshake are deferred to ``open``, as in
VDDK. Writable opens use ``NfcRandomAccessOpenDisk``; read-only
opens use ``NfcGetVmFiles``. ``snapshot_ref`` is accepted for API
compatibility and is not sent on the ticket SOAP call.
Args:
server_name: vCenter or ESXi hostname/IP.
thumbprint: SHA-1 thumbprint of the management TLS certificate.
username: VIM user name.
password: VIM password.
vmx_spec: VM selector, ``moref=vm-…``.
snapshot_ref: Snapshot moref; unused on the NFC ticket.
read_only: When False, the disk may be opened for write.
transport_modes: ``nbd`` or a colon list that includes ``nbd``.
port: HTTPS port, usually 443.
allow_untrusted: Skip management TLS verification when True.
"""
LOG.debug("Connecting VixDiskLib: %s", server_name)
_require_nbd(transport_modes)
vm_moref = _parse_vm_moref(vmx_spec)
si = nfc_auth.connect_vim(
server_name,
username,
password,
port=port,
thumbprint=thumbprint,
allow_untrusted=allow_untrusted or not thumbprint)
conn = _Connection(
si, vm_moref, snapshot_ref, thumbprint,
allow_untrusted or not thumbprint, read_only)
try:
yield conn
finally:
self.disconnect(conn)
@contextlib.contextmanager
def open(
self,
conn: _Connection,
disk_path: str,
flags: int = VIXDISKLIB_FLAG_OPEN_READ_ONLY) -> Iterator[_DiskHandle]:
"""Open ``disk_path`` over NFC. Matches ``VixDiskLib_Open``.
Args:
conn: Connection from ``connect``.
disk_path: Datastore path of the VMDK.
flags: Open flags. ``VIXDISKLIB_FLAG_OPEN_READ_ONLY`` opens
the disk read-only; omit it for write. Compression flags
are not implemented.
"""
LOG.debug("Openning VixDiskLib disk: %s", disk_path)
if flags & _COMPRESSION_FLAGS:
raise NotImplementedError(
"NBD compression open flags are not supported")
read_only = bool(flags & VIXDISKLIB_FLAG_OPEN_READ_ONLY)
if not read_only and conn.read_only:
raise NotImplementedError(
"ConnectEx was read-only; cannot open for write")
vm = vim.VirtualMachine(conn.vm_moref, conn.si._stub)
ticket = nfc_auth.get_nfc_ticket(
conn.si, vm, read_only=read_only, disk_path=disk_path)
authd_sock = nfc_auth.connect_authd(
ticket, allow_untrusted=conn.allow_untrusted)
session = nfc_auth.NfcAuthSession(conn.si, ticket, authd_sock)
try:
disk = nfc_open.open_disk(
session, disk_path, read_only=read_only)
except Exception:
authd_sock.close()
raise
handle = _DiskHandle(disk, authd_sock)
try:
yield handle
finally:
self.close(handle)
def read(
self,
disk_handle: _DiskHandle,
start_sector: int,
num_sectors: int,
buf: Union[ctypes.Array, bytearray, memoryview]) -> None:
"""Read ``num_sectors`` from ``start_sector`` into ``buf``.
Args:
disk_handle: Handle from ``open``.
start_sector: First sector to read.
num_sectors: Number of sectors to read.
buf: Destination buffer (``get_buffer`` or a writable bytes-like).
"""
data = disk_handle.disk.read(start_sector, num_sectors)
if isinstance(buf, (bytearray, memoryview)):
if len(buf) < len(data):
raise Exception(
f"read buffer is {len(buf)} bytes, need {len(data)}")
buf[:len(data)] = data
return
ctypes.memmove(buf, data, len(data))
def write(
self,
disk_handle: _DiskHandle,
start_sector: int,
num_sectors: int,
buf: Union[ctypes.Array, bytes, bytearray, memoryview]) -> None:
"""Write ``num_sectors`` from ``buf`` starting at ``start_sector``.
Args:
disk_handle: Handle from ``open``.
start_sector: First sector to write.
num_sectors: Number of sectors to write.
buf: Source buffer (``get_buffer`` or a bytes-like).
"""
length = num_sectors * VIXDISKLIB_SECTOR_SIZE
if isinstance(buf, (bytes, bytearray, memoryview)):
data = bytes(buf[:length])
else:
data = buf.raw[:length]
disk_handle.disk.write(start_sector, num_sectors, data)
def close(self, disk_handle: _DiskHandle) -> None:
"""Close the VMDK and the authd socket used for NFC.
Args:
disk_handle: Handle from ``open``.
"""
LOG.debug("Closing VixDiskLib disk handle: %s", disk_handle)
try:
disk_handle.disk.close()
finally:
try:
disk_handle.authd_sock.close()
except OSError:
pass
def disconnect(self, conn: _Connection) -> None:
"""Logout of the VIM session.
Args:
conn: Connection from ``connect``.
"""
LOG.debug("Disconnecting VixDiskLib")
Disconnect(conn.si)
def exit(self) -> None:
"""No-op; there is no native VDDK library to tear down."""
return