[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:
Joseph Doherty
2026-05-05 15:06:48 -04:00
parent 4ebfd8e3a3
commit 3b09297b27
7 changed files with 358 additions and 108 deletions
@@ -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<dyn std::error::Error>> {
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<String> = 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<std::net::SocketAddr, Box<dyn std::error::Error>> {
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")?,
)
}
@@ -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 =