d4ee5f3a18
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>
155 lines
5.1 KiB
Rust
155 lines
5.1 KiB
Rust
//! `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
|
|
);
|
|
}
|
|
}
|