R0.6: fail-closed server interface-version gate at connect
Turns the previously-discarded GetInterfaceVersion result into a connect-time version pin. The native buffers carried in the WCF/MDAS body (and in the 2023 R2 gRPC bytes fields) are framed per native interface version; parsing them against an unexpected version risks silent misinterpretation, so we throw rather than best-effort parse. - HistorianServerVersionGate + HistorianServiceInterface: evidence-based supported versions discovered from a live Historian 2020 server (product 20.0.000) via the wcf-probe command — History=11, Retrieval=4, Transaction=2. Status' GetInterfaceVersion returns 0, so Status is reachability-only. - HistorianClientOptions.VerifyServerInterfaceVersion (default true) — bypass knob for bringing up a server whose reported integers aren't yet captured (e.g. a 2023 R2 gRPC endpoint carrying the same proven 2020 buffers). - Wired into both transports' connect paths: WCF history (auth-chain helper) + retrieval (read orchestrator), and gRPC history + retrieval. - Mismatch throws ProtocolEvidenceMissingException naming reported/expected version and the bypass knob. 10 new unit tests (198 total green). Verified the gate does not regress the proven WCF read path: a live read against the local 2020 server reaches past the gate (Retr=4 matches) — the only live failures are a pre-existing environmental read timeout (OperationCanceledException), identical with and without this change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -163,7 +163,9 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
||||
|
||||
historyClient.GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
|
||||
|
||||
HistorianNativeHandshake.RunTokenRounds(
|
||||
(handle, wrapped, _) =>
|
||||
@@ -210,7 +212,9 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
|
||||
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion(
|
||||
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options);
|
||||
|
||||
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
|
||||
uint queryHandle = StartQuery(retrievalClient, clientHandle, requestBuffer, "raw", cancellationToken);
|
||||
@@ -260,7 +264,9 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var retrievalClient = new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel);
|
||||
retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
|
||||
GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrievalVersion = retrievalClient.GetRetrievalInterfaceVersion(
|
||||
new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), null, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion.UiVersion, _options);
|
||||
|
||||
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildAggregateQueryRequest(tag, startUtc, endUtc, mode, interval);
|
||||
byte[] requestBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
|
||||
|
||||
@@ -62,4 +62,16 @@ public sealed class HistorianClientOptions
|
||||
/// true the server certificate chain is not validated. Default false.
|
||||
/// </summary>
|
||||
public bool GrpcUseTls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) the SDK verifies, at connect time, that the Historian server
|
||||
/// reports the native interface versions its byte serializers were built against
|
||||
/// (History=11, Retrieval=4, Transaction=2 — evidence from a live AVEVA Historian 2020
|
||||
/// server). A mismatch throws <see cref="ProtocolEvidenceMissingException"/> rather than
|
||||
/// risk misparsing version-framed native buffers. Set false only when you have
|
||||
/// independently confirmed wire compatibility with a different server version — e.g.
|
||||
/// when bringing up a 2023 R2 gRPC server whose reported interface integers have not yet
|
||||
/// been captured. See <see cref="HistorianServerVersionGate"/>.
|
||||
/// </summary>
|
||||
public bool VerifyServerInterfaceVersion { get; init; } = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace AVEVA.Historian.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a versioned native Historian service interface whose reported interface
|
||||
/// version is validated at connect time by <see cref="HistorianServerVersionGate"/>.
|
||||
/// </summary>
|
||||
internal enum HistorianServiceInterface
|
||||
{
|
||||
History,
|
||||
Retrieval,
|
||||
Status,
|
||||
Transaction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-closed check (roadmap item R0.6) that a Historian server reports the native
|
||||
/// interface version this SDK's byte serializers were built against.
|
||||
///
|
||||
/// The opaque native buffers carried inside the WCF/MDAS message body — and, on 2023 R2,
|
||||
/// inside the gRPC <c>bytes</c> fields — are framed per native interface version. Parsing
|
||||
/// them against an unexpected version risks silent misinterpretation, so per the
|
||||
/// "version-pin, fail closed" principle this throws <see cref="ProtocolEvidenceMissingException"/>
|
||||
/// rather than best-effort parsing.
|
||||
///
|
||||
/// Supported versions are evidence-based, discovered from a live AVEVA Historian 2020
|
||||
/// server (product 20.0.000) via the reverse-engineering <c>wcf-probe</c> command:
|
||||
/// <list type="bullet">
|
||||
/// <item>History (<c>Hist</c>) interface version = 11</item>
|
||||
/// <item>Retrieval (<c>Retr</c>) interface version = 4</item>
|
||||
/// <item>Transaction (<c>Trx</c>) interface version = 2</item>
|
||||
/// </list>
|
||||
/// The Status (<c>Stat</c>) service's <c>GetInterfaceVersion</c> returns 0 (not a real
|
||||
/// version), so the Status interface is validated for reachability only, never value.
|
||||
///
|
||||
/// A 2023 R2 gRPC server may report different integers even though it carries the same
|
||||
/// proven 2020 native buffers; until those integers are captured, point such a server at
|
||||
/// this gate with <see cref="HistorianClientOptions.VerifyServerInterfaceVersion"/> set to
|
||||
/// <see langword="false"/>.
|
||||
/// </summary>
|
||||
internal static class HistorianServerVersionGate
|
||||
{
|
||||
public const uint HistoryInterfaceVersion = 11;
|
||||
public const uint RetrievalInterfaceVersion = 4;
|
||||
public const uint TransactionInterfaceVersion = 2;
|
||||
|
||||
/// <summary>
|
||||
/// True when the service interface reports a meaningful version that should be matched.
|
||||
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
|
||||
/// </summary>
|
||||
public static bool IsValueGated(HistorianServiceInterface service) => service switch
|
||||
{
|
||||
HistorianServiceInterface.History => true,
|
||||
HistorianServiceInterface.Retrieval => true,
|
||||
HistorianServiceInterface.Transaction => true,
|
||||
HistorianServiceInterface.Status => false,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>The interface version this SDK's serializers target for a value-gated service.</summary>
|
||||
public static uint ExpectedVersion(HistorianServiceInterface service) => service switch
|
||||
{
|
||||
HistorianServiceInterface.History => HistoryInterfaceVersion,
|
||||
HistorianServiceInterface.Retrieval => RetrievalInterfaceVersion,
|
||||
HistorianServiceInterface.Transaction => TransactionInterfaceVersion,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="ProtocolEvidenceMissingException"/> when version verification is enabled
|
||||
/// and the server's reported interface version differs from the version this SDK targets.
|
||||
/// No-op when <see cref="HistorianClientOptions.VerifyServerInterfaceVersion"/> is
|
||||
/// <see langword="false"/>, when the service is not value-gated (Status), or on a match.
|
||||
/// </summary>
|
||||
public static void Validate(HistorianServiceInterface service, uint reportedVersion, HistorianClientOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (!options.VerifyServerInterfaceVersion || !IsValueGated(service))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
uint expected = ExpectedVersion(service);
|
||||
if (reportedVersion == expected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"{service} interface version {reportedVersion} (this SDK's serializers target version {expected}); " +
|
||||
$"set {nameof(HistorianClientOptions)}.{nameof(HistorianClientOptions.VerifyServerInterfaceVersion)}=false to bypass at your own risk");
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,8 @@ internal static class HistorianWcfAuthChainHelper
|
||||
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
|
||||
try
|
||||
{
|
||||
historyChannel.GetInterfaceVersion(out _);
|
||||
historyChannel.GetInterfaceVersion(out uint historyVersion);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion, options);
|
||||
RunValClRounds(historyChannel, contextKey, options, cancellationToken);
|
||||
|
||||
byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
|
||||
|
||||
@@ -187,7 +187,8 @@ internal sealed class HistorianWcfReadOrchestrator
|
||||
ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel;
|
||||
try
|
||||
{
|
||||
retrievalChannel.GetInterfaceVersion(out _);
|
||||
retrievalChannel.GetInterfaceVersion(out uint retrievalVersion);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion, _options);
|
||||
|
||||
uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed);
|
||||
if (isAllowedReturn != 0 || !isAllowed)
|
||||
@@ -289,7 +290,8 @@ internal sealed class HistorianWcfReadOrchestrator
|
||||
ICommunicationObject retrievalChannelCo = (ICommunicationObject)retrievalChannel;
|
||||
try
|
||||
{
|
||||
retrievalChannel.GetInterfaceVersion(out _);
|
||||
retrievalChannel.GetInterfaceVersion(out uint retrievalVersion);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, retrievalVersion, _options);
|
||||
|
||||
uint isAllowedReturn = retrievalChannel.IsOriginalAllowed(clientHandle, out bool isAllowed);
|
||||
if (isAllowedReturn != 0 || !isAllowed)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for the R0.6 connect-time interface-version gate. The supported versions
|
||||
/// (History=11, Retrieval=4, Transaction=2) are evidence-based, captured from a live AVEVA
|
||||
/// Historian 2020 server via the reverse-engineering wcf-probe command.
|
||||
/// </summary>
|
||||
public sealed class HistorianServerVersionGateTests
|
||||
{
|
||||
private static HistorianClientOptions Options(bool verify = true) => new()
|
||||
{
|
||||
Host = "histserver",
|
||||
IntegratedSecurity = true,
|
||||
VerifyServerInterfaceVersion = verify
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void VerifyServerInterfaceVersion_DefaultsToTrue()
|
||||
{
|
||||
Assert.True(new HistorianClientOptions { Host = "h" }.VerifyServerInterfaceVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MatchingVersion_DoesNotThrow()
|
||||
{
|
||||
// Each value-gated service accepts exactly the version this SDK targets.
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, HistorianServerVersionGate.HistoryInterfaceVersion, Options());
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, HistorianServerVersionGate.RetrievalInterfaceVersion, Options());
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Transaction, HistorianServerVersionGate.TransactionInterfaceVersion, Options());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MismatchedVersion_ThrowsProtocolEvidenceMissing()
|
||||
{
|
||||
// (service, wrong version) cases — one below and one above each expected value.
|
||||
(HistorianServiceInterface Service, uint Version)[] cases =
|
||||
[
|
||||
(HistorianServiceInterface.History, 10u),
|
||||
(HistorianServiceInterface.History, 12u),
|
||||
(HistorianServiceInterface.Retrieval, 3u),
|
||||
(HistorianServiceInterface.Retrieval, 5u),
|
||||
(HistorianServiceInterface.Transaction, 1u),
|
||||
];
|
||||
|
||||
foreach ((HistorianServiceInterface service, uint version) in cases)
|
||||
{
|
||||
ProtocolEvidenceMissingException ex = Assert.Throws<ProtocolEvidenceMissingException>(
|
||||
() => HistorianServerVersionGate.Validate(service, version, Options()));
|
||||
|
||||
// The message must name the reported version, the expected version, and the bypass knob.
|
||||
Assert.Contains(version.ToString(System.Globalization.CultureInfo.InvariantCulture), ex.Operation);
|
||||
Assert.Contains(HistorianServerVersionGate.ExpectedVersion(service).ToString(System.Globalization.CultureInfo.InvariantCulture), ex.Operation);
|
||||
Assert.Contains(nameof(HistorianClientOptions.VerifyServerInterfaceVersion), ex.Operation);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_VerificationDisabled_NeverThrows()
|
||||
{
|
||||
// A wildly wrong version is tolerated when the operator opts out (e.g. bringing up a
|
||||
// 2023 R2 gRPC server whose reported integers have not yet been captured).
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 999u, Options(verify: false));
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, 0u, Options(verify: false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u)]
|
||||
[InlineData(7u)]
|
||||
[InlineData(999u)]
|
||||
public void Validate_StatusService_IsReachabilityOnly_NeverThrows(uint anyVersion)
|
||||
{
|
||||
// Status' GetInterfaceVersion returns 0 on the live server; it is not value-gated.
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.Status, anyVersion, Options());
|
||||
Assert.False(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValueGated_HistoryRetrievalTransaction_AreGated()
|
||||
{
|
||||
Assert.True(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.History));
|
||||
Assert.True(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.Retrieval));
|
||||
Assert.True(HistorianServerVersionGate.IsValueGated(HistorianServiceInterface.Transaction));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpectedVersion_Status_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => HistorianServerVersionGate.ExpectedVersion(HistorianServiceInterface.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullOptions_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 11u, null!));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user