[F12] mxaccess-nmx: NmxClient::create — auto-resolving COM-activation factory
rust / build / test / clippy / fmt (push) Has been cancelled

New constructor NmxClient::create(ntlm_factory) gated on
cfg(all(windows, feature = "windows-com")). New crate feature
mxaccess-nmx/windows-com propagates to mxaccess-rpc/windows-com.
Mirrors ManagedNmxService2Client.Create() (cs:30-64) plus
ResolveService (cs:491-523).

Six-step bring-up:
  1. com_objref_provider::marshal_activated_iunknown_objref(
       "NmxSvc.NmxService", MarshalContext::DifferentMachine)
     activates and emits the OBJREF.
  2. ComObjRef::parse extracts oxid + the activated server's IUnknown
     IPID.
  3. resolve_oxid_with_managed_ntlm_packet_integrity against
     127.0.0.1:135 (RPCSS endpoint mapper) returns the server's
     (host, port) bindings + IRemUnknown IPID.
  4. parse_bracketed_host_port pulls the host + port out of the
     ncacn_ip_tcp binding's `host[port]` text. Uses rfind for the
     rightmost brackets so FQDN forms (foo.example.com[1234])
     round-trip — matches the .NET ParseBracketedHost/Port shape at
     cs:540-561.
  5. A fresh DceRpcTcpClient binds to IRemUnknown and calls
     RemQueryInterface(iunknown_ipid, INmxService2_IID,
                        fresh_causality_id, public_refs=5).
  6. A second fresh transport binds to INmxService2 via Self::connect.

The ntlm_factory: impl FnMut() -> NtlmClientContext closure is
invoked three times (one per bind); each NtlmClientContext is
consumed by its bind, so the factory must produce fresh contexts.

New NmxClientError variants:
  - Activation(ProviderError) — only emitted with windows-com on.
  - EndpointResolution { reason } — covers no ncacn_ip_tcp binding,
    malformed host[port], non-zero RemQueryInterface HRESULT.

6 offline tests on parse_bracketed_host_port: FQDN host extraction,
rfind for rightmost brackets, rejection of missing '[' / missing
']' / non-numeric port / port overflow.

1 live test (#[ignore], gated on MX_LIVE + MX_TEST_USER /
MX_TEST_PASSWORD / MX_TEST_DOMAIN populated by
tools/Setup-LiveProbeEnv.ps1): round-trips the full chain against
the AVEVA install on this host. Resolved INmxService2 IPID is
non-zero — verified end-to-end.

Workspace: mxaccess-nmx 17 → 23 (+6). All other crates unchanged.

Closes F12 in design/followups.md. F6 (ComObjRefProvider port) was
the prior blocker; with both landed, the COM-activation path is
end-to-end functional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 22:21:49 -04:00
parent cf9dbaf568
commit daa4ea3f16
3 changed files with 312 additions and 5 deletions
+3 -5
View File
@@ -218,11 +218,6 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M
**Why deferred:** `RemUnknownMessages.cs` declares the opnums (`:9-10`) but does not implement encoders/decoders. The Rust port matches that exactly per "port what is already proven."
**Resolves when:** The .NET reference adds bodies for opnums 4 / 5 (or a captured frame establishes the on-wire shape). At that point port them into `rem_unknown.rs` alongside the existing `RemQueryInterface` codec.
### F12 — `NmxClient::create` (auto-resolving COM-activation factory)
**Severity:** P1
**Source:** M3 stream B, `crates/mxaccess-nmx/src/client.rs`
**Why deferred:** `ManagedNmxService2Client.Create()` (`ManagedNmxService2Client.cs:30-64`) auto-discovers `(host, port, service_ipid)` by activating the `NmxSvc.NmxService` COM ProgID, marshalling the resulting `IUnknown` to an OBJREF, calling `IObjectExporter::ResolveOxid` against the OXID inside, then `IRemUnknown::RemQueryInterface` to get the `INmxService2` IPID. **F6 (the `ComObjRefProvider` port) is now resolved**, so the underlying `marshal_activated_iunknown_objref` primitive exists; what remains is wiring it into `NmxClient::create` and threading `mxaccess-rpc`'s `windows-com` feature through `mxaccess-nmx`.
**Resolves when:** `NmxClient::create()` lands behind a matching `mxaccess-nmx` feature gate. ~30 lines that chain the existing primitives: `com_objref_provider::marshal_activated_iunknown_objref("NmxSvc.NmxService", MarshalContext::Local)``ComObjRef::parse``object_exporter_client::resolve_oxid_with_managed_ntlm_packet_integrity``rem_unknown::encode_rem_query_interface_request` over a temporary transport → `NmxClient::connect`.
### F16 — Real `Session::recover_connection` reconnect loop (re-bind + re-advise)
**Severity:** P1
@@ -232,6 +227,9 @@ The fixture is captured by `MxAsbClient.Probe --dump-deterministic-hmac` (`src/M
## Resolved
### F12 — `NmxClient::create` (auto-resolving COM-activation factory)
**Resolved:** 2026-05-05 (commit `<this commit>`). Builds on F6: new `NmxClient::create(ntlm_factory)` constructor in `crates/mxaccess-nmx/src/client.rs`, gated on `cfg(all(windows, feature = "windows-com"))`. New crate-level feature `mxaccess-nmx/windows-com` propagates to `mxaccess-rpc/windows-com`. Mirrors `ManagedNmxService2Client.Create()` (`cs:30-64`) + `ResolveService` (`cs:491-523`) — six steps: (1) `com_objref_provider::marshal_activated_iunknown_objref("NmxSvc.NmxService", MarshalContext::DifferentMachine)` activates the COM class and emits an OBJREF blob; (2) `ComObjRef::parse` extracts `oxid` + `ipid` (the activated server's `IUnknown` IPID); (3) `resolve_oxid_with_managed_ntlm_packet_integrity` against `127.0.0.1:135` (RPCSS endpoint mapper) returns the server's `(host, port)` bindings + `IRemUnknown` IPID; (4) the `ncacn_ip_tcp` non-security binding's `host[port]` text is parsed via the new `parse_bracketed_host_port` helper (mirrors the .NET `ParseBracketedHost` / `ParseBracketedPort` pair, using `rfind` so FQDNs with `.` round-trip — matches `cs:540-561`); (5) a fresh transport binds to `IRemUnknown` and calls `RemQueryInterface(iunknown_ipid, INmxService2_IID, fresh_causality_id, public_refs=5)` — the `RemQiResult` carries the new `INmxService2` IPID; (6) a second fresh transport binds to `INmxService2` via `Self::connect`. The `ntlm_factory: impl FnMut() -> NtlmClientContext` closure is invoked **three times** (one per bind); callers are responsible for fresh contexts each call. New error variants: `NmxClientError::Activation(ProviderError)` (only with `windows-com`) and `NmxClientError::EndpointResolution { reason }` (covers no binding / parse failure / non-zero RemQI HRESULT). 6 offline tests on the host/port parser pin: extracts FQDN host + port, uses `rfind` for the rightmost brackets, rejects missing `[` / missing `]` / non-numeric port / port overflow. 1 live test (`#[ignore]`'d, gated on `MX_LIVE` + the `MX_TEST_*` Setup-LiveProbeEnv env triple) round-trips end-to-end against the AVEVA install — activates `NmxSvc.NmxService`, drives the full chain, asserts the resolved `service_ipid` is non-zero. Live verification: passes. Workspace tests went 17 → 23 in mxaccess-nmx (+6).
### F6 — Port `ComObjRefProvider.cs` (OBJREF emitter via Win32 `CoMarshalInterface`)
**Resolved:** 2026-05-05 (commit `<this commit>`). New module `crates/mxaccess-rpc/src/com_objref_provider.rs` (~330 LoC including tests) gated on `cfg(all(windows, feature = "windows-com"))`. Pulls `windows = "0.59"` (features `Win32_Foundation` + `Win32_System_Com` + `Win32_System_Com_Marshal` + `Win32_System_Com_StructuredStorage` + `Win32_System_Memory`) as an optional dep behind the existing `windows-com` feature; default footprint stays slim. Public API mirrors `ComObjRefProvider.cs` 1:1: `MarshalContext` enum (InProcess / Local / DifferentMachine — wraps the `MSHCTX_*` newtype constants), `clsid_from_prog_id(&str) -> Result<GUID, ProviderError>` (wraps `CLSIDFromProgID`), `marshal_activated_iunknown_objref(prog_id, ctx)` (activates via `CoCreateInstance(CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER | CLSCTX_REMOTE_SERVER)` then marshals), `marshal_iunknown_objref(unknown, ctx)` (uses `IUnknown::IID`), `marshal_interface_objref(unknown, iid, ctx)` (the underlying `CoMarshalInterface` over an HGlobal-backed `IStream`). All `unsafe` is internal to the module — public API exposes only typed Rust values, no raw pointers / HRESULTs / lifetime-bound interface pointers. Each `unsafe` block carries an inline SAFETY comment. `ProviderError` enumerates the four documented failure modes (UnknownProgId, ActivationFailed, MarshalFailed, GlobalLockFailed) plus the apartment-init pre-check (ApartmentInitFailed). Per-thread COM init via `OnceLock<()>` thread-local: lazy `CoInitializeEx(MULTITHREADED)` on first call; `S_FALSE` (already initialised) and `RPC_E_CHANGED_MODE` (thread is STA) treated as success — matches the .NET runtime's tolerant apartment behaviour. 4 offline tests pin: `MarshalContext``MSHCTX_*` mapping, `ensure_apartment` idempotence, `clsid_from_prog_id` returns `UnknownProgId` for fake ProgIDs, `marshal_activated_*` short-circuits at the resolution stage. 1 live test (`#[ignore]`'d, gated on `MX_LIVE`) round-trips the real `NmxSvc.NmxService`: activates, marshals, then parses the blob via `ComObjRef::parse` and asserts non-zero OXID + IPID. Live verification: passes against the AVEVA install on this host. Workspace tests went 183 → was 179 in mxaccess-rpc (+4 new). Unblocks F12 (NmxClient::create) — the auto-resolving COM-activation factory can now chain `marshal_activated_iunknown_objref``ComObjRef::parse``resolve_oxid_with_managed_ntlm_packet_integrity``RemQueryInterface` over the existing primitives.
+7
View File
@@ -18,5 +18,12 @@ tracing = { workspace = true }
thiserror = { workspace = true }
rand = "0.8"
[features]
default = []
# Pulls the COM-activation factory `NmxClient::create` (F12). Propagates
# through to `mxaccess-rpc/windows-com` so the Win32 `CoMarshalInterface`
# emitter is available. On non-Windows targets the gate is a no-op.
windows-com = ["mxaccess-rpc/windows-com"]
[lints]
workspace = true
+302
View File
@@ -95,6 +95,23 @@ pub enum NmxClientError {
/// `GalaxyTagMetadata.ProjectWriteValue` (`cs:62,70`).
#[error("unsupported data type: {0}")]
UnsupportedDataType(#[from] UnsupportedDataType),
/// COM activation / OBJREF marshalling failed during
/// [`NmxClient::create`] — typically `REGDB_E_CLASSNOTREG` (the AVEVA
/// install is missing) or `CO_E_SERVER_EXEC_FAILURE` (NmxSvc.exe
/// failed to launch). Only emitted when the `windows-com` feature is
/// enabled.
#[cfg(all(windows, feature = "windows-com"))]
#[error("NmxSvc COM activation failed: {0}")]
Activation(#[from] mxaccess_rpc::com_objref_provider::ProviderError),
/// `ResolveOxid` returned without a usable `ncacn_ip_tcp` binding,
/// the binding's `host[port]` couldn't be parsed, or `IRemUnknown::RemQueryInterface`
/// returned a non-zero HRESULT / error code. Mirrors the
/// `InvalidOperationException` at
/// `ManagedNmxService2Client.cs:519,545,559`.
#[error("NmxSvc endpoint resolution failed: {reason}")]
EndpointResolution { reason: String },
}
/// Generates a random correlation `Cid` for each outgoing `OrpcThis` —
@@ -106,6 +123,34 @@ fn fresh_orpc_this() -> OrpcThis {
OrpcThis::create(Guid::new(rand::random()), None)
}
/// Parse a `host[port]` binding string of the shape `ManagedNmxService2Client`
/// expects (`cs:540-561`). The host is everything before the **last** `[`,
/// the port is the decimal text between that `[` and the **last** `]`.
///
/// Used by [`NmxClient::create`] only.
fn parse_bracketed_host_port(binding: &str) -> Result<(String, u16), NmxClientError> {
let open = binding.rfind('[').ok_or_else(|| NmxClientError::EndpointResolution {
reason: format!("binding {binding:?} has no '['"),
})?;
let close = binding.rfind(']').ok_or_else(|| NmxClientError::EndpointResolution {
reason: format!("binding {binding:?} has no ']'"),
})?;
if open == 0 || close <= open {
return Err(NmxClientError::EndpointResolution {
reason: format!("binding {binding:?} has malformed brackets"),
});
}
let host = binding[..open].to_string();
let port_text = &binding[open + 1..close];
let port: u16 =
port_text
.parse()
.map_err(|e: std::num::ParseIntError| NmxClientError::EndpointResolution {
reason: format!("binding {binding:?} port {port_text:?} parse: {e}"),
})?;
Ok((host, port))
}
/// Async `INmxService2` client. Owns one `DceRpcTcpClient` connection that
/// has already completed an authenticated bind to the `INmxService2` IID.
///
@@ -152,6 +197,176 @@ impl NmxClient {
})
}
/// Auto-resolve `(host, port, service_ipid)` via COM activation +
/// OXID resolution + `IRemUnknown::RemQueryInterface`, then bind to
/// `INmxService2` and return a ready-to-use client.
///
/// Mirrors `ManagedNmxService2Client.Create()` (`cs:30-64`) +
/// `ResolveService` (`cs:491-523`). Only available when the crate
/// is built with the `windows-com` feature on Windows.
///
/// `ntlm_factory` is invoked **three times**: once for the
/// `ResolveOxid` call against `127.0.0.1:135` (RPCSS endpoint
/// mapper), once for the `IRemUnknown` bind against the discovered
/// NmxSvc endpoint, and once for the final `INmxService2` bind on a
/// fresh transport. Each NTLM context is consumed by its bind; the
/// caller is responsible for producing fresh ones (typically by
/// re-reading credentials from `MX_RPC_*` env vars via
/// [`NtlmClientContext::from_env`]).
///
/// Steps:
///
/// 1. `marshal_activated_iunknown_objref("NmxSvc.NmxService", DifferentMachine)`
/// activates the COM class and emits an OBJREF blob.
/// 2. [`mxaccess_rpc::objref::ComObjRef::parse`] extracts `oxid` +
/// `ipid` (the activated server's `IUnknown` IPID).
/// 3. [`mxaccess_rpc::object_exporter_client::resolve_oxid_with_managed_ntlm_packet_integrity`]
/// against `127.0.0.1:135` returns the server's `(host, port)`
/// bindings + `IRemUnknown` IPID.
/// 4. The `ncacn_ip_tcp` binding's `host[port]` text is parsed.
/// 5. A fresh transport binds to `IRemUnknown` and calls
/// `RemQueryInterface(iunknown_ipid, INmxService2)` to obtain the
/// `INmxService2` IPID.
/// 6. A second fresh transport binds to `INmxService2` and is
/// returned wrapped in this client.
///
/// # Errors
///
/// [`NmxClientError::Activation`] for COM activation /
/// `CoMarshalInterface` failures;
/// [`NmxClientError::EndpointResolution`] when `ResolveOxid`
/// returns no `ncacn_ip_tcp` binding, the host/port string is
/// malformed, or `RemQueryInterface` returns a non-zero HRESULT;
/// [`NmxClientError::Transport`] for I/O / NTLM failures during
/// any of the three binds.
#[cfg(all(windows, feature = "windows-com"))]
pub async fn create(
mut ntlm_factory: impl FnMut() -> NtlmClientContext,
) -> Result<Self, NmxClientError> {
use mxaccess_rpc::com_objref_provider::{
marshal_activated_iunknown_objref, MarshalContext,
};
use mxaccess_rpc::object_exporter::PROTSEQ_NCACN_IP_TCP;
use mxaccess_rpc::object_exporter_client::{
resolve_oxid_with_managed_ntlm_packet_integrity, ResolveOxidOutcome,
};
use mxaccess_rpc::objref::ComObjRef;
use mxaccess_rpc::rem_unknown::{
encode_rem_query_interface_request, parse_rem_query_interface_response,
IREM_UNKNOWN_IID, REM_QUERY_INTERFACE_OPNUM,
};
// Step 1+2: Activate NmxSvc.NmxService and parse OBJREF.
let blob = marshal_activated_iunknown_objref(
"NmxSvc.NmxService",
MarshalContext::DifferentMachine,
)?;
let objref = ComObjRef::parse(&blob).map_err(|e| NmxClientError::EndpointResolution {
reason: format!("OBJREF parse: {e}"),
})?;
// Step 3: ResolveOxid against the local RPCSS endpoint mapper.
let exporter_addr: SocketAddr = "127.0.0.1:135"
.parse()
.map_err(|e: std::net::AddrParseError| NmxClientError::EndpointResolution {
reason: format!("invalid 127.0.0.1:135 literal: {e}"),
})?;
let outcome = resolve_oxid_with_managed_ntlm_packet_integrity(
exporter_addr,
objref.oxid,
&[PROTSEQ_NCACN_IP_TCP],
ntlm_factory(),
)
.await?;
let resolved = match outcome {
ResolveOxidOutcome::Result(r) => r,
ResolveOxidOutcome::Failure(f) => {
return Err(NmxClientError::EndpointResolution {
reason: format!(
"ResolveOxid returned failure status 0x{:08X}",
f.error_status
),
});
}
};
if resolved.error_status != 0 {
return Err(NmxClientError::EndpointResolution {
reason: format!(
"ResolveOxid completed with non-zero error_status 0x{:08X}",
resolved.error_status
),
});
}
// Step 4: Find the ncacn_ip_tcp binding and parse host[port].
let endpoint = resolved
.bindings
.iter()
.find(|b| b.tower_id == PROTSEQ_NCACN_IP_TCP && !b.is_security_binding)
.ok_or_else(|| NmxClientError::EndpointResolution {
reason: "ResolveOxid returned no ncacn_ip_tcp binding".to_string(),
})?;
let (host, port) = parse_bracketed_host_port(&endpoint.value)?;
let svc_addr: SocketAddr =
tokio::net::lookup_host((host.as_str(), port))
.await
.map_err(|e| NmxClientError::EndpointResolution {
reason: format!("DNS lookup of {host}:{port} failed: {e}"),
})?
.next()
.ok_or_else(|| NmxClientError::EndpointResolution {
reason: format!("DNS resolution of {host}:{port} produced no addresses"),
})?;
// Step 5: Bind IRemUnknown on a fresh transport and call
// RemQueryInterface(iunknown_ipid, INmxService2).
let mut rem_qi_client = DceRpcTcpClient::connect(svc_addr)
.await
.map_err(TransportError::from)?;
rem_qi_client
.bind_with_managed_ntlm_packet_integrity(IREM_UNKNOWN_IID, 0, 0, ntlm_factory())
.await?;
// Native uses `public_refs = 5` (`RemUnknownMessages.cs:12`); the
// Rust signature requires it explicitly so the default isn't
// hidden in the call-site.
let qi_request = encode_rem_query_interface_request(
objref.ipid,
svc::INTERFACE_ID,
Guid::new(rand::random()),
5,
);
let qi_response = rem_qi_client
.call_bound_object(
resolved.rem_unknown_ipid,
REM_QUERY_INTERFACE_OPNUM,
&qi_request,
)
.await?;
let parsed = parse_rem_query_interface_response(&qi_response.stub_data)
.map_err(TransportError::from)?;
let qi_result = parsed.result.ok_or_else(|| NmxClientError::EndpointResolution {
reason: format!(
"RemQueryInterface response had no REMQIRESULT (error_code 0x{:08X})",
parsed.error_code
),
})?;
if qi_result.hresult != 0 || parsed.error_code != 0 {
return Err(NmxClientError::EndpointResolution {
reason: format!(
"RemQueryInterface failed: hresult=0x{:08X}, error_code=0x{:08X}",
qi_result.hresult, parsed.error_code
),
});
}
let service_ipid = qi_result.standard_object_reference.ipid;
// Drop the QI transport; the .NET reference uses a `using` block
// for the same reason — the IRemUnknown bind is single-use.
drop(rem_qi_client);
// Step 6: Final transport bound to INmxService2.
Self::connect(svc_addr, service_ipid, ntlm_factory()).await
}
/// Construct from an already-bound transport. Useful when a caller
/// has already negotiated the bind (e.g. for tests against a hand-rolled
/// server, or for an unauthenticated probe path).
@@ -897,6 +1112,93 @@ mod tests {
"127.0.0.1:0".parse().unwrap()
}
// ----- F12 host[port] parser ------------------------------------------
#[test]
fn parse_bracketed_extracts_host_and_port() {
let (h, p) = parse_bracketed_host_port("DESKTOP-6JL3KKO[55690]").unwrap();
assert_eq!(h, "DESKTOP-6JL3KKO");
assert_eq!(p, 55690);
}
#[test]
fn parse_bracketed_uses_last_brackets() {
// The native ResolveOxid bindings can include FQDN forms like
// `host.subdomain[12345]` — `rfind` keeps the right boundary.
let (h, p) = parse_bracketed_host_port("foo.example.com[1234]").unwrap();
assert_eq!(h, "foo.example.com");
assert_eq!(p, 1234);
}
#[test]
fn parse_bracketed_rejects_missing_open() {
let err = parse_bracketed_host_port("hostonly").unwrap_err();
assert!(matches!(err, NmxClientError::EndpointResolution { .. }));
}
#[test]
fn parse_bracketed_rejects_missing_close() {
let err = parse_bracketed_host_port("host[1234").unwrap_err();
assert!(matches!(err, NmxClientError::EndpointResolution { .. }));
}
#[test]
fn parse_bracketed_rejects_non_numeric_port() {
let err = parse_bracketed_host_port("host[abc]").unwrap_err();
assert!(matches!(err, NmxClientError::EndpointResolution { .. }));
}
#[test]
fn parse_bracketed_rejects_port_overflow() {
let err = parse_bracketed_host_port("host[100000]").unwrap_err();
assert!(matches!(err, NmxClientError::EndpointResolution { .. }));
}
/// Live integration test for [`NmxClient::create`]. Activates
/// `NmxSvc.NmxService` and resolves the `INmxService2` IPID via the
/// real OBJREF + OXID + RemQI chain. Gated on `MX_LIVE` plus the
/// `MX_TEST_USER` / `MX_TEST_PASSWORD` / `MX_TEST_DOMAIN` triple
/// populated by `tools/Setup-LiveProbeEnv.ps1` (which fetches them
/// from Infisical).
#[cfg(all(windows, feature = "windows-com"))]
#[tokio::test(flavor = "current_thread")]
#[ignore = "requires AVEVA + MX_LIVE; gated on env vars from Setup-LiveProbeEnv.ps1"]
async fn live_create_resolves_inmxservice2() {
if std::env::var_os("MX_LIVE").is_none() {
eprintln!("MX_LIVE not set; skipping");
return;
}
let user = match std::env::var("MX_TEST_USER") {
Ok(s) if !s.is_empty() => s,
_ => {
eprintln!("MX_TEST_USER not set; skipping");
return;
}
};
let password = match std::env::var("MX_TEST_PASSWORD") {
Ok(s) if !s.is_empty() => s,
_ => {
eprintln!("MX_TEST_PASSWORD not set; skipping");
return;
}
};
let domain = std::env::var("MX_TEST_DOMAIN").unwrap_or_default();
let factory = || {
mxaccess_rpc::ntlm::NtlmClientContext::new(&user, &password, &domain, None)
};
let client = NmxClient::create(factory)
.await
.expect("NmxClient::create round-trip");
// The resolved IPID must be non-zero — the activated server
// always picks a real GUID.
assert_ne!(
client.service_ipid().as_bytes(),
&[0u8; 16],
"service IPID is all-zero (RemQueryInterface didn't return a real IPID)"
);
}
/// Spin a hand-rolled DCE/RPC server that:
/// 1. accepts one connection,
/// 2. drains one Bind PDU and replies with a 16-byte BindAck shell,