diff --git a/design/followups.md b/design/followups.md index b7dd8f7..cf7d996 100644 --- a/design/followups.md +++ b/design/followups.md @@ -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 ``). 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 ``). 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` (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. diff --git a/rust/crates/mxaccess-nmx/Cargo.toml b/rust/crates/mxaccess-nmx/Cargo.toml index e4470bb..6e62b7e 100644 --- a/rust/crates/mxaccess-nmx/Cargo.toml +++ b/rust/crates/mxaccess-nmx/Cargo.toml @@ -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 diff --git a/rust/crates/mxaccess-nmx/src/client.rs b/rust/crates/mxaccess-nmx/src/client.rs index 2fa9399..f3e69e0 100644 --- a/rust/crates/mxaccess-nmx/src/client.rs +++ b/rust/crates/mxaccess-nmx/src/client.rs @@ -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 { + 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,