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,201 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using AVEVA.Historian.Client.Wcf.Contracts;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class HistorianWcfAuthChainHelper
|
||||
{
|
||||
private const int OpenConnection3MinResponseLength = 5;
|
||||
private const int CredentialBlockSizeBytes = 1026;
|
||||
private const int MaxValClRounds = 8;
|
||||
private const string ClientNodeNameFallback = "AVEVA.Historian.Client";
|
||||
private const string ClientDataSourceId = "2020.406.2652.2";
|
||||
private const string ClientDllVersionString = "2020.406.2652.2";
|
||||
private const byte NativeClientType = 4;
|
||||
public const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
|
||||
public const uint NativeIntegratedEventConnectionMode = 0x501;
|
||||
private const byte NativeClientCommonInfoFormatVersion = 4;
|
||||
private const ushort NativeHcalVersion = 17;
|
||||
private const uint NativeClientVersionInt = 999_999;
|
||||
private const ushort NativeOpen2ClientVersion = 9;
|
||||
|
||||
/// <summary>
|
||||
/// Runs Hist.GetV → Hist.ValCl × N → Hist.Open2 against the configured /Hist endpoint and
|
||||
/// returns the transient /Retr client handle decoded from the OpenConnection3 response.
|
||||
/// Caller is responsible for opening the matching /Retr channel.
|
||||
/// </summary>
|
||||
public static uint OpenAuthenticatedConnection(
|
||||
HistorianClientOptions options,
|
||||
Binding historyBinding,
|
||||
EndpointAddress historyEndpoint,
|
||||
Guid contextKey,
|
||||
CancellationToken cancellationToken,
|
||||
uint connectionMode = NativeIntegratedReadOnlyConnectionMode,
|
||||
Action<IHistoryServiceContract2, OpenConnectionContext>? additionalSetup = null)
|
||||
{
|
||||
ChannelFactory<IHistoryServiceContract2> historyFactory = new(historyBinding, historyEndpoint);
|
||||
|
||||
try
|
||||
{
|
||||
IHistoryServiceContract2 historyChannel = historyFactory.CreateChannel();
|
||||
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
|
||||
try
|
||||
{
|
||||
historyChannel.GetInterfaceVersion(out _);
|
||||
RunValClRounds(historyChannel, contextKey, options, cancellationToken);
|
||||
|
||||
byte[] open2Request = BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
|
||||
bool open2Success = historyChannel.OpenConnection2(ref open2Request, out byte[] open2Response, out byte[] open2Error);
|
||||
open2Response ??= [];
|
||||
open2Error ??= [];
|
||||
if (!open2Success || open2Response.Length < OpenConnection3MinResponseLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Open2 failed (Success={open2Success}, ResponseLen={open2Response.Length}, ErrorLen={open2Error.Length}).");
|
||||
}
|
||||
|
||||
uint clientHandle = BinaryPrimitives.ReadUInt32LittleEndian(open2Response.AsSpan(1, 4));
|
||||
Guid storageSessionId = open2Response.Length >= 21
|
||||
? new Guid(open2Response.AsSpan(5, 16))
|
||||
: Guid.Empty;
|
||||
|
||||
if (additionalSetup is not null)
|
||||
{
|
||||
additionalSetup(historyChannel, new OpenConnectionContext(contextKey, clientHandle, storageSessionId));
|
||||
}
|
||||
|
||||
return clientHandle;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseChannelSafely(historyChannelCo);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseFactorySafely(historyFactory);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct OpenConnectionContext(Guid ContextKey, uint ClientHandle, Guid StorageSessionId);
|
||||
|
||||
private static void RunValClRounds(IHistoryServiceContract2 channel, Guid contextKey, HistorianClientOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
using HistorianSspiClient sspi = options.IntegratedSecurity
|
||||
? new HistorianSspiClient(options.TargetSpn)
|
||||
: new HistorianSspiClient(options.TargetSpn, ParseDomain(options.UserName), ParseUserName(options.UserName), options.Password);
|
||||
string handle = contextKey.ToString("D").ToUpperInvariant();
|
||||
byte[] incoming = [];
|
||||
|
||||
for (int round = 0; round < MaxValClRounds; round++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
HistorianSspiStepResult step = sspi.Next(incoming);
|
||||
byte[] outgoing = step.Token;
|
||||
HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(outgoing);
|
||||
byte[] wrapped = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(round == 0, outgoing);
|
||||
|
||||
bool serverSuccess = channel.ValidateClientCredential(handle, wrapped, out byte[] serverOutput, out byte[] errorBuffer);
|
||||
serverOutput ??= [];
|
||||
errorBuffer ??= [];
|
||||
|
||||
if (!serverSuccess)
|
||||
{
|
||||
throw new InvalidOperationException($"ValCl round {round} rejected (errorLen={errorBuffer.Length}).");
|
||||
}
|
||||
|
||||
ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput);
|
||||
if (response is null || !response.Continue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
incoming = response.Token;
|
||||
if (step.IsCompleted && incoming.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"ValCl exceeded {MaxValClRounds} rounds without terminal success.");
|
||||
}
|
||||
|
||||
private static string ParseDomain(string userName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userName)) return string.Empty;
|
||||
int slash = userName.IndexOf('\\');
|
||||
return slash > 0 ? userName[..slash] : string.Empty;
|
||||
}
|
||||
|
||||
private static string ParseUserName(string userName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userName)) return string.Empty;
|
||||
int slash = userName.IndexOf('\\');
|
||||
return slash > 0 ? userName[(slash + 1)..] : userName;
|
||||
}
|
||||
|
||||
private static byte[] BuildOpenConnection3Request(string host, Guid contextKey, uint connectionMode)
|
||||
{
|
||||
Process current = Process.GetCurrentProcess();
|
||||
string machineName = Environment.MachineName;
|
||||
string processName = string.IsNullOrEmpty(current.ProcessName) ? ClientNodeNameFallback : current.ProcessName;
|
||||
_ = host; // host reserved for remote-orchestrator extension
|
||||
|
||||
HistorianOpen2Request open2 = new(
|
||||
HostName: machineName,
|
||||
ProcessName: string.Empty,
|
||||
ProcessId: checked((uint)current.Id),
|
||||
UserName: string.Empty,
|
||||
Password: [],
|
||||
ClientType: NativeClientType,
|
||||
ClientVersion: NativeOpen2ClientVersion,
|
||||
ConnectionMode: connectionMode,
|
||||
MetadataNamespace: HistorianMetadataNamespace.Empty);
|
||||
|
||||
HistorianClientCommonInfo commonInfo = new(
|
||||
FormatVersion: NativeClientCommonInfoFormatVersion,
|
||||
ServerNodeName: machineName,
|
||||
ClientNodeName: processName,
|
||||
ProcessId: checked((uint)current.Id),
|
||||
HcalVersion: NativeHcalVersion,
|
||||
ProcessName: string.Empty,
|
||||
Proxy: string.Empty,
|
||||
DataSourceId: ClientDataSourceId,
|
||||
ShardId: Guid.Empty,
|
||||
ClientVersion: NativeClientVersionInt,
|
||||
ClientTimestamp: (ulong)DateTime.UtcNow.ToFileTimeUtc(),
|
||||
ClientDllVersion: ClientDllVersionString);
|
||||
|
||||
return HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
|
||||
open2,
|
||||
commonInfo,
|
||||
contextKey,
|
||||
credentialBlock: new byte[CredentialBlockSizeBytes]);
|
||||
}
|
||||
|
||||
private static void CloseChannelSafely(ICommunicationObject channel)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (channel.State == CommunicationState.Faulted) channel.Abort();
|
||||
else channel.Close();
|
||||
}
|
||||
catch { try { channel.Abort(); } catch { } }
|
||||
}
|
||||
|
||||
private static void CloseFactorySafely<TChannel>(ChannelFactory<TChannel> factory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (factory.State == CommunicationState.Faulted) factory.Abort();
|
||||
else factory.Close();
|
||||
}
|
||||
catch { try { factory.Abort(); } catch { } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user