feat(grpc-events): v8 OpenConnection serializer + native error decode (Path A disproven)
Builds the native 2023 R2 version-8 OpenConnection format, which (unlike v6) carries a ConnectionType byte (Event vs Process) — required because the 2023 R2 server returns event rows only on an Event connection. - HistorianOpen2Protocol.SerializeNativeOpenConnectionVersion8: reproduces the 302-byte v8 layout decoded from a live capture (version 8, markers, client-key GUID, username HString, length-prefixed credential token, ClientType / ConnectionType / flag / constant word / compact metadata / two empty strings; the tail reuses WriteClientCommonInfo). Golden-tested (Version8EventSerializerReproducesCapturedNativeStructure). - HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request: ConnectionType= Event, zeroed credential token (mirroring how v6 zeros its credential block and relies on the separate ValidateClientCredential handshake). - HistorianGrpcHandshake.OpenSession: optional eventConnection switch; the OpenConnection failure path now decodes the native error (type/code/ASCII). Path A (reuse ValidateClientCredential + zeroed token) was live-tested and DISPROVEN: the server parses the v8 buffer but rejects it at the auth check with native 132/34 "EstablishConnection Failed to get client key" — the v8 path looks up the client key in the registry HistoryService.ExchangeKey (ECDH) populates, not the one ValidateClientCredential does. The event orchestrator is therefore reverted to the v6 session (gated test still pins the no-row throw). The v8 serializer/builder are retained for Path B (implement ExchangeKey). 323/323 offline tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -138,6 +138,11 @@ internal sealed class HistorianGrpcEventOrchestrator
|
||||
private List<HistorianEvent> 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Builds the native 2023 R2 <b>version-8</b> OpenConnection request for an <b>Event</b> connection
|
||||
/// (<c>ConnectionType=Event</c>). 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 <c>ValidateClientCredential</c> handshake, exactly as
|
||||
/// the v6 path already relies on (it sends a zeroed credential block). See
|
||||
/// docs/reverse-engineering/grpc-event-query-capture.md.
|
||||
/// </summary>
|
||||
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]);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
|
||||
@@ -50,6 +50,49 @@ internal static class HistorianOpen2Protocol
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the native 2023 R2 <b>version-8</b> OpenConnection request — the format the stock client
|
||||
/// uses, which (unlike v6) carries a <paramref name="connectionType"/> byte (Event vs Process). The
|
||||
/// tail is the same <see cref="HistorianClientCommonInfo"/> 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 <c>HistorianString</c>, 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).
|
||||
/// </summary>
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user