[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:
Joseph Doherty
2026-05-05 19:29:48 -04:00
parent ce27b63010
commit 104efc4e9b
5 changed files with 236 additions and 55 deletions
+21 -2
View File
@@ -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
View File
@@ -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
/// `&lt;a:To&gt;` 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))
+16 -2
View File
@@ -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;