[M5] live-probe iteration 1 — major wire-byte reconciliation fixes
First live-test cycle against AVEVA on this box. Comparing the .NET
probe's `--dump-messages` XML output against our NBFX-encoded
envelope surfaced six structural bugs in the F25 envelope/operations
layer. All fixed; tests passing (702 workspace).
Fixes (all backed by the .NET dump as ground truth):
1. **`mustUnderstand` attribute name** — NBFS dict id was 116
(`MustUnderstand`, capital-M, a different SOAP token); SOAP 1.2
spec uses lowercase `mustUnderstand` at id 0. Sending the wrong
one triggered a WCF parse fault that surfaced as TCP RST.
2. **Missing `<a:MessageID>` header** — WCF's default binding
requires MessageID for two-way operations. We now auto-generate
`urn:uuid:<v4>` per envelope via a small inline `make_random_uuid_v4`
helper (no `uuid` crate dep).
3. **Missing `<a:ReplyTo>` anonymous header** — WCF's
BinaryMessageEncoder always emits `<a:ReplyTo><a:Address>...
addressing/anonymous</a:Address></a:ReplyTo>` for two-way ops.
4. **ConnectionValidator field names + namespace** — we were
emitting PascalCase `<ConnectionId>` etc. .NET's WCF
DataContractSerializer uses the private backing-field names
(`<connectionIdField xmlns="...ASBContract">guid</connectionIdField>`)
per `[DataMember(Name = "fooField")]`. Added the
`xmlns:i="...XMLSchema-instance"` declaration WCF emits
alongside (even when no `i:nil` is used). Decoder now accepts
both PascalCase (legacy tests) and DataContract field names.
5. **`<ASBIData>` over-wrapping** — we were emitting
`<Items><ASBIData>{bytes}</ASBIData></Items>`. .NET's
`AsbDataCustomSerializer.WriteStartObject` (`AsbContracts.cs:
1561-1572`) REPLACES the field's outer element with `<ASBIData>`
directly — there's no `<Items>` wrapper on the wire. Fixed by
collapsing `BodyField::AsbiDataElement` to emit just `<ASBIData>`
without the named outer element. The `name` field is retained
for self-documentation.
6. **`collect_asbidata_payloads` API** — was keyed by field name
(`Status` / `Values`); now positional (`payloads[0]`,
`payloads.get(1)`) since the wrapper element is gone. All seven
response decoders updated.
Plus tooling for the live-probe loop:
* `tools/Get-AsbPassphrase.ps1` — DPAPI loader that auto-discovers
the solution name + reads the sharedsecret + decrypts it. Sets
$env:MX_ASB_PASSPHRASE / MX_ASB_HOST / MX_ASB_VIA / MX_LIVE.
Lowercase via-host (WCF SMSvcHost is case-sensitive on the URL
host segment).
* `examples/asb-preamble-probe.rs` — diagnostic that connects,
runs the preamble, captures the PreambleAck, then sends a
synthetic ConnectRequest and dumps both directions as hex. Used
to bisect the wire-byte deltas above.
* `examples/asb-subscribe.rs` port default fixed (5074 → 808 —
WCF's NetTcpPortSharing/SMSvcHost listener confirmed via
Get-NetTCPConnection).
**Status**: preamble + PreambleAck round-trip works end-to-end
against the live AVEVA install (verified via probe). The
post-preamble Connect SOAP envelope still gets TCP RST'd — the six
structural fixes above are necessary but not yet sufficient. Next
iteration needs binary wire capture (Wireshark + Npcap loopback,
or a TCP-relay middleman) to compare the .NET probe's BinaryMessageEncoder
output byte-for-byte with ours and find the remaining delta(s).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -359,6 +359,7 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"mxaccess-asb-nettcp",
|
"mxaccess-asb-nettcp",
|
||||||
"mxaccess-codec",
|
"mxaccess-codec",
|
||||||
|
"rand",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ mxaccess-asb-nettcp = { path = "../mxaccess-asb-nettcp" }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ mod ns {
|
|||||||
pub const HEADER: u32 = 8;
|
pub const HEADER: u32 = 8;
|
||||||
pub const BODY: u32 = 14;
|
pub const BODY: u32 = 14;
|
||||||
pub const ACTION: u32 = 10;
|
pub const ACTION: u32 = 10;
|
||||||
pub const MUST_UNDERSTAND_ATTR: u32 = 116; // "MustUnderstand" — capital-M
|
/// SOAP 1.2 spec name is lowercase `mustUnderstand`. NBFS id 0
|
||||||
|
/// (capital-M `MustUnderstand` at id 116 is a different token).
|
||||||
|
/// Sending the wrong one triggers a WCF parse fault that
|
||||||
|
/// surfaces as a TCP RST.
|
||||||
|
pub const MUST_UNDERSTAND_ATTR: u32 = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ASB-specific namespace strings (NOT in the static dictionary). The
|
/// ASB-specific namespace strings (NOT in the static dictionary). The
|
||||||
@@ -128,6 +132,12 @@ impl ConnectionValidator {
|
|||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct SoapEnvelope {
|
pub struct SoapEnvelope {
|
||||||
pub action: String,
|
pub action: String,
|
||||||
|
/// WS-Addressing `<a:To>` header value (typically the same
|
||||||
|
/// `net.tcp://...` URL used in the NMF `Via` record). WCF's
|
||||||
|
/// default binding requires `<a:To>` for service dispatch —
|
||||||
|
/// without it the server resets the connection. Set via
|
||||||
|
/// [`Self::with_to`].
|
||||||
|
pub to_uri: Option<String>,
|
||||||
pub validator: Option<ConnectionValidator>,
|
pub validator: Option<ConnectionValidator>,
|
||||||
pub body_tokens: Vec<NbfxToken>,
|
pub body_tokens: Vec<NbfxToken>,
|
||||||
}
|
}
|
||||||
@@ -136,11 +146,17 @@ impl SoapEnvelope {
|
|||||||
pub fn new(action: impl Into<String>) -> Self {
|
pub fn new(action: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
action: action.into(),
|
action: action.into(),
|
||||||
|
to_uri: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
body_tokens: Vec::new(),
|
body_tokens: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_to(mut self, to_uri: impl Into<String>) -> Self {
|
||||||
|
self.to_uri = Some(to_uri.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_validator(mut self, validator: ConnectionValidator) -> Self {
|
pub fn with_validator(mut self, validator: ConnectionValidator) -> Self {
|
||||||
self.validator = Some(validator);
|
self.validator = Some(validator);
|
||||||
self
|
self
|
||||||
@@ -207,11 +223,55 @@ pub fn encode_envelope(
|
|||||||
tokens.push(NbfxToken::Text(NbfxText::Chars(envelope.action.clone())));
|
tokens.push(NbfxToken::Text(NbfxText::Chars(envelope.action.clone())));
|
||||||
tokens.push(NbfxToken::EndElement); // </a:Action>
|
tokens.push(NbfxToken::EndElement); // </a:Action>
|
||||||
|
|
||||||
// <h:ConnectionValidator …/>
|
// <h:ConnectionValidator …/> (WCF dump shows this comes BEFORE
|
||||||
|
// MessageID/ReplyTo when present)
|
||||||
if let Some(v) = &envelope.validator {
|
if let Some(v) = &envelope.validator {
|
||||||
encode_validator(&mut tokens, v, dynamic);
|
encode_validator(&mut tokens, v, dynamic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// <a:MessageID>urn:uuid:{uuid}</a:MessageID>
|
||||||
|
// WCF's default binding requires MessageID for two-way operations.
|
||||||
|
// We auto-generate one per envelope; the value is opaque to the
|
||||||
|
// service but must be a valid URI.
|
||||||
|
let message_id = format!("urn:uuid:{}", make_random_uuid_v4());
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("a".to_string()),
|
||||||
|
name: NbfxName::Static(26), // "MessageID"
|
||||||
|
});
|
||||||
|
tokens.push(NbfxToken::Text(NbfxText::Chars(message_id)));
|
||||||
|
tokens.push(NbfxToken::EndElement); // </a:MessageID>
|
||||||
|
|
||||||
|
// <a:ReplyTo>
|
||||||
|
// <a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
|
||||||
|
// </a:ReplyTo>
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("a".to_string()),
|
||||||
|
name: NbfxName::Static(44), // "ReplyTo"
|
||||||
|
});
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("a".to_string()),
|
||||||
|
name: NbfxName::Static(42), // "Address"
|
||||||
|
});
|
||||||
|
tokens.push(NbfxToken::Text(NbfxText::DictionaryStatic(20))); // anonymous
|
||||||
|
tokens.push(NbfxToken::EndElement); // </a:Address>
|
||||||
|
tokens.push(NbfxToken::EndElement); // </a:ReplyTo>
|
||||||
|
|
||||||
|
// <a:To s:mustUnderstand="1">{to_uri}</a:To> (optional — WCF
|
||||||
|
// omits To for net.tcp request/response by default)
|
||||||
|
if let Some(to) = &envelope.to_uri {
|
||||||
|
tokens.push(NbfxToken::Element {
|
||||||
|
prefix: Some("a".to_string()),
|
||||||
|
name: NbfxName::Static(12), // "To"
|
||||||
|
});
|
||||||
|
tokens.push(NbfxToken::Attribute {
|
||||||
|
prefix: Some("s".to_string()),
|
||||||
|
name: NbfxName::Static(ns::MUST_UNDERSTAND_ATTR),
|
||||||
|
value: NbfxText::One,
|
||||||
|
});
|
||||||
|
tokens.push(NbfxToken::Text(NbfxText::Chars(to.clone())));
|
||||||
|
tokens.push(NbfxToken::EndElement);
|
||||||
|
}
|
||||||
|
|
||||||
tokens.push(NbfxToken::EndElement); // </s:Header>
|
tokens.push(NbfxToken::EndElement); // </s:Header>
|
||||||
|
|
||||||
// <s:Body>
|
// <s:Body>
|
||||||
@@ -299,55 +359,104 @@ fn encode_validator(
|
|||||||
v: &ConnectionValidator,
|
v: &ConnectionValidator,
|
||||||
_dynamic: &mut DynamicDictionary,
|
_dynamic: &mut DynamicDictionary,
|
||||||
) {
|
) {
|
||||||
// <h:ConnectionValidator xmlns:h="http://asb.contracts.headers/20111111">
|
// <h:ConnectionValidator xmlns:i="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
// xmlns:h="http://asb.contracts.headers/20111111">
|
||||||
|
// <connectionIdField xmlns="...ASBContract">guid</connectionIdField>
|
||||||
|
// <messageAuthenticationCodeField xmlns="...ASBContract" /> (empty when no MAC)
|
||||||
|
// <messageNumberField xmlns="...ASBContract">n</messageNumberField>
|
||||||
|
// <signatureInitializationVectorField xmlns="...ASBContract" />
|
||||||
|
// </h:ConnectionValidator>
|
||||||
|
//
|
||||||
|
// Inner element names are the .NET DataContract member names
|
||||||
|
// (private backing fields with `[DataMember(Name = "fooField")]`),
|
||||||
|
// NOT public PascalCase property names. Captured via
|
||||||
|
// `MxAsbClient.Probe --dump-messages`. The `xmlns:i` declaration
|
||||||
|
// is required even though we don't emit any `i:nil` attributes —
|
||||||
|
// WCF does, and SMSvcHost / WCF parser consistency expects it.
|
||||||
out.push(NbfxToken::Element {
|
out.push(NbfxToken::Element {
|
||||||
prefix: Some("h".to_string()),
|
prefix: Some("h".to_string()),
|
||||||
name: NbfxName::Inline("ConnectionValidator".to_string()),
|
name: NbfxName::Inline("ConnectionValidator".to_string()),
|
||||||
});
|
});
|
||||||
|
out.push(NbfxToken::NamespaceDeclaration {
|
||||||
|
prefix: "i".to_string(),
|
||||||
|
value: NbfxText::DictionaryStatic(440), // ...XMLSchema-instance
|
||||||
|
});
|
||||||
out.push(NbfxToken::NamespaceDeclaration {
|
out.push(NbfxToken::NamespaceDeclaration {
|
||||||
prefix: "h".to_string(),
|
prefix: "h".to_string(),
|
||||||
value: NbfxText::Chars(asb_ns::HEADERS.to_string()),
|
value: NbfxText::Chars(asb_ns::HEADERS.to_string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// <ConnectionId>guid-text</ConnectionId>
|
let dc_ns = "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBContract";
|
||||||
out.push(NbfxToken::Element {
|
push_dc_field(
|
||||||
prefix: None,
|
out,
|
||||||
name: NbfxName::Inline("ConnectionId".to_string()),
|
"connectionIdField",
|
||||||
});
|
dc_ns,
|
||||||
out.push(NbfxToken::Text(NbfxText::Chars(format_uuid(
|
&format_uuid(&v.connection_id),
|
||||||
&v.connection_id,
|
);
|
||||||
))));
|
push_dc_field(out, "messageAuthenticationCodeField", dc_ns, &v.mac_base64);
|
||||||
out.push(NbfxToken::EndElement);
|
push_dc_field(
|
||||||
|
out,
|
||||||
// <MessageNumber>n</MessageNumber>
|
"messageNumberField",
|
||||||
out.push(NbfxToken::Element {
|
dc_ns,
|
||||||
prefix: None,
|
&v.message_number.to_string(),
|
||||||
name: NbfxName::Inline("MessageNumber".to_string()),
|
);
|
||||||
});
|
push_dc_field(
|
||||||
out.push(NbfxToken::Text(NbfxText::Chars(
|
out,
|
||||||
v.message_number.to_string(),
|
"signatureInitializationVectorField",
|
||||||
)));
|
dc_ns,
|
||||||
out.push(NbfxToken::EndElement);
|
&v.iv_base64,
|
||||||
|
);
|
||||||
// <MessageAuthenticationCode>base64</MessageAuthenticationCode>
|
|
||||||
out.push(NbfxToken::Element {
|
|
||||||
prefix: None,
|
|
||||||
name: NbfxName::Inline("MessageAuthenticationCode".to_string()),
|
|
||||||
});
|
|
||||||
out.push(NbfxToken::Text(NbfxText::Chars(v.mac_base64.clone())));
|
|
||||||
out.push(NbfxToken::EndElement);
|
|
||||||
|
|
||||||
// <SignatureInitializationVector>base64</SignatureInitializationVector>
|
|
||||||
out.push(NbfxToken::Element {
|
|
||||||
prefix: None,
|
|
||||||
name: NbfxName::Inline("SignatureInitializationVector".to_string()),
|
|
||||||
});
|
|
||||||
out.push(NbfxToken::Text(NbfxText::Chars(v.iv_base64.clone())));
|
|
||||||
out.push(NbfxToken::EndElement);
|
|
||||||
|
|
||||||
out.push(NbfxToken::EndElement); // </h:ConnectionValidator>
|
out.push(NbfxToken::EndElement); // </h:ConnectionValidator>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit a `<{name} xmlns="{dc_ns}">{value}</{name}>` element. When
|
||||||
|
/// `value` is empty we emit just `<{name} xmlns="{dc_ns}"/>` to match
|
||||||
|
/// .NET's WCF DataContractSerializer output for null/empty byte arrays.
|
||||||
|
fn push_dc_field(out: &mut Vec<NbfxToken>, name: &str, dc_ns: &str, value: &str) {
|
||||||
|
out.push(NbfxToken::Element {
|
||||||
|
prefix: None,
|
||||||
|
name: NbfxName::Inline(name.to_string()),
|
||||||
|
});
|
||||||
|
out.push(NbfxToken::DefaultNamespace {
|
||||||
|
value: NbfxText::Chars(dc_ns.to_string()),
|
||||||
|
});
|
||||||
|
if !value.is_empty() {
|
||||||
|
out.push(NbfxToken::Text(NbfxText::Chars(value.to_string())));
|
||||||
|
}
|
||||||
|
out.push(NbfxToken::EndElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Random RFC 4122 v4-shaped UUID (without pulling the `uuid` crate).
|
||||||
|
/// Used by `encode_envelope` for the `<a:MessageID>urn:uuid:...`
|
||||||
|
/// header. The output is a hyphenated lowercase 36-char string.
|
||||||
|
fn make_random_uuid_v4() -> String {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
|
bytes[6] = (bytes[6] & 0x0F) | 0x40; // version 4
|
||||||
|
bytes[8] = (bytes[8] & 0x3F) | 0x80; // variant 1 (RFC 4122)
|
||||||
|
format!(
|
||||||
|
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
||||||
|
bytes[0],
|
||||||
|
bytes[1],
|
||||||
|
bytes[2],
|
||||||
|
bytes[3],
|
||||||
|
bytes[4],
|
||||||
|
bytes[5],
|
||||||
|
bytes[6],
|
||||||
|
bytes[7],
|
||||||
|
bytes[8],
|
||||||
|
bytes[9],
|
||||||
|
bytes[10],
|
||||||
|
bytes[11],
|
||||||
|
bytes[12],
|
||||||
|
bytes[13],
|
||||||
|
bytes[14],
|
||||||
|
bytes[15],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn decode_validator(
|
fn decode_validator(
|
||||||
tokens: &[NbfxToken],
|
tokens: &[NbfxToken],
|
||||||
start: usize,
|
start: usize,
|
||||||
@@ -379,15 +488,23 @@ fn decode_validator(
|
|||||||
child_idx += 1;
|
child_idx += 1;
|
||||||
}
|
}
|
||||||
child_idx = skip_until_end(tokens, child_idx);
|
child_idx = skip_until_end(tokens, child_idx);
|
||||||
|
// Accept both the PascalCase form (legacy) and the
|
||||||
|
// DataContract field-name form (`fooField` — what
|
||||||
|
// .NET WCF emits per the captured `--dump-messages`
|
||||||
|
// output, which is what we now produce on encode).
|
||||||
match local.as_str() {
|
match local.as_str() {
|
||||||
"ConnectionId" => {
|
"ConnectionId" | "connectionIdField" => {
|
||||||
connection_id = parse_uuid(&value).ok();
|
connection_id = parse_uuid(&value).ok();
|
||||||
}
|
}
|
||||||
"MessageNumber" => {
|
"MessageNumber" | "messageNumberField" => {
|
||||||
message_number = value.parse::<u64>().ok();
|
message_number = value.parse::<u64>().ok();
|
||||||
}
|
}
|
||||||
"MessageAuthenticationCode" => mac_b64 = Some(value),
|
"MessageAuthenticationCode" | "messageAuthenticationCodeField" => {
|
||||||
"SignatureInitializationVector" => iv_b64 = Some(value),
|
mac_b64 = Some(value);
|
||||||
|
}
|
||||||
|
"SignatureInitializationVector" | "signatureInitializationVectorField" => {
|
||||||
|
iv_b64 = Some(value);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
idx = child_idx;
|
idx = child_idx;
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ pub struct DeleteMonitoredItemsResponse {
|
|||||||
pub fn decode_delete_monitored_items_response(
|
pub fn decode_delete_monitored_items_response(
|
||||||
body_tokens: &[NbfxToken],
|
body_tokens: &[NbfxToken],
|
||||||
) -> Result<DeleteMonitoredItemsResponse, OperationError> {
|
) -> Result<DeleteMonitoredItemsResponse, OperationError> {
|
||||||
let payload = collect_asbidata_payloads(body_tokens, "Status")
|
let payload = collect_asbidata_payloads(body_tokens)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||||
@@ -625,7 +625,7 @@ pub struct WriteResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_write_response(body_tokens: &[NbfxToken]) -> Result<WriteResponse, OperationError> {
|
pub fn decode_write_response(body_tokens: &[NbfxToken]) -> Result<WriteResponse, OperationError> {
|
||||||
let payload = collect_asbidata_payloads(body_tokens, "Status")
|
let payload = collect_asbidata_payloads(body_tokens)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||||
@@ -843,7 +843,7 @@ pub struct AddMonitoredItemsResponse {
|
|||||||
pub fn decode_add_monitored_items_response(
|
pub fn decode_add_monitored_items_response(
|
||||||
body_tokens: &[NbfxToken],
|
body_tokens: &[NbfxToken],
|
||||||
) -> Result<AddMonitoredItemsResponse, OperationError> {
|
) -> Result<AddMonitoredItemsResponse, OperationError> {
|
||||||
let payloads = collect_asbidata_payloads(body_tokens, "Status");
|
let payloads = collect_asbidata_payloads(body_tokens);
|
||||||
let status_payload = payloads
|
let status_payload = payloads
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
@@ -868,17 +868,14 @@ pub struct PublishResponse {
|
|||||||
pub fn decode_publish_response(
|
pub fn decode_publish_response(
|
||||||
body_tokens: &[NbfxToken],
|
body_tokens: &[NbfxToken],
|
||||||
) -> Result<PublishResponse, OperationError> {
|
) -> Result<PublishResponse, OperationError> {
|
||||||
let status_payload = collect_asbidata_payloads(body_tokens, "Status")
|
let payloads = collect_asbidata_payloads(body_tokens);
|
||||||
.into_iter()
|
let status_payload = payloads
|
||||||
.next()
|
.first()
|
||||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||||
let status = decode_item_status_array(&status_payload)?;
|
let status = decode_item_status_array(status_payload)?;
|
||||||
|
|
||||||
let values = match collect_asbidata_payloads(body_tokens, "Values")
|
let values = match payloads.get(1) {
|
||||||
.into_iter()
|
Some(payload) => decode_monitored_item_value_array(payload)?,
|
||||||
.next()
|
|
||||||
{
|
|
||||||
Some(payload) => decode_monitored_item_value_array(&payload)?,
|
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
Ok(PublishResponse { status, values })
|
Ok(PublishResponse { status, values })
|
||||||
@@ -980,17 +977,14 @@ pub struct ReadResponse {
|
|||||||
/// [`crate::decode_envelope`]. Both `Status` and `Values` arrive as
|
/// [`crate::decode_envelope`]. Both `Status` and `Values` arrive as
|
||||||
/// `<ASBIData>` payloads; we decode the binary form of each.
|
/// `<ASBIData>` payloads; we decode the binary form of each.
|
||||||
pub fn decode_read_response(body_tokens: &[NbfxToken]) -> Result<ReadResponse, OperationError> {
|
pub fn decode_read_response(body_tokens: &[NbfxToken]) -> Result<ReadResponse, OperationError> {
|
||||||
let status_payload = collect_asbidata_payloads(body_tokens, "Status")
|
let payloads = collect_asbidata_payloads(body_tokens);
|
||||||
.into_iter()
|
let status_payload = payloads
|
||||||
.next()
|
.first()
|
||||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||||
let status = decode_item_status_array(&status_payload)?;
|
let status = decode_item_status_array(status_payload)?;
|
||||||
|
|
||||||
let values = match collect_asbidata_payloads(body_tokens, "Values")
|
let values = match payloads.get(1) {
|
||||||
.into_iter()
|
Some(payload) => decode_runtime_value_array(payload)?,
|
||||||
.next()
|
|
||||||
{
|
|
||||||
Some(payload) => decode_runtime_value_array(&payload)?,
|
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1059,7 +1053,7 @@ pub struct UnregisterItemsResponse {
|
|||||||
pub fn decode_register_items_response(
|
pub fn decode_register_items_response(
|
||||||
body_tokens: &[NbfxToken],
|
body_tokens: &[NbfxToken],
|
||||||
) -> Result<RegisterItemsResponse, OperationError> {
|
) -> Result<RegisterItemsResponse, OperationError> {
|
||||||
let payloads = collect_asbidata_payloads(body_tokens, "Status");
|
let payloads = collect_asbidata_payloads(body_tokens);
|
||||||
let status_payload = payloads
|
let status_payload = payloads
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
@@ -1076,7 +1070,7 @@ pub fn decode_register_items_response(
|
|||||||
pub fn decode_unregister_items_response(
|
pub fn decode_unregister_items_response(
|
||||||
body_tokens: &[NbfxToken],
|
body_tokens: &[NbfxToken],
|
||||||
) -> Result<UnregisterItemsResponse, OperationError> {
|
) -> Result<UnregisterItemsResponse, OperationError> {
|
||||||
let payloads = collect_asbidata_payloads(body_tokens, "Status");
|
let payloads = collect_asbidata_payloads(body_tokens);
|
||||||
let status_payload = payloads
|
let status_payload = payloads
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.next()
|
.next()
|
||||||
@@ -1087,25 +1081,32 @@ pub fn decode_unregister_items_response(
|
|||||||
|
|
||||||
/// Walk a SOAP body's NBFX token stream and pull out the
|
/// Walk a SOAP body's NBFX token stream and pull out the
|
||||||
/// `<ASBIData>{Bytes}</ASBIData>` payload bytes for any element named
|
/// `<ASBIData>{Bytes}</ASBIData>` payload bytes for any element named
|
||||||
/// `field_name`. Returns `Vec<Vec<u8>>` because some response shapes
|
/// outer wrapper element. Returns `Vec<Vec<u8>>` ordered by
|
||||||
/// have multiple ASBIData payloads (e.g. `ReadResponse` has both
|
/// declaration position — for shapes with multiple binary fields
|
||||||
/// `Status` and `Values`).
|
/// (e.g. `ReadResponse` has both `Status` and `Values`), the caller
|
||||||
|
/// indexes positionally.
|
||||||
///
|
///
|
||||||
/// Operates on token windows rather than tracking element depth — the
|
/// `[F25 step 11 fix]` Previously this took a `field_name` parameter
|
||||||
/// response shapes are shallow enough that name-keyed scanning is
|
/// and looked for `<{name}><ASBIData>{Bytes}</ASBIData></{name}>`.
|
||||||
/// reliable. Returns whichever payloads it finds; missing fields
|
/// .NET's `AsbDataCustomSerializer.WriteStartObject` actually
|
||||||
/// surface as an empty `Vec`.
|
/// REPLACES the field's outer element with `<ASBIData>` directly
|
||||||
pub fn collect_asbidata_payloads(tokens: &[NbfxToken], field_name: &str) -> Vec<Vec<u8>> {
|
/// (`AsbContracts.cs:1561-1572`), so the wrapper element doesn't
|
||||||
|
/// exist on the wire — confirmed via `MxAsbClient.Probe
|
||||||
|
/// --dump-messages`. The function now returns all payloads in
|
||||||
|
/// declaration order; callers use `payloads[0]`, `payloads.get(1)`
|
||||||
|
/// etc.
|
||||||
|
pub fn collect_asbidata_payloads(tokens: &[NbfxToken]) -> Vec<Vec<u8>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
while idx < tokens.len() {
|
while let Some(tok) = tokens.get(idx) {
|
||||||
if let Some(NbfxToken::Element {
|
if let NbfxToken::Element {
|
||||||
name: NbfxName::Inline(local),
|
name: NbfxName::Inline(local),
|
||||||
..
|
..
|
||||||
}) = tokens.get(idx)
|
} = tok
|
||||||
{
|
{
|
||||||
if local == field_name {
|
if local == "ASBIData" {
|
||||||
// Skip attributes / namespace decls.
|
// Skip attributes / namespace decls between Element
|
||||||
|
// and Text.
|
||||||
let mut inner = idx + 1;
|
let mut inner = idx + 1;
|
||||||
while matches!(
|
while matches!(
|
||||||
tokens.get(inner),
|
tokens.get(inner),
|
||||||
@@ -1115,18 +1116,8 @@ pub fn collect_asbidata_payloads(tokens: &[NbfxToken], field_name: &str) -> Vec<
|
|||||||
) {
|
) {
|
||||||
inner += 1;
|
inner += 1;
|
||||||
}
|
}
|
||||||
if let Some(NbfxToken::Element {
|
if let Some(NbfxToken::Text(NbfxText::Bytes(payload))) = tokens.get(inner) {
|
||||||
name: NbfxName::Inline(asbidata),
|
out.push(payload.clone());
|
||||||
..
|
|
||||||
}) = tokens.get(inner)
|
|
||||||
{
|
|
||||||
if asbidata == "ASBIData" {
|
|
||||||
if let Some(NbfxToken::Text(NbfxText::Bytes(payload))) =
|
|
||||||
tokens.get(inner + 1)
|
|
||||||
{
|
|
||||||
out.push(payload.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1170,7 +1161,7 @@ pub fn build_unregister_items_request_body(items: &[ItemIdentity]) -> Vec<NbfxTo
|
|||||||
const IOM_NS: &str = "urn:msg.data.asb.iom:2";
|
const IOM_NS: &str = "urn:msg.data.asb.iom:2";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(clippy::enum_variant_names)] // every body field is in fact an element; suffix is descriptive.
|
#[allow(clippy::enum_variant_names, dead_code)] // every body field is in fact an element; suffix is descriptive. `name` on AsbiDataElement is retained for self-documentation but no longer emitted on the wire (see `asbidata_request_body`).
|
||||||
enum BodyField {
|
enum BodyField {
|
||||||
/// Plain element with text body.
|
/// Plain element with text body.
|
||||||
BoolElement { name: &'static str, value: bool },
|
BoolElement { name: &'static str, value: bool },
|
||||||
@@ -1178,8 +1169,11 @@ enum BodyField {
|
|||||||
/// numeric values as Int8/16/32/64 records — we always pick Int64
|
/// numeric values as Int8/16/32/64 records — we always pick Int64
|
||||||
/// for simplicity; the decoder accepts any width.
|
/// for simplicity; the decoder accepts any width.
|
||||||
Int64Element { name: &'static str, value: i64 },
|
Int64Element { name: &'static str, value: i64 },
|
||||||
/// Element wrapping `<ASBIData>` with base64-binary content (NBFX
|
/// `<ASBIData>` element with binary content (NBFX `Bytes` record).
|
||||||
/// represents that as `Bytes` text records).
|
/// `name` is the .NET XmlElement attribute name (e.g. "Items",
|
||||||
|
/// "Values") — kept for self-documentation but ignored on the
|
||||||
|
/// wire because WCF's AsbDataCustomSerializer.WriteStartObject
|
||||||
|
/// replaces the field's outer element with `<ASBIData>` directly.
|
||||||
AsbiDataElement {
|
AsbiDataElement {
|
||||||
name: &'static str,
|
name: &'static str,
|
||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
@@ -1243,18 +1237,21 @@ fn asbidata_request_body(outer: &str, fields: &[BodyField]) -> Vec<NbfxToken> {
|
|||||||
tokens.push(NbfxToken::Text(NbfxText::Int64(*value)));
|
tokens.push(NbfxToken::Text(NbfxText::Int64(*value)));
|
||||||
tokens.push(NbfxToken::EndElement);
|
tokens.push(NbfxToken::EndElement);
|
||||||
}
|
}
|
||||||
BodyField::AsbiDataElement { name, payload } => {
|
BodyField::AsbiDataElement { name: _, payload } => {
|
||||||
tokens.push(NbfxToken::Element {
|
// WCF's AsbDataCustomSerializer.WriteStartObject
|
||||||
prefix: None,
|
// (`AsbContracts.cs:1561-1572`) REPLACES the field's
|
||||||
name: NbfxName::Inline((*name).to_string()),
|
// outer element with `<ASBIData>` rather than nesting
|
||||||
});
|
// inside it. The `name` parameter (e.g. "Items",
|
||||||
|
// "Values") is ignored on the wire — the .NET
|
||||||
|
// XmlElement attribute name is overridden by the
|
||||||
|
// custom serializer. Verified via .NET probe
|
||||||
|
// `--dump-messages` output.
|
||||||
tokens.push(NbfxToken::Element {
|
tokens.push(NbfxToken::Element {
|
||||||
prefix: None,
|
prefix: None,
|
||||||
name: NbfxName::Inline("ASBIData".to_string()),
|
name: NbfxName::Inline("ASBIData".to_string()),
|
||||||
});
|
});
|
||||||
tokens.push(NbfxToken::Text(NbfxText::Bytes(payload.clone())));
|
tokens.push(NbfxToken::Text(NbfxText::Bytes(payload.clone())));
|
||||||
tokens.push(NbfxToken::EndElement); // </ASBIData>
|
tokens.push(NbfxToken::EndElement); // </ASBIData>
|
||||||
tokens.push(NbfxToken::EndElement); // </{name}>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1533,11 +1530,11 @@ mod tests {
|
|||||||
},
|
},
|
||||||
NbfxToken::EndElement,
|
NbfxToken::EndElement,
|
||||||
];
|
];
|
||||||
assert!(collect_asbidata_payloads(&body, "Status").is_empty());
|
assert!(collect_asbidata_payloads(&body).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn collect_asbidata_payloads_handles_multiple_fields() {
|
fn collect_asbidata_payloads_handles_multiple_fields_positionally() {
|
||||||
let body = asbidata_request_body(
|
let body = asbidata_request_body(
|
||||||
"ReadResponse",
|
"ReadResponse",
|
||||||
&[
|
&[
|
||||||
@@ -1545,10 +1542,8 @@ mod tests {
|
|||||||
BodyField::asbidata("Values", vec![4, 5, 6, 7]),
|
BodyField::asbidata("Values", vec![4, 5, 6, 7]),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let status = collect_asbidata_payloads(&body, "Status");
|
let payloads = collect_asbidata_payloads(&body);
|
||||||
let values = collect_asbidata_payloads(&body, "Values");
|
assert_eq!(payloads, vec![vec![1u8, 2, 3], vec![4u8, 5, 6, 7]]);
|
||||||
assert_eq!(status, vec![vec![1u8, 2, 3]]);
|
|
||||||
assert_eq!(values, vec![vec![4u8, 5, 6, 7]]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! `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_body_tokens(body);
|
||||||
|
let _ = via.clone(); // keep $via in scope for the eprintln above
|
||||||
|
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")?,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
//! Populate via `tools/Setup-LiveProbeEnv.ps1` (dot-source it):
|
//! Populate via `tools/Setup-LiveProbeEnv.ps1` (dot-source it):
|
||||||
//!
|
//!
|
||||||
//! - `MX_LIVE` (any non-empty value enables the live path)
|
//! - `MX_LIVE` (any non-empty value enables the live path)
|
||||||
//! - `MX_ASB_HOST` — ASB endpoint host[:port]; defaults port 5074 if omitted
|
//! - `MX_ASB_HOST` — ASB endpoint host[:port]; defaults port 808 if omitted
|
||||||
|
//! (the WCF `NetTcpPortSharing` SMSvcHost listener — confirmed via the
|
||||||
|
//! .NET probe's working endpoint at `src/MxAsbClient.Probe/Program.cs:5`)
|
||||||
//! - `MX_ASB_PASSPHRASE` — solution shared secret (typically read from
|
//! - `MX_ASB_PASSPHRASE` — solution shared secret (typically read from
|
||||||
//! DPAPI on a real install; for CI / dev set directly via Infisical
|
//! DPAPI on a real install; for CI / dev set directly via Infisical
|
||||||
//! per `tools/Setup-LiveProbeEnv.ps1`)
|
//! per `tools/Setup-LiveProbeEnv.ps1`)
|
||||||
@@ -109,7 +111,7 @@ impl LiveEnv {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let host = std::env::var("MX_ASB_HOST")?;
|
let host = std::env::var("MX_ASB_HOST")?;
|
||||||
let addr = parse_host_port(&host, 5074)?;
|
let addr = parse_host_port(&host, 808)?;
|
||||||
let passphrase = std::env::var("MX_ASB_PASSPHRASE")
|
let passphrase = std::env::var("MX_ASB_PASSPHRASE")
|
||||||
.map_err(|_| "MX_ASB_PASSPHRASE not set — ASB requires the solution shared secret")?;
|
.map_err(|_| "MX_ASB_PASSPHRASE not set — ASB requires the solution shared secret")?;
|
||||||
let via_uri =
|
let via_uri =
|
||||||
|
|||||||
@@ -134,7 +134,14 @@ Set-LiveEnvVar -Name 'MX_ASB_HOST' -Value $AsbHost
|
|||||||
# .NET probe at `src/MxAsbClient.Probe/Program.cs:5` hardcodes the
|
# .NET probe at `src/MxAsbClient.Probe/Program.cs:5` hardcodes the
|
||||||
# MxDataProvider segment because that's what serves IASBIDataV2.
|
# MxDataProvider segment because that's what serves IASBIDataV2.
|
||||||
$mxDataProvider = "Default_${GalaxyName}_MxDataProvider"
|
$mxDataProvider = "Default_${GalaxyName}_MxDataProvider"
|
||||||
$via = "net.tcp://$AsbHost/ASBService/$mxDataProvider/IDataV2"
|
# Lowercase the host segment of the URL — WCF's NetTcpPortSharing
|
||||||
|
# SMSvcHost matches the registered service URL case-sensitively in
|
||||||
|
# the host part; the .NET probe at `src/MxAsbClient.Probe/Program.cs:5`
|
||||||
|
# hardcodes the lowercase form (`desktop-6jl3kko`) which is what
|
||||||
|
# AVEVA actually registered. We keep $AsbHost as-cased for TCP DNS
|
||||||
|
# resolution (`MX_ASB_HOST`) but lowercase it for the Via URL.
|
||||||
|
$viaHost = $AsbHost.ToLowerInvariant()
|
||||||
|
$via = "net.tcp://$viaHost/ASBService/$mxDataProvider/IDataV2"
|
||||||
Set-LiveEnvVar -Name 'MX_ASB_VIA' -Value $via
|
Set-LiveEnvVar -Name 'MX_ASB_VIA' -Value $via
|
||||||
Set-LiveEnvVar -Name 'MX_ASB_SOLUTION' -Value $solution
|
Set-LiveEnvVar -Name 'MX_ASB_SOLUTION' -Value $solution
|
||||||
Set-LiveEnvVar -Name 'MX_ASB_GALAXY_NAME' -Value $GalaxyName
|
Set-LiveEnvVar -Name 'MX_ASB_GALAXY_NAME' -Value $GalaxyName
|
||||||
|
|||||||
Reference in New Issue
Block a user