c95824a65d
Full read-only SDK (src/AVEVA.Historian.Client) implementing the CLAUDE.md required
surface against AVEVA Historian's binary WCF protocol — no native AVEVA runtime
dependency. All operations live-verified against a local Historian:
- ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, ReadEventsAsync
- BrowseTagNamesAsync, GetTagMetadataAsync (17 native data-type codes mapped)
- GetConnectionStatusAsync, GetStoreForwardStatusAsync, GetSystemParameterAsync
- 108/108 unit + integration tests pass
Includes the reverse-engineering toolkit (tools/AVEVA.Historian.ReverseEngineering)
used to decode the protocol: WCF probes, IL inspection via dnlib, and IL-rewrite
instrumentation (instrument-wcf-{write,read}message etc.) plus the .NET Framework
trace harness (tools/AVEVA.Historian.NativeTraceHarness) for parity testing.
Sanitized handoff evidence under docs/reverse-engineering/. Native AVEVA binaries
(current/, aveva-install-x64/, aveva-install-x86/) are gitignored — fetch separately
from the AVEVA installer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
225 lines
7.7 KiB
PowerShell
225 lines
7.7 KiB
PowerShell
param(
|
|
[string]$SshUser = "dohertj2",
|
|
[string]$SshHost = "10.100.0.35",
|
|
[string]$RelayListenHost = "0.0.0.0",
|
|
[int]$RelayListenPort = 32568,
|
|
[string]$TargetHost = "10.100.0.48",
|
|
[int]$TargetPort = 32568,
|
|
[switch]$RewriteEndpointHost,
|
|
[ValidateSet("history", "event")]
|
|
[string]$Scenario = "history",
|
|
[string]$TagName = "OtOpcUaParityTest_001.Counter",
|
|
[string]$RetrievalMode = "Full",
|
|
[int]$LookbackMinutes = 1440,
|
|
[int]$MaxRows = 1,
|
|
[UInt64]$ResolutionTicks = 0,
|
|
[string[]]$HarnessExtraArgs = @(),
|
|
[string]$OutputPath = $null,
|
|
[string]$HarnessOutputPath = $null
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
|
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
|
$OutputPath = Join-Path $repoRoot "docs\reverse-engineering\debian-relay-$Scenario-$stamp.ndjson"
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($HarnessOutputPath)) {
|
|
$HarnessOutputPath = Join-Path $repoRoot "docs\reverse-engineering\native-trace-harness-via-debian-relay-$Scenario-$stamp.json"
|
|
}
|
|
|
|
$localPy = Join-Path $env:TEMP "histsdk-relay-$stamp.py"
|
|
$remotePy = "/tmp/histsdk-relay-$stamp.py"
|
|
$remoteLog = "/tmp/histsdk-relay-$stamp.ndjson"
|
|
$sshTarget = "$SshUser@$SshHost"
|
|
|
|
$relaySource = @'
|
|
import argparse, json, selectors, socket, time
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--listen', default='0.0.0.0')
|
|
parser.add_argument('--listen-port', type=int, default=32568)
|
|
parser.add_argument('--target', required=True)
|
|
parser.add_argument('--target-port', type=int, default=32568)
|
|
parser.add_argument('--prefix', type=int, default=16)
|
|
parser.add_argument('--rewrite-from', default='')
|
|
parser.add_argument('--rewrite-to', default='')
|
|
args = parser.parse_args()
|
|
sel = selectors.DefaultSelector()
|
|
peers = {}
|
|
conn_id = 0
|
|
|
|
def emit(kind, **payload):
|
|
payload['kind'] = kind
|
|
payload['ts'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
|
print(json.dumps(payload, separators=(',', ':')), flush=True)
|
|
|
|
def safe_ascii(data):
|
|
out = []
|
|
for b in data:
|
|
if 32 <= b <= 126:
|
|
out.append(chr(b))
|
|
elif b in (9, 10, 13):
|
|
out.append(' ')
|
|
else:
|
|
out.append('.')
|
|
return ''.join(out)
|
|
|
|
def classify(data):
|
|
if data.startswith(b'\x00\x01\x00\x01\x02\x02') and b'net.tcp:/' in data:
|
|
return 'nettcp-preamble'
|
|
if data == b'\x0a':
|
|
return 'nettcp-preamble-ack'
|
|
if data.startswith(b'\x16\x03'):
|
|
return 'tls-record'
|
|
if len(data) >= 9 and data[5:13] == b'NTLMSSP\x00':
|
|
return 'ntlmssp'
|
|
if len(data) >= 11 and data[7:15] == b'NTLMSSP\x00':
|
|
return 'ntlmssp'
|
|
if data.startswith(b'\x09\x15application/'):
|
|
return 'nettcp-content-type'
|
|
return 'unknown'
|
|
|
|
def safe_detail(data):
|
|
kind = classify(data)
|
|
if kind in ('nettcp-preamble', 'nettcp-content-type'):
|
|
return safe_ascii(data)
|
|
return None
|
|
|
|
rewrite_from = args.rewrite_from.encode('ascii') if args.rewrite_from else b''
|
|
rewrite_to = args.rewrite_to.encode('ascii') if args.rewrite_to else b''
|
|
if rewrite_from and len(rewrite_from) != len(rewrite_to):
|
|
raise SystemExit('rewrite strings must have equal byte length')
|
|
|
|
def accept(sock):
|
|
global conn_id
|
|
client, addr = sock.accept()
|
|
client.setblocking(False)
|
|
upstream = socket.create_connection((args.target, args.target_port), timeout=8)
|
|
upstream.setblocking(False)
|
|
conn_id += 1
|
|
cid = conn_id
|
|
peers[client] = (upstream, cid, 'c2s')
|
|
peers[upstream] = (client, cid, 's2c')
|
|
sel.register(client, selectors.EVENT_READ, relay)
|
|
sel.register(upstream, selectors.EVENT_READ, relay)
|
|
emit('connect', id=cid, client=str(addr), target=f'{args.target}:{args.target_port}')
|
|
|
|
def close_pair(sock):
|
|
peer_info = peers.pop(sock, None)
|
|
if peer_info is None:
|
|
return
|
|
other, cid, direction = peer_info
|
|
peers.pop(other, None)
|
|
for s in (sock, other):
|
|
try: sel.unregister(s)
|
|
except Exception: pass
|
|
try: s.close()
|
|
except Exception: pass
|
|
emit('close', id=cid, direction=direction)
|
|
|
|
def relay(sock):
|
|
peer_info = peers.get(sock)
|
|
if peer_info is None:
|
|
return
|
|
other, cid, direction = peer_info
|
|
try:
|
|
data = sock.recv(65536)
|
|
except Exception as exc:
|
|
emit('recv_error', id=cid, direction=direction, error=repr(exc))
|
|
close_pair(sock)
|
|
return
|
|
if not data:
|
|
close_pair(sock)
|
|
return
|
|
if direction == 'c2s' and rewrite_from and rewrite_from in data:
|
|
data = data.replace(rewrite_from, rewrite_to)
|
|
emit('rewrite', id=cid, direction=direction, from_value=args.rewrite_from, to_value=args.rewrite_to)
|
|
detail = safe_detail(data)
|
|
event = {
|
|
'id': cid,
|
|
'direction': direction,
|
|
'bytes': len(data),
|
|
'classification': classify(data),
|
|
'prefix': data[:args.prefix].hex()
|
|
}
|
|
if detail is not None:
|
|
event['safe_ascii'] = detail
|
|
emit('data', **event)
|
|
try:
|
|
other.sendall(data)
|
|
except Exception as exc:
|
|
emit('send_error', id=cid, direction=direction, error=repr(exc))
|
|
close_pair(sock)
|
|
|
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
listener.bind((args.listen, args.listen_port))
|
|
listener.listen(16)
|
|
listener.setblocking(False)
|
|
sel.register(listener, selectors.EVENT_READ, accept)
|
|
emit('listening', listen=f'{args.listen}:{args.listen_port}', target=f'{args.target}:{args.target_port}')
|
|
while True:
|
|
for key, _ in sel.select(timeout=1):
|
|
key.data(key.fileobj)
|
|
'@
|
|
|
|
Push-Location $repoRoot
|
|
try {
|
|
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutputPath) | Out-Null
|
|
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $HarnessOutputPath) | Out-Null
|
|
Set-Content -LiteralPath $localPy -Value $relaySource -Encoding UTF8
|
|
scp -q $localPy "${sshTarget}:$remotePy"
|
|
|
|
$rewriteArgs = ""
|
|
if ($RewriteEndpointHost) {
|
|
if ($SshHost.Length -ne $TargetHost.Length) {
|
|
throw "RewriteEndpointHost requires SshHost and TargetHost to have equal string length."
|
|
}
|
|
$rewriteArgs = " --rewrite-from $SshHost --rewrite-to $TargetHost"
|
|
}
|
|
|
|
$remoteCommand = "nohup python3 $remotePy --listen $RelayListenHost --listen-port $RelayListenPort --target $TargetHost --target-port $TargetPort$rewriteArgs > $remoteLog 2>&1 < /dev/null & echo `$!"
|
|
$relayPid = (ssh -o BatchMode=yes -o ConnectTimeout=8 $sshTarget $remoteCommand).Trim()
|
|
Start-Sleep -Seconds 1
|
|
ssh -o BatchMode=yes $sshTarget "cat $remoteLog" | Tee-Object -FilePath $OutputPath | Out-Host
|
|
|
|
$harnessArgs = @(
|
|
"--scenario", $Scenario,
|
|
"--server-name", $SshHost,
|
|
"--tag", $TagName,
|
|
"--retrieval-mode", $RetrievalMode,
|
|
"--lookback-minutes", $LookbackMinutes.ToString(),
|
|
"--max-rows", $MaxRows.ToString()
|
|
)
|
|
|
|
if ($ResolutionTicks -gt 0) {
|
|
$harnessArgs += @("--resolution-ticks", $ResolutionTicks.ToString())
|
|
}
|
|
|
|
if ($HarnessExtraArgs.Count -gt 0) {
|
|
$harnessArgs += $HarnessExtraArgs
|
|
}
|
|
|
|
& .\tools\AVEVA.Historian.NativeTraceHarness\bin\Debug\net481\AVEVA.Historian.NativeTraceHarness.exe @harnessArgs |
|
|
Set-Content -Path $HarnessOutputPath -Encoding UTF8
|
|
$harnessExit = $LASTEXITCODE
|
|
|
|
ssh -o BatchMode=yes $sshTarget "cat $remoteLog" | Set-Content -Path $OutputPath -Encoding UTF8
|
|
exit $harnessExit
|
|
}
|
|
finally {
|
|
try {
|
|
if ($relayPid) {
|
|
ssh -o BatchMode=yes $sshTarget "kill $relayPid 2>/dev/null || true; rm -f $remotePy" | Out-Null
|
|
}
|
|
}
|
|
catch {
|
|
Write-Warning "Relay cleanup failed: $_"
|
|
}
|
|
|
|
Remove-Item -LiteralPath $localPy -ErrorAction SilentlyContinue
|
|
Pop-Location
|
|
}
|