2867310817
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>
129 lines
4.6 KiB
Rust
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")?,
|
|
)
|
|
}
|