3b09297b27
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>
153 lines
6.5 KiB
PowerShell
153 lines
6.5 KiB
PowerShell
# Get-AsbPassphrase.ps1 — read the ASB solution shared secret from the local
|
|
# Windows registry + DPAPI and export the env vars the Rust port's
|
|
# `asb-subscribe` example expects.
|
|
#
|
|
# Mirrors `src/MxAsbClient/AsbRegistry.cs:21-41`:
|
|
# 1. Look up the default solution name at
|
|
# HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\DefaultASBSolution
|
|
# (or the value passed via -SolutionName).
|
|
# 2. Read the `sharedsecret` REG_BINARY at
|
|
# HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\<solution>\sharedsecret.
|
|
# 3. DPAPI-decrypt the bytes (LocalMachine scope, entropy = "wonderware"
|
|
# UTF-16LE-encoded — the .NET reference's `Entropy` constant at
|
|
# `AsbRegistry.cs:10`).
|
|
# 4. UTF-16LE-decode the cleartext into the passphrase string.
|
|
# 5. Export $env:MX_ASB_PASSPHRASE plus convenience-derived $env:MX_ASB_HOST
|
|
# and $env:MX_ASB_VIA. Sets $env:MX_LIVE=1 to enable the example's
|
|
# live path.
|
|
#
|
|
# Hard rules:
|
|
# - Plaintext passphrase NEVER printed to the console (use -Show to opt in).
|
|
# - DPAPI scope is LocalMachine; the caller's Windows account must be
|
|
# authorised against the encrypted blob (typically: any local
|
|
# Administrator or the AVEVA service account).
|
|
# - Dot-source so env vars persist in the calling pwsh session.
|
|
#
|
|
# Usage:
|
|
# . .\tools\Get-AsbPassphrase.ps1 # default solution from registry
|
|
# . .\tools\Get-AsbPassphrase.ps1 -SolutionName 'Default_ZB_MxDataProvider'
|
|
# . .\tools\Get-AsbPassphrase.ps1 -GalaxyName 'ZB' # builds MX_ASB_VIA
|
|
# .\tools\Get-AsbPassphrase.ps1 -DryRun # print what would be set
|
|
# .\tools\Get-AsbPassphrase.ps1 -Show # print plaintext (CI / debug only!)
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$SolutionName,
|
|
[string]$GalaxyName = 'ZB',
|
|
[string]$AsbHost = $env:COMPUTERNAME,
|
|
[switch]$DryRun,
|
|
[switch]$Show
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Mirror `AsbRegistry.RegistryPath` at `cs:12-14`. The registry layout uses
|
|
# the WoW64 redirector path on 64-bit hosts because the AVEVA service that
|
|
# wrote the value runs as 32-bit.
|
|
$ServicesKeyPath = if ([Environment]::Is64BitOperatingSystem) {
|
|
'HKLM:\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices'
|
|
} else {
|
|
'HKLM:\SOFTWARE\ArchestrA\ArchestrAServices'
|
|
}
|
|
|
|
# DPAPI entropy — `AsbRegistry.cs:10`.
|
|
$DpapiEntropy = [System.Text.Encoding]::Unicode.GetBytes('wonderware')
|
|
|
|
function Resolve-AsbSolutionName {
|
|
param([string]$Override)
|
|
if ($Override) {
|
|
return $Override
|
|
}
|
|
if (-not (Test-Path $ServicesKeyPath)) {
|
|
throw "ArchestrAServices registry key not found at $ServicesKeyPath. Is AVEVA System Platform installed?"
|
|
}
|
|
$default = (Get-ItemProperty -Path $ServicesKeyPath -ErrorAction Stop).DefaultASBSolution
|
|
if (-not $default) {
|
|
throw "DefaultASBSolution registry value is empty under $ServicesKeyPath. Pass -SolutionName explicitly."
|
|
}
|
|
return $default
|
|
}
|
|
|
|
function Get-AsbSharedSecretBytes {
|
|
param([string]$Solution)
|
|
$path = "$ServicesKeyPath\$Solution"
|
|
if (-not (Test-Path $path)) {
|
|
throw "Solution registry key not found at $path. Solution=$Solution may be misspelt."
|
|
}
|
|
$value = (Get-ItemProperty -Path $path -ErrorAction Stop).sharedsecret
|
|
if (-not $value) {
|
|
throw "sharedsecret value missing under $path."
|
|
}
|
|
if ($value -isnot [byte[]]) {
|
|
throw "sharedsecret value at $path is not REG_BINARY (got $($value.GetType().Name))."
|
|
}
|
|
return $value
|
|
}
|
|
|
|
function Unprotect-AsbSecret {
|
|
param([byte[]]$Protected)
|
|
Add-Type -AssemblyName System.Security
|
|
try {
|
|
$clear = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
|
$Protected,
|
|
$DpapiEntropy,
|
|
[System.Security.Cryptography.DataProtectionScope]::LocalMachine
|
|
)
|
|
} catch {
|
|
throw "DPAPI decrypt failed: $_. Possible causes: this account isn't authorised against the LocalMachine-scope blob; the AVEVA service was provisioned under a different machine identity; the sharedsecret bytes are corrupt."
|
|
}
|
|
return [System.Text.Encoding]::Unicode.GetString($clear).TrimEnd("`0")
|
|
}
|
|
|
|
function Set-LiveEnvVar {
|
|
param([string]$Name, [string]$Value, [switch]$Sensitive)
|
|
$display = if ($Sensitive -and -not $Show) { '***redacted***' } else { $Value }
|
|
if ($DryRun) {
|
|
Write-Host "[DRY] $Name = $display" -ForegroundColor Yellow
|
|
} else {
|
|
Set-Item -Path "Env:$Name" -Value $Value
|
|
Write-Host "[SET] $Name = $display" -ForegroundColor Green
|
|
}
|
|
}
|
|
|
|
# --- main flow -------------------------------------------------------------
|
|
|
|
Write-Host 'mxaccess ASB passphrase loader' -ForegroundColor Cyan
|
|
Write-Host " registry: $ServicesKeyPath" -ForegroundColor DarkGray
|
|
|
|
$solution = Resolve-AsbSolutionName -Override $SolutionName
|
|
Write-Host " solution: $solution" -ForegroundColor DarkGray
|
|
|
|
$protected = Get-AsbSharedSecretBytes -Solution $solution
|
|
Write-Host " sharedsecret bytes: $($protected.Length) (DPAPI-protected)" -ForegroundColor DarkGray
|
|
|
|
$passphrase = Unprotect-AsbSecret -Protected $protected
|
|
Write-Host " passphrase chars: $($passphrase.Length) (decrypted)" -ForegroundColor DarkGray
|
|
Write-Host ''
|
|
|
|
Set-LiveEnvVar -Name 'MX_LIVE' -Value '1'
|
|
Set-LiveEnvVar -Name 'MX_ASB_HOST' -Value $AsbHost
|
|
# The endpoint URL targets the per-Galaxy MxDataProvider WCF service —
|
|
# `Default_<galaxy>_MxDataProvider`. That's a DIFFERENT name from the
|
|
# system-wide ArchestrA solution that owns the sharedsecret (typically
|
|
# `Archestra_<HOST>`); both live under the same registry root but the
|
|
# .NET probe at `src/MxAsbClient.Probe/Program.cs:5` hardcodes the
|
|
# MxDataProvider segment because that's what serves IASBIDataV2.
|
|
$mxDataProvider = "Default_${GalaxyName}_MxDataProvider"
|
|
# 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_SOLUTION' -Value $solution
|
|
Set-LiveEnvVar -Name 'MX_ASB_GALAXY_NAME' -Value $GalaxyName
|
|
Set-LiveEnvVar -Name 'MX_ASB_PASSPHRASE' -Value $passphrase -Sensitive
|
|
|
|
Write-Host ''
|
|
Write-Host 'Done. Run the example with:' -ForegroundColor Green
|
|
Write-Host ' cargo run -p mxaccess --example asb-subscribe' -ForegroundColor DarkGray
|