diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs index ff0d62f..4c2dfc9 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs @@ -138,6 +138,11 @@ internal sealed class HistorianGrpcEventOrchestrator private List RunEventChain(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken) { using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); + // NOTE: the event read needs an Event-type (v8) connection, but the v8 OpenConnection is + // server-coupled to HistoryService.ExchangeKey (ECDH) — it rejects a v8 open made on our + // ValidateClientCredential session with native 132/34 "EstablishConnection Failed to get client + // key" (Path A, disproven 2026-06-23). Staying on the v6 session until ExchangeKey is + // implemented (Path B). The v8 serializer + BuildEventOpenConnectionVersion8Request are ready. HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken); RegisterCmEventTag(connection, session, cancellationToken); diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs index 79d88b1..eb0972c 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs @@ -50,7 +50,8 @@ internal static class HistorianGrpcHandshake HistorianGrpcConnection connection, HistorianClientOptions options, CancellationToken cancellationToken, - uint connectionMode = HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode) + uint connectionMode = HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode, + bool eventConnection = false) { DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout); @@ -79,8 +80,12 @@ internal static class HistorianGrpcHandshake options, cancellationToken); - byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request( - options.Host, contextKey, connectionMode); + // Event reads require an Event-type connection (ConnectionType=Event), which only the native + // v8 OpenConnection format carries — the v6 buffer has no such field. Reuse the same + // ValidateClientCredential session; the v8 credential token is zeroed (auth already done). + byte[] open2Request = eventConnection + ? HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request(contextKey, options.UserName) + : HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode); GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection( new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) }, @@ -92,7 +97,11 @@ internal static class HistorianGrpcHandshake if (!(open2.Status?.BSuccess ?? false)) { byte[] err = open2.Status?.BtError?.ToByteArray() ?? []; - throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length})."); + HistorianNativeError? decoded = HistorianOpen2Protocol.TryReadNativeError(err); + string ascii = new(err.Where(b => b is >= 0x20 and < 0x7F).Select(b => (char)b).ToArray()); + throw new InvalidOperationException( + $"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}, " + + $"native={decoded?.Type}/{decoded?.Code}{(decoded?.Name is { } n ? $" {n}" : "")}, ascii='{ascii}')."); } (uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs index fcec618..77d9d4c 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs @@ -134,6 +134,56 @@ internal static class HistorianNativeHandshake credentialBlock: new byte[CredentialBlockSizeBytes]); } + // v8 OpenConnection constants (2023 R2), decoded from the native Event-connection capture. + private const byte NativeConnectionTypeEvent = 1; + private const byte NativeConnectionFlagEvent = 1; + private const ushort NativeHcalVersionV8 = 18; + private const string ClientDataSourceIdV8 = "2023.1219.4004.5"; + private const byte NativeClientCommonInfoFormatVersionV8 = 3; + private const int CredentialTokenSizeBytes = 26; + private static readonly Guid UnsetShardId = new(new byte[] + { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + }); + + /// + /// Builds the native 2023 R2 version-8 OpenConnection request for an Event connection + /// (ConnectionType=Event). The 2023 R2 server returns event rows only on an event connection, + /// and the v6 OpenConnection buffer has no ConnectionType field. The credential token is zeroed: the + /// session is authenticated by the preceding ValidateClientCredential handshake, exactly as + /// the v6 path already relies on (it sends a zeroed credential block). See + /// docs/reverse-engineering/grpc-event-query-capture.md. + /// + public static byte[] BuildEventOpenConnectionVersion8Request(Guid contextKey, string userName) + { + Process current = Process.GetCurrentProcess(); + string machineName = Environment.MachineName; + string processName = string.IsNullOrEmpty(current.ProcessName) ? ClientNodeNameFallback : current.ProcessName; + + HistorianClientCommonInfo commonInfo = new( + FormatVersion: NativeClientCommonInfoFormatVersionV8, + ServerNodeName: machineName, + ClientNodeName: processName, + ProcessId: checked((uint)current.Id), + HcalVersion: NativeHcalVersionV8, + ProcessName: string.Empty, + Proxy: string.Empty, + DataSourceId: ClientDataSourceIdV8, + ShardId: UnsetShardId, + ClientVersion: NativeClientVersionInt, + ClientTimestamp: (ulong)DateTime.UtcNow.ToFileTimeUtc(), + ClientDllVersion: string.Empty); + + return HistorianOpen2Protocol.SerializeNativeOpenConnectionVersion8( + commonInfo, + contextKey, + NativeConnectionTypeEvent, + NativeConnectionFlagEvent, + userName ?? string.Empty, + credentialToken: new byte[CredentialTokenSizeBytes]); + } + /// /// Decodes the OpenConnection response blob: byte 0 = protocol version, bytes 1..4 = /// transient /Retr client handle (UInt32 LE), bytes 5..20 = storage session GUID. diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianOpen2Protocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianOpen2Protocol.cs index f60f061..5cf5831 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianOpen2Protocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianOpen2Protocol.cs @@ -50,6 +50,49 @@ internal static class HistorianOpen2Protocol return stream.ToArray(); } + /// + /// Builds the native 2023 R2 version-8 OpenConnection request — the format the stock client + /// uses, which (unlike v6) carries a byte (Event vs Process). The + /// tail is the same v6 emits; the head is the v8 layout + /// decoded from a live capture (docs/reverse-engineering/grpc-event-query-capture.md): version 8, + /// two marker bytes, the client-key GUID, a username HistorianString, a length-prefixed + /// credential token, then ClientType / ConnectionType / flag / a constant word / compact metadata + /// namespace / two empty strings. The credential token is normally an ECDH-derived blob in the + /// native cert path; this serializer sends it as supplied (zeroed for the Negotiate-auth path, + /// mirroring how the v6 request already sends a zeroed credential block and relies on the separate + /// ValidateClientCredential handshake for authentication). + /// + public static byte[] SerializeNativeOpenConnectionVersion8( + HistorianClientCommonInfo commonInfo, + Guid clientKey, + byte connectionType, + byte connectionFlag, + string userName, + byte[] credentialToken) + { + using MemoryStream stream = new(); + using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true); + + writer.Write((byte)8); // [0] format version 8 + writer.Write((byte)0xF0); // [1] constant marker + writer.Write(new byte[19]); // [2..20] zero padding + writer.Write((byte)1); // [21] constant marker + writer.Write(clientKey.ToByteArray()); // [22..37] per-session client key + WriteHistorianString(writer, userName); // username (UTF-16 HistorianString) + writer.Write((ushort)credentialToken.Length); // credential-token length (u16) + writer.Write(credentialToken); // credential token (zeroed for the Negotiate path) + writer.Write((byte)4); // ClientType = 4 + writer.Write(connectionType); // ConnectionType (Event = 1 / Process = 2) + writer.Write(connectionFlag); // type flag (Event = 1 / Process = 0) + writer.Write((ushort)3); // constant word observed at [97..98] + WriteCompactMetadataNamespace(writer, HistorianMetadataNamespace.Empty); + WriteHistorianString(writer, string.Empty); + WriteHistorianString(writer, string.Empty); + WriteClientCommonInfo(writer, commonInfo); // tail: FormatVersion 3 => no ClientDllVersion + writer.Write(0u); // trailing terminator + return stream.ToArray(); + } + private static void WriteNativeOpenConnectionContent( BinaryWriter writer, HistorianOpen2Request request, diff --git a/tests/AVEVA.Historian.Client.Tests/WcfOpen2ProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfOpen2ProtocolTests.cs index 3646d94..3afd730 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfOpen2ProtocolTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfOpen2ProtocolTests.cs @@ -5,6 +5,44 @@ namespace AVEVA.Historian.Client.Tests; public sealed class WcfOpen2ProtocolTests { + [Fact] + public void Version8EventSerializerReproducesCapturedNativeStructure() + { + // Field sizes chosen to match the live 2023 R2 Event-connection capture (302 bytes): + // username=12, token=26, serverNode=15, clientNode=32, datasource=16. + // See docs/reverse-engineering/grpc-event-query-capture.md. + var clientKey = new Guid("11223344-5566-7788-99aa-bbccddeeff00"); + byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnectionVersion8( + new HistorianClientCommonInfo( + FormatVersion: 3, + ServerNodeName: new string('S', 15), + ClientNodeName: new string('C', 32), + ProcessId: 0x11223344, + HcalVersion: 18, + ProcessName: string.Empty, + Proxy: string.Empty, + DataSourceId: new string('D', 16), + ShardId: new Guid(Enumerable.Repeat((byte)0xFF, 16).ToArray()), + ClientVersion: 999_999, + ClientTimestamp: 0x01DD02426F9B6F6C, + ClientDllVersion: string.Empty), + clientKey, + connectionType: 1, // Event + connectionFlag: 1, // Event + userName: new string('U', 12), + credentialToken: new byte[26]); + + Assert.Equal(302, actual.Length); // exact native length for these field sizes + Assert.Equal(0x08, actual[0]); // format version 8 + Assert.Equal(0xF0, actual[1]); // marker + Assert.Equal(0x01, actual[21]); // marker + Assert.Equal(clientKey.ToByteArray(), actual[22..38]); + Assert.Equal(0x04, actual[94]); // ClientType + Assert.Equal(0x01, actual[95]); // ConnectionType = Event + Assert.Equal(0x01, actual[96]); // type flag = Event + Assert.Equal([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], actual[270..286]); // ShardId + } + [Fact] public void LegacyVersion1SerializerMatchesDecompiledSaveOpenConnectionParamsLayout() {