Add 2023 R2 gRPC transport (RemoteGrpc) reusing native byte payloads
Stands up HistorianTransport.RemoteGrpc end-to-end for the read path, built on the recovered 2023 R2 gRPC contract (gRPC-Web/HTTP-1.1, port 32565, gzip). The opaque protobuf `bytes` fields carry the SAME native binary payloads as the 2020 WCF/MDAS path, so the proven serializers and parsers are reused unchanged. - Grpc/Protos/*.proto: 6 protoc-validated contracts recovered from embedded FileDescriptors (authoritative, not guessed). - Grpc/HistorianGrpcChannelFactory: GrpcWebHandler/HTTP-1.1 channel, ResolvePort/ResolveAddress, optional TLS + gzip. - Grpc/HistorianGrpcReadOrchestrator: mirrors the WCF read chain over gRPC; auth uses HistoryService.ExchangeKey (the gRPC ValCl op). - Wcf/HistorianNativeHandshake: transport-agnostic Open2 request builder + SSPI/Negotiate token loop + response decode, shared by WCF and gRPC. - Op map (2020 -> gRPC): ValCl->ExchangeKey, Open2->OpenConnection, StartQuery2->StartQuery, GetNextQueryResultBuffer2->GetNextQueryResultBuffer. - HistorianClientOptions: DefaultGrpcPort=32565, GrpcUseTls. - csproj: Google.Protobuf, Grpc.Net.Client(.Web), Grpc.Tools codegen. Not yet live-verified against a 2023 R2 server: ExchangeKey is the first thing to revisit if a live server rejects the handshake; the inner byte payloads are the proven 2020 protocol. Gated live test via HISTORIAN_GRPC_HOST. 188 unit tests green; build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-agnostic pieces of the native Historian connect handshake: building the
|
||||
/// OpenConnection3 v6 request buffer, running the SSPI/NTLM token-exchange rounds, and
|
||||
/// decoding the OpenConnection response. Shared by the WCF/MDAS path
|
||||
/// (<see cref="HistorianWcfAuthChainHelper"/>) and the 2023 R2 gRPC path
|
||||
/// (<c>Grpc.HistorianGrpcReadOrchestrator</c>). The byte payloads are identical across
|
||||
/// transports — only the envelope (WCF operation vs gRPC method) differs.
|
||||
/// </summary>
|
||||
internal static class HistorianNativeHandshake
|
||||
{
|
||||
private const int CredentialBlockSizeBytes = 1026;
|
||||
private const int OpenConnectionMinResponseLength = 5;
|
||||
private const int MaxTokenRounds = 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;
|
||||
private const byte NativeClientCommonInfoFormatVersion = 4;
|
||||
private const ushort NativeHcalVersion = 17;
|
||||
private const uint NativeClientVersionInt = 999_999;
|
||||
private const ushort NativeOpen2ClientVersion = 9;
|
||||
|
||||
/// <summary>Result of one transport-level credential-token exchange.</summary>
|
||||
internal readonly record struct TokenExchangeResult(bool Success, byte[] ServerOutput, byte[] Error);
|
||||
|
||||
/// <summary>
|
||||
/// Performs a single credential-token round on the wire. <paramref name="handle"/> is the
|
||||
/// upper-case context-key GUID, <paramref name="wrappedToken"/> is the AVEVA-wrapped SSPI
|
||||
/// token (round byte + length + token). The WCF path maps this to
|
||||
/// <c>Hist.ValidateClientCredential</c>; the gRPC path maps it to
|
||||
/// <c>HistoryService.ExchangeKey</c> (the renamed handshake op).
|
||||
/// </summary>
|
||||
internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
|
||||
|
||||
/// <summary>
|
||||
/// Drives the SSPI/NTLM negotiate loop against the supplied <paramref name="exchange"/>
|
||||
/// delegate until the server signals terminal success. Mirrors the native two-round
|
||||
/// (69→239, 93→1) sequence.
|
||||
/// </summary>
|
||||
public static void RunTokenRounds(
|
||||
TokenExchange exchange,
|
||||
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 < MaxTokenRounds; round++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
HistorianSspiStepResult step = sspi.Next(incoming);
|
||||
byte[] outgoing = step.Token;
|
||||
HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(outgoing);
|
||||
byte[] wrapped = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(round == 0, outgoing);
|
||||
|
||||
TokenExchangeResult result = exchange(handle, wrapped, round);
|
||||
byte[] serverOutput = result.ServerOutput ?? [];
|
||||
byte[] error = result.Error ?? [];
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Credential token round {round} rejected (errorLen={error.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($"Credential token exchange exceeded {MaxTokenRounds} rounds without terminal success.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the native OpenConnection3 (Open2) version-6 request buffer. Identical bytes are
|
||||
/// sent over WCF (<c>Hist.OpenConnection2</c>) and gRPC
|
||||
/// (<c>HistoryService.OpenConnection.btConnectionRequest</c>).
|
||||
/// </summary>
|
||||
public 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]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the OpenConnection response blob: byte 0 = protocol version, bytes 1..4 =
|
||||
/// transient /Retr client handle (UInt32 LE), bytes 5..20 = storage session GUID.
|
||||
/// </summary>
|
||||
public static (uint ClientHandle, Guid StorageSessionId) ParseOpenConnectionResponse(ReadOnlySpan<byte> response)
|
||||
{
|
||||
if (response.Length < OpenConnectionMinResponseLength)
|
||||
{
|
||||
throw new InvalidOperationException($"OpenConnection response too short (ResponseLen={response.Length}).");
|
||||
}
|
||||
|
||||
uint clientHandle = BinaryPrimitives.ReadUInt32LittleEndian(response.Slice(1, 4));
|
||||
Guid storageSessionId = response.Length >= 21 ? new Guid(response.Slice(5, 16)) : Guid.Empty;
|
||||
return (clientHandle, storageSessionId);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.Versioning;
|
||||
using System.ServiceModel;
|
||||
using System.ServiceModel.Channels;
|
||||
using AVEVA.Historian.Client.Wcf.Contracts;
|
||||
@@ -10,12 +7,6 @@ namespace AVEVA.Historian.Client.Wcf;
|
||||
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;
|
||||
/// <summary>
|
||||
@@ -25,10 +16,6 @@ internal static class HistorianWcfAuthChainHelper
|
||||
/// Open2 is opened with 0x402 (read-only); 0x401 unlocks write capability.
|
||||
/// </summary>
|
||||
public const uint NativeIntegratedWriteEnabledConnectionMode = 0x401;
|
||||
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
|
||||
@@ -61,7 +48,7 @@ internal static class HistorianWcfAuthChainHelper
|
||||
historyChannel.GetInterfaceVersion(out _);
|
||||
RunValClRounds(historyChannel, contextKey, options, cancellationToken);
|
||||
|
||||
byte[] open2Request = BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
|
||||
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
|
||||
bool open2Success = historyChannel.OpenConnection2(ref open2Request, out byte[] open2Response, out byte[] open2Error);
|
||||
open2Response ??= [];
|
||||
open2Error ??= [];
|
||||
@@ -71,10 +58,7 @@ internal static class HistorianWcfAuthChainHelper
|
||||
$"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;
|
||||
(uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||
|
||||
if (additionalSetup is not null)
|
||||
{
|
||||
@@ -98,97 +82,15 @@ internal static class HistorianWcfAuthChainHelper
|
||||
|
||||
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)
|
||||
HistorianNativeHandshake.RunTokenRounds(
|
||||
(handle, wrapped, _) =>
|
||||
{
|
||||
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,
|
||||
bool serverSuccess = channel.ValidateClientCredential(handle, wrapped, out byte[] serverOutput, out byte[] errorBuffer);
|
||||
return new HistorianNativeHandshake.TokenExchangeResult(serverSuccess, serverOutput ?? [], errorBuffer ?? []);
|
||||
},
|
||||
contextKey,
|
||||
credentialBlock: new byte[CredentialBlockSizeBytes]);
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static void CloseChannelSafely(ICommunicationObject channel)
|
||||
|
||||
@@ -371,7 +371,7 @@ internal sealed class HistorianWcfReadOrchestrator
|
||||
}
|
||||
}
|
||||
|
||||
private static HistorianDataQueryRequest BuildDataQueryRequest(string tag, DateTime startUtc, DateTime endUtc, int maxValues)
|
||||
internal static HistorianDataQueryRequest BuildDataQueryRequest(string tag, DateTime startUtc, DateTime endUtc, int maxValues)
|
||||
{
|
||||
return new HistorianDataQueryRequest(
|
||||
TagNames: [tag],
|
||||
@@ -382,7 +382,7 @@ internal sealed class HistorianWcfReadOrchestrator
|
||||
Option: string.Empty);
|
||||
}
|
||||
|
||||
private static HistorianDataQueryRequest BuildAggregateQueryRequest(
|
||||
internal static HistorianDataQueryRequest BuildAggregateQueryRequest(
|
||||
string tag,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
@@ -427,7 +427,7 @@ internal sealed class HistorianWcfReadOrchestrator
|
||||
return (uint)mode;
|
||||
}
|
||||
|
||||
private static uint MapRetrievalModeToAggregationType(Models.RetrievalMode mode) => mode switch
|
||||
internal static uint MapRetrievalModeToAggregationType(Models.RetrievalMode mode) => mode switch
|
||||
{
|
||||
Models.RetrievalMode.TimeWeightedAverage => 0,
|
||||
Models.RetrievalMode.Interpolated => 3,
|
||||
|
||||
Reference in New Issue
Block a user