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:
@@ -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)
|
||||
Reference in New Issue
Block a user