Wire SDK for remote-TCP end to end; live-verify RemoteTcpIntegrated
Executes docs/plans/tcp-connection-validation.md. Full read-only SDK
surface now works against a remote AVEVA Historian over Net.TCP with
Windows transport authentication. 124/124 tests pass; the +10 new live
integration tests in RemoteTcpIntegrationTests.cs are gated by
HISTORIAN_REMOTE_TCP_HOST + HISTORIAN_REMOTE_TCP_TAG.
Two SDK bugs found while executing the plan:
1. Historian2020ProtocolDialect.ReadRawAsync / ReadAggregateAsync /
ReadAtTimeAsync / ReadEventsAsync had explicit
`if (_options.Transport != HistorianTransport.LocalPipe) return Missing<T>`
guards. These were a guardrail from before the orchestrators handled
TCP; the orchestrators have always used CreateBindingPair(options)
which dispatches on transport correctly. Gates removed.
2. HistorianWcfStatusClient and HistorianWcfEventOrchestrator hardcoded
HistorianWcfBindingFactory.CreatePipeEndpointAddress for the auxiliary
services (Stat, Trx, Retr). Worked for LocalPipe; for TCP it produced
an EndpointAddress with scheme net.pipe attached to a TCP binding
(channel factory rejected the URI). Worse, when only the endpoint was
transport-aware, the binding still requested a Windows-transport-
security upgrade that the Stat endpoint over TCP doesn't support
(auxiliaries don't repeat the auth — the Hist session is already
authenticated). Added two helpers:
- HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(options, name)
-> net.pipe for LocalPipe, net.tcp for remote
- HistorianWcfBindingFactory.CreateAuxiliaryBinding(options)
-> NamedPipe for LocalPipe, plain MdasNetTcpBinding for remote
Both call sites updated.
Live verification against the remote (probed previously in prior
sessions; reachability re-confirmed today):
- ProbeAsync over RemoteTcpIntegrated and RemoteTcpCertificate
- ReadRawAsync (8 samples returned for SysTimeSec)
- ReadAggregateAsync (TimeWeightedAverage, 1-min cycle, 10-min window)
- ReadAtTimeAsync (3 timestamps)
- BrowseTagNamesAsync (finds the test tag)
- GetTagMetadataAsync (full metadata populated)
- ReadEventsAsync (chain runs without throwing)
- GetConnectionStatusAsync (ConnectedToServer=true)
- GetSystemParameterAsync (HistorianVersion="20,0,000,000")
The default 'NT SERVICE\aahClientAccessPoint' SPN turned out to work
for the remote too — discovery workstream A (SPN-finding) was not
needed in practice.
README and the TCP plan doc updated to reflect the executed status.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,14 +15,9 @@ internal sealed class Historian2020ProtocolDialect
|
||||
|
||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.Transport != HistorianTransport.LocalPipe)
|
||||
{
|
||||
return Missing<HistorianSample>($"StartDataRetrievalQuery/Full over {_options.Transport}", cancellationToken);
|
||||
}
|
||||
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return Missing<HistorianSample>("StartDataRetrievalQuery/Full requires Windows for the LocalPipe + SSPI path", cancellationToken);
|
||||
return Missing<HistorianSample>("StartDataRetrievalQuery/Full requires Windows for the SSPI path", cancellationToken);
|
||||
}
|
||||
|
||||
return ReadRawWindowsAsync(tag, startUtc, endUtc, maxValues, cancellationToken);
|
||||
@@ -38,14 +33,9 @@ internal sealed class Historian2020ProtocolDialect
|
||||
|
||||
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, RetrievalMode mode, TimeSpan interval, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.Transport != HistorianTransport.LocalPipe)
|
||||
{
|
||||
return Missing<HistorianAggregateSample>($"StartDataRetrievalQuery/{mode} over {_options.Transport}", cancellationToken);
|
||||
}
|
||||
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return Missing<HistorianAggregateSample>($"StartDataRetrievalQuery/{mode} requires Windows for the LocalPipe + SSPI path", cancellationToken);
|
||||
return Missing<HistorianAggregateSample>($"StartDataRetrievalQuery/{mode} requires Windows for the SSPI path", cancellationToken);
|
||||
}
|
||||
|
||||
return ReadAggregateWindowsAsync(tag, startUtc, endUtc, mode, interval, cancellationToken);
|
||||
@@ -63,14 +53,9 @@ internal sealed class Historian2020ProtocolDialect
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (_options.Transport != HistorianTransport.LocalPipe)
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException($"StartDataRetrievalQuery/Interpolated at-time over {_options.Transport}");
|
||||
}
|
||||
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
throw new ProtocolEvidenceMissingException("StartDataRetrievalQuery/Interpolated at-time requires Windows for the LocalPipe + SSPI path");
|
||||
throw new ProtocolEvidenceMissingException("StartDataRetrievalQuery/Interpolated at-time requires Windows for the SSPI path");
|
||||
}
|
||||
|
||||
#pragma warning disable CA1416
|
||||
@@ -86,14 +71,9 @@ internal sealed class Historian2020ProtocolDialect
|
||||
|
||||
public IAsyncEnumerable<HistorianEvent> ReadEventsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.Transport != HistorianTransport.LocalPipe)
|
||||
{
|
||||
return Missing<HistorianEvent>($"StartEventDataRetrievalQuery over {_options.Transport}", cancellationToken);
|
||||
}
|
||||
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return Missing<HistorianEvent>("StartEventDataRetrievalQuery requires Windows for the LocalPipe + SSPI path", cancellationToken);
|
||||
return Missing<HistorianEvent>("StartEventDataRetrievalQuery requires Windows for the SSPI path", cancellationToken);
|
||||
}
|
||||
|
||||
return ReadEventsWindowsAsync(startUtc, endUtc, cancellationToken);
|
||||
|
||||
@@ -164,4 +164,42 @@ internal static class HistorianWcfBindingFactory
|
||||
|
||||
return new EndpointAddress($"net.pipe://{host}/{serviceName}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the appropriate endpoint address for an auxiliary service (Stat, Trx, etc.)
|
||||
/// based on the transport — net.pipe for LocalPipe, net.tcp for the remote variants.
|
||||
/// Use this rather than <see cref="CreatePipeEndpointAddress"/> directly when the calling
|
||||
/// code may run under any transport.
|
||||
/// </summary>
|
||||
public static EndpointAddress CreateAuxiliaryEndpointAddress(HistorianClientOptions options, string serviceName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
|
||||
|
||||
return options.Transport == HistorianTransport.LocalPipe
|
||||
? CreatePipeEndpointAddress(options.Host, serviceName)
|
||||
: CreateEndpointAddress(options.Host, options.Port, serviceName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the appropriate binding for an auxiliary service (Stat, Trx, etc.) given the
|
||||
/// transport. For LocalPipe, same NamedPipe binding as History. For remote TCP variants,
|
||||
/// plain <see cref="CreateMdasNetTcpBinding"/> — auxiliaries don't repeat the Windows-
|
||||
/// transport-security upgrade that the History service negotiates; the established session
|
||||
/// authenticates the client already.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public static Binding CreateAuxiliaryBinding(HistorianClientOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
TimeSpan timeout = options.RequestTimeout;
|
||||
return options.Transport switch
|
||||
{
|
||||
HistorianTransport.LocalPipe => CreateMdasNetNamedPipeBinding(timeout),
|
||||
HistorianTransport.RemoteTcpIntegrated => CreateMdasNetTcpBinding(timeout),
|
||||
HistorianTransport.RemoteTcpCertificate => CreateMdasNetTcpBinding(timeout),
|
||||
_ => throw new NotSupportedException($"Transport {options.Transport} is not supported.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +92,14 @@ internal sealed class HistorianWcfEventOrchestrator
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var (histBinding, histEndpoint, retrBinding, retrEndpoint) = HistorianWcfBindingFactory.CreateBindingPair(_options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Status);
|
||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(_options.Host, HistorianWcfServiceNames.Transaction);
|
||||
Binding auxBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(_options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Status);
|
||||
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
|
||||
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
_options, histBinding, histEndpoint, contextKey, cancellationToken,
|
||||
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
|
||||
additionalSetup: (historyChannel, context) =>
|
||||
AddCmEventTagViaAddT(historyChannel, context, histBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
|
||||
AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
|
||||
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,12 +36,13 @@ internal static class HistorianWcfStatusClient
|
||||
{
|
||||
Guid contextKey = Guid.NewGuid();
|
||||
var (histBinding, histEndpoint, _, _) = HistorianWcfBindingFactory.CreateBindingPair(options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreatePipeEndpointAddress(options.Host, HistorianWcfServiceNames.Status);
|
||||
Binding statusBinding = HistorianWcfBindingFactory.CreateAuxiliaryBinding(options);
|
||||
EndpointAddress statusEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(options, HistorianWcfServiceNames.Status);
|
||||
|
||||
string? value = null;
|
||||
HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
|
||||
options, histBinding, histEndpoint, contextKey, CancellationToken.None,
|
||||
additionalSetup: (_, context) => value = QuerySystemParameter(histBinding, statusEndpoint, context.ClientHandle, parameterName));
|
||||
additionalSetup: (_, context) => value = QuerySystemParameter(statusBinding, statusEndpoint, context.ClientHandle, parameterName));
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user