diff --git a/README.md b/README.md index 254794b..283ffdf 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,17 @@ from VDDK 8 NBD traffic; see `docs/`. ## Status -Implemented against vCenter 8 / ESXi 8. Default transport is `nbdssl` -(`nbd` is still available): +Implemented against vCenter 8 / ESXi 8, including a standalone ESXi +host with no vCenter. Default transport is `nbdssl` (`nbd` is still +available): -- `VixDiskLib_ConnectEx` (UID credentials) +- `VixDiskLib_ConnectEx` (UID credentials; vCenter or direct ESXi) - `VixDiskLib_Open` (datastore path, read-only or read-write) - `VixDiskLib_Read` (optional ``skip_decompression`` packs FastLZ extras) - `VixDiskLib_Write` Not implemented: compression open flags other than FastLZ, CBT / -allocated-block queries, disk geometry (`DDB_GET`), encrypted disks, -and direct ESXi `ha-nfc` without vCenter `vpxa-nfc`. +allocated-block queries, disk geometry (`DDB_GET`), and encrypted disks. Requires Python 3.10 or later. @@ -96,11 +96,17 @@ password: secret allow_untrusted: true datacenter: Datacenter datastore: datastore0 +esxi: + username: root + password: secret ``` A session-scoped pytest fixture creates an empty VM with a 10 GiB thin disk on that datastore and tears it down when the session ends. Tests -write known patterns and read them back. +write known patterns and read them back. Direct-ESXi tests pick the lab +VM's host from vCenter and log into hostd (default ``root`` and the +vCenter password) so NFC uses ``ha-nfc-service`` instead of +``nfcService``. They skip when lockdown is on or hostd login fails. ```bash tox -e integration diff --git a/docs/nfc_auth.md b/docs/nfc_auth.md index e19b515..91a8f61 100644 --- a/docs/nfc_auth.md +++ b/docs/nfc_auth.md @@ -57,9 +57,9 @@ VDDK logs this as `Connected to VIM Server` / `Authenticating user` / `Logged in!`. OpenVixDiskLib keeps that `ServiceInstance` and its stub for the ticket call. -Direct ESXi login is the same SOAP login against hostd, but the NFC -moref and service name differ (`ha-nfc` instead of `nfcService` / -`vpxa-nfc`). The lab path is vCenter-mediated. +Direct ESXi login is the same SOAP login, this time against hostd +instead of vCenter. The NFC moref and service/PROXY name differ; see +"Direct ESXi (no vCenter)" below for the verified values. ## Stage 2: NFC ticket @@ -253,6 +253,90 @@ are for local ESXi credentials. With a vCenter ticket: argument is not what VDDK sends. The SHA-1 value is for verifying the TLS certificate, not for the `THUMBPRINT_SHA2` command. +## Direct ESXi (no vCenter) + +Captured against a standalone ESXi 8.0.3 host (`apiType: HostAgent`, +no vCenter in the picture at all) with the same SSL-hook technique +from `docs/ssl_hook.md`, using real VDDK 8.0.3 pointed straight at the +host (`vmxSpec=moref=`, `serverName=`). This corrects an +earlier guess in this file that assumed the moref would be `ha-nfc`. + +Differences from the vCenter-mediated path above: + +| Item | vCenter-mediated | Direct ESXi (verified) | +| ------------------------------- | ---------------------- | -------------------------- | +| `NfcService` moref | `nfcService` | `ha-nfc-service` | +| `NfcGetVmFilesResponse.service` | `vpxa-nfc` | `nfc` | +| `NfcGetVmFilesResponse.host` | present (ESXi address) | **absent** (omitted field) | +| authd `PROXY` line | `PROXY vpxa-nfc` | `PROXY nfc` | +| authd success line | `200 Connect ha-nfc` | `200 Connect ha-nfc` | + +The `NfcGetVmFiles` SOAP call itself is unchanged (`vm` argument only); +only the `_this` moref and the response fields differ: + +```xml + + <_this type="NfcService">ha-nfc-service + 1 + +``` + +```xml + + + 902 + ... + nfc + 1.1 + ... + + +``` + +Since `host` is absent, the client must already know where to dial +authd: the same ESXi host it just logged into over VIM. A vCenter +ticket always fills `host` because that ESXi address is not otherwise +known to the client. + +### Finding the `ha-nfc-service` moref + +VDDK does not hardcode this moref either. Before the `NfcGetVmFiles` +call, it issues an undocumented `RetrieveInternalContent` call on the +same `ServiceInstance` moref used for the public +`RetrieveServiceContent`: + +```xml + + <_this type="ServiceInstance">ServiceInstance + +``` + +The response carries ~20 undocumented managed-object refs +(`agentManager`, `llProvisioningManager`, `diskManager`, +`nfcService`, `proxyService`, ...); only `nfcService` matters here. +Its value was `nfcService` in the earlier vCenter capture and +`ha-nfc-service` on this bare ESXi host — VDDK reads it from this +response rather than assuming either name. + +### OpenVixDiskLib fix + +`openvixdisklib/nfc_auth.py` previously hardcoded +`NFC_SERVICE_MOID = "nfcService"`, which fails outright against a bare +ESXi host with `vmodl.fault.ManagedObjectNotFound`. It now resolves +the moref the same way VDDK does: `_nfc_service_moid()` issues the +`RetrieveInternalContent` SOAP call as raw XML over the existing +authenticated stub connection (registering pyVmomi types for the full +undocumented response schema wasn't worth it for one field) and +regex-extracts `nfcService` from the reply. + +`connect_authd()` also gained a `fallback_host` parameter: when +`ticket.host` is unset (the direct-ESXi case above), it dials the VIM +connection's own host instead. `openvixdisklib.py` passes +`conn.si._stub.host` for this. + +Validated end-to-end (`ConnectEx` + `Open` + `Read`, both `nbd` and +`nbdssl` transports) against a live standalone ESXi 8.0.3 host. + ## OpenVixDiskLib | Piece | Module | Reuses pyVmomi? | @@ -273,10 +357,13 @@ Run: ```bash .venv/bin/pytest tests/integration/test_nfc_auth.py +.venv/bin/pytest tests/integration/test_direct_esxi.py ``` -The test completes VIM login and the authd handshake (`200 Connect`) -and asserts an established TLS socket on `ticket.host:ticket.port`. +`test_nfc_auth.py` completes VIM login and the authd handshake against +vCenter. `test_direct_esxi.py` picks the lab VM's ESXi host from +inventory and repeats ConnectEx / Open / Read on hostd, where the +ticket omits `host` and NfcService is `ha-nfc-service`. ## What comes after authentication diff --git a/docs/nfc_open.md b/docs/nfc_open.md index bf61550..2aeffc2 100644 --- a/docs/nfc_open.md +++ b/docs/nfc_open.md @@ -249,7 +249,6 @@ I/O: `docs/nfc_read.md`, `docs/nfc_write.md`, and - `DDB_GET` / geometry / zlib and skipz compression / encryption keys - `NFC_DELTA_DISK`, change-block tracking - 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 `docs/nfc_write.md`. diff --git a/docs/reverse_engineering_procedure.md b/docs/reverse_engineering_procedure.md index b988f5c..6ed8273 100644 --- a/docs/reverse_engineering_procedure.md +++ b/docs/reverse_engineering_procedure.md @@ -9,7 +9,8 @@ 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, transports `nbd` and `nbdssl`. Validation method: +ESXi 8, transports `nbd` and `nbdssl`, plus a standalone ESXi 8.0.3 +host with no vCenter (Step 13). Validation method: `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). @@ -381,8 +382,33 @@ not an OPEN_FILE bit. Capture VDDK with that flag (NBD + the port-902 Replay: pip `pyfastlz` via `openvixdisklib/fastlz.py` (NFC extra is raw FastLZ, without the wrapper's 4-byte length prefix) plus `NfcDisk` compression on each IO. Proof: -`tests/integration/test_nfc_read_write.py` (`fastlz`) and -`tests/perf/test_compare.py`. +## Step 13 — Direct ESXi (`ha-nfc`) without vCenter + +Same SSL-hook technique (Step 4), this time pointing VDDK 8.0.3 +straight at a standalone ESXi 8.0.3 host (`vmxSpec=moref=`, +`serverName=`, no vCenter in the topology). Confirmed +OpenVixDiskLib's hardcoded `NFC_SERVICE_MOID = "nfcService"` fails on +this host with `vmodl.fault.ManagedObjectNotFound` *before* touching +the capture — reproduced with plain `openvixdisklib` calls, no hook +needed to see that failure. + +The capture showed VDDK does not hardcode the moref either: it calls +an undocumented `RetrieveInternalContent` on the `ServiceInstance` +moref first, and reads `nfcService` from the reply (`ha-nfc-service` +on this host, vs. `nfcService` in the earlier vCenter capture). +`NfcGetVmFilesResponse.service` was `nfc` (not `vpxa-nfc`), and its +`host` field was **absent** — the authd endpoint is implicitly the +same host already logged into. Full detail in `docs/nfc_auth.md` +("Direct ESXi (no vCenter)"). + +Fix: `_nfc_service_moid()` in `openvixdisklib/nfc_auth.py` issues the +`RetrieveInternalContent` call as raw SOAP over the existing stub +connection (its response schema has ~20 other undocumented morefs not +worth registering with pyVmomi's type system for one field), and +`connect_authd()` takes a `fallback_host` used when `ticket.host` is +unset. Validated end-to-end (`ConnectEx`/`Open`/`Read`, `nbd` and +`nbdssl`) against the live host. + ## What to write down @@ -408,4 +434,3 @@ Not yet reversed, same loop as above: - `NFC_DELTA_DISK`, CBT / `QueryAllocatedBlocks` - `VixDiskLib_GetInfo` capacity - Host-switch AIO messages -- Direct ESXi `ha-nfc` without vCenter `vpxa-nfc` diff --git a/openvixdisklib/nfc_auth.py b/openvixdisklib/nfc_auth.py index 49d85aa..b733f3a 100644 --- a/openvixdisklib/nfc_auth.py +++ b/openvixdisklib/nfc_auth.py @@ -19,16 +19,18 @@ import contextlib import hashlib +import re import socket import ssl +import types from pyVim.connect import Disconnect, SmartConnect from pyVmomi import vim from pyVmomi.VmomiSupport import F_OPTIONAL, CreateManagedType, GetVmodlType -NFC_SERVICE_MOID = "nfcService" AUTHD_DEFAULT_PORT = 902 _NFC_TYPES_REGISTERED = False +_NFC_SERVICE_MOID_RE = re.compile(r"]*>([^<]+)") def _ssl_client_context(verify: bool = True) -> ssl.SSLContext: @@ -121,6 +123,44 @@ def _register_nfc_types() -> None: _NFC_TYPES_REGISTERED = True +def _nfc_service_moid(si: vim.ServiceInstance) -> str: + """Return the NfcService moref via the internal ``RetrieveInternalContent`` call. + + vCenter and a bare ESXi host disagree on this moref (``nfcService`` vs. + ``ha-nfc-service``); VDDK resolves it dynamically instead of assuming + vCenter's name, which is why OpenVixDiskLib must too. The response also + carries ~20 other undocumented managed-object refs (agent manager, disk + manager, and so on) that aren't worth registering with pyVmomi's type + system just to read one field, so the call is issued as raw SOAP over + the existing authenticated connection and only ``nfcService`` is pulled + out of the XML. + """ + stub = si._stub + info = types.SimpleNamespace( + wsdlName="RetrieveInternalContent", version=stub.version, params=() + ) + request = stub.SerializeRequest(si, info, ()) + headers = { + "Cookie": stub.cookie, + "SOAPAction": stub.versionId, + "Content-Type": "text/xml; charset=utf-8", + } + conn = stub.GetConnection() + try: + conn.request("POST", stub.path, request, headers) + response = conn.getresponse() + body = response.read().decode("utf-8") + finally: + stub.ReturnConnection(conn) + match = _NFC_SERVICE_MOID_RE.search(body) + if response.status != 200 or not match: + raise RuntimeError( + f"RetrieveInternalContent (status {response.status}) " + "had no nfcService moref" + ) + return match.group(1) + + def nfc_service(si: vim.ServiceInstance) -> vim.NfcService: """Return the vCenter/ESXi NfcService managed object on ``si``'s SOAP stub. @@ -129,7 +169,7 @@ def nfc_service(si: vim.ServiceInstance) -> vim.NfcService: """ _register_nfc_types() nfc_cls = GetVmodlType("vim.NfcService") - return nfc_cls(NFC_SERVICE_MOID, si._stub) + return nfc_cls(_nfc_service_moid(si), si._stub) def connect_vim( @@ -315,6 +355,7 @@ def connect_authd( allow_untrusted: bool = False, timeout: float = 30.0, nfc_ssl: bool = True, + fallback_host: str | None = None, ) -> ssl.SSLSocket: """Complete the ESXi authd handshake using an NFC HostServiceTicket. @@ -337,8 +378,12 @@ def connect_authd( 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). + fallback_host: Host to dial when ``ticket.host`` is unset. A ticket + issued directly by a bare ESXi host (no vCenter) omits ``host`` + entirely, since the authd endpoint is that same host; pass the + VIM connection's host in that case. """ - host = ticket.host + host = ticket.host or fallback_host port = ticket.port or AUTHD_DEFAULT_PORT raw = socket.create_connection((host, port), timeout=timeout) try: @@ -463,7 +508,7 @@ def authenticate( read_only=read_only, ) authd_sock = connect_authd( - ticket, allow_untrusted=allow_untrusted, nfc_ssl=nfc_ssl + ticket, allow_untrusted=allow_untrusted, nfc_ssl=nfc_ssl, fallback_host=host ) except Exception: Disconnect(si) diff --git a/openvixdisklib/openvixdisklib.py b/openvixdisklib/openvixdisklib.py index cafb7f9..fce8275 100644 --- a/openvixdisklib/openvixdisklib.py +++ b/openvixdisklib/openvixdisklib.py @@ -305,7 +305,10 @@ def open( conn.si, vm, read_only=read_only, disk_path=None if read_only else disk_path ) authd_sock = nfc_auth.connect_authd( - ticket, allow_untrusted=conn.allow_untrusted, nfc_ssl=nfc_ssl + ticket, + allow_untrusted=conn.allow_untrusted, + nfc_ssl=nfc_ssl, + fallback_host=conn.si._stub.host.rsplit(":", 1)[0], ) session = nfc_auth.NfcAuthSession(conn.si, ticket, authd_sock, nfc_ssl=nfc_ssl) try: diff --git a/tests/conftest.py b/tests/conftest.py index a972d1e..4cf6402 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ destroy_lab_vm, ensure_vddk_library_path, require_vddk, + resolve_direct_esxi_lab, ) @@ -31,6 +32,12 @@ def lab() -> Iterator[LabEnv]: destroy_lab_vm(env) +@pytest.fixture(scope="session") +def esxi_lab(lab: LabEnv) -> LabEnv: + """The session lab VM addressed through its ESXi host, not vCenter.""" + return resolve_direct_esxi_lab(lab) + + @pytest.fixture(scope="session") def vddk() -> None: """Skip VDDK-backed tests when ``libvixDiskLib`` cannot be loaded.""" diff --git a/tests/integration/base.py b/tests/integration/base.py index 57157fc..0e3d382 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -162,6 +162,124 @@ def _connect_vim( ) +def _esxi_direct_credentials(vc_password: str) -> tuple[str, str]: + """Return ESXi hostd credentials for direct-connect tests. + + Optional ``esxi.username`` / ``esxi.password`` in ``.test_config.yaml`` + override the defaults (``root`` and the vCenter password). + """ + if not os.path.isfile(_CONFIG_PATH): + return "root", vc_password + with open(_CONFIG_PATH, encoding="utf-8") as config_file: + data = yaml.safe_load(config_file) or {} + section = data.get("esxi") + if not isinstance(section, dict): + section = {} + username = str(section["username"]) if section.get("username") else "root" + password = str(section["password"]) if section.get("password") else vc_password + return username, password + + +def _host_management_ip(host: vim.HostSystem) -> str: + vnics = list(host.config.network.vnic or []) + for vnic in vnics: + ip = vnic.spec.ip.ipAddress if vnic.spec.ip else None + if ip and vnic.device == "vmk0": + return str(ip) + for vnic in vnics: + ip = vnic.spec.ip.ipAddress if vnic.spec.ip else None + if ip: + return str(ip) + if host.name: + return str(host.name) + raise RuntimeError(f"no management IPv4 on host {host._moId}") + + +def resolve_direct_esxi_lab(lab: LabEnv) -> LabEnv: + """Return a ``LabEnv`` that talks to the lab VM's ESXi host, not vCenter. + + The host is the one ``lab``'s VM is registered on. Hostd credentials + default to ``root`` plus the vCenter password. Tests should skip when + lockdown is on or hostd login fails. + """ + si = _connect_vim( + lab.host, + lab.username, + lab.password, + lab.port, + lab.thumbprint, + lab.allow_untrusted, + ) + esxi_host = "" + instance_uuid = "" + try: + if si.content.about.apiType == "HostAgent": + return lab + vm = vim.VirtualMachine(lab.vm_moref, si._stub) + host = vm.runtime.host + lockdown = str(getattr(host.config, "lockdownMode", "") or "") + if lockdown and not lockdown.endswith("lockdownDisabled"): + pytest.skip(f"ESXi {host.name} is in lockdown ({lockdown})") + esxi_host = _host_management_ip(host) + instance_uuid = vm.config.instanceUuid + if not instance_uuid: + pytest.skip(f"lab VM {lab.vm_moref} has no instanceUuid") + finally: + Disconnect(si) + + username, password = _esxi_direct_credentials(lab.password) + try: + thumbprint = nfc_auth.get_ssl_cert_thumbprint(esxi_host, lab.port) + except OSError as exc: + pytest.skip(f"cannot reach ESXi {esxi_host}: {exc}") + try: + esxi_si = _connect_vim( + esxi_host, + username, + password, + lab.port, + thumbprint, + lab.allow_untrusted, + ) + except vim.fault.InvalidLogin: + pytest.skip( + f"direct ESXi login to {esxi_host} failed; set esxi.username / " + "esxi.password in .test_config.yaml" + ) + except vim.fault.NoPermission: + pytest.skip( + f"ESXi user has no hostd privileges on {esxi_host}; set " + "esxi.username / esxi.password in .test_config.yaml" + ) + except OSError as exc: + pytest.skip(f"cannot connect to ESXi {esxi_host}: {exc}") + try: + if esxi_si.content.about.apiType != "HostAgent": + pytest.skip( + f"{esxi_host} apiType is {esxi_si.content.about.apiType}, not HostAgent" + ) + found = esxi_si.content.searchIndex.FindByUuid(None, instance_uuid, True, True) + if found is None: + pytest.skip( + f"lab VM instanceUuid {instance_uuid} not found on ESXi {esxi_host}" + ) + return LabEnv( + host=esxi_host, + port=lab.port, + username=username, + password=password, + allow_untrusted=lab.allow_untrusted, + datacenter=lab.datacenter, + datastore=lab.datastore, + thumbprint=thumbprint, + vm_moref=found._moId, + vmx_spec=f"moref={found._moId}", + disk_path=lab.disk_path, + ) + finally: + Disconnect(esxi_si) + + def _wait_for_task(task: vim.Task) -> Any: deadline = time.monotonic() + _TASK_TIMEOUT_S while task.info.state in (vim.TaskInfo.State.running, vim.TaskInfo.State.queued): diff --git a/tests/integration/test_direct_esxi.py b/tests/integration/test_direct_esxi.py new file mode 100644 index 0000000..e9a59eb --- /dev/null +++ b/tests/integration/test_direct_esxi.py @@ -0,0 +1,83 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Direct ESXi (HostAgent) NFC path, using the lab VM's host from vCenter.""" + +import pytest +from pyVim.connect import Disconnect + +from openvixdisklib import nfc_auth +from openvixdisklib import openvixdisklib as vixdisklib +from tests.integration.base import ( + SECTOR_AT_1GB, + SECTOR_SIZE, + LabEnv, + _connect_vim, + pattern_bytes, +) + + +class TestDirectEsxi: + def test_nfc_service_moref_is_host_agent(self, esxi_lab: LabEnv) -> None: + """RetrieveInternalContent must yield the ESXi NfcService moref.""" + si = _connect_vim( + esxi_lab.host, + esxi_lab.username, + esxi_lab.password, + esxi_lab.port, + esxi_lab.thumbprint, + esxi_lab.allow_untrusted, + ) + try: + assert si.content.about.apiType == "HostAgent" + moref = nfc_auth.nfc_service(si)._moId + assert moref != "nfcService" + assert moref == "ha-nfc-service" + finally: + Disconnect(si) + + @pytest.mark.parametrize("nfc_ssl", [True, False], ids=["nbdssl", "nbd"]) + def test_authd_handshake_omits_ticket_host( + self, esxi_lab: LabEnv, nfc_ssl: bool + ) -> None: + """Direct-ESXi tickets omit ``host`` and PROXY ``nfc``, not ``vpxa-nfc``.""" + with esxi_lab.authenticate(nfc_ssl=nfc_ssl) as session: + assert not session.ticket.host + assert session.ticket.service == "nfc" + assert session.ticket.sessionId + assert session.nfc_ssl is nfc_ssl + assert session.authd_sock.getpeername()[0] + assert session.authd_sock.version() + assert session.authd_sock.cipher() + + @pytest.mark.parametrize("transport_mode", ["nbdssl", "nbd"]) + def test_write_and_read_sector_zero_and_one_gib( + self, esxi_lab: LabEnv, transport_mode: str + ) -> None: + """ConnectEx + Open + Write/Read against hostd, not vCenter.""" + handle = vixdisklib.VixDiskLibHandle( + vixdisklib_compatibility_version="8.0", config_path=None + ) + write_buf = vixdisklib.get_buffer(SECTOR_SIZE) + read_buf = vixdisklib.get_buffer(SECTOR_SIZE) + connect_kwargs = esxi_lab.vixdisklib_connect_kwargs( + { + "allow_untrusted": esxi_lab.allow_untrusted, + "transport_modes": transport_mode, + } + ) + patterns = { + 0: pattern_bytes(SECTOR_SIZE, b"OVDL-ESX0"), + SECTOR_AT_1GB: pattern_bytes(SECTOR_SIZE, b"OVDL-ESX1"), + } + with ( + handle.connect(**connect_kwargs) as conn, + handle.open(conn, esxi_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) + read_buf[:SECTOR_SIZE] = b"\xa5" * SECTOR_SIZE + handle.read(disk, start, 1, read_buf) + assert read_buf.raw[:SECTOR_SIZE] == expected diff --git a/tests/integration/test_nfc_auth.py b/tests/integration/test_nfc_auth.py index e074f1e..d6c32e1 100644 --- a/tests/integration/test_nfc_auth.py +++ b/tests/integration/test_nfc_auth.py @@ -11,7 +11,11 @@ def test_authd_handshake_completes(self, lab: LabEnv) -> None: """Complete VIM login and authd PROXY through ``200 Connect``.""" with lab.authenticate() as session: ticket = session.ticket - assert ticket.host + # ticket.host is unset on a direct-ESXi ticket (no vCenter): + # the authd endpoint is implicitly the host already logged + # into. connect_authd() falls back to that host, so the + # socket's peer address is the reliable check here. + assert session.authd_sock.getpeername()[0] assert ticket.port assert ticket.sessionId assert session.nfc_ssl is True