[M5] mxaccess-asb: F28 wire-format fixes — AuthenticateMe accepted live
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 `<a:Action>` 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: `<ConnectionId xmlns="http://asb.contracts.data/
20111111">guid</ConnectionId>` (PascalCase property name in
data namespace)
DataContract: `<connectionIdField xmlns="http://schemas.data
contract.org/2004/07/ArchestrAServices.ASBContract">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) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -129,6 +129,45 @@ impl ConnectionValidator {
|
||||
/// pre-built NBFX token stream for the operation-specific element
|
||||
/// (`<RegisterItemsRequest .../>`, etc.); operation-encoder helpers
|
||||
/// (next F25 iteration) produce these.
|
||||
/// Serialization shape for the `<ConnectionValidator>` 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 `<a:To>` 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<String> = 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(
|
||||
// <h:ConnectionValidator …/> (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);
|
||||
}
|
||||
|
||||
// <a:MessageID>{16-byte UUID via UniqueIdText}</a:MessageID>
|
||||
@@ -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<NbfxToken>,
|
||||
v: &ConnectionValidator,
|
||||
_dynamic: &mut DynamicDictionary,
|
||||
fmt: ValidatorWireFormat,
|
||||
) {
|
||||
// <h:ConnectionValidator xmlns:i="http://www.w3.org/2001/XMLSchema-instance"
|
||||
// xmlns:h="http://asb.contracts.headers/20111111">
|
||||
// <connectionIdField xmlns="...ASBContract">guid</connectionIdField>
|
||||
// <messageAuthenticationCodeField xmlns="...ASBContract" /> (empty when no MAC)
|
||||
// <messageNumberField xmlns="...ASBContract">n</messageNumberField>
|
||||
// <signatureInitializationVectorField xmlns="...ASBContract" />
|
||||
// <h:ConnectionValidator xmlns:h="http://asb.contracts.headers/20111111"
|
||||
// ... shape depends on fmt ...>
|
||||
// ... per-format children ...
|
||||
// </h:ConnectionValidator>
|
||||
//
|
||||
// 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); // </h:ConnectionValidator>
|
||||
}
|
||||
|
||||
/// Emit `<{name} xmlns="{ns}">{value}</{name}>` for an XmlSerializer-
|
||||
/// formatted text field. No xmlns:i (no nil semantics).
|
||||
fn push_xml_text_field(out: &mut Vec<NbfxToken>, 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<NbfxToken>, 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}</{name}>` element. When
|
||||
/// `value` is empty we emit just `<{name} xmlns="{dc_ns}"/>` to match
|
||||
/// .NET's WCF DataContractSerializer output for null/empty byte arrays.
|
||||
|
||||
@@ -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 `<a:To>`
|
||||
// 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) =>
|
||||
{
|
||||
|
||||
@@ -10,6 +10,16 @@ public sealed record AsbConnectionOptions
|
||||
|
||||
public bool DumpMessages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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`.
|
||||
/// </summary>
|
||||
public string? Via { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Endpoint))
|
||||
|
||||
@@ -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<IAsbDataV2> factory = new(binding, new EndpointAddress(endpoint));
|
||||
// Optional `ClientVia`: when set, TCP-connect to that URL but
|
||||
// keep the `<a:To>` 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<IAsbDataV2> 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;
|
||||
|
||||
Reference in New Issue
Block a user