Files
mxaccess/rust/crates/mxaccess/examples/asb-preamble-probe.rs
T
Joseph Doherty 2867310817 [M5] mxaccess-asb: WCF binary message header (action+to dict pre-pop)
Adds the binary header block that WCF prepends to SizedEnvelope
payloads. Reverse-engineered from .NET probe wire bytes captured via
asb-relay.

Wire form (per the .NET capture analysis in the previous commit):
```
[outer length, multibyte-int31]
  [string-1 length, multibyte-int31] [UTF-8 bytes]   ← dict id 1 (action)
  [string-2 length, multibyte-int31] [UTF-8 bytes]   ← dict id 3 (to)
[NBFX <s:Envelope>...]
```

Inside the NBFX envelope, `<a:Action>` and `<a:To>` reference the
pre-pop strings via `DictionaryText 0xAA {odd-id}` instead of inlining
their values. The header strings get assigned odd dict ids
(1, 3, 5, ...); even ids stay reserved for the [MC-NBFS] static dict.

Encode side:
* `encode_envelope` now emits header [action, to] before NBFX. `to_uri`
  defaults to empty string when None — caller-supplied `with_to(uri)`
  is the supported path.
* AsbClient's `send_envelope` and `send_envelope_one_way` auto-fill
  `to_uri` from `self.via_uri` when not set.
* New private `encode_binary_header(strings)` helper.

Decode side:
* New `parse_binary_header_prefix(input)` heuristically detects + parses
  the header (look for plausible NBFX element record byte 0x40-0x77 at
  the offset implied by the outer length).
* New `resolve_with_header(text, dynamic, header)` resolves
  `DictionaryText` with odd id by indexing into header.strings; even
  ids fall through to static-dict lookup as before.

Tests pass (72) — round-trip envelope → bytes → envelope recovers
action through the new dict-id resolution path.

Live status: this commit gets us further but the connect SOAP
envelope still TCP-RSTs at SMSvcHost. The remaining delta vs the .NET
capture is structural NBFX optimisation: .NET uses single-letter
prefix-element/attribute records (0x44-0x77 PrefixDictionaryElement
_<a-z>, 0x0C-0x25 PrefixDictionaryAttribute_<a-z>, 0x0B
DictionaryXmlnsAttribute) while our F21 encoder always uses the long
forms (0x43 prefix-string + name-dict-id, etc.). Logically
equivalent but WCF's parser likely strict on which form it accepts.

Next iteration will add short-form encoding to F21 for single-letter
prefixes (s:, a:, h:, i:) which covers every namespace prefix in our
envelope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:40:59 -04:00

129 lines
4.6 KiB
Rust

//! `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_to(&via)
.with_body_tokens(body);
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")?,
)
}