R1.4 GetHistorianInfo: bounded out on 2020 WCF (named-value-only, no struct)

Captured the native HistorianAccess.GetHistorianInfo(out HistorianInfo, out err)
and decoded the wire: over 2020 WCF, GETHI is a named-value query whose only
working key is "HistorianVersion" (response ~30 bytes = the version string).
Probed 7 storage-mode key names -> all ok=False/err. The 518-byte HISTORIAN_INFO
struct + EventStorageMode@514 is the 2023R2 HCAL-native/gRPC model (confirmed
from the decompiled 2023R2 source); on 2020 the native client derives the mode
outside the WCF wire.

Version is already exposed (ProbeAsync/GetRuntimeParameterAsync), so no hollow
GetHistorianInfoAsync is shipped (same disposition as R1.3 timezone). This
completes the reachable 2020-WCF M1 read surface; remaining M1 = config writes
(gated on explicit request) or gRPC/2023R2-only items.

RE aids kept: harness `historian-info` scenario, Capture-HistorianInfo.ps1,
decode-historian-info-capture.py, and StringHandleProbeDiagnosticTests
.GETHI_CandidateInfoNames (asserts the named-value-only finding; gated).
Docs: wcf-historian-info.md (new) + roadmap/matrix/wall-doc updates. 230 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-20 23:42:27 -04:00
parent 1a539882d0
commit fbd839077b
8 changed files with 441 additions and 7 deletions
+102
View File
@@ -0,0 +1,102 @@
<#
.SYNOPSIS
Captures the native AVEVA client's GetHistorianInfo wire traffic (HCAL roadmap R1.4)
so the WCF GETHI request that returns the FULL HISTORIAN_INFO struct can be decoded
instead of guessed.
.DESCRIPTION
Drives the .NET-Framework NativeTraceHarness's `historian-info` scenario against the live
Historian with an IL-rewritten copy of aahClientManaged.dll whose
ClientMessageEncoder.WriteMessage AND ReadMessage are instrumented to log every MDAS body
(the same pipeline that produced every other proven request/response shape). The harness
opens a normal authenticated process connection and calls
HistorianAccess.GetHistorianInfo(out HistorianInfo, out err).
Decode with scripts/decode-historian-info-capture.py: locate the WCF.WriteMessage.Body
whose op is GETHI -> that is the GetHistorianInfo request; read off the leading string
handle and the pRequestBuff layout (distinct from the named-value "HistorianVersion"
request). The paired WCF.ReadMessage.Body is the pResponseBuff = the 518-byte
HISTORIAN_INFO struct (version string @0 UTF-16 null-terminated, EventStorageMode int32 @514).
.NOTES
Read-only status call; no data is written. Artifacts are diagnostic and gitignored.
Sanitize before copying anything into docs/ -- never commit raw capture NDJSON,
credentials, hostnames, or customer tag names.
#>
[CmdletBinding()]
param(
[string]$ServerName = "localhost",
[int]$TcpPort = 32568,
[string]$Configuration = "Debug"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-historian-info"
$currentCopy = Join-Path $captureDir "current-copy"
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
$capturePath = Join-Path $captureDir "historian-info-capture-latest.ndjson"
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
Write-Host "== Instrumenting WriteMessage + ReadMessage ==" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
# Chain via a distinct intermediate file (reading+writing the same path drops the second
# hook on the mixed-mode native image). Final dll carries both hooks with distinct Phase
# strings: WCF.WriteMessage.Body and WCF.ReadMessage.Body.
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
Write-Host "== Capturing historian-info ==" -ForegroundColor Green
$harnessArgs = @(
"--scenario", "historian-info",
"--server-name", $ServerName,
"--tcp-port", "$TcpPort",
"--current-dir", $currentCopy,
"--managed-dll-path", $harnessDll
)
$harnessJson = $null
try {
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$harnessJson = & dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1
} catch {
Write-Host " (historian-info raised: $($_.Exception.Message))" -ForegroundColor Yellow
} finally {
$ErrorActionPreference = $prevEap
}
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
Write-Host " -> $recCount records -> $capturePath"
Write-Host "Harness output (GetHistorianInfoReturned / HistorianInfo):" -ForegroundColor Cyan
$harnessJson | Select-Object -Last 24
Write-Host "`nDecode with: python scripts\decode-historian-info-capture.py" -ForegroundColor Cyan
+142
View File
@@ -0,0 +1,142 @@
"""Decode the GetHistorianInfo (GETHI) WCF request/response (HCAL R1.4).
Reads the chained WriteMessage+ReadMessage capture produced by
scripts/Capture-HistorianInfo.ps1 and locates the GetHistorianInfo exchange. The goal is
to learn (a) the pRequestBuff that returns the FULL HISTORIAN_INFO struct (distinct from the
named-value "HistorianVersion" request) and (b) the response struct layout: the analysis
folder says it's 518 bytes with the version string (UTF-16, null-terminated) at offset 0 and
EventStorageMode (int32) at offset 514.
We flag candidate bodies by the GETHI op action, by the server version value, and by a
response length near 518, then dump bytes + the int32 at offset 514 so the layout can be
read off directly.
Output is diagnostic. Sanitize before copying into docs/.
"""
import base64
import json
import struct
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CAPDIR = REPO_ROOT / "artifacts" / "reverse-engineering" / "instrumented-wcf-historian-info"
CAP = CAPDIR / "historian-info-capture-latest.ndjson"
# The GETHI op action (WS-Addressing) the native client sends. The server version value is
# version-shaped, not secret; used only to locate the response.
OP_ASCII = b"GetHistorianInfo"
OP_GETHI = b"GETHI"
VERSION = "20,0,000,000"
VERSION_U16 = VERSION.encode("utf-16-le")
VERSION_ASCII = VERSION.encode("ascii")
def hexdump(label, buf, base=0):
print(f"=== {label}: {len(buf)} bytes ===")
for off in range(0, len(buf), 16):
c = buf[off:off + 16]
hp = " ".join(f"{x:02X}" for x in c)
ap = "".join(chr(x) if 32 <= x < 127 else "." for x in c)
print(f" {base + off:04X} {hp:<48} |{ap}|")
print()
def ascii_strings(buf, minlen=3):
out, cur, start = [], [], 0
for i, x in enumerate(buf):
if 32 <= x < 127:
if not cur:
start = i
cur.append(chr(x))
else:
if len(cur) >= minlen:
out.append((start, "".join(cur)))
cur = []
if len(cur) >= minlen:
out.append((start, "".join(cur)))
return out
def u16_strings(buf, minlen=3):
out, i = [], 0
while i < len(buf) - 1:
j, chars = i, []
while j < len(buf) - 1 and 32 <= buf[j] < 127 and buf[j + 1] == 0:
chars.append(chr(buf[j]))
j += 2
if len(chars) >= minlen:
out.append((i, "".join(chars)))
i = j
else:
i += 1
return out
def main() -> int:
if not CAP.exists():
print(f"Missing capture: {CAP}\nRun scripts/Capture-HistorianInfo.ps1 first.")
return 1
records = []
for line in CAP.open(encoding="utf-8-sig"):
if line.strip():
records.append(json.loads(line))
print(f"== {len(records)} MDAS bodies captured ==")
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
flags = []
if OP_ASCII in body or OP_GETHI in body:
flags.append("GETHI-OP")
if VERSION_U16 in body or VERSION_ASCII in body:
flags.append("VERSION")
# A ~518-byte embedded struct is the tell for the full-info response.
if 500 <= len(body) <= 4096:
flags.append(f"len={len(body)}")
print(f" [{idx:02d}] {rec.get('Phase'):26s} len={len(body):5d} {','.join(flags)}")
def find(predicate):
hits = []
for idx, rec in enumerate(records):
body = base64.b64decode(rec["Base64"])
if predicate(rec, body):
hits.append((idx, rec, body))
return hits
print("\n== Request candidate(s): WriteMessage bodies tagged GETHI-OP ==")
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.WriteMessage.Body"
and (OP_ASCII in b or OP_GETHI in b)):
hexdump(f"[{idx}] WriteMessage", body)
print(" UTF-16 strings:")
for off, s in u16_strings(body):
print(f" 0x{off:04X} {s!r}")
print(" ASCII strings:")
for off, s in ascii_strings(body):
print(f" 0x{off:04X} {s!r}")
print()
print("\n== Response candidate(s): ReadMessage bodies carrying VERSION ==")
for idx, rec, body in find(lambda r, b: r.get("Phase") == "WCF.ReadMessage.Body"
and (VERSION_U16 in b or VERSION_ASCII in b)):
hexdump(f"[{idx}] ReadMessage", body)
print(" UTF-16 strings:")
for off, s in u16_strings(body):
print(f" 0x{off:04X} {s!r}")
# The analysis folder pins EventStorageMode @ offset 514 (int32) inside the
# 518-byte struct. The struct is embedded in the MDAS body at some base; scan for
# a plausible version@0 run and print the int32 514 bytes after each candidate base.
print(" Candidate struct decodes (version@base, int32 @ base+514):")
for base_off, s in u16_strings(body):
if any(ch.isdigit() for ch in s) and "," in s:
idx514 = base_off + 514
if idx514 + 4 <= len(body):
mode = struct.unpack_from("<i", body, idx514)[0]
print(f" base=0x{base_off:04X} version={s!r} int32@+514={mode}")
print()
return 0
if __name__ == "__main__":
sys.exit(main())