[M5] examples: asb-relay TCP middleman for live wire-byte capture

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 ...                                                      ← <s:Envelope>

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 `<s:Envelope>` (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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 15:27:54 -04:00
parent 3b09297b27
commit d4ee5f3a18
+154
View File
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error + Send + Sync>> {
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<R, W>(
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<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!(
"[#{conn_id}] {tag} {offset:08x} {:<48} {}",
hex.join(" "),
ascii
);
}
}