[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:
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user