[M5] live-probe iteration 1 — major wire-byte reconciliation fixes
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 `<a:MessageID>` header** — WCF's default binding
requires MessageID for two-way operations. We now auto-generate
`urn:uuid:<v4>` per envelope via a small inline `make_random_uuid_v4`
helper (no `uuid` crate dep).
3. **Missing `<a:ReplyTo>` anonymous header** — WCF's
BinaryMessageEncoder always emits `<a:ReplyTo><a:Address>...
addressing/anonymous</a:Address></a:ReplyTo>` for two-way ops.
4. **ConnectionValidator field names + namespace** — we were
emitting PascalCase `<ConnectionId>` etc. .NET's WCF
DataContractSerializer uses the private backing-field names
(`<connectionIdField xmlns="...ASBContract">guid</connectionIdField>`)
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. **`<ASBIData>` over-wrapping** — we were emitting
`<Items><ASBIData>{bytes}</ASBIData></Items>`. .NET's
`AsbDataCustomSerializer.WriteStartObject` (`AsbContracts.cs:
1561-1572`) REPLACES the field's outer element with `<ASBIData>`
directly — there's no `<Items>` wrapper on the wire. Fixed by
collapsing `BodyField::AsbiDataElement` to emit just `<ASBIData>`
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<a:To>` header value (typically the same
|
||||
/// `net.tcp://...` URL used in the NMF `Via` record). WCF's
|
||||
/// default binding requires `<a:To>` for service dispatch —
|
||||
/// without it the server resets the connection. Set via
|
||||
/// [`Self::with_to`].
|
||||
pub to_uri: Option<String>,
|
||||
pub validator: Option<ConnectionValidator>,
|
||||
pub body_tokens: Vec<NbfxToken>,
|
||||
}
|
||||
@@ -136,11 +146,17 @@ impl SoapEnvelope {
|
||||
pub fn new(action: impl Into<String>) -> Self {
|
||||
Self {
|
||||
action: action.into(),
|
||||
to_uri: None,
|
||||
validator: None,
|
||||
body_tokens: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_to(mut self, to_uri: impl Into<String>) -> 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); // </a:Action>
|
||||
|
||||
// <h:ConnectionValidator …/>
|
||||
// <h:ConnectionValidator …/> (WCF dump shows this comes BEFORE
|
||||
// MessageID/ReplyTo when present)
|
||||
if let Some(v) = &envelope.validator {
|
||||
encode_validator(&mut tokens, v, dynamic);
|
||||
}
|
||||
|
||||
// <a:MessageID>urn:uuid:{uuid}</a:MessageID>
|
||||
// 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); // </a:MessageID>
|
||||
|
||||
// <a:ReplyTo>
|
||||
// <a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
|
||||
// </a:ReplyTo>
|
||||
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); // </a:Address>
|
||||
tokens.push(NbfxToken::EndElement); // </a:ReplyTo>
|
||||
|
||||
// <a:To s:mustUnderstand="1">{to_uri}</a:To> (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); // </s:Header>
|
||||
|
||||
// <s:Body>
|
||||
@@ -299,55 +359,104 @@ fn encode_validator(
|
||||
v: &ConnectionValidator,
|
||||
_dynamic: &mut DynamicDictionary,
|
||||
) {
|
||||
// <h:ConnectionValidator xmlns:h="http://asb.contracts.headers/20111111">
|
||||
// <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>
|
||||
//
|
||||
// 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()),
|
||||
});
|
||||
|
||||
// <ConnectionId>guid-text</ConnectionId>
|
||||
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);
|
||||
|
||||
// <MessageNumber>n</MessageNumber>
|
||||
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);
|
||||
|
||||
// <MessageAuthenticationCode>base64</MessageAuthenticationCode>
|
||||
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);
|
||||
|
||||
// <SignatureInitializationVector>base64</SignatureInitializationVector>
|
||||
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); // </h:ConnectionValidator>
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn push_dc_field(out: &mut Vec<NbfxToken>, 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 `<a:MessageID>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::<u64>().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;
|
||||
|
||||
Reference in New Issue
Block a user