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:
dohertj2
2026-05-04 07:33:50 -04:00
parent 1b31c24c8d
commit 6888b8c55a
7 changed files with 316 additions and 38 deletions
@@ -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);