From 3b09297b27ff8704a34b47761d8b3f206d904aa6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 15:06:48 -0400 Subject: [PATCH] =?UTF-8?q?[M5]=20live-probe=20iteration=201=20=E2=80=94?= =?UTF-8?q?=20major=20wire-byte=20reconciliation=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First live-test cycle against AVEVA on this box. Comparing the .NET probe's `--dump-messages` XML output against our NBFX-encoded envelope surfaced six structural bugs in the F25 envelope/operations layer. All fixed; tests passing (702 workspace). Fixes (all backed by the .NET dump as ground truth): 1. **`mustUnderstand` attribute name** — NBFS dict id was 116 (`MustUnderstand`, capital-M, a different SOAP token); SOAP 1.2 spec uses lowercase `mustUnderstand` at id 0. Sending the wrong one triggered a WCF parse fault that surfaced as TCP RST. 2. **Missing `` header** — WCF's default binding requires MessageID for two-way operations. We now auto-generate `urn:uuid:` per envelope via a small inline `make_random_uuid_v4` helper (no `uuid` crate dep). 3. **Missing `` anonymous header** — WCF's BinaryMessageEncoder always emits `... addressing/anonymous` for two-way ops. 4. **ConnectionValidator field names + namespace** — we were emitting PascalCase `` etc. .NET's WCF DataContractSerializer uses the private backing-field names (`guid`) per `[DataMember(Name = "fooField")]`. Added the `xmlns:i="...XMLSchema-instance"` declaration WCF emits alongside (even when no `i:nil` is used). Decoder now accepts both PascalCase (legacy tests) and DataContract field names. 5. **`` over-wrapping** — we were emitting `{bytes}`. .NET's `AsbDataCustomSerializer.WriteStartObject` (`AsbContracts.cs: 1561-1572`) REPLACES the field's outer element with `` directly — there's no `` wrapper on the wire. Fixed by collapsing `BodyField::AsbiDataElement` to emit just `` without the named outer element. The `name` field is retained for self-documentation. 6. **`collect_asbidata_payloads` API** — was keyed by field name (`Status` / `Values`); now positional (`payloads[0]`, `payloads.get(1)`) since the wrapper element is gone. All seven response decoders updated. Plus tooling for the live-probe loop: * `tools/Get-AsbPassphrase.ps1` — DPAPI loader that auto-discovers the solution name + reads the sharedsecret + decrypts it. Sets $env:MX_ASB_PASSPHRASE / MX_ASB_HOST / MX_ASB_VIA / MX_LIVE. Lowercase via-host (WCF SMSvcHost is case-sensitive on the URL host segment). * `examples/asb-preamble-probe.rs` — diagnostic that connects, runs the preamble, captures the PreambleAck, then sends a synthetic ConnectRequest and dumps both directions as hex. Used to bisect the wire-byte deltas above. * `examples/asb-subscribe.rs` port default fixed (5074 → 808 — WCF's NetTcpPortSharing/SMSvcHost listener confirmed via Get-NetTCPConnection). **Status**: preamble + PreambleAck round-trip works end-to-end against the live AVEVA install (verified via probe). The post-preamble Connect SOAP envelope still gets TCP RST'd — the six structural fixes above are necessary but not yet sufficient. Next iteration needs binary wire capture (Wireshark + Npcap loopback, or a TCP-relay middleman) to compare the .NET probe's BinaryMessageEncoder output byte-for-byte with ours and find the remaining delta(s). Co-Authored-By: Claude Opus 4.7 (1M context) --- rust/Cargo.lock | 1 + rust/crates/mxaccess-asb/Cargo.toml | 1 + rust/crates/mxaccess-asb/src/envelope.rs | 201 ++++++++++++++---- rust/crates/mxaccess-asb/src/operations.rs | 121 +++++------ .../mxaccess/examples/asb-preamble-probe.rs | 127 +++++++++++ .../crates/mxaccess/examples/asb-subscribe.rs | 6 +- tools/Get-AsbPassphrase.ps1 | 9 +- 7 files changed, 358 insertions(+), 108 deletions(-) create mode 100644 rust/crates/mxaccess/examples/asb-preamble-probe.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4197118..8ed0787 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -359,6 +359,7 @@ version = "0.0.0" dependencies = [ "mxaccess-asb-nettcp", "mxaccess-codec", + "rand", "thiserror", "tokio", "tracing", diff --git a/rust/crates/mxaccess-asb/Cargo.toml b/rust/crates/mxaccess-asb/Cargo.toml index e2b22b8..fd342aa 100644 --- a/rust/crates/mxaccess-asb/Cargo.toml +++ b/rust/crates/mxaccess-asb/Cargo.toml @@ -14,6 +14,7 @@ mxaccess-asb-nettcp = { path = "../mxaccess-asb-nettcp" } thiserror = { workspace = true } tracing = { workspace = true } tokio = { workspace = true } +rand = { workspace = true } [features] default = [] diff --git a/rust/crates/mxaccess-asb/src/envelope.rs b/rust/crates/mxaccess-asb/src/envelope.rs index 24d4f9d..7c351d5 100644 --- a/rust/crates/mxaccess-asb/src/envelope.rs +++ b/rust/crates/mxaccess-asb/src/envelope.rs @@ -75,7 +75,11 @@ mod ns { pub const HEADER: u32 = 8; pub const BODY: u32 = 14; pub const ACTION: u32 = 10; - pub const MUST_UNDERSTAND_ATTR: u32 = 116; // "MustUnderstand" — capital-M + /// SOAP 1.2 spec name is lowercase `mustUnderstand`. NBFS id 0 + /// (capital-M `MustUnderstand` at id 116 is a different token). + /// Sending the wrong one triggers a WCF parse fault that + /// surfaces as a TCP RST. + pub const MUST_UNDERSTAND_ATTR: u32 = 0; } /// ASB-specific namespace strings (NOT in the static dictionary). The @@ -128,6 +132,12 @@ impl ConnectionValidator { #[derive(Debug, Clone, PartialEq)] pub struct SoapEnvelope { pub action: String, + /// WS-Addressing `` header value (typically the same + /// `net.tcp://...` URL used in the NMF `Via` record). WCF's + /// default binding requires `` for service dispatch — + /// without it the server resets the connection. Set via + /// [`Self::with_to`]. + pub to_uri: Option, pub validator: Option, pub body_tokens: Vec, } @@ -136,11 +146,17 @@ impl SoapEnvelope { pub fn new(action: impl Into) -> Self { Self { action: action.into(), + to_uri: None, validator: None, body_tokens: Vec::new(), } } + pub fn with_to(mut self, to_uri: impl Into) -> Self { + self.to_uri = Some(to_uri.into()); + self + } + pub fn with_validator(mut self, validator: ConnectionValidator) -> Self { self.validator = Some(validator); self @@ -207,11 +223,55 @@ pub fn encode_envelope( tokens.push(NbfxToken::Text(NbfxText::Chars(envelope.action.clone()))); tokens.push(NbfxToken::EndElement); // - // + // (WCF dump shows this comes BEFORE + // MessageID/ReplyTo when present) if let Some(v) = &envelope.validator { encode_validator(&mut tokens, v, dynamic); } + // urn:uuid:{uuid} + // WCF's default binding requires MessageID for two-way operations. + // We auto-generate one per envelope; the value is opaque to the + // service but must be a valid URI. + let message_id = format!("urn:uuid:{}", make_random_uuid_v4()); + tokens.push(NbfxToken::Element { + prefix: Some("a".to_string()), + name: NbfxName::Static(26), // "MessageID" + }); + tokens.push(NbfxToken::Text(NbfxText::Chars(message_id))); + tokens.push(NbfxToken::EndElement); // + + // + // http://www.w3.org/2005/08/addressing/anonymous + // + tokens.push(NbfxToken::Element { + prefix: Some("a".to_string()), + name: NbfxName::Static(44), // "ReplyTo" + }); + tokens.push(NbfxToken::Element { + prefix: Some("a".to_string()), + name: NbfxName::Static(42), // "Address" + }); + tokens.push(NbfxToken::Text(NbfxText::DictionaryStatic(20))); // anonymous + tokens.push(NbfxToken::EndElement); // + tokens.push(NbfxToken::EndElement); // + + // {to_uri} (optional — WCF + // omits To for net.tcp request/response by default) + if let Some(to) = &envelope.to_uri { + tokens.push(NbfxToken::Element { + prefix: Some("a".to_string()), + name: NbfxName::Static(12), // "To" + }); + tokens.push(NbfxToken::Attribute { + prefix: Some("s".to_string()), + name: NbfxName::Static(ns::MUST_UNDERSTAND_ATTR), + value: NbfxText::One, + }); + tokens.push(NbfxToken::Text(NbfxText::Chars(to.clone()))); + tokens.push(NbfxToken::EndElement); + } + tokens.push(NbfxToken::EndElement); // // @@ -299,55 +359,104 @@ fn encode_validator( v: &ConnectionValidator, _dynamic: &mut DynamicDictionary, ) { - // + // + // guid + // (empty when no MAC) + // n + // + // + // + // Inner element names are the .NET DataContract member names + // (private backing fields with `[DataMember(Name = "fooField")]`), + // NOT public PascalCase property names. Captured via + // `MxAsbClient.Probe --dump-messages`. The `xmlns:i` declaration + // is required even though we don't emit any `i:nil` attributes — + // WCF does, and SMSvcHost / WCF parser consistency expects it. out.push(NbfxToken::Element { prefix: Some("h".to_string()), name: NbfxName::Inline("ConnectionValidator".to_string()), }); + out.push(NbfxToken::NamespaceDeclaration { + prefix: "i".to_string(), + value: NbfxText::DictionaryStatic(440), // ...XMLSchema-instance + }); out.push(NbfxToken::NamespaceDeclaration { prefix: "h".to_string(), value: NbfxText::Chars(asb_ns::HEADERS.to_string()), }); - // guid-text - out.push(NbfxToken::Element { - prefix: None, - name: NbfxName::Inline("ConnectionId".to_string()), - }); - out.push(NbfxToken::Text(NbfxText::Chars(format_uuid( - &v.connection_id, - )))); - out.push(NbfxToken::EndElement); - - // n - out.push(NbfxToken::Element { - prefix: None, - name: NbfxName::Inline("MessageNumber".to_string()), - }); - out.push(NbfxToken::Text(NbfxText::Chars( - v.message_number.to_string(), - ))); - out.push(NbfxToken::EndElement); - - // base64 - out.push(NbfxToken::Element { - prefix: None, - name: NbfxName::Inline("MessageAuthenticationCode".to_string()), - }); - out.push(NbfxToken::Text(NbfxText::Chars(v.mac_base64.clone()))); - out.push(NbfxToken::EndElement); - - // base64 - out.push(NbfxToken::Element { - prefix: None, - name: NbfxName::Inline("SignatureInitializationVector".to_string()), - }); - out.push(NbfxToken::Text(NbfxText::Chars(v.iv_base64.clone()))); - out.push(NbfxToken::EndElement); + let dc_ns = "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract"; + push_dc_field( + out, + "connectionIdField", + dc_ns, + &format_uuid(&v.connection_id), + ); + push_dc_field(out, "messageAuthenticationCodeField", dc_ns, &v.mac_base64); + push_dc_field( + out, + "messageNumberField", + dc_ns, + &v.message_number.to_string(), + ); + push_dc_field( + out, + "signatureInitializationVectorField", + dc_ns, + &v.iv_base64, + ); out.push(NbfxToken::EndElement); // } +/// Emit a `<{name} xmlns="{dc_ns}">{value}` element. When +/// `value` is empty we emit just `<{name} xmlns="{dc_ns}"/>` to match +/// .NET's WCF DataContractSerializer output for null/empty byte arrays. +fn push_dc_field(out: &mut Vec, name: &str, dc_ns: &str, value: &str) { + out.push(NbfxToken::Element { + prefix: None, + name: NbfxName::Inline(name.to_string()), + }); + out.push(NbfxToken::DefaultNamespace { + value: NbfxText::Chars(dc_ns.to_string()), + }); + if !value.is_empty() { + out.push(NbfxToken::Text(NbfxText::Chars(value.to_string()))); + } + out.push(NbfxToken::EndElement); +} + +/// Random RFC 4122 v4-shaped UUID (without pulling the `uuid` crate). +/// Used by `encode_envelope` for the `urn:uuid:...` +/// header. The output is a hyphenated lowercase 36-char string. +fn make_random_uuid_v4() -> String { + use rand::RngCore; + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes[6] = (bytes[6] & 0x0F) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3F) | 0x80; // variant 1 (RFC 4122) + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15], + ) +} + fn decode_validator( tokens: &[NbfxToken], start: usize, @@ -379,15 +488,23 @@ fn decode_validator( child_idx += 1; } child_idx = skip_until_end(tokens, child_idx); + // Accept both the PascalCase form (legacy) and the + // DataContract field-name form (`fooField` — what + // .NET WCF emits per the captured `--dump-messages` + // output, which is what we now produce on encode). match local.as_str() { - "ConnectionId" => { + "ConnectionId" | "connectionIdField" => { connection_id = parse_uuid(&value).ok(); } - "MessageNumber" => { + "MessageNumber" | "messageNumberField" => { message_number = value.parse::().ok(); } - "MessageAuthenticationCode" => mac_b64 = Some(value), - "SignatureInitializationVector" => iv_b64 = Some(value), + "MessageAuthenticationCode" | "messageAuthenticationCodeField" => { + mac_b64 = Some(value); + } + "SignatureInitializationVector" | "signatureInitializationVectorField" => { + iv_b64 = Some(value); + } _ => {} } idx = child_idx; diff --git a/rust/crates/mxaccess-asb/src/operations.rs b/rust/crates/mxaccess-asb/src/operations.rs index e41dfbe..4bd0d68 100644 --- a/rust/crates/mxaccess-asb/src/operations.rs +++ b/rust/crates/mxaccess-asb/src/operations.rs @@ -517,7 +517,7 @@ pub struct DeleteMonitoredItemsResponse { pub fn decode_delete_monitored_items_response( body_tokens: &[NbfxToken], ) -> Result { - let payload = collect_asbidata_payloads(body_tokens, "Status") + let payload = collect_asbidata_payloads(body_tokens) .into_iter() .next() .ok_or(OperationError::MissingField { field: "Status" })?; @@ -625,7 +625,7 @@ pub struct WriteResponse { } pub fn decode_write_response(body_tokens: &[NbfxToken]) -> Result { - let payload = collect_asbidata_payloads(body_tokens, "Status") + let payload = collect_asbidata_payloads(body_tokens) .into_iter() .next() .ok_or(OperationError::MissingField { field: "Status" })?; @@ -843,7 +843,7 @@ pub struct AddMonitoredItemsResponse { pub fn decode_add_monitored_items_response( body_tokens: &[NbfxToken], ) -> Result { - let payloads = collect_asbidata_payloads(body_tokens, "Status"); + let payloads = collect_asbidata_payloads(body_tokens); let status_payload = payloads .into_iter() .next() @@ -868,17 +868,14 @@ pub struct PublishResponse { pub fn decode_publish_response( body_tokens: &[NbfxToken], ) -> Result { - let status_payload = collect_asbidata_payloads(body_tokens, "Status") - .into_iter() - .next() + let payloads = collect_asbidata_payloads(body_tokens); + let status_payload = payloads + .first() .ok_or(OperationError::MissingField { field: "Status" })?; - let status = decode_item_status_array(&status_payload)?; + let status = decode_item_status_array(status_payload)?; - let values = match collect_asbidata_payloads(body_tokens, "Values") - .into_iter() - .next() - { - Some(payload) => decode_monitored_item_value_array(&payload)?, + let values = match payloads.get(1) { + Some(payload) => decode_monitored_item_value_array(payload)?, None => Vec::new(), }; Ok(PublishResponse { status, values }) @@ -980,17 +977,14 @@ pub struct ReadResponse { /// [`crate::decode_envelope`]. Both `Status` and `Values` arrive as /// `` payloads; we decode the binary form of each. pub fn decode_read_response(body_tokens: &[NbfxToken]) -> Result { - let status_payload = collect_asbidata_payloads(body_tokens, "Status") - .into_iter() - .next() + let payloads = collect_asbidata_payloads(body_tokens); + let status_payload = payloads + .first() .ok_or(OperationError::MissingField { field: "Status" })?; - let status = decode_item_status_array(&status_payload)?; + let status = decode_item_status_array(status_payload)?; - let values = match collect_asbidata_payloads(body_tokens, "Values") - .into_iter() - .next() - { - Some(payload) => decode_runtime_value_array(&payload)?, + let values = match payloads.get(1) { + Some(payload) => decode_runtime_value_array(payload)?, None => Vec::new(), }; @@ -1059,7 +1053,7 @@ pub struct UnregisterItemsResponse { pub fn decode_register_items_response( body_tokens: &[NbfxToken], ) -> Result { - let payloads = collect_asbidata_payloads(body_tokens, "Status"); + let payloads = collect_asbidata_payloads(body_tokens); let status_payload = payloads .into_iter() .next() @@ -1076,7 +1070,7 @@ pub fn decode_register_items_response( pub fn decode_unregister_items_response( body_tokens: &[NbfxToken], ) -> Result { - let payloads = collect_asbidata_payloads(body_tokens, "Status"); + let payloads = collect_asbidata_payloads(body_tokens); let status_payload = payloads .into_iter() .next() @@ -1087,25 +1081,32 @@ pub fn decode_unregister_items_response( /// Walk a SOAP body's NBFX token stream and pull out the /// `{Bytes}` payload bytes for any element named -/// `field_name`. Returns `Vec>` because some response shapes -/// have multiple ASBIData payloads (e.g. `ReadResponse` has both -/// `Status` and `Values`). +/// outer wrapper element. Returns `Vec>` ordered by +/// declaration position — for shapes with multiple binary fields +/// (e.g. `ReadResponse` has both `Status` and `Values`), the caller +/// indexes positionally. /// -/// Operates on token windows rather than tracking element depth — the -/// response shapes are shallow enough that name-keyed scanning is -/// reliable. Returns whichever payloads it finds; missing fields -/// surface as an empty `Vec`. -pub fn collect_asbidata_payloads(tokens: &[NbfxToken], field_name: &str) -> Vec> { +/// `[F25 step 11 fix]` Previously this took a `field_name` parameter +/// and looked for `<{name}>{Bytes}`. +/// .NET's `AsbDataCustomSerializer.WriteStartObject` actually +/// REPLACES the field's outer element with `` directly +/// (`AsbContracts.cs:1561-1572`), so the wrapper element doesn't +/// exist on the wire — confirmed via `MxAsbClient.Probe +/// --dump-messages`. The function now returns all payloads in +/// declaration order; callers use `payloads[0]`, `payloads.get(1)` +/// etc. +pub fn collect_asbidata_payloads(tokens: &[NbfxToken]) -> Vec> { let mut out = Vec::new(); let mut idx = 0; - while idx < tokens.len() { - if let Some(NbfxToken::Element { + while let Some(tok) = tokens.get(idx) { + if let NbfxToken::Element { name: NbfxName::Inline(local), .. - }) = tokens.get(idx) + } = tok { - if local == field_name { - // Skip attributes / namespace decls. + if local == "ASBIData" { + // Skip attributes / namespace decls between Element + // and Text. let mut inner = idx + 1; while matches!( tokens.get(inner), @@ -1115,18 +1116,8 @@ pub fn collect_asbidata_payloads(tokens: &[NbfxToken], field_name: &str) -> Vec< ) { inner += 1; } - if let Some(NbfxToken::Element { - name: NbfxName::Inline(asbidata), - .. - }) = tokens.get(inner) - { - if asbidata == "ASBIData" { - if let Some(NbfxToken::Text(NbfxText::Bytes(payload))) = - tokens.get(inner + 1) - { - out.push(payload.clone()); - } - } + if let Some(NbfxToken::Text(NbfxText::Bytes(payload))) = tokens.get(inner) { + out.push(payload.clone()); } } } @@ -1170,7 +1161,7 @@ pub fn build_unregister_items_request_body(items: &[ItemIdentity]) -> Vec` with base64-binary content (NBFX - /// represents that as `Bytes` text records). + /// `` element with binary content (NBFX `Bytes` record). + /// `name` is the .NET XmlElement attribute name (e.g. "Items", + /// "Values") — kept for self-documentation but ignored on the + /// wire because WCF's AsbDataCustomSerializer.WriteStartObject + /// replaces the field's outer element with `` directly. AsbiDataElement { name: &'static str, payload: Vec, @@ -1243,18 +1237,21 @@ fn asbidata_request_body(outer: &str, fields: &[BodyField]) -> Vec { tokens.push(NbfxToken::Text(NbfxText::Int64(*value))); tokens.push(NbfxToken::EndElement); } - BodyField::AsbiDataElement { name, payload } => { - tokens.push(NbfxToken::Element { - prefix: None, - name: NbfxName::Inline((*name).to_string()), - }); + BodyField::AsbiDataElement { name: _, payload } => { + // WCF's AsbDataCustomSerializer.WriteStartObject + // (`AsbContracts.cs:1561-1572`) REPLACES the field's + // outer element with `` rather than nesting + // inside it. The `name` parameter (e.g. "Items", + // "Values") is ignored on the wire — the .NET + // XmlElement attribute name is overridden by the + // custom serializer. Verified via .NET probe + // `--dump-messages` output. tokens.push(NbfxToken::Element { prefix: None, name: NbfxName::Inline("ASBIData".to_string()), }); tokens.push(NbfxToken::Text(NbfxText::Bytes(payload.clone()))); tokens.push(NbfxToken::EndElement); // - tokens.push(NbfxToken::EndElement); // } } } @@ -1533,11 +1530,11 @@ mod tests { }, NbfxToken::EndElement, ]; - assert!(collect_asbidata_payloads(&body, "Status").is_empty()); + assert!(collect_asbidata_payloads(&body).is_empty()); } #[test] - fn collect_asbidata_payloads_handles_multiple_fields() { + fn collect_asbidata_payloads_handles_multiple_fields_positionally() { let body = asbidata_request_body( "ReadResponse", &[ @@ -1545,10 +1542,8 @@ mod tests { BodyField::asbidata("Values", vec![4, 5, 6, 7]), ], ); - let status = collect_asbidata_payloads(&body, "Status"); - let values = collect_asbidata_payloads(&body, "Values"); - assert_eq!(status, vec![vec![1u8, 2, 3]]); - assert_eq!(values, vec![vec![4u8, 5, 6, 7]]); + let payloads = collect_asbidata_payloads(&body); + assert_eq!(payloads, vec![vec![1u8, 2, 3], vec![4u8, 5, 6, 7]]); } #[test] diff --git a/rust/crates/mxaccess/examples/asb-preamble-probe.rs b/rust/crates/mxaccess/examples/asb-preamble-probe.rs new file mode 100644 index 0000000..d41763b --- /dev/null +++ b/rust/crates/mxaccess/examples/asb-preamble-probe.rs @@ -0,0 +1,127 @@ +//! `asb-preamble-probe` — diagnostic for the F20 NMF preamble vs what +//! AVEVA's NetTcpPortSharing (SMSvcHost) actually accepts. +//! +//! Connects to the configured ASB endpoint, sends the canonical +//! preamble that F25's `AsbClient::send_preamble` would send, then +//! reads up to 256 bytes from the peer and prints both sides as +//! hex. Useful for diffing against a Wireshark / pktmon capture of +//! the .NET reference probe. + +use std::time::Duration; + +use mxaccess_asb::{SoapEnvelope, actions, build_connect_request_body, encode_envelope}; +use mxaccess_asb_nettcp::nbfx::DynamicDictionary; +use mxaccess_asb_nettcp::nmf::{self, NmfRecord}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let host = std::env::var("MX_ASB_HOST")?; + let via = std::env::var("MX_ASB_VIA")?; + let addr = parse_host_port(&host, 808)?; + eprintln!("connecting {addr} (via={via})"); + + let mut stream = TcpStream::connect(addr).await?; + let mut buf = Vec::new(); + nmf::encode_preamble(&via, &mut buf)?; + eprintln!("sending {} preamble bytes:", buf.len()); + print_hex("OUT", &buf); + + stream.write_all(&buf).await?; + stream.flush().await?; + + // Read the PreambleAck (single 0x0b byte) before pushing further. + let mut ack = [0u8; 1]; + tokio::time::timeout(Duration::from_secs(5), stream.read_exact(&mut ack)).await??; + eprintln!("preamble-ack byte: 0x{:02x}", ack[0]); + if ack[0] != 0x0b { + eprintln!("expected PreambleAck (0x0b); aborting"); + return Ok(()); + } + + // Build a synthetic ConnectRequest with a placeholder public key + // (32 bytes, not a real DH public key — SMSvcHost dispatches by + // the wire URL but WCF inside it will eventually decode the + // envelope and may reject the body. That rejection is what we + // want to observe.) + let connection_id = [0xAAu8; 16]; + let public_key = vec![0xBBu8; 32]; + let body = build_connect_request_body(connection_id, &public_key); + let envelope = SoapEnvelope::new(actions::CONNECT).with_body_tokens(body); + let _ = via.clone(); // keep $via in scope for the eprintln above + let mut dynamic = DynamicDictionary::new(); + let payload = encode_envelope(&envelope, &mut dynamic)?; + eprintln!("envelope NBFX bytes: {}", payload.len()); + print_hex("ENV", &payload); + + let mut framed = Vec::new(); + NmfRecord::SizedEnvelope(payload).encode_into(&mut framed)?; + eprintln!( + "framed SizedEnvelope: {} bytes (1 type + varint len + body)", + framed.len() + ); + print_hex("OUT", &framed); + stream.write_all(&framed).await?; + stream.flush().await?; + + // Read up to 4096 bytes back — Fault would be small, ConnectResponse + // would be larger (~200-400 bytes typically). + let mut reply = vec![0u8; 4096]; + let read = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut reply)).await; + match read { + Ok(Ok(0)) => eprintln!("peer closed cleanly (0 bytes)"), + Ok(Ok(n)) => { + eprintln!("got {n} bytes back:"); + if let Some(slice) = reply.get(..n) { + print_hex("IN ", slice); + } + // First byte tells us the record type. + match reply.first().copied().unwrap_or(0) { + 0x06 => eprintln!("response = SizedEnvelope (good — WCF accepted the request)"), + 0x07 => eprintln!("response = End (peer drained cleanly)"), + 0x08 => eprintln!("response = Fault (peer rejected; check the message text)"), + other => eprintln!("response = unknown record type 0x{other:02x}"), + } + } + Ok(Err(e)) => eprintln!("read error: {e}"), + Err(_) => eprintln!("read timed out after 10s"), + } + Ok(()) +} + +fn print_hex(tag: &str, bytes: &[u8]) { + for chunk in bytes.chunks(16) { + let hex: Vec = chunk.iter().map(|b| format!("{b:02x}")).collect(); + let ascii: String = chunk + .iter() + .map(|b| { + if b.is_ascii_graphic() || *b == b' ' { + *b as char + } else { + '.' + } + }) + .collect(); + eprintln!("{tag} {:<48} {}", hex.join(" "), ascii); + } +} + +fn parse_host_port( + s: &str, + default_port: u16, +) -> Result> { + if let Ok(addr) = s.parse() { + return Ok(addr); + } + let with_port = if s.contains(':') { + s.to_string() + } else { + format!("{s}:{default_port}") + }; + Ok( + std::net::ToSocketAddrs::to_socket_addrs(&with_port.as_str())? + .next() + .ok_or("no addrs resolved")?, + ) +} diff --git a/rust/crates/mxaccess/examples/asb-subscribe.rs b/rust/crates/mxaccess/examples/asb-subscribe.rs index fb1a557..28f2fe2 100644 --- a/rust/crates/mxaccess/examples/asb-subscribe.rs +++ b/rust/crates/mxaccess/examples/asb-subscribe.rs @@ -20,7 +20,9 @@ //! Populate via `tools/Setup-LiveProbeEnv.ps1` (dot-source it): //! //! - `MX_LIVE` (any non-empty value enables the live path) -//! - `MX_ASB_HOST` — ASB endpoint host[:port]; defaults port 5074 if omitted +//! - `MX_ASB_HOST` — ASB endpoint host[:port]; defaults port 808 if omitted +//! (the WCF `NetTcpPortSharing` SMSvcHost listener — confirmed via the +//! .NET probe's working endpoint at `src/MxAsbClient.Probe/Program.cs:5`) //! - `MX_ASB_PASSPHRASE` — solution shared secret (typically read from //! DPAPI on a real install; for CI / dev set directly via Infisical //! per `tools/Setup-LiveProbeEnv.ps1`) @@ -109,7 +111,7 @@ impl LiveEnv { return Ok(None); } let host = std::env::var("MX_ASB_HOST")?; - let addr = parse_host_port(&host, 5074)?; + let addr = parse_host_port(&host, 808)?; let passphrase = std::env::var("MX_ASB_PASSPHRASE") .map_err(|_| "MX_ASB_PASSPHRASE not set — ASB requires the solution shared secret")?; let via_uri = diff --git a/tools/Get-AsbPassphrase.ps1 b/tools/Get-AsbPassphrase.ps1 index 6b2fb55..03b85d2 100644 --- a/tools/Get-AsbPassphrase.ps1 +++ b/tools/Get-AsbPassphrase.ps1 @@ -134,7 +134,14 @@ Set-LiveEnvVar -Name 'MX_ASB_HOST' -Value $AsbHost # .NET probe at `src/MxAsbClient.Probe/Program.cs:5` hardcodes the # MxDataProvider segment because that's what serves IASBIDataV2. $mxDataProvider = "Default_${GalaxyName}_MxDataProvider" -$via = "net.tcp://$AsbHost/ASBService/$mxDataProvider/IDataV2" +# Lowercase the host segment of the URL — WCF's NetTcpPortSharing +# SMSvcHost matches the registered service URL case-sensitively in +# the host part; the .NET probe at `src/MxAsbClient.Probe/Program.cs:5` +# hardcodes the lowercase form (`desktop-6jl3kko`) which is what +# AVEVA actually registered. We keep $AsbHost as-cased for TCP DNS +# resolution (`MX_ASB_HOST`) but lowercase it for the Via URL. +$viaHost = $AsbHost.ToLowerInvariant() +$via = "net.tcp://$viaHost/ASBService/$mxDataProvider/IDataV2" Set-LiveEnvVar -Name 'MX_ASB_VIA' -Value $via Set-LiveEnvVar -Name 'MX_ASB_SOLUTION' -Value $solution Set-LiveEnvVar -Name 'MX_ASB_GALAXY_NAME' -Value $GalaxyName