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,92 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Grpc.Net.Client.Web;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="GrpcChannel"/> for the 2023 R2 Historian Client Access Point,
|
||||
/// replicating the stock <c>Archestra.Historian.GrpcClient.GrpcClientBase.InitializeBase</c>
|
||||
/// transport shape: gRPC-Web (binary) over HTTP/1.1, optional TLS with an
|
||||
/// untrusted-certificate bypass, and gzip request encoding.
|
||||
/// </summary>
|
||||
internal static class HistorianGrpcChannelFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the effective gRPC port: when the caller left <see cref="HistorianClientOptions.Port"/>
|
||||
/// at the WCF default (32568), the 2023 R2 gRPC default (32565) is substituted; otherwise the
|
||||
/// explicit value is honoured.
|
||||
/// </summary>
|
||||
internal static int ResolvePort(HistorianClientOptions options) =>
|
||||
options.Port == HistorianClientOptions.DefaultPort ? HistorianClientOptions.DefaultGrpcPort : options.Port;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the channel address. TLS uses <c>https://{ServerDnsIdentity|Host}:{port}</c> (the
|
||||
/// DNS-identity override lets the URL match the server certificate name when connecting by IP);
|
||||
/// plaintext uses <c>http://{Host}:{port}</c>.
|
||||
/// </summary>
|
||||
internal static string ResolveAddress(HistorianClientOptions options)
|
||||
{
|
||||
int port = ResolvePort(options);
|
||||
if (options.GrpcUseTls)
|
||||
{
|
||||
string tlsHost = !string.IsNullOrEmpty(options.ServerDnsIdentity) ? options.ServerDnsIdentity! : options.Host;
|
||||
return $"https://{tlsHost}:{port}";
|
||||
}
|
||||
|
||||
return $"http://{options.Host}:{port}";
|
||||
}
|
||||
|
||||
public static HistorianGrpcConnection Create(HistorianClientOptions options)
|
||||
{
|
||||
string address = ResolveAddress(options);
|
||||
|
||||
var httpHandler = new HttpClientHandler();
|
||||
if (options.AllowUntrustedServerCertificate)
|
||||
{
|
||||
httpHandler.ServerCertificateCustomValidationCallback = AcceptAnyCertificate;
|
||||
}
|
||||
|
||||
// gRPC-Web binary mode over HTTP/1.1 — matches the stock client (GrpcWebMode.GrpcWeb,
|
||||
// HttpVersion 1.1). The 2023 R2 HCAP endpoint speaks gRPC-Web, not bare HTTP/2 gRPC.
|
||||
var webHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, httpHandler)
|
||||
{
|
||||
HttpVersion = new Version(1, 1)
|
||||
};
|
||||
|
||||
var channelOptions = new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = webHandler
|
||||
};
|
||||
|
||||
GrpcChannel channel = GrpcChannel.ForAddress(address, channelOptions);
|
||||
|
||||
// The stock client always advertises gzip request encoding; honour the option so
|
||||
// bandwidth-limited links can disable it.
|
||||
var metadata = new Metadata();
|
||||
if (options.Compression)
|
||||
{
|
||||
metadata.Add("grpc-internal-encoding-request", "gzip");
|
||||
}
|
||||
|
||||
return new HistorianGrpcConnection(channel, metadata);
|
||||
}
|
||||
|
||||
private static bool AcceptAnyCertificate(
|
||||
HttpRequestMessage request,
|
||||
X509Certificate2? certificate,
|
||||
X509Chain? chain,
|
||||
SslPolicyErrors errors) => true;
|
||||
}
|
||||
|
||||
/// <summary>A live gRPC channel plus the per-call metadata header set.</summary>
|
||||
internal sealed class HistorianGrpcConnection(GrpcChannel channel, Metadata metadata) : IDisposable
|
||||
{
|
||||
public GrpcChannel Channel { get; } = channel;
|
||||
|
||||
public Metadata Metadata { get; } = metadata;
|
||||
|
||||
public void Dispose() => Channel.Dispose();
|
||||
}
|
||||
Reference in New Issue
Block a user