From d4ee5f3a18f516232e632b324d2a37728b218be5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 15:27:54 -0400 Subject: [PATCH] [M5] examples: asb-relay TCP middleman for live wire-byte capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listens on MX_RELAY_LISTEN (default 127.0.0.1:8088) and forwards to MX_RELAY_UPSTREAM (default 127.0.0.1:808 — AVEVA's NetTcpPortSharing SMSvcHost listener). Hex-dumps every byte both directions to stderr with C->S / S->C tags + per-direction offset prefixes. Usage: $env:MX_RELAY_LISTEN = '0.0.0.0:8088' .\rust\target\debug\examples\asb-relay.exe 2> relay.log # then in another shell: dotnet run --project src\MxAsbClient.Probe -c Release -- ` '--endpoint=net.tcp://desktop-6jl3kko:8088/ASBService/Default_ZB_MxDataProvider/IDataV2' Tested against the live AVEVA install on this box — captured a 620-byte client→server exchange including the full .NET probe's preamble, SizedEnvelope, and End record. The capture surfaced one critical missing piece in our wire format: **WCF binary message framing prepends Action + To strings out-of-band** before the actual NBFX SOAP envelope. The .NET probe's envelope payload begins: 74 27 [39 bytes "http://asb.contracts/20111111:connectIn"] ← Action 4b [75 bytes "net.tcp://desktop-6jl3kko:8088/.../IDataV2"] ← To 56 02 ... ← The 0x74 / 0x4b prefix bytes appear to be WCF-internal framing that stores Action and To headers OUT of the SOAP envelope as a binary optimization. Our F25 envelope encoder doesn't emit this — it goes straight to `` (which the probe captured as `56 02 ...` PrefixDictionaryElement_s + dict id 2). This is likely why the server fault'd at AddressFilter mismatch in the previous iteration. Note: when going through the relay, the .NET probe's `:8088` port appears in the To URL inside the binary header, which doesn't match the registered service URL on SMSvcHost — so this exact relay setup returns the AddressFilterMismatch fault. The capture is still valuable (we see what bytes WCF emits for our action/header structure). For a fault-free dispatch, we'd need to: * rewrite the binary header's port (0x4b length / URL bytes) at the relay, OR * listen on port 808 directly (requires stopping SMSvcHost), OR * run an admin-elevated Wireshark/Npcap loopback capture. Cleanup: dotnet probe must use `--endpoint=URL` (single arg with `=`), not space-separated; the probe's GetArg helper splits on `=`. Co-Authored-By: Claude Opus 4.7 (1M context) --- rust/crates/mxaccess/examples/asb-relay.rs | 154 +++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 rust/crates/mxaccess/examples/asb-relay.rs diff --git a/rust/crates/mxaccess/examples/asb-relay.rs b/rust/crates/mxaccess/examples/asb-relay.rs new file mode 100644 index 0000000..8f973f5 --- /dev/null +++ b/rust/crates/mxaccess/examples/asb-relay.rs @@ -0,0 +1,154 @@ +//! `asb-relay` — TCP middleman that captures both sides of an ASB +//! exchange. +//! +//! Listens on `MX_RELAY_LISTEN` (default `127.0.0.1:8088`) and forwards +//! every connection to `MX_RELAY_UPSTREAM` (default `127.0.0.1:808`, +//! AVEVA's `NetTcpPortSharing` SMSvcHost listener). All bytes both +//! directions are hex-dumped to stderr with direction + offset +//! prefixes, so you can `> capture.log 2>&1` and diff client-vs-server +//! bytes byte-for-byte. +//! +//! Use with the .NET probe: +//! +//! ```powershell +//! # Terminal A: start the relay +//! cargo run -p mxaccess --example asb-relay 2> .\dotnet.log +//! +//! # Terminal B: point the .NET probe at the relay +//! dotnet run --project src\MxAsbClient.Probe -c Release -- ` +//! --endpoint "net.tcp://desktop-6jl3kko:8088/ASBService/Default_ZB_MxDataProvider/IDataV2" +//! ``` +//! +//! Then run our Rust client through the relay: +//! +//! ```powershell +//! $env:MX_ASB_HOST = "127.0.0.1:8088" +//! cargo run -p mxaccess --example asb-preamble-probe 2> .\rust.log +//! ``` +//! +//! Diff the two logs to find wire-byte deltas. Direction labels are +//! `C->S` (client→server) and `S->C` (server→client). +//! +//! ## Important caveat: SMSvcHost URL matching +//! +//! When the relay forwards to `127.0.0.1:808`, the SMSvcHost dispatcher +//! looks at the NMF `Via` record's URL host segment to pick which +//! registered service to route to. The .NET probe's default URL has +//! the hostname `desktop-6jl3kko`, NOT `127.0.0.1` — and SMSvcHost +//! resolves the registered service by the URL the AVEVA installer +//! recorded (which is the actual hostname). Use the actual hostname +//! in the .NET probe `--endpoint` arg even when sending through the +//! relay; only the TCP socket changes (we listen on `127.0.0.1:8088`, +//! the .NET probe's TCP DNS still resolves to localhost so it +//! connects to us, but the URL inside the preamble routes correctly +//! at SMSvcHost). + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let listen_addr = + std::env::var("MX_RELAY_LISTEN").unwrap_or_else(|_| "127.0.0.1:8088".to_string()); + let upstream_addr = + std::env::var("MX_RELAY_UPSTREAM").unwrap_or_else(|_| "127.0.0.1:808".to_string()); + + let listener = TcpListener::bind(&listen_addr).await?; + eprintln!( + "asb-relay listening on {} → forwarding to {}", + listen_addr, upstream_addr + ); + eprintln!("hex prefixes: C->S = client→server, S->C = server→client"); + + let conn_counter = Arc::new(AtomicUsize::new(0)); + + loop { + let (client_stream, peer) = listener.accept().await?; + let conn_id = conn_counter.fetch_add(1, Ordering::Relaxed); + let upstream_addr = upstream_addr.clone(); + eprintln!("[#{conn_id}] accepted {peer}"); + + tokio::spawn(async move { + if let Err(e) = handle_connection(conn_id, client_stream, &upstream_addr).await { + eprintln!("[#{conn_id}] connection error: {e}"); + } + eprintln!("[#{conn_id}] closed"); + }); + } +} + +async fn handle_connection( + conn_id: usize, + mut client: TcpStream, + upstream_addr: &str, +) -> Result<(), Box> { + let mut server = TcpStream::connect(upstream_addr).await?; + eprintln!("[#{conn_id}] upstream connected"); + + // Disable Nagle on both sides so log timing matches actual flushes. + let _ = client.set_nodelay(true); + let _ = server.set_nodelay(true); + + let (mut cr, mut cw) = client.split(); + let (mut sr, mut sw) = server.split(); + + let cs = forward(conn_id, "C->S", &mut cr, &mut sw); + let sc = forward(conn_id, "S->C", &mut sr, &mut cw); + + let _ = tokio::join!(cs, sc); + Ok(()) +} + +async fn forward( + conn_id: usize, + tag: &'static str, + reader: &mut R, + writer: &mut W, +) -> std::io::Result<()> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let mut buf = vec![0u8; 8192]; + let mut total = 0usize; + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } + if let Some(slice) = buf.get(..n) { + print_hex(conn_id, tag, total, slice); + writer.write_all(slice).await?; + writer.flush().await?; + } + total += n; + } + let _ = writer.shutdown().await; + eprintln!("[#{conn_id}] {tag} EOF after {total} bytes"); + Ok(()) +} + +fn print_hex(conn_id: usize, tag: &str, base_offset: usize, bytes: &[u8]) { + for (chunk_idx, chunk) in bytes.chunks(16).enumerate() { + let offset = base_offset + chunk_idx * 16; + let hex: Vec = 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!( + "[#{conn_id}] {tag} {offset:08x} {:<48} {}", + hex.join(" "), + ascii + ); + } +}