diff --git a/README.md b/README.md index 1545ef6..445b948 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ from VDDK 8 NBD traffic; see `docs/`. ## Status -Implemented against vCenter 8 / ESXi 8, transport `nbd`: +Implemented against vCenter 8 / ESXi 8. Default transport is `nbdssl` +(`nbd` is still available): - `VixDiskLib_ConnectEx` (UID credentials) - `VixDiskLib_Open` (datastore path, read-only or read-write) @@ -45,7 +46,7 @@ with handle.connect( username="administrator@vsphere.local", password="secret", vmx_spec="moref=vm-1234", - transport_modes="nbd", + transport_modes="nbdssl", read_only=False) as conn: with handle.open(conn, "[datastore] vm/vm.vmdk", flags=0) as disk: handle.write(disk, 0, 1, buf) diff --git a/docs/nfc_auth.md b/docs/nfc_auth.md index ff7c569..4244e37 100644 --- a/docs/nfc_auth.md +++ b/docs/nfc_auth.md @@ -25,16 +25,17 @@ password to ESXi port 902. It: 3. Connects to the ESXi **authd** daemon on TCP 902, upgrades to TLS, and presents that ticket. -| VDDK call | What actually happens | -| --------------------------------- | ---------------------------------------------------------- | -| `VixDiskLib_InitEx` | Load plugins, SSL, logging | -| `VixDiskLib_ConnectEx` | SOAP `SessionManager.Login` to vCenter | -| `VixDiskLib_Open` (read-only) | `NfcGetVmFiles` ticket, then authd handshake, then NFC I/O | -| `VixDiskLib_Open` (read-write) | `NfcRandomAccessOpenDisk` ticket (disk key + host) | -| `transport_modes="nbd"` | NBD over NFC (`vpxa-nfc://...@esxi:902`) | -| `vmxSpec=moref=vm-13098` | VM managed object used as the ticket target | -| `snapshot_ref` | Not consumed by the ticket call itself | -| `VIXDISKLIB_CRED_UID` | Username/password for VIM only | +| VDDK call | What actually happens | +| ------------------------------------ | ---------------------------------------------------------- | +| `VixDiskLib_InitEx` | Load plugins, SSL, logging | +| `VixDiskLib_ConnectEx` | SOAP `SessionManager.Login` to vCenter | +| `VixDiskLib_Open` (read-only) | `NfcGetVmFiles` ticket, then authd handshake, then NFC I/O | +| `VixDiskLib_Open` (read-write) | `NfcRandomAccessOpenDisk` ticket (disk key + host) | +| `transport_modes="nbdssl"` (default) | NBDSSL (`vpxa-nfcssl://...@esxi:902`, second TLS wrap) | +| `transport_modes="nbd"` | NBD over NFC (`vpxa-nfc://...@esxi:902`) | +| `vmxSpec=moref=vm-13098` | VM managed object used as the ticket target | +| `snapshot_ref` | Not consumed by the ticket call itself | +| `VIXDISKLIB_CRED_UID` | Username/password for VIM only | Lab topology used for capture: @@ -185,8 +186,9 @@ Plain-text connection is deprecated; use SSL to connect to NFC server ``` `useSSL=0` does **not** mean skip TLS on 902. It means skip a second -NFCSSL wrap after authd TLS (`THUMBPRINT_SHA2 PlainText`). The -management channel is still TLS. +NFCSSL wrap after authd TLS. The management channel is still TLS. +`useSSL=1` (nbdssl) is the same authd commands with `PROXY vpxa-nfcssl` +and a second TLS handshake after `200 Connect`. Intercepted writes/reads after the TLS handshake: @@ -200,6 +202,20 @@ C -> PROXY vpxa-nfc\r\n S -> 200 Connect ha-nfc\r\n ``` +NBDSSL uses the same `SESSION` / `BANNER` / `THUMBPRINT_SHA2 PlainText` +sequence. The ticket still has `service=vpxa-nfc`; the client rewrites +the PROXY argument: + +``` +C -> PROXY vpxa-nfcssl\r\n +S -> 200 Connect ha-nfcssl\r\n +``` + +After that reply, authd TLS is finished and `ha-nfcssl` expects a **new** +TLS ClientHello on the same TCP connection (`useSSL=1`). NFC frames then +travel as TLS application data of that second session. NBD (`useSSL=0`) +skips the second wrap and sends NFC as raw TCP instead. + Notes: - `SESSION` does not get a reply of its own. Waiting for a line after @@ -209,10 +225,11 @@ Notes: commands, so `THUMBPRINT_SHA2 ` is parsed as one token and returns `501 Invalid arguments`. `PlainText` has no extra spaces/colons and is the argument VDDK sends. -- `PROXY` uses `ticket.service` (`vpxa-nfc` via vCenter). The success - line names the host-side NFC endpoint (`ha-nfc`). -- After `200 Connect`, the socket speaks binary NFC (not documented - here). +- `PROXY` uses `ticket.service` (`vpxa-nfc` via vCenter) for NBD. NBDSSL + appends `ssl` (`vpxa-nfcssl`). The success line names the host-side + endpoint (`ha-nfc` or `ha-nfcssl`). +- After `200 Connect`, NBD speaks binary NFC on the raw fd. NBDSSL + starts a second TLS handshake, then the same NFC protocol. ### Commands that are not used for this ticket type @@ -259,7 +276,8 @@ and asserts an established TLS socket on `ticket.host:ticket.port`. ## What comes after authentication -Authentication stops at `200 Connect ha-nfc`. Opening the VMDK and -reading or writing sectors is documented in `docs/nfc_open.md` and -implemented in `openvixdisklib/nfc_open.py`. The datastore path is -consumed there (and, for writes, as `diskDeviceKey` on the ticket). +Authentication stops at `200 Connect ha-nfc` (NBD) or `200 Connect +ha-nfcssl` (NBDSSL). Opening the VMDK and reading or writing sectors is +documented in `docs/nfc_open.md` and implemented in +`openvixdisklib/nfc_open.py`. The datastore path is consumed there +(and, for writes, as `diskDeviceKey` on the ticket). diff --git a/docs/nfc_open.md b/docs/nfc_open.md index c5b9346..569cade 100644 --- a/docs/nfc_open.md +++ b/docs/nfc_open.md @@ -11,53 +11,60 @@ VDDK 8.0.2 verbose logs Authentication is already done: VIM login, NFC ticket (`NfcGetVmFiles` for read-only, `NfcRandomAccessOpenDisk` for write), TLS to authd, `SESSION` / `BANNER` / `THUMBPRINT_SHA2 PlainText` / `PROXY`. This -stage starts at `200 Connect ha-nfc` and ends with an open file handle -that can read and write sectors. Flags `0x1a` require the writable -ticket; the same flags on a `GetVmFiles` ticket fail with -`VIX_E_FILE_READ_ONLY`. +stage starts at `200 Connect ha-nfc` (NBD) or `200 Connect ha-nfcssl` +(NBDSSL) and ends with an open file handle that can read and write +sectors. Flags `0x1a` require the writable ticket; the same flags on a +`GetVmFiles` ticket fail with `VIX_E_FILE_READ_ONLY`. ## Mapping from VDDK -| VDDK call / log | Wire effect | -| ---------------------------------------------------- | -------------------------------------------------------- | -| `VixDiskLib_Open` | Ticket + authd (see `nfc_auth.md`), then this protocol | -| `NBD_ClientOpen` `vpxa-nfc://[ds] path.vmdk@esxi:902` | Datastore path is the NFC open argument, not the ticket | -| `useSSL=0` | NFC bytes are raw TCP, not `SSL_write` | -| `NfcProcessSessionParams` flags `0x3` | Classic 264-byte session messages | -| `SendConnectionDataMsg` payloadInfo 4 and 7 | Client name `vddk` (4) and opId `nbdmode` (7) | -| Server version 11 | Classic version message; 11 on this ESXi 8 lab | -| `NfcAio_OpenSession` | AIO framing after the classic handshake | -| `NfcUtil_PrintFileInfoOpenFlag` `NFC_DISK` `0x1e` | `NFC_AIO_MSG_OPEN_FILE` (read-only) | -| Open without `VIXDISKLIB_FLAG_OPEN_READ_ONLY` | `OPEN_FILE` flags `0x1a` (read-write) | -| `VixDiskLib_Read` / `VixDiskLib_Write` | `NFC_AIO_MSG_IO` + sector bytes | +| VDDK call / log | Wire effect | +| ------------------------------------------------------------- | ---------------------------------------------------------- | +| `VixDiskLib_Open` | Ticket + authd (see `nfc_auth.md`), then this protocol | +| `NBD_ClientOpen` `vpxa-nfc://[ds] path.vmdk@esxi:902` | Datastore path is the NFC open argument, not the ticket | +| `NBD_ClientOpen` `vpxa-nfcssl://…` / `useSSL=1` | Same NFC after a second TLS handshake on the authd fd | +| `useSSL=0` | NFC bytes are raw TCP, not `SSL_write` | +| `NfcProcessSessionParams` flags `0x3` | Classic 264-byte session messages | +| `SendConnectionDataMsg` payloadInfo 4 and 7 | Client name `vddk` (4) and opId `nbdmode` (7) | +| Server version 11 | Classic version message; 11 on this ESXi 8 lab | +| `NfcAio_OpenSession` | AIO framing after the classic handshake | +| `NfcUtil_PrintFileInfoOpenFlag` `NFC_DISK` `0x1e` | `NFC_AIO_MSG_OPEN_FILE` (read-only) | +| Open without `VIXDISKLIB_FLAG_OPEN_READ_ONLY` | `OPEN_FILE` flags `0x1a` (read-write) | +| `VixDiskLib_Read` / `VixDiskLib_Write` | `NFC_AIO_MSG_IO` + sector bytes | `snapshot_ref` is still not on the wire. Integration tests pass the flat VMDK created with the temporary lab VM. -## After PROXY: plaintext on the TLS fd +## After PROXY: NBD plaintext vs NBDSSL wrap -`THUMBPRINT_SHA2 PlainText` tells authd not to wrap NFC in a second -TLS session. VDDK logs `useSSL=0` and “Plain-text connection is -deprecated”. +`THUMBPRINT_SHA2 PlainText` is used for both transports. The PROXY +service name selects whether NFC gets a second TLS session. -On the wire that means: +NBD (`PROXY vpxa-nfc` → `200 Connect ha-nfc`, VDDK `useSSL=0`): 1. Authd commands stay inside the original TLS session (`SSL_write` / `SSL_read`). -2. After `200 Connect ha-nfc`, VDDK calls `write(SSL_get_fd(ssl), …)` - and `read` on that same descriptor. Those buffers are NFC, not TLS - records (`0x17 0x03 …`). -3. ESXi’s `ha-nfc` side does the same: replies are plaintext NFC. +2. After `200 Connect ha-nfc`, NFC is `write(SSL_get_fd(ssl), …)` / + `read` on that descriptor. Those buffers are not TLS records + (`0x17 0x03 …`). +3. An SSL hook that only interposes `SSL_write` / `SSL_read` goes + silent after PROXY; a `write` / `read` hook on port 902 shows the + frames. +4. Python must not use `SSLSocket.send` here: that would encrypt bytes + the server now reads as NFC. `nfc_open.takeover_authd_socket` dups + the fd. `unwrap()` / `SSL_shutdown` is not used. -An SSL hook that only interposes `SSL_write` / `SSL_read` therefore -goes silent after PROXY. Interposing `write` / `read` and filtering -`getpeername` port 902 shows the frames. +NBDSSL (`PROXY vpxa-nfcssl` → `200 Connect ha-nfcssl`, `useSSL=1`): -Python must not use `SSLSocket.send` for this stage: that would -`SSL_write` and encrypt bytes the server now reads as NFC. -`nfc_open.takeover_authd_socket` dups `SSL_get_fd` and uses a raw -`socket.socket`. `unwrap()` / `SSL_shutdown` is not used; VDDK does -not send `close_notify` before NFC. +1. Authd commands are the same, including `THUMBPRINT_SHA2 PlainText`. +2. After `200 Connect ha-nfcssl`, both sides abandon the authd TLS + session. `ha-nfcssl` expects a new ClientHello on the same TCP + connection. +3. `nfc_open.wrap_nfcssl_socket` dups the fd and + `SSLContext.wrap_socket`s it. NFC then uses `SSLSocket.sendall` / + `recv` (TLS application data). The classic 264-byte handshake still + sends the ASCII body `PlainText`; that is NFC's own encoding, not + the authd transport. ## Classic 264-byte messages @@ -200,6 +207,7 @@ classic type 4 `NFC_SESSION_COMPLETE`. | ----------------------------- | ----------------------------------------------- | | VIM + authd | `openvixdisklib.nfc_auth.authenticate` | | Dup fd, skip TLS for NFC | `openvixdisklib.nfc_open.takeover_authd_socket` | +| Second TLS for nbdssl | `openvixdisklib.nfc_open.wrap_nfcssl_socket` | | Handshake + AIO + OPEN_FILE | `openvixdisklib.nfc_open.open_disk` | | Sector read / write / close | `openvixdisklib.nfc_open.NfcDisk` | @@ -218,8 +226,7 @@ I/O: `docs/nfc_read.md`, `docs/nfc_write.md`, and - `DDB_GET` / geometry / compression / encryption keys - `NFC_DELTA_DISK`, change-block tracking -- Host-switch (`NFC_AIO_SWITCH_HOST_*`) and a second NFCSSL wrap - (`useSSL=1`, not what VDDK NBD used here) +- Host-switch (`NFC_AIO_SWITCH_HOST_*`) - Direct ESXi `ha-nfc` without vCenter `vpxa-nfc` Reads after open are in `docs/nfc_read.md`. Writes are in diff --git a/docs/reverse_engineering_procedure.md b/docs/reverse_engineering_procedure.md index 9299cb0..eddaf44 100644 --- a/docs/reverse_engineering_procedure.md +++ b/docs/reverse_engineering_procedure.md @@ -9,9 +9,9 @@ NFC work can follow the same loop instead of rediscovering it. Scope so far: `VixDiskLib_ConnectEx` + `VixDiskLib_Open` + `VixDiskLib_Read` + `VixDiskLib_Write` against lab vCenter 8.0.1 / -ESXi 8, transport `nbd`. Driver: `tests/integration/` (the session-scoped -`lab` fixture creates a temporary empty VM with a 10 GiB disk and -destroys it when the pytest session ends). +ESXi 8, transports `nbd` and `nbdssl`. Driver: `tests/integration/` (the +session-scoped `lab` fixture creates a temporary empty VM with a 10 GiB +disk and destroys it when the pytest session ends). Rule from `AGENTS.md`: reuse pyVmomi for every public VIM operation. Only reimplement what pyVmomi does not expose. @@ -254,6 +254,32 @@ single oversized write the way VDDK sends an oversized read. Details: `tests/integration/test_nfc_read_write.py` and the VDDK cross-check in `tests/integration/test_crosscheck.py`. +## Step 11 — NBDSSL: second TLS after `PROXY vpxa-nfcssl` + +VDDK strings name `nbdssl`, `vpxa-nfcssl://`, and `ha-nfcssl`. The NFC +ticket SOAP call is unchanged (`service` stays `vpxa-nfc`). Transport is +an authd/client choice: + +1. Same `SESSION` / `BANNER` / `THUMBPRINT_SHA2 PlainText` as NBD. +2. `PROXY vpxa-nfcssl` → `200 Connect ha-nfcssl`. +3. A new TLS handshake on the **same TCP connection** (not TLS-in-TLS + and not `THUMBPRINT_SHA2 `). The colon thumbprint is still + `501 Invalid arguments`. +4. Classic NFC handshake type 43 still sends ASCII `PlainText`. I/O + framing is unchanged. + +Replay: `connect_authd(..., nfc_ssl=True)` plus +`nfc_open.wrap_nfcssl_socket`. Sending NFC on the first authd +`SSLSocket` after `ha-nfcssl` fails (`BAD_RECORD_TYPE`); sending +plaintext NFC on the dup'd fd gets EOF. Dup + `wrap_socket` is the +working subset. Proof: `tests/integration/test_nfc_open.py` (`nbdssl`) +and `test_openvixdisklib.py` with `transport_modes="nbdssl"`. + +Native `VixDiskLib_ConnectEx(..., transport_modes="nbdssl")` through +the old `VixDiskLibConnectParams` ctypes struct can still log nbdssl +and then fall back to `vpxa-nfc` / `useSSL=0`. Do not treat that log +line as a wire capture of NFCSSL. + ## What to write down After a stage works: @@ -278,5 +304,4 @@ Not yet reversed, same loop as above: - `NFC_DELTA_DISK`, CBT / `QueryAllocatedBlocks` - `VixDiskLib_GetInfo` capacity - Host-switch AIO messages -- `useSSL=1` (second NFCSSL wrap) - Direct ESXi `ha-nfc` without vCenter `vpxa-nfc` diff --git a/openvixdisklib/nfc_auth.py b/openvixdisklib/nfc_auth.py index c4d2bdd..96002ac 100644 --- a/openvixdisklib/nfc_auth.py +++ b/openvixdisklib/nfc_auth.py @@ -242,10 +242,25 @@ def _expect_code(line: str, code: str, what: str) -> str: return line[len(code):].lstrip() +def nfcssl_service_name(service: str) -> str: + """Return the NFCSSL authd PROXY service for an NFC service name. + + VCenter tickets still report ``vpxa-nfc``. NBDSSL uses + ``PROXY vpxa-nfcssl`` (or ``ha-nfcssl`` on a direct ESXi ticket). + + Args: + service: Ticket ``service`` field, for example ``vpxa-nfc``. + """ + if service.endswith("ssl"): + return service + return f"{service}ssl" + + def connect_authd( ticket: vim.HostServiceTicket, allow_untrusted: bool = False, - timeout: float = 30.0) -> ssl.SSLSocket: + timeout: float = 30.0, + nfc_ssl: bool = True) -> ssl.SSLSocket: """Complete the ESXi authd handshake using an NFC HostServiceTicket. Wire sequence captured from VDDK against authd on TCP 902: @@ -253,14 +268,20 @@ def connect_authd( 1. Read the plaintext 220 banner, then wrap the socket with TLS. 2. SESSION 3. BANNER - 4. THUMBPRINT_SHA2 PlainText (NFC data stays on this TLS socket) - 5. PROXY (vpxa-nfc when connecting via vCenter) + 4. THUMBPRINT_SHA2 PlainText + 5. PROXY (vpxa-nfc / nbd) or vpxa-nfcssl (nbdssl) + + ``THUMBPRINT_SHA2 PlainText`` is used for both transports. NBDSSL + is selected by the PROXY service name; after ``200 Connect + ha-nfcssl`` a second TLS handshake is started in ``nfc_open``. 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. + nfc_ssl: When True (the default), PROXY to the NFCSSL service + used by nbdssl. Pass False for plaintext NFC (nbd). """ host = ticket.host port = ticket.port or AUTHD_DEFAULT_PORT @@ -294,6 +315,8 @@ def connect_authd( _expect_code(_readline(ssock), "200", "THUMBPRINT_SHA2") service = ticket.service or "vpxa-nfc" + if nfc_ssl: + service = nfcssl_service_name(service) ssock.sendall(f"PROXY {service}\r\n".encode("ascii")) _expect_code(_readline(ssock), "200", "PROXY") return ssock @@ -309,10 +332,12 @@ class NfcAuthSession: self, si: vim.ServiceInstance, ticket: vim.HostServiceTicket, - authd_sock: ssl.SSLSocket) -> None: + authd_sock: ssl.SSLSocket, + nfc_ssl: bool = True) -> None: self.si = si self.ticket = ticket self.authd_sock = authd_sock + self.nfc_ssl = nfc_ssl def close(self) -> None: """Close the authd socket and logout of the VIM session.""" @@ -338,7 +363,8 @@ def authenticate( allow_untrusted: bool = False, disk_device_key: Optional[int] = None, disk_path: Optional[str] = None, - read_only: bool = True) -> NfcAuthSession: + read_only: bool = True, + nfc_ssl: bool = True) -> NfcAuthSession: """Login to vSphere and complete NFC authd authentication for a VM. Args: @@ -354,6 +380,8 @@ def authenticate( disk_path: Datastore path used to resolve ``disk_device_key``. read_only: When False, request a writable ``NfcRandomAccessOpenDisk`` ticket. + nfc_ssl: When True (the default), complete authd with the NFCSSL + PROXY service used by nbdssl. Pass False for nbd. """ si = connect_vim( host, username, password, port=port, @@ -364,8 +392,8 @@ def authenticate( 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) + ticket, allow_untrusted=allow_untrusted, nfc_ssl=nfc_ssl) except Exception: Disconnect(si) raise - return NfcAuthSession(si, ticket, authd_sock) + return NfcAuthSession(si, ticket, authd_sock, nfc_ssl=nfc_ssl) diff --git a/openvixdisklib/nfc_open.py b/openvixdisklib/nfc_open.py index 7b7f0e1..0f9f389 100644 --- a/openvixdisklib/nfc_open.py +++ b/openvixdisklib/nfc_open.py @@ -3,10 +3,12 @@ """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: +After ``nfc_auth.connect_authd`` returns ``200 Connect``, NBD +(``useSSL=0``) stops using ``SSL_write`` and speaks NFC as raw TCP on +that file descriptor. NBDSSL (``useSSL=1``) starts a second TLS +handshake on the same TCP connection (``200 Connect ha-nfcssl``) and +speaks the same NFC frames as TLS application data. This module dups +the authd fd and speaks: 1. Classic 264-byte NFC messages (handshake, version, connection data, AIO session open). @@ -24,7 +26,7 @@ import socket import ssl import struct -from openvixdisklib.nfc_auth import NfcAuthSession +from openvixdisklib.nfc_auth import NfcAuthSession, _ssl_client_context NFC_MSG_SIZE = 264 NFC_AIO_MAGIC = 0xA100DA7A @@ -93,6 +95,30 @@ def takeover_authd_socket(ssock: ssl.SSLSocket) -> socket.socket: return raw +def wrap_nfcssl_socket( + ssock: ssl.SSLSocket, + server_hostname: str) -> ssl.SSLSocket: + """Start the second TLS session used by NBDSSL after PROXY. + + After ``200 Connect ha-nfcssl``, authd TLS is finished and + ``ha-nfcssl`` expects a new ClientHello on the same TCP connection. + The fd is dup'd so the original authd ``SSLSocket`` can be closed + later without ``SSL_shutdown`` of this NFCSSL session. + + Args: + ssock: The TLS socket from ``nfc_auth.connect_authd``. + server_hostname: Host name passed to ``SSLContext.wrap_socket``. + """ + raw = takeover_authd_socket(ssock) + ssl_context = _ssl_client_context(verify=False) + try: + return ssl_context.wrap_socket( + raw, server_hostname=server_hostname) + except Exception: + raw.close() + raise + + def _recvn(sock: socket.socket, size: int) -> bytes: buf = bytearray() while len(buf) < size: @@ -146,7 +172,7 @@ class NfcDisk: """Wrap an AIO session that already has ``path`` open. Args: - sock: Raw NFC socket after handshake. + sock: NFC socket after handshake (raw TCP for nbd, TLS for nbdssl). path: Datastore path that was opened. handle: Server file handle from OPEN_FILE. sector_size: Sector size from the OPEN_FILE reply. @@ -401,7 +427,9 @@ def open_disk( 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``. + ``NFC_AIO_MSG_OPEN_FILE`` with type ``NFC_DISK``. NBD dups the + authd fd and sends plaintext NFC. NBDSSL wraps that dup in a + second TLS session (``session.nfc_ssl``). Args: session: Result of ``nfc_auth.authenticate``. @@ -412,7 +440,11 @@ def open_disk( 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) + if session.nfc_ssl: + sock = wrap_nfcssl_socket( + session.authd_sock, session.ticket.host) + else: + 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) diff --git a/openvixdisklib/openvixdisklib.py b/openvixdisklib/openvixdisklib.py index 2963001..759ab29 100644 --- a/openvixdisklib/openvixdisklib.py +++ b/openvixdisklib/openvixdisklib.py @@ -70,13 +70,20 @@ def _parse_vm_moref(vmx_spec: Optional[str]) -> str: return vmx_spec -def _require_nbd(transport_modes: Optional[str]) -> None: +def _select_transport(transport_modes: Optional[str]) -> str: + """Return the first requested transport this replacement implements. + + ``None`` defaults to ``nbdssl``. A colon-separated list (VDDK + style, for example ``file:nbdssl:nbd``) picks the first of + ``nbdssl`` or ``nbd``. + """ 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}") + return "nbdssl" + for mode in transport_modes.split(":"): + if mode in ("nbdssl", "nbd"): + return mode + raise NotImplementedError( + f"supported transports are nbdssl and nbd, got {transport_modes!r}") class _Connection: @@ -89,13 +96,15 @@ class _Connection: snapshot_ref: Optional[str], thumbprint: Optional[str], allow_untrusted: bool, - read_only: bool) -> None: + read_only: bool, + transport_mode: str) -> 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 + self.transport_mode = transport_mode class _DiskHandle: @@ -104,9 +113,11 @@ class _DiskHandle: def __init__( self, disk: nfc_open.NfcDisk, - authd_sock) -> None: + authd_sock, + transport_mode: str) -> None: self.disk = disk self.authd_sock = authd_sock + self.transport_mode = transport_mode class VixDiskLibHandle: @@ -161,12 +172,11 @@ class VixDiskLibHandle: def get_transport_modes(self) -> list[str]: """Return the transport modes this replacement implements.""" - return ["nbd"] + return ["nbdssl", "nbd"] def get_transport_mode(self, disk_handle: _DiskHandle) -> str: """Return the transport used for ``disk_handle``.""" - del disk_handle - return "nbd" + return disk_handle.transport_mode @contextlib.contextmanager def connect( @@ -196,12 +206,14 @@ class VixDiskLibHandle: 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``. + transport_modes: ``nbdssl``, ``nbd``, or a colon list. The + first supported mode is used; ``None`` defaults to + ``nbdssl``. port: HTTPS port, usually 443. allow_untrusted: Skip management TLS verification when True. """ LOG.debug("Connecting VixDiskLib: %s", server_name) - _require_nbd(transport_modes) + transport_mode = _select_transport(transport_modes) vm_moref = _parse_vm_moref(vmx_spec) si = nfc_auth.connect_vim( server_name, @@ -212,7 +224,8 @@ class VixDiskLibHandle: allow_untrusted=allow_untrusted or not thumbprint) conn = _Connection( si, vm_moref, snapshot_ref, thumbprint, - allow_untrusted or not thumbprint, read_only) + allow_untrusted or not thumbprint, read_only, + transport_mode) try: yield conn finally: @@ -243,18 +256,20 @@ class VixDiskLibHandle: "ConnectEx was read-only; cannot open for write") vm = vim.VirtualMachine(conn.vm_moref, conn.si._stub) + nfc_ssl = conn.transport_mode == "nbdssl" 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) + ticket, allow_untrusted=conn.allow_untrusted, nfc_ssl=nfc_ssl) + session = nfc_auth.NfcAuthSession( + conn.si, ticket, authd_sock, nfc_ssl=nfc_ssl) try: disk = nfc_open.open_disk( session, disk_path, read_only=read_only) except Exception: authd_sock.close() raise - handle = _DiskHandle(disk, authd_sock) + handle = _DiskHandle(disk, authd_sock, conn.transport_mode) try: yield handle finally: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..65a9866 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,37 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Session-scoped lab fixtures for live vSphere tests.""" + +from __future__ import annotations + +import os +from collections.abc import Iterator + +import pytest + +from tests.integration.base import ( + LabEnv, + create_lab_vm, + destroy_lab_vm, + ensure_vddk_library_path, + require_vddk, +) + + +@pytest.fixture(scope="session") +def lab() -> Iterator[LabEnv]: + """Create one temporary lab VM for the whole pytest session.""" + os.environ.pop("LD_PRELOAD", None) + ensure_vddk_library_path() + env = create_lab_vm() + try: + yield env + finally: + destroy_lab_vm(env) + + +@pytest.fixture(scope="session") +def vddk() -> None: + """Skip VDDK-backed tests when ``libvixDiskLib`` cannot be loaded.""" + require_vddk() diff --git a/tests/integration/base.py b/tests/integration/base.py index 39f21a5..133ca5c 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -60,7 +60,10 @@ class LabEnv: vmx_spec: str disk_path: str - def authenticate(self, read_only: bool = True) -> NfcAuthSession: + def authenticate( + self, + read_only: bool = True, + nfc_ssl: bool = True) -> NfcAuthSession: """Login to the lab vCenter and complete NFC authd for the temp VM.""" return nfc_auth.authenticate( host=self.host, @@ -70,7 +73,8 @@ class LabEnv: thumbprint=self.thumbprint, allow_untrusted=self.allow_untrusted, disk_path=None if read_only else self.disk_path, - read_only=read_only) + read_only=read_only, + nfc_ssl=nfc_ssl) def vixdisklib_connect_kwargs( self, extra: Optional[dict[str, Any]] = None) -> dict[str, Any]: @@ -82,7 +86,7 @@ class LabEnv: "username": self.username, "password": self.password, "vmx_spec": self.vmx_spec, - "transport_modes": "nbd", + "transport_modes": "nbdssl", "read_only": False, } if extra: diff --git a/tests/integration/test_nfc_auth.py b/tests/integration/test_nfc_auth.py index 5d46e09..e074f1e 100644 --- a/tests/integration/test_nfc_auth.py +++ b/tests/integration/test_nfc_auth.py @@ -14,5 +14,14 @@ class TestNfcAuth: assert ticket.host assert ticket.port assert ticket.sessionId + assert session.nfc_ssl is True + assert session.authd_sock.version() + assert session.authd_sock.cipher() + + def test_authd_nbd_handshake_completes(self, lab: LabEnv) -> None: + """Complete authd with plaintext NFC after PROXY (nbd).""" + with lab.authenticate(nfc_ssl=False) as session: + assert session.nfc_ssl is False + assert session.ticket.sessionId assert session.authd_sock.version() assert session.authd_sock.cipher() diff --git a/tests/integration/test_nfc_open.py b/tests/integration/test_nfc_open.py index 874c0cc..20d6c34 100644 --- a/tests/integration/test_nfc_open.py +++ b/tests/integration/test_nfc_open.py @@ -3,15 +3,20 @@ """Exercise NFC disk open and a one-sector write/read against the lab.""" +import pytest + from openvixdisklib import nfc_open from tests.integration.base import LabEnv, SECTOR_SIZE, pattern_bytes class TestNfcOpen: - def test_open_disk_and_read_first_sector(self, lab: LabEnv) -> None: + @pytest.mark.parametrize( + "nfc_ssl", [True, False], ids=["nbdssl", "nbd"]) + def test_open_disk_and_read_first_sector( + self, lab: LabEnv, nfc_ssl: bool) -> None: """Open the temp VMDK, write sector 0, and read it back.""" expected = pattern_bytes(SECTOR_SIZE, b"NFC-OPEN-S0") - with lab.authenticate(read_only=False) as session: + with lab.authenticate(read_only=False, nfc_ssl=nfc_ssl) as session: with nfc_open.open_disk( session, lab.disk_path, read_only=False) as disk: assert disk.path == lab.disk_path diff --git a/tests/integration/test_openvixdisklib.py b/tests/integration/test_openvixdisklib.py index 9831001..c006b35 100644 --- a/tests/integration/test_openvixdisklib.py +++ b/tests/integration/test_openvixdisklib.py @@ -3,13 +3,17 @@ """Exercise the VDDK-compatible openvixdisklib handle against the lab.""" +import pytest + from openvixdisklib import openvixdisklib as vixdisklib from tests.integration.base import ( LabEnv, SECTOR_AT_1GB, SECTOR_SIZE, pattern_bytes) class TestOpenvixdisklib: - def test_write_and_read_sector_zero_and_one_gib(self, lab: LabEnv) -> None: + @pytest.mark.parametrize("transport_mode", ["nbdssl", "nbd"]) + def test_write_and_read_sector_zero_and_one_gib( + self, lab: LabEnv, transport_mode: str) -> None: """Write then read sector 0 and the sector at a 1 GiB offset.""" handle = vixdisklib.VixDiskLibHandle( vixdisklib_compatibility_version="8.0", @@ -18,13 +22,16 @@ class TestOpenvixdisklib: read_buf = vixdisklib.get_buffer(SECTOR_SIZE) connect_kwargs = lab.vixdisklib_connect_kwargs({ "allow_untrusted": lab.allow_untrusted, + "transport_modes": transport_mode, }) patterns = { 0: pattern_bytes(SECTOR_SIZE, b"OVDL-S0"), SECTOR_AT_1GB: pattern_bytes(SECTOR_SIZE, b"OVDL-1GB"), } + assert handle.get_transport_modes() == ["nbdssl", "nbd"] with handle.connect(**connect_kwargs) as conn: with handle.open(conn, lab.disk_path, flags=0) as disk: + assert handle.get_transport_mode(disk) == transport_mode for start, expected in patterns.items(): write_buf[:SECTOR_SIZE] = expected handle.write(disk, start, 1, write_buf) diff --git a/tests/perf/__init__.py b/tests/perf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/perf/test_compare.py b/tests/perf/test_compare.py new file mode 100644 index 0000000..2a2ab8c --- /dev/null +++ b/tests/perf/test_compare.py @@ -0,0 +1,88 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Compare openvixdisklib and native VDDK I/O throughput against the lab.""" + +from __future__ import annotations + +import time +from typing import Any, Optional + +from openvixdisklib import openvixdisklib as open_vix +from tests.integration import vixdisklib +from tests.integration.base import LabEnv, SECTOR_SIZE, pattern_bytes + +_SIZES = ( + ("64KiB", 64 * 1024), + ("129 sectors", 129 * SECTOR_SIZE), + ("32MiB", 32 * 1024 * 1024), +) + + +def _connect_extra(lab: LabEnv, module: Any) -> Optional[dict[str, Any]]: + """Return extra ``connect`` kwargs needed by ``module``.""" + if module is open_vix: + return {"allow_untrusted": lab.allow_untrusted} + return None + + +def _time_write_read( + lab: LabEnv, + module: Any, + payload: bytes) -> tuple[float, float]: + """Write ``payload`` at sector 0, read it back, and return durations.""" + n_sectors = len(payload) // SECTOR_SIZE + handle = module.VixDiskLibHandle( + vixdisklib_compatibility_version="8.0", + config_path=None) + write_buf = module.get_buffer(len(payload)) + read_buf = module.get_buffer(len(payload)) + write_buf[:len(payload)] = payload + kwargs = lab.vixdisklib_connect_kwargs(_connect_extra(lab, module)) + with handle.connect(**kwargs) as conn: + with handle.open(conn, lab.disk_path, flags=0) as disk: + started = time.perf_counter() + handle.write(disk, 0, n_sectors, write_buf) + write_s = time.perf_counter() - started + read_buf[:len(payload)] = b"\xa5" * len(payload) + started = time.perf_counter() + handle.read(disk, 0, n_sectors, read_buf) + read_s = time.perf_counter() - started + assert read_buf.raw[:len(payload)] == payload + return write_s, read_s + + +def _mib_per_s(nbytes: int, seconds: float) -> float: + if seconds <= 0: + return float("inf") + return (nbytes / (1024 * 1024)) / seconds + + +class TestCompare: + def test_write_read_throughput(self, lab: LabEnv, vddk: None) -> None: + """Time matching write/read sizes on VDDK and openvixdisklib.""" + libraries = ( + ("vddk", vixdisklib), + ("openvixdisklib", open_vix), + ) + rows: list[tuple[str, str, float, float, float, float]] = [] + for label, nbytes in _SIZES: + payload = pattern_bytes(nbytes, f"PERF-{label}-".encode()) + for name, module in libraries: + write_s, read_s = _time_write_read(lab, module, payload) + rows.append(( + label, + name, + write_s, + read_s, + _mib_per_s(nbytes, write_s), + _mib_per_s(nbytes, read_s), + )) + print() + print( + f"{'size':<14} {'library':<16} {'write_s':>10} {'read_s':>10} " + f"{'write_MiB/s':>12} {'read_MiB/s':>12}") + for label, name, write_s, read_s, write_r, read_r in rows: + print( + f"{label:<14} {name:<16} {write_s:10.3f} {read_s:10.3f} " + f"{write_r:12.1f} {read_r:12.1f}")