Initial commit: managed .NET 10 AVEVA Historian SDK + reverse-engineering toolkit
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>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
param(
|
||||
[string]$SshUser = "dohertj2",
|
||||
[string]$SshHost = "10.100.0.35",
|
||||
[string]$TargetHost = "10.100.0.48",
|
||||
[int]$ListenPort = 32568,
|
||||
[int]$TargetPort = 32568,
|
||||
[ValidateSet("history", "event")]
|
||||
[string]$Scenario = "history",
|
||||
[string]$TagName = "OtOpcUaParityTest_001.Counter",
|
||||
[int]$LookbackMinutes = 1440,
|
||||
[int]$MaxRows = 1,
|
||||
[string]$OutputPath = $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\system-boundary-via-debian-relay-$Scenario-$stamp.ndjson"
|
||||
}
|
||||
|
||||
$localPy = Join-Path $env:TEMP "histsdk-simple-relay-$stamp.py"
|
||||
$remotePy = "/tmp/histsdk-simple-relay-$stamp.py"
|
||||
$remoteLog = "/tmp/histsdk-simple-relay-$stamp.log"
|
||||
$sshTarget = "$SshUser@$SshHost"
|
||||
$tcpOwnerLog = Join-Path $env:TEMP "histsdk-tcp-owner-$stamp.ndjson"
|
||||
|
||||
$relaySource = @'
|
||||
import argparse, selectors, socket, sys, time
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--listen-port", type=int, default=32568)
|
||||
parser.add_argument("--target", required=True)
|
||||
parser.add_argument("--target-port", type=int, default=32568)
|
||||
args = parser.parse_args()
|
||||
|
||||
sel = selectors.DefaultSelector()
|
||||
peers = {}
|
||||
|
||||
def log(text):
|
||||
print(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), text, flush=True)
|
||||
|
||||
def close_pair(sock):
|
||||
peer = peers.pop(sock, None)
|
||||
if peer is None:
|
||||
return
|
||||
peers.pop(peer, None)
|
||||
for item in (sock, peer):
|
||||
try: sel.unregister(item)
|
||||
except Exception: pass
|
||||
try: item.close()
|
||||
except Exception: pass
|
||||
|
||||
def relay(sock):
|
||||
peer = peers.get(sock)
|
||||
if peer is None:
|
||||
return
|
||||
try:
|
||||
data = sock.recv(65536)
|
||||
except Exception as exc:
|
||||
log("recv_error " + repr(exc))
|
||||
close_pair(sock)
|
||||
return
|
||||
if not data:
|
||||
close_pair(sock)
|
||||
return
|
||||
try:
|
||||
peer.sendall(data)
|
||||
except Exception as exc:
|
||||
log("send_error " + repr(exc))
|
||||
close_pair(sock)
|
||||
|
||||
def accept(listener):
|
||||
client, address = listener.accept()
|
||||
client.setblocking(False)
|
||||
upstream = socket.create_connection((args.target, args.target_port), timeout=8)
|
||||
upstream.setblocking(False)
|
||||
peers[client] = upstream
|
||||
peers[upstream] = client
|
||||
sel.register(client, selectors.EVENT_READ, relay)
|
||||
sel.register(upstream, selectors.EVENT_READ, relay)
|
||||
log("connect " + repr(address))
|
||||
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind(("0.0.0.0", args.listen_port))
|
||||
listener.listen(16)
|
||||
listener.setblocking(False)
|
||||
sel.register(listener, selectors.EVENT_READ, accept)
|
||||
log("listening")
|
||||
|
||||
while True:
|
||||
for key, _ in sel.select(timeout=1):
|
||||
key.data(key.fileobj)
|
||||
'@
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
Set-Content -LiteralPath $localPy -Value $relaySource -Encoding UTF8
|
||||
scp -q $localPy "${sshTarget}:$remotePy"
|
||||
$remoteCommand = "nohup python3 $remotePy --listen-port $ListenPort --target $TargetHost --target-port $TargetPort > $remoteLog 2>&1 < /dev/null & echo `$!"
|
||||
$relayPid = (ssh -o BatchMode=yes -o ConnectTimeout=8 $sshTarget $remoteCommand).Trim()
|
||||
Start-Sleep -Seconds 1
|
||||
"--- relay startup log ---" | Tee-Object -FilePath $OutputPath -Append | Out-Host
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=8 $sshTarget "cat $remoteLog 2>/dev/null || true" |
|
||||
Tee-Object -FilePath $OutputPath -Append |
|
||||
Out-Host
|
||||
|
||||
$monitorJob = Start-Job -ScriptBlock {
|
||||
param($RemoteAddress, $RemotePort, $LogPath)
|
||||
$deadline = (Get-Date).AddSeconds(45)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Get-NetTCPConnection -RemoteAddress $RemoteAddress -RemotePort $RemotePort -ErrorAction SilentlyContinue |
|
||||
ForEach-Object {
|
||||
$processName = $null
|
||||
try {
|
||||
$processName = (Get-Process -Id $_.OwningProcess -ErrorAction Stop).ProcessName
|
||||
}
|
||||
catch {
|
||||
$processName = $null
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
TimestampUtc = (Get-Date).ToUniversalTime().ToString("O")
|
||||
LocalAddress = $_.LocalAddress
|
||||
LocalPort = $_.LocalPort
|
||||
RemoteAddress = $_.RemoteAddress
|
||||
RemotePort = $_.RemotePort
|
||||
State = $_.State.ToString()
|
||||
OwningProcess = $_.OwningProcess
|
||||
ProcessName = $processName
|
||||
} | ConvertTo-Json -Compress
|
||||
} | Add-Content -LiteralPath $LogPath
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
} -ArgumentList $SshHost, $ListenPort, $tcpOwnerLog
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Attach-NativeTraceHarnessSystemBoundaryCapture.ps1 `
|
||||
-Scenario $Scenario `
|
||||
-ServerName $SshHost `
|
||||
-TagName $TagName `
|
||||
-LookbackMinutes $LookbackMinutes `
|
||||
-MaxRows $MaxRows `
|
||||
-PreLoadSleepSeconds 10 `
|
||||
-OutputPath $OutputPath
|
||||
|
||||
Stop-Job -Job $monitorJob -ErrorAction SilentlyContinue
|
||||
Receive-Job -Job $monitorJob -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-Job -Job $monitorJob -Force -ErrorAction SilentlyContinue
|
||||
|
||||
"--- windows tcp owner monitor ---" | Tee-Object -FilePath $OutputPath -Append | Out-Host
|
||||
Get-Content -LiteralPath $tcpOwnerLog -ErrorAction SilentlyContinue |
|
||||
Sort-Object -Unique |
|
||||
Tee-Object -FilePath $OutputPath -Append |
|
||||
Out-Host
|
||||
|
||||
"--- relay final log ---" | Tee-Object -FilePath $OutputPath -Append | Out-Host
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=8 $sshTarget "cat $remoteLog 2>/dev/null || true" |
|
||||
Tee-Object -FilePath $OutputPath -Append |
|
||||
Out-Host
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
if ($relayPid) {
|
||||
ssh -o BatchMode=yes $sshTarget "kill $relayPid 2>/dev/null || true; rm -f $remotePy $remoteLog" | Out-Null
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Relay cleanup failed: $_"
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $localPy -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $tcpOwnerLog -ErrorAction SilentlyContinue
|
||||
Pop-Location
|
||||
}
|
||||
Reference in New Issue
Block a user