gRPC M0: probe (R0.4, live-verified) + system-param (R0.3) + shared handshake
Roadmap docs/plans/hcal-roadmap.md, milestone M0 (gRPC parity for the DONE surface). Now unblocked for live verification by a reachable 2023 R2 server. - R0.4 Probe over gRPC: new HistorianGrpcProbe calls History/Retrieval/Status GetInterfaceVersion (unauthenticated). ProbeAsync routes over gRPC when Transport==RemoteGrpc. LIVE-VERIFIED against a real 2023 R2 server — needs no credentials (runs before the auth loop), so it works despite the auth blocker. - R0.3 System parameter over gRPC: new HistorianGrpcStatusClient calls StatusService.GetSystemParameter over the authenticated session; routed in the dialect. Built + unit-tested (request/response field mapping pinned). Live-verification pending an auth fix (see below). - Extracted the proven auth handshake from HistorianGrpcReadOrchestrator into shared Grpc/HistorianGrpcHandshake (reused by read + status + future browse/metadata). Repointed the IL structural guardrail test to it. - Diagnostics: round-failure now decodes the native server error + hex/ASCII preview (HistorianNativeHandshake.DescribeError). This surfaced the live auth blocker as SEC_E_LOGON_DENIED (0x8009030C) at NTLM round 1 — framing is correct, the credential did not validate. Probable cause: stale file password or NAM-domain NTLM restriction (Kerberos/RDP works, NTLM denied; no SPN path over the tunnel). 216 unit tests pass; live gRPC probe passes. Sanitization scan clean. 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:
@@ -9,15 +9,39 @@ HCAL replacement, built on the **2023 R2 gRPC transport**. Derived from
|
|||||||
> protocol serializer/parser + golden-byte unit test + an env-gated live integration
|
> protocol serializer/parser + golden-byte unit test + an env-gated live integration
|
||||||
> test against the local Historian.
|
> test against the local Historian.
|
||||||
|
|
||||||
## Progress (updated 2026-06-19)
|
## Progress (updated 2026-06-21)
|
||||||
|
|
||||||
- ✅ **R0.6 version gate** — `HistorianServerVersionGate` + `HistorianClientOptions.VerifyServerInterfaceVersion`;
|
- ✅ **R0.6 version gate** — `HistorianServerVersionGate` + `HistorianClientOptions.VerifyServerInterfaceVersion`;
|
||||||
fail-closed on connect, wired into both WCF and gRPC paths. Supported versions are
|
fail-closed on connect, wired into both WCF and gRPC paths. Supported versions are
|
||||||
evidence-based (Hist=11, Retr=4, Trx=2; Status reachability-only), captured from the
|
evidence-based (Hist=11/12, Retr=4, Trx=2; Status reachability-only), captured from the
|
||||||
live server. 10 unit tests.
|
live server. History 12 (2023 R2 gRPC) accepted alongside 11 (buffer-compatible).
|
||||||
- ✅ **CW-1 capture pipeline** — `ProtocolCaptureSanitizer` + `ProtocolFixtureWriter` +
|
- ✅ **CW-1 capture pipeline** — `ProtocolCaptureSanitizer` + `ProtocolFixtureWriter` +
|
||||||
`capture-tag-info` CLI command; produces sanitized `fixtures/protocol/<op>/` golden files.
|
`capture-tag-info` CLI command; produces sanitized `fixtures/protocol/<op>/` golden files.
|
||||||
11 unit tests. First fixture: `get-tag-info/analog-*.json`.
|
11 unit tests. First fixture: `get-tag-info/analog-*.json`.
|
||||||
|
- ✅ **gRPC auth handshake (read chain)** — LIVE-VERIFIED 2026-06-21 against a real 2023 R2
|
||||||
|
server: `ReadRawAsync` over `RemoteGrpc` returns rows. Token loop routes to
|
||||||
|
`StorageService.ValidateClientCredential`. Shared handshake extracted to
|
||||||
|
`Grpc/HistorianGrpcHandshake` for reuse by the status/browse/metadata paths.
|
||||||
|
- ✅ **R0.4 Probe over gRPC** — `Grpc/HistorianGrpcProbe` (History/Retrieval/Status
|
||||||
|
`GetInterfaceVersion`); `ProbeAsync` routes over gRPC when `Transport==RemoteGrpc`.
|
||||||
|
**LIVE-VERIFIED 2026-06-21** (no credentials required — runs before the auth loop).
|
||||||
|
- 🟡 **R0.3 System parameter over gRPC** — `Grpc/HistorianGrpcStatusClient.GetSystemParameterAsync`
|
||||||
|
(`StatusService.GetSystemParameter`); routed in the dialect. Built + unit-tested
|
||||||
|
(request/response field mapping pinned). **Live-verification pending an auth fix** — see blocker.
|
||||||
|
Code path is the proven handshake + a single string-in/string-out RPC.
|
||||||
|
|
||||||
|
> ⚠️ **Auth blocker (2026-06-21):** live gRPC ops needing a client handle (R0.1/R0.2/R0.3 and the
|
||||||
|
> read chain) fail at NTLM **round 1**. The decoded server error is
|
||||||
|
> `SEC_E_LOGON_DENIED` (0x8009030C) from `aahClientAccessPoint::CServerContext::ProcessClient…` —
|
||||||
|
> round 0 (NEGOTIATE) succeeds, round 1 (the password-bearing AUTHENTICATE) is denied. The token
|
||||||
|
> **framing is correct** (a framing fault would surface as `SEC_E_INVALID_TOKEN`); the server parsed
|
||||||
|
> a valid NTLM message but the credential did not validate. The creds in the gitignored file are
|
||||||
|
> byte-faithfully parsed (verified) and the domain user logs in via **RDP (Kerberos)**, so the
|
||||||
|
> probable cause is one of: (a) the file password is **stale** vs the account, or (b) the NAM domain
|
||||||
|
> **restricts NTLM** ("Network security: Restrict NTLM") — Kerberos/RDP works but NTLM is denied, and
|
||||||
|
> over the SOCKS/SSH tunnel (host→127.0.0.1, no SPN) the client cannot use Kerberos. Probe (R0.4) is
|
||||||
|
> unaffected (unauthenticated). Diagnostic: the round-failure exception now decodes the native error
|
||||||
|
> + a hex/ASCII preview (`HistorianNativeHandshake.DescribeError`).
|
||||||
|
|
||||||
> ⚠️ **Live-verification constraint:** the local Historian is **2020** (WCF, port 32568) — the
|
> ⚠️ **Live-verification constraint:** the local Historian is **2020** (WCF, port 32568) — the
|
||||||
> 2023 R2 gRPC endpoint (32565) is absent. M0's gRPC routing (R0.1–R0.4) can be built and
|
> 2023 R2 gRPC endpoint (32565) is absent. M0's gRPC routing (R0.1–R0.4) can be built and
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using Grpc.Core;
|
||||||
|
using AVEVA.Historian.Client.Wcf;
|
||||||
|
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||||
|
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared 2023 R2 gRPC authentication handshake. Opens an authenticated History session over an
|
||||||
|
/// existing <see cref="HistorianGrpcConnection"/> and returns the transient client handle used by
|
||||||
|
/// the Retrieval/Status services. Extracted from <see cref="HistorianGrpcReadOrchestrator"/> so the
|
||||||
|
/// read, status, and (future) browse/metadata gRPC paths all drive the identical chain:
|
||||||
|
/// <c>HistoryService.GetInterfaceVersion → StorageService.ValidateClientCredential (token loop) →
|
||||||
|
/// HistoryService.OpenConnection</c>. The byte payloads (OpenConnection3 v6 request, NTLM token
|
||||||
|
/// framing) are the proven 2020 protocol and transfer unchanged inside protobuf <c>bytes</c> fields.
|
||||||
|
///
|
||||||
|
/// See <see cref="HistorianGrpcReadOrchestrator"/> for the op-routing rationale (the Negotiate loop
|
||||||
|
/// belongs on StorageService.ValidateClientCredential, NOT HistoryService.ExchangeKey).
|
||||||
|
/// </summary>
|
||||||
|
internal static class HistorianGrpcHandshake
|
||||||
|
{
|
||||||
|
public static uint OpenAuthenticatedConnection(
|
||||||
|
HistorianGrpcConnection connection,
|
||||||
|
HistorianClientOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
||||||
|
|
||||||
|
Guid contextKey = Guid.NewGuid();
|
||||||
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||||
|
|
||||||
|
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
||||||
|
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||||
|
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, options);
|
||||||
|
|
||||||
|
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
||||||
|
HistorianNativeHandshake.RunTokenRounds(
|
||||||
|
(handle, wrapped, _) =>
|
||||||
|
{
|
||||||
|
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
|
||||||
|
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
|
||||||
|
connection.Metadata,
|
||||||
|
Deadline(),
|
||||||
|
cancellationToken);
|
||||||
|
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
|
||||||
|
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
||||||
|
bool success = response.Status?.BSuccess ?? false;
|
||||||
|
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
||||||
|
},
|
||||||
|
contextKey,
|
||||||
|
options,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
|
||||||
|
options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
|
||||||
|
|
||||||
|
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
||||||
|
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
|
||||||
|
connection.Metadata,
|
||||||
|
Deadline(),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
|
||||||
|
if (!(open2.Status?.BSuccess ?? false))
|
||||||
|
{
|
||||||
|
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
|
||||||
|
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
(uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
||||||
|
return clientHandle;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||||
|
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||||
|
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2023 R2 gRPC reachability probe (roadmap item R0.4). Mirrors <see cref="Wcf.HistorianWcfProbe"/>
|
||||||
|
/// over the gRPC transport: it calls the unauthenticated <c>GetInterfaceVersion</c> RPC on the
|
||||||
|
/// History, Retrieval, and Status services and applies the same success criteria. No credentials
|
||||||
|
/// are required — these RPCs run before the SSPI/Negotiate token loop — so the probe works even
|
||||||
|
/// when authentication is unavailable.
|
||||||
|
/// </summary>
|
||||||
|
internal static class HistorianGrpcProbe
|
||||||
|
{
|
||||||
|
public static async Task<bool> ProbeAsync(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
return await Task.Run(() => Probe(options, cancellationToken), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Probe(HistorianClientOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
DateTime deadline = DateTime.UtcNow.Add(options.ConnectTimeout > TimeSpan.Zero ? options.ConnectTimeout : TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||||
|
GrpcHistory.GetInterfaceVersionResponse history = historyClient.GetInterfaceVersion(
|
||||||
|
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||||
|
|
||||||
|
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||||
|
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval = retrievalClient.GetRetrievalInterfaceVersion(
|
||||||
|
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||||
|
|
||||||
|
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||||
|
GrpcStatus.GetStatusInterfaceVersionResponse status = statusClient.GetStatusInterfaceVersion(
|
||||||
|
new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, cancellationToken);
|
||||||
|
|
||||||
|
return history.UiError == 0
|
||||||
|
&& history.UiVersion > 0
|
||||||
|
&& retrieval.UiError == 0
|
||||||
|
&& retrieval.UiVersion > 0
|
||||||
|
&& status.UiError == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@ using Google.Protobuf;
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using AVEVA.Historian.Client.Models;
|
using AVEVA.Historian.Client.Models;
|
||||||
using AVEVA.Historian.Client.Wcf;
|
using AVEVA.Historian.Client.Wcf;
|
||||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
|
||||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||||
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
|
||||||
|
|
||||||
namespace AVEVA.Historian.Client.Grpc;
|
namespace AVEVA.Historian.Client.Grpc;
|
||||||
|
|
||||||
@@ -166,51 +164,7 @@ internal sealed class HistorianGrpcReadOrchestrator
|
|||||||
}
|
}
|
||||||
|
|
||||||
private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
|
private uint OpenAuthenticatedConnection(HistorianGrpcConnection connection, CancellationToken cancellationToken)
|
||||||
{
|
=> HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, _options, cancellationToken);
|
||||||
Guid contextKey = Guid.NewGuid();
|
|
||||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
|
||||||
|
|
||||||
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
|
||||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
|
||||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
|
|
||||||
|
|
||||||
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
|
||||||
HistorianNativeHandshake.RunTokenRounds(
|
|
||||||
(handle, wrapped, _) =>
|
|
||||||
{
|
|
||||||
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
|
|
||||||
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
|
|
||||||
connection.Metadata,
|
|
||||||
Deadline(),
|
|
||||||
cancellationToken);
|
|
||||||
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
|
|
||||||
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
|
||||||
bool success = response.Status?.BSuccess ?? false;
|
|
||||||
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
|
||||||
},
|
|
||||||
contextKey,
|
|
||||||
_options,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(
|
|
||||||
_options.Host, contextKey, HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
|
|
||||||
|
|
||||||
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
|
||||||
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
|
|
||||||
connection.Metadata,
|
|
||||||
Deadline(),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
|
|
||||||
if (!(open2.Status?.BSuccess ?? false))
|
|
||||||
{
|
|
||||||
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
|
|
||||||
throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}).");
|
|
||||||
}
|
|
||||||
|
|
||||||
(uint clientHandle, _) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
|
||||||
return clientHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<HistorianSample> RunQuery(
|
private List<HistorianSample> RunQuery(
|
||||||
HistorianGrpcConnection connection,
|
HistorianGrpcConnection connection,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using GrpcStatus = ArchestrA.Grpc.Contract.Status;
|
||||||
|
|
||||||
|
namespace AVEVA.Historian.Client.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2023 R2 gRPC status client (roadmap item R0.3). Mirrors
|
||||||
|
/// <see cref="Wcf.HistorianWcfStatusClient"/> over the gRPC transport: it opens an authenticated
|
||||||
|
/// History session via <see cref="HistorianGrpcHandshake"/> and queries the StatusService for the
|
||||||
|
/// resulting client handle. <c>GetSystemParameter</c> carries the parameter name as a protobuf
|
||||||
|
/// string and returns the value string directly — there is no opaque native buffer to decode.
|
||||||
|
/// </summary>
|
||||||
|
internal static class HistorianGrpcStatusClient
|
||||||
|
{
|
||||||
|
public static Task<string?> GetSystemParameterAsync(
|
||||||
|
HistorianClientOptions options,
|
||||||
|
string parameterName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(parameterName);
|
||||||
|
return Task.Run(() => GetSystemParameter(options, parameterName, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetSystemParameter(HistorianClientOptions options, string parameterName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options);
|
||||||
|
uint clientHandle = HistorianGrpcHandshake.OpenAuthenticatedConnection(connection, options, cancellationToken);
|
||||||
|
|
||||||
|
var statusClient = new GrpcStatus.StatusService.StatusServiceClient(connection.Channel);
|
||||||
|
GrpcStatus.GetSystemParameterResponse response = statusClient.GetSystemParameter(
|
||||||
|
new GrpcStatus.GetSystemParameterRequest { UiHandle = clientHandle, StrParameterName = parameterName },
|
||||||
|
connection.Metadata,
|
||||||
|
DateTime.UtcNow.Add(options.RequestTimeout),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return (response.Status?.BSuccess ?? false) ? response.StrParameterValue : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ public sealed class HistorianClient : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
|
public async Task<bool> ProbeAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
|
return _options.Transport == HistorianTransport.RemoteGrpc
|
||||||
|
? await Grpc.HistorianGrpcProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false)
|
||||||
|
: await HistorianWcfProbe.ProbeAsync(_options, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
public IAsyncEnumerable<HistorianSample> ReadRawAsync(
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ internal sealed class Historian2020ProtocolDialect
|
|||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
return Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
|
return UseGrpc
|
||||||
|
? HistorianGrpcStatusClient.GetSystemParameterAsync(_options, name, cancellationToken)
|
||||||
|
: Wcf.HistorianWcfStatusClient.GetSystemParameterAsync(_options, name, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async IAsyncEnumerable<T> Missing<T>(
|
private static async IAsyncEnumerable<T> Missing<T>(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ internal static class HistorianNativeHandshake
|
|||||||
/// upper-case context-key GUID, <paramref name="wrappedToken"/> is the AVEVA-wrapped SSPI
|
/// upper-case context-key GUID, <paramref name="wrappedToken"/> is the AVEVA-wrapped SSPI
|
||||||
/// token (round byte + length + token). The WCF path maps this to
|
/// token (round byte + length + token). The WCF path maps this to
|
||||||
/// <c>Hist.ValidateClientCredential</c>; the gRPC path maps it to
|
/// <c>Hist.ValidateClientCredential</c>; the gRPC path maps it to
|
||||||
/// <c>HistoryService.ExchangeKey</c> (the renamed handshake op).
|
/// <c>StorageService.ValidateClientCredential</c> (the op that kept the 2020 token framing).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
|
internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
|
||||||
|
|
||||||
@@ -70,7 +70,8 @@ internal static class HistorianNativeHandshake
|
|||||||
|
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Credential token round {round} rejected (errorLen={error.Length}).");
|
throw new InvalidOperationException(
|
||||||
|
$"Credential token round {round} rejected (errorLen={error.Length}).{DescribeError(error)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput);
|
ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput);
|
||||||
@@ -162,4 +163,32 @@ internal static class HistorianNativeHandshake
|
|||||||
int slash = userName.IndexOf('\\');
|
int slash = userName.IndexOf('\\');
|
||||||
return slash > 0 ? userName[(slash + 1)..] : userName;
|
return slash > 0 ? userName[(slash + 1)..] : userName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders a diagnostic suffix for a rejected credential round: the decoded native error
|
||||||
|
/// (type/code/name) plus a short hex + printable-ASCII preview of the server error buffer.
|
||||||
|
/// Keeps secrets out — error buffers carry server status codes/messages, not credentials.
|
||||||
|
/// </summary>
|
||||||
|
private static string DescribeError(byte[] error)
|
||||||
|
{
|
||||||
|
if (error.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianNativeError? native = HistorianOpen2Protocol.TryReadNativeError(error);
|
||||||
|
string nativePart = native is null
|
||||||
|
? string.Empty
|
||||||
|
: $" native(type={native.Type}, code={native.Code}{(native.Name is null ? string.Empty : $", {native.Name}")})";
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> preview = error.AsSpan(0, Math.Min(error.Length, 64));
|
||||||
|
string hex = Convert.ToHexString(preview);
|
||||||
|
char[] ascii = new char[preview.Length];
|
||||||
|
for (int i = 0; i < preview.Length; i++)
|
||||||
|
{
|
||||||
|
ascii[i] = preview[i] is >= 0x20 and < 0x7F ? (char)preview[i] : '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{nativePart} hex={hex} ascii=\"{new string(ascii)}\"";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace AVEVA.Historian.Client.Tests;
|
|||||||
/// credentials (live-confirmed against a real 2023 R2 server, 2026-06-21). An earlier revision
|
/// credentials (live-confirmed against a real 2023 R2 server, 2026-06-21). An earlier revision
|
||||||
/// routed the loop to ExchangeKey; this test fails if that regression returns.
|
/// routed the loop to ExchangeKey; this test fails if that regression returns.
|
||||||
///
|
///
|
||||||
/// It works by disassembling the IL of <c>HistorianGrpcReadOrchestrator</c> (and its
|
/// It works by disassembling the IL of <c>HistorianGrpcHandshake</c> (and its
|
||||||
/// compiler-generated nested closure types — the token-loop call lives inside a lambda) and
|
/// compiler-generated nested closure types — the token-loop call lives inside a lambda) and
|
||||||
/// collecting every method invoked.
|
/// collecting every method invoked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -20,8 +20,10 @@ public sealed class HistorianGrpcHandshakeRoutingTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Handshake_UsesValidateClientCredential_NotExchangeKey()
|
public void Handshake_UsesValidateClientCredential_NotExchangeKey()
|
||||||
{
|
{
|
||||||
|
// The auth token loop lives in the shared handshake helper (reused by the read, status,
|
||||||
|
// and future browse/metadata gRPC paths).
|
||||||
HashSet<string> calledMethods = CollectCalledMethodNames(
|
HashSet<string> calledMethods = CollectCalledMethodNames(
|
||||||
"AVEVA.Historian.Client.Grpc.HistorianGrpcReadOrchestrator");
|
"AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake");
|
||||||
|
|
||||||
Assert.Contains("ValidateClientCredential", calledMethods);
|
Assert.Contains("ValidateClientCredential", calledMethods);
|
||||||
Assert.DoesNotContain("ExchangeKey", calledMethods);
|
Assert.DoesNotContain("ExchangeKey", calledMethods);
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ namespace AVEVA.Historian.Client.Tests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HistorianGrpcIntegrationTests
|
public sealed class HistorianGrpcIntegrationTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ProbeAsync_OverGrpc_ReturnsTrue()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeAsync calls the unauthenticated GetInterfaceVersion RPCs, so it succeeds even when
|
||||||
|
// credentials are unavailable — no HISTORIAN_USER/PASSWORD required.
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
Assert.True(await client.ProbeAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadRawAsync_OverGrpc_ReturnsAtLeastOneRow()
|
public async Task ReadRawAsync_OverGrpc_ReturnsAtLeastOneRow()
|
||||||
{
|
{
|
||||||
@@ -37,6 +52,20 @@ public sealed class HistorianGrpcIntegrationTests
|
|||||||
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSystemParameterAsync_OverGrpc_ReturnsValue()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HistorianClient client = new(BuildOptions(host));
|
||||||
|
string? version = await client.GetSystemParameterAsync("HistorianVersion", CancellationToken.None);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(version));
|
||||||
|
}
|
||||||
|
|
||||||
private static HistorianClientOptions BuildOptions(string host)
|
private static HistorianClientOptions BuildOptions(string host)
|
||||||
{
|
{
|
||||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||||
|
|||||||
@@ -100,6 +100,41 @@ public sealed class HistorianGrpcTransportTests
|
|||||||
Assert.Equal((uint)HistorianDataQueryProtocol.QueryRequestTypeData, decoded.UiQueryRequestType);
|
Assert.Equal((uint)HistorianDataQueryProtocol.QueryRequestTypeData, decoded.UiQueryRequestType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InterfaceVersionResponses_ExposeErrorAndVersion_AsProbeExpects()
|
||||||
|
{
|
||||||
|
// R0.4 ProbeAsync reads uiError/uiVersion off each service's GetInterfaceVersion response.
|
||||||
|
// Pin that field mapping (success = uiError 0 + uiVersion > 0) via a protobuf round-trip.
|
||||||
|
var history = GrpcHistory.GetInterfaceVersionResponse.Parser.ParseFrom(
|
||||||
|
new GrpcHistory.GetInterfaceVersionResponse { UiError = 0, UiVersion = 12 }.ToByteArray());
|
||||||
|
var retrieval = GetRetrievalInterfaceVersionResponse.Parser.ParseFrom(
|
||||||
|
new GetRetrievalInterfaceVersionResponse { UiError = 0, UiVersion = 4 }.ToByteArray());
|
||||||
|
|
||||||
|
Assert.Equal(0u, history.UiError);
|
||||||
|
Assert.Equal(12u, history.UiVersion);
|
||||||
|
Assert.Equal(0u, retrieval.UiError);
|
||||||
|
Assert.Equal(4u, retrieval.UiVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSystemParameterMessages_CarryHandleNameAndValue_AsStatusClientExpects()
|
||||||
|
{
|
||||||
|
// R0.3 sends {uiHandle, strParameterName} and reads strParameterValue when status succeeds.
|
||||||
|
var request = ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest.Parser.ParseFrom(
|
||||||
|
new ArchestrA.Grpc.Contract.Status.GetSystemParameterRequest { UiHandle = 9, StrParameterName = "HistorianVersion" }.ToByteArray());
|
||||||
|
Assert.Equal(9u, request.UiHandle);
|
||||||
|
Assert.Equal("HistorianVersion", request.StrParameterName);
|
||||||
|
|
||||||
|
var response = ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse.Parser.ParseFrom(
|
||||||
|
new ArchestrA.Grpc.Contract.Status.GetSystemParameterResponse
|
||||||
|
{
|
||||||
|
Status = new ArchestrA.Grpc.Contract.RequestStatus.Status { BSuccess = true },
|
||||||
|
StrParameterValue = "20.0.000"
|
||||||
|
}.ToByteArray());
|
||||||
|
Assert.True(response.Status.BSuccess);
|
||||||
|
Assert.Equal("20.0.000", response.StrParameterValue);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
|
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user