From 104efc4e9baba5caa8ac1fb73f19bbc832385180 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 19:29:48 -0400 Subject: [PATCH] =?UTF-8?q?[M5]=20mxaccess-asb:=20F28=20wire-format=20fixe?= =?UTF-8?q?s=20=E2=80=94=20AuthenticateMe=20accepted=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three wire-level bugs surfaced by side-by-side relay capture against the .NET probe routed via the new --via flag: 1. **Dynamic-dictionary id drift**. Our `encode_envelope` hardcoded action_dict_id=1 / to_dict_id=3, which is correct for the FIRST message in a session but wrong for every subsequent one. The per-session dynamic dict accumulates across messages: Connect's binary header pre-pops [action,to] at ids 1,3; AuthenticateMe must reference the new action at id 5 (continuing the odd sequence) and the To URL at id 3 (still in the dict from Connect). Fix uses `DynamicDictionary::position_of` + `intern` to compute the right wire id, only pre-popping strings that are NEW to the session. Captured against .NET probe via asb-relay: AuthenticateMe binary header has only one string (action) at offset 0x260 (`06 de 08 2f 2e ...`), and `` value `ab 05` references the new id 5. 2. **ConnectionValidator wire format depends on operation**. .NET's `IAsbDataV2` declares `[XmlSerializerFormat]` on AuthenticateMe, Disconnect, KeepAlive (one-way ops) — those use XmlSerializer for the ENTIRE message including the [MessageHeader] ConnectionValid- ator. Other ops use the default DataContractSerializer. The wire shapes differ: XmlSerializer: `guid` (PascalCase property name in data namespace) DataContract: `guid` (private "fooField" name in datacontract namespace) New `ValidatorWireFormat::for_action` picks the right shape per action; `encode_validator` now branches on it. New helpers `push_xml_text_field` / `push_xml_byte_array_field` for the XmlSerializer form. The DataContract form is preserved verbatim for Register/Read/Write/etc. 3. **Decoder missing 0x0A** (`ShortDictionaryXmlnsAttribute`). The server's RegisterItemsResponse uses `0x0A {dict-id}` to set the default namespace from the static dict; our decoder bailed out with `UnknownRecord(10)`. New decode arm produces a `DefaultNamespace` token with `DictionaryStatic` value. **.NET probe gains a `--via` flag** (`AsbConnectionOptions.Via` → `ChannelFactory.CreateChannel(addr, viaUri)`) so the probe can be routed through asb-relay for byte-level capture without triggering an `AddressFilterMismatch` fault. CoreWCF / .NET 10 dropped `ClientViaBehavior`; the `CreateChannel(addr, via)` overload is the modern equivalent. Live status (this commit): Connect handshake works, AuthenticateMe no longer faults (canonical XML + crypto + wire-format all match .NET now), RegisterItemsResponse comes back from the server (a real response, not a dispatcher fault). One remaining issue: our response decoder hits `MissingField { field: "Status" }` — the server's RegisterItemsResponse uses a slightly different element naming or encoding than `collect_asbidata_payloads` expects. Next iteration hunts that. Workspace: 710 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- rust/crates/mxaccess-asb-nettcp/src/nbfx.rs | 10 + rust/crates/mxaccess-asb/src/envelope.rs | 230 +++++++++++++++----- src/MxAsbClient.Probe/Program.cs | 23 +- src/MxAsbClient/AsbConnectionOptions.cs | 10 + src/MxAsbClient/MxAsbDataClient.cs | 18 +- 5 files changed, 236 insertions(+), 55 deletions(-) diff --git a/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs b/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs index d270b27..0028902 100644 --- a/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs +++ b/rust/crates/mxaccess-asb-nettcp/src/nbfx.rs @@ -759,6 +759,16 @@ pub fn decode_tokens( value: NbfxText::DictionaryStatic(id), }); } + // 0x0A — no-prefix (default xmlns) variant of 0x0B. Sets + // the default namespace to a dict-resolved string. WCF + // emits this on the response side when the default ns is + // a well-known string (e.g. urn:invensys.schemas). + REC_SHORT_DICT_XMLNS_ATTRIBUTE => { + let id = decode_int31(input, &mut cursor)?; + tokens.push(NbfxToken::DefaultNamespace { + value: NbfxText::DictionaryStatic(id), + }); + } // Text records — directly produce a Text token, plus an // implicit EndElement when the `*WithEndElement` variant was // used (record byte LSB = 1). diff --git a/rust/crates/mxaccess-asb/src/envelope.rs b/rust/crates/mxaccess-asb/src/envelope.rs index 531316d..252f3ae 100644 --- a/rust/crates/mxaccess-asb/src/envelope.rs +++ b/rust/crates/mxaccess-asb/src/envelope.rs @@ -129,6 +129,45 @@ impl ConnectionValidator { /// pre-built NBFX token stream for the operation-specific element /// (``, etc.); operation-encoder helpers /// (next F25 iteration) produce these. +/// Serialization shape for the `` SOAP header. +/// .NET picks one based on the operation's `[XmlSerializerFormat]` +/// attribute (set on `AuthenticateMe`/`Disconnect`/`KeepAlive` and +/// nothing else); the Rust port must match. +/// +/// * `XmlSerializer` — public-property names (`ConnectionId`, +/// `MessageNumber`, `MessageAuthenticationCode`, +/// `SignatureInitializationVector`) in the +/// `http://asb.contracts.data/20111111` namespace (per the +/// `[XmlType]` on the class). +/// * `DataContract` — private-field names (`connectionIdField`, +/// `messageAuthenticationCodeField`, `messageNumberField`, +/// `signatureInitializationVectorField`) in the +/// `http://schemas.datacontract.org/2004/07/ArchestrAServices +/// .ASBContract` namespace (per the `[DataContract]` on the class). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValidatorWireFormat { + XmlSerializer, + DataContract, +} + +impl ValidatorWireFormat { + /// Pick the wire format based on the SOAP action. Mirrors + /// `IAsbDataV2`'s `[XmlSerializerFormat]` attribute distribution + /// (`AsbContracts.cs:14-58`): authenticateMeIn, disconnectIn, + /// keepAliveIn carry the attribute; everything else uses the + /// default DataContract format. + pub fn for_action(action: &str) -> Self { + if action.ends_with(":authenticateMeIn") + || action.ends_with(":disconnectIn") + || action.ends_with(":keepAliveIn") + { + Self::XmlSerializer + } else { + Self::DataContract + } + } +} + #[derive(Debug, Clone, PartialEq)] pub struct SoapEnvelope { pub action: String, @@ -211,15 +250,42 @@ pub fn encode_envelope( // ---- Binary header block ---- // // Pre-populate the per-session dynamic dictionary with strings the - // NBFX envelope below references. Each string gets an odd dict id - // (`1, 3, 5, ...` — even ids are reserved for [MC-NBFS] static). - // We always include action + to even when `to_uri` is None: in - // that case we use an empty string for the To slot. WCF's default - // service dispatcher requires both populated for net.tcp. + // NBFX envelope below references. The dynamic dict is *cumulative* + // per session — each `intern` returns a 0-based slot whose wire + // dict id is `slot * 2 + 1` (odd ids only; even ids are reserved + // for `[MC-NBFS]` static). + // + // The binary message header pre-pops only **new** strings (those + // not already in the dict), in order. Strings interned by an + // earlier message stay in the dict at their original wire id and + // can be referenced by later messages without being re-popped. + // + // Captured against `MxAsbClient.Probe --via …` through `asb-relay`: + // Connect's binary header pre-pops `[action, to]` (ids 1, 3). + // AuthenticateMe's binary header only pre-pops the new action + // (id 5); the `` element references id 3, still in the dict + // from Connect's pre-pop. Earlier `encode_envelope` versions + // hardcoded action_dict_id=1 / to_dict_id=3 every message — + // wrong for any non-first message in a session, because id 1 / + // id 3 then resolved to whatever the *first* message put there. let to_uri = envelope.to_uri.clone().unwrap_or_default(); - let action_dict_id: u32 = 1; - let to_dict_id: u32 = 3; - let header_strings = [envelope.action.as_str(), to_uri.as_str()]; + let mut header_strings: Vec = Vec::with_capacity(2); + let action_dict_id: u32 = match dynamic.position_of(&envelope.action) { + Some(slot) => slot * 2 + 1, + None => { + let slot = dynamic.intern(&envelope.action); + header_strings.push(envelope.action.clone()); + slot * 2 + 1 + } + }; + let to_dict_id: u32 = match dynamic.position_of(&to_uri) { + Some(slot) => slot * 2 + 1, + None => { + let slot = dynamic.intern(&to_uri); + header_strings.push(to_uri.clone()); + slot * 2 + 1 + } + }; // ---- NBFX envelope tokens ---- let mut tokens = vec![ @@ -257,7 +323,8 @@ pub fn encode_envelope( // (when present, comes before // MessageID/ReplyTo per the .NET dump's element order) if let Some(v) = &envelope.validator { - encode_validator(&mut tokens, v, dynamic); + let fmt = ValidatorWireFormat::for_action(&envelope.action); + encode_validator(&mut tokens, v, dynamic, fmt); } // {16-byte UUID via UniqueIdText} @@ -327,7 +394,8 @@ pub fn encode_envelope( let mut nbfx_bytes = Vec::with_capacity(estimate_envelope_size(envelope)); encode_tokens(&tokens, dynamic, &mut nbfx_bytes)?; - let header_bytes = encode_binary_header(&header_strings)?; + let header_strs: Vec<&str> = header_strings.iter().map(String::as_str).collect(); + let header_bytes = encode_binary_header(&header_strs)?; let mut out = Vec::with_capacity(header_bytes.len() + nbfx_bytes.len()); out.extend_from_slice(&header_bytes); @@ -534,58 +602,118 @@ fn encode_validator( out: &mut Vec, v: &ConnectionValidator, _dynamic: &mut DynamicDictionary, + fmt: ValidatorWireFormat, ) { - // - // guid - // (empty when no MAC) - // n - // + // + // ... per-format children ... // - // - // 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()), - }); - 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, - ); + match fmt { + ValidatorWireFormat::DataContract => { + // DataContractSerializer form: private "fooField" names in + // the ASBContract namespace, plus an `xmlns:i` declaration + // for the `i:nil="true"` attribute that WCF emits on empty + // byte[] members. Captured via .NET probe wire dump. + 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()), + }); + 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, + ); + } + ValidatorWireFormat::XmlSerializer => { + // XmlSerializer form: public property names (PascalCase) in + // the data namespace from `[XmlType]` on the class. No + // `xmlns:i` declaration here — XmlSerializer doesn't emit + // `xsi:nil` for empty byte[] (it uses self-closing + // elements instead, but byte[] in the wire format actually + // shows a Chars text record holding the base64-encoded + // empty content per the .NET probe capture; see + // push_xml_byte_array_field). Captured via + // `MxAsbClient.Probe --via …` for AuthenticateMe. + out.push(NbfxToken::NamespaceDeclaration { + prefix: "h".to_string(), + value: NbfxText::Chars(asb_ns::HEADERS.to_string()), + }); + let data_ns = "http://asb.contracts.data/20111111"; + push_xml_text_field(out, "ConnectionId", data_ns, &format_uuid(&v.connection_id)); + push_xml_text_field( + out, + "MessageNumber", + data_ns, + &v.message_number.to_string(), + ); + push_xml_byte_array_field(out, "MessageAuthenticationCode", data_ns, &v.mac_base64); + push_xml_byte_array_field( + out, + "SignatureInitializationVector", + data_ns, + &v.iv_base64, + ); + } + } out.push(NbfxToken::EndElement); // } +/// Emit `<{name} xmlns="{ns}">{value}` for an XmlSerializer- +/// formatted text field. No xmlns:i (no nil semantics). +fn push_xml_text_field(out: &mut Vec, name: &str, ns: &str, value: &str) { + out.push(NbfxToken::Element { + prefix: None, + name: NbfxName::Inline(name.to_string()), + }); + out.push(NbfxToken::DefaultNamespace { + value: NbfxText::Chars(ns.to_string()), + }); + out.push(NbfxToken::Text(NbfxText::Chars(value.to_string()))); + out.push(NbfxToken::EndElement); +} + +/// Emit a `byte[]` field in XmlSerializer format. Empty value → +/// `<{name} xmlns="{ns}" />` (self-closing, NO xsi:nil — that's the +/// DataContract behaviour). Non-empty → `<{name} xmlns="{ns}">b64`. +fn push_xml_byte_array_field(out: &mut Vec, name: &str, ns: &str, b64: &str) { + out.push(NbfxToken::Element { + prefix: None, + name: NbfxName::Inline(name.to_string()), + }); + out.push(NbfxToken::DefaultNamespace { + value: NbfxText::Chars(ns.to_string()), + }); + if !b64.is_empty() { + out.push(NbfxToken::Text(NbfxText::Chars(b64.to_string()))); + } + 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. diff --git a/src/MxAsbClient.Probe/Program.cs b/src/MxAsbClient.Probe/Program.cs index 2b89abd..f3b1632 100644 --- a/src/MxAsbClient.Probe/Program.cs +++ b/src/MxAsbClient.Probe/Program.cs @@ -3,6 +3,11 @@ using System.Globalization; string endpoint = GetArg(args, "--endpoint") ?? "net.tcp://desktop-6jl3kko/ASBService/Default_ZB_MxDataProvider/IDataV2"; +// `--via` overrides the TCP destination without changing the `` +// SOAP header (so the registered service URL still matches inside +// SMSvcHost). Use to route the probe through `asb-relay` for wire +// byte capture, e.g. `--via net.tcp://127.0.0.1:8088/...`. +string? clientVia = GetArg(args, "--via"); string[] tags = GetArgs(args, "--tag"); if (tags.Length == 0) { @@ -300,7 +305,14 @@ if (probeConnectFailure) { try { - using MxAsbDataClient connectFailureClient = MxAsbDataClient.Connect(endpoint, solution, Console.WriteLine, dumpMessages); + using MxAsbDataClient connectFailureClient = MxAsbDataClient.Connect(new AsbConnectionOptions +{ + Endpoint = endpoint, + SolutionName = solution, + Trace = Console.WriteLine, + DumpMessages = dumpMessages, + Via = clientVia, +}); Console.WriteLine("connect_failure_observed=False"); } catch (Exception ex) @@ -322,7 +334,14 @@ if (compatibilitySubscribe) return; } -using MxAsbDataClient client = MxAsbDataClient.Connect(endpoint, solution, Console.WriteLine, dumpMessages); +using MxAsbDataClient client = MxAsbDataClient.Connect(new AsbConnectionOptions +{ + Endpoint = endpoint, + SolutionName = solution, + Trace = Console.WriteLine, + DumpMessages = dumpMessages, + Via = clientVia, +}); int publishedEventCount = 0; client.PublishedValueReceived += (_, value) => { diff --git a/src/MxAsbClient/AsbConnectionOptions.cs b/src/MxAsbClient/AsbConnectionOptions.cs index 06f00fe..420bbf2 100644 --- a/src/MxAsbClient/AsbConnectionOptions.cs +++ b/src/MxAsbClient/AsbConnectionOptions.cs @@ -10,6 +10,16 @@ public sealed record AsbConnectionOptions public bool DumpMessages { get; init; } + /// + /// Optional `ClientVia` URL — if set, WCF will TCP-connect to this + /// URL but address messages with the `Endpoint` URL (so the + /// `<a:To>` SOAP header still matches the registered service). + /// Used to route the .NET probe through the `asb-relay` middleman + /// without triggering an `AddressFilterMismatch` fault. Mirrors + /// `System.ServiceModel.Description.ClientViaBehavior`. + /// + public string? Via { get; init; } + public void Validate() { if (string.IsNullOrWhiteSpace(Endpoint)) diff --git a/src/MxAsbClient/MxAsbDataClient.cs b/src/MxAsbClient/MxAsbDataClient.cs index d116f43..a6b3f74 100644 --- a/src/MxAsbClient/MxAsbDataClient.cs +++ b/src/MxAsbClient/MxAsbDataClient.cs @@ -68,7 +68,19 @@ public sealed class MxAsbDataClient : IDisposable AsbSystemAuthenticator authenticator = new(passphrase, cryptoParameters, trace); trace?.Invoke("asb.stage=authenticator-ready"); NetTcpBinding binding = CreateBinding(); - ChannelFactory factory = new(binding, new EndpointAddress(endpoint)); + // Optional `ClientVia`: when set, TCP-connect to that URL but + // keep the `` header pointing at the registered Endpoint. + // Used to route through `asb-relay` for byte-level capture + // without triggering an `AddressFilterMismatch` fault. CoreWCF / + // .NET 10 dropped `ClientViaBehavior`; the equivalent is to + // pass the Via URL through to `CreateChannel(addr, viaUri)`. + EndpointAddress endpointAddress = new(endpoint); + ChannelFactory factory = new(binding, endpointAddress); + Uri? viaUri = string.IsNullOrWhiteSpace(options.Via) ? null : new Uri(options.Via); + if (viaUri is not null) + { + trace?.Invoke($"asb.client_via={viaUri}"); + } AsbDataCustomSerializer.Trace = dumpMessages ? trace : null; int replacedSerializers = AsbCustomSerializerContractBehavior.ReplaceSerializer(factory.Endpoint.Contract); trace?.Invoke($"asb.serializer.behaviors-replaced={replacedSerializers}"); @@ -83,7 +95,9 @@ public sealed class MxAsbDataClient : IDisposable { trace?.Invoke("asb.stage=open-factory"); factory.Open(); - channel = factory.CreateChannel(); + channel = viaUri is not null + ? factory.CreateChannel(endpointAddress, viaUri) + : factory.CreateChannel(); trace?.Invoke("asb.stage=open-channel"); clientChannel = (IClientChannel)channel;