de8d5e91ce
Live investigation (direct from a VPN host to the 2023 R2 historian's real WCF port) showed the certificate transport + NegotiateAuthentication auth work cross-platform, and that the event-read chain needs the 0x501 event connection mode for CM_EVENT RegisterTags to succeed (0x402/0x401 fail). Even with registration succeeding over a window that has events, StartEventQuery returns a 0-row header and long-polls — the same server-side per-connection row gate proven for gRPC. Adds: EventReadConnectionModeOverride (diagnostic), and spike knobs — cross-platform cert gate, version-check bypass, per-call timeout, overall budget with phase-diagnostic dump, connection-mode override. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
165 lines
10 KiB
C#
165 lines
10 KiB
C#
using System.Runtime.Versioning;
|
|
using AVEVA.Historian.Client.Models;
|
|
using AVEVA.Historian.Client.Wcf;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace AVEVA.Historian.Client.Tests;
|
|
|
|
/// <remarks>
|
|
/// C2 live diagnostic spike (pending.md C2): does the managed WCF event-read path return rows
|
|
/// against an event-bearing 2023 R2 historian? gRPC event reads are a proven server-side dead-end
|
|
/// (docs/reverse-engineering/grpc-event-query-capture.md); the WCF transport is C2's only listed
|
|
/// unblock but is itself unproven (the orchestrator documents native code 76 / 85 on this server).
|
|
///
|
|
/// Drives <see cref="HistorianWcfEventOrchestrator"/> directly over RemoteTcpIntegrated and dumps the
|
|
/// full native chain. It NEVER fails the suite (skip+log diagnostic) and is inert off Windows — the
|
|
/// GREEN/RED call is by reading the printed "events observed" count + native return codes.
|
|
///
|
|
/// Gated by <c>HISTORIAN_WCF_EVENT_HOST</c> (independent of the gRPC live vars so it never runs by
|
|
/// accident). Optional: <c>HISTORIAN_WCF_EVENT_PORT</c> (default 32568), <c>HISTORIAN_WCF_EVENT_USER</c>
|
|
/// + <c>HISTORIAN_WCF_EVENT_PASSWORD</c> (absent => IntegratedSecurity), <c>HISTORIAN_WCF_EVENT_SPN</c>
|
|
/// (Kerberos SPN override; the default is the LocalPipe identity and will not authenticate remotely),
|
|
/// <c>HISTORIAN_WCF_EVENT_DAYS</c> (lookback window, default 90 — the live event store held 71,332
|
|
/// events in -90d).
|
|
///
|
|
/// Run from the Windows capture rig over VPN:
|
|
/// dotnet test --filter "FullyQualifiedName~WcfEventReadSpike" -l "console;verbosity=detailed"
|
|
/// </remarks>
|
|
[SupportedOSPlatform("windows")]
|
|
public sealed class WcfEventReadSpikeTests
|
|
{
|
|
private readonly ITestOutputHelper _output;
|
|
|
|
public WcfEventReadSpikeTests(ITestOutputHelper output) => _output = output;
|
|
|
|
[Fact]
|
|
public async Task WcfEventRead_DiagnosticDump_AgainstRemoteHistorian()
|
|
{
|
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_HOST");
|
|
bool certificate = string.Equals(
|
|
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase);
|
|
// The certificate (TLS) transport and NegotiateAuthentication-based app auth are cross-platform;
|
|
// the integrated (Windows SSPI transport-security) transport is Windows-only. Skip when the gate
|
|
// is unconfigured, or when an integrated run is requested off Windows.
|
|
if (string.IsNullOrWhiteSpace(host) || (!certificate && !OperatingSystem.IsWindows()))
|
|
{
|
|
return;
|
|
}
|
|
|
|
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PORT"), out int p)
|
|
? p : HistorianClientOptions.DefaultPort; // 32568
|
|
int days = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DAYS"), out int d)
|
|
? d : 90;
|
|
string? user = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_USER");
|
|
string? password = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PASSWORD");
|
|
string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN");
|
|
string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID");
|
|
string? via = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VIA");
|
|
bool bypassVersion = string.Equals(
|
|
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_NOVERSIONCHECK"), "1", StringComparison.Ordinal);
|
|
int timeoutSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TIMEOUT_SEC"), out int ts) && ts > 0
|
|
? ts : 30;
|
|
string? connModeRaw = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_CONNMODE");
|
|
uint? connModeOverride = null;
|
|
if (!string.IsNullOrWhiteSpace(connModeRaw))
|
|
{
|
|
string hex = connModeRaw.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? connModeRaw[2..] : connModeRaw;
|
|
if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out uint cm)) { connModeOverride = cm; }
|
|
}
|
|
bool allowUntrusted = string.Equals(
|
|
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_ALLOW_UNTRUSTED"), "1", StringComparison.Ordinal);
|
|
// Certificate transport carries no Windows transport credential (cert-validated TLS channel);
|
|
// integrated derives one from the logged-in identity unless an explicit DOMAIN\user is supplied.
|
|
// App-level ValidateClientCredential still uses UserName/Password (or the process identity).
|
|
bool integrated = !certificate && string.IsNullOrEmpty(user);
|
|
|
|
HistorianClientOptions options = new()
|
|
{
|
|
Host = host,
|
|
Port = port,
|
|
Transport = certificate ? HistorianTransport.RemoteTcpCertificate : HistorianTransport.RemoteTcpIntegrated,
|
|
IntegratedSecurity = integrated,
|
|
UserName = user ?? string.Empty,
|
|
Password = password ?? string.Empty,
|
|
TargetSpn = string.IsNullOrWhiteSpace(spn) ? "NT SERVICE\\aahClientAccessPoint" : spn,
|
|
ServerDnsIdentity = string.IsNullOrWhiteSpace(dnsId) ? null : dnsId,
|
|
AllowUntrustedServerCertificate = allowUntrusted,
|
|
ConnectViaAddress = string.IsNullOrWhiteSpace(via) ? null : via,
|
|
VerifyServerInterfaceVersion = !bypassVersion,
|
|
ConnectTimeout = TimeSpan.FromSeconds(timeoutSec),
|
|
RequestTimeout = TimeSpan.FromSeconds(timeoutSec),
|
|
EventReadConnectionModeOverride = connModeOverride,
|
|
};
|
|
|
|
HistorianWcfEventOrchestrator orchestrator = new(options);
|
|
DateTime endUtc = DateTime.UtcNow;
|
|
DateTime startUtc = endUtc - TimeSpan.FromDays(days);
|
|
|
|
int budgetSec = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_BUDGET_SEC"), out int bgs) && bgs > 0
|
|
? bgs : 60;
|
|
|
|
int observed = 0;
|
|
bool hasFirstEvent = false;
|
|
string outcome = "completed";
|
|
try
|
|
{
|
|
// Race the read against an overall budget. The chain is synchronous WCF, so a stuck call
|
|
// can't be token-cancelled — but the orchestrator's static/instance diagnostics are set as it
|
|
// progresses, so on a budget timeout we still dump them to see which phase (auth / CM_EVENT
|
|
// registration / query) is hanging. The abandoned task keeps running in the background harness.
|
|
using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSec));
|
|
Task<int> readTask = Task.Run(async () =>
|
|
{
|
|
int n = 0;
|
|
await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, budget.Token))
|
|
{
|
|
n++;
|
|
_ = evt; // event identity intentionally NOT logged (sanitized)
|
|
}
|
|
return n;
|
|
});
|
|
|
|
Task finished = await Task.WhenAny(readTask, Task.Delay(TimeSpan.FromSeconds(budgetSec + 5)));
|
|
if (finished == readTask)
|
|
{
|
|
observed = await readTask;
|
|
hasFirstEvent = observed > 0;
|
|
}
|
|
else
|
|
{
|
|
outcome = $"TIMED OUT after {budgetSec}s (chain still running — see return codes for phase reached)";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Default: type name only (sanitized). Opt into messages for live binding/protocol debugging
|
|
// via HISTORIAN_WCF_EVENT_VERBOSE=1 — binding/protocol errors may carry the endpoint host
|
|
// (already known to the operator) but never credentials; still off by default.
|
|
outcome = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VERBOSE"), "1", StringComparison.Ordinal)
|
|
? $"threw {ex.GetType().FullName} :: {ex.Message} | inner={ex.InnerException?.GetType().FullName} :: {ex.InnerException?.Message}"
|
|
: $"threw {ex.GetType().Name}";
|
|
}
|
|
|
|
// Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY.
|
|
_output.WriteLine($"[C2 WCF spike] transport: {options.Transport} (integratedSec={integrated}, allowUntrusted={allowUntrusted}, connMode={(connModeOverride.HasValue ? "0x" + connModeOverride.Value.ToString("X") : "default-0x402")})");
|
|
_output.WriteLine($"[C2 WCF spike] outcome: {outcome}");
|
|
_output.WriteLine($"[C2 WCF spike] events observed: {observed}");
|
|
_output.WriteLine($"[C2 WCF spike] hasFirstEvent: {hasFirstEvent}");
|
|
_output.WriteLine($"[C2 WCF spike] LastUpdC3ReturnCode: {HistorianWcfEventOrchestrator.LastUpdC3ReturnCode}");
|
|
_output.WriteLine($"[C2 WCF spike] LastRTag2ReturnCode: {HistorianWcfEventOrchestrator.LastRTag2ReturnCode}");
|
|
_output.WriteLine($"[C2 WCF spike] LastAddReturnCode(EnsT2): {HistorianWcfEventOrchestrator.LastAddReturnCode}");
|
|
_output.WriteLine($"[C2 WCF spike] LastAddOutputLength: {HistorianWcfEventOrchestrator.LastAddOutputLength}");
|
|
_output.WriteLine($"[C2 WCF spike] LastEnsT2PayloadSha256: {HistorianWcfEventOrchestrator.LastEnsT2PayloadSha256}");
|
|
_output.WriteLine($"[C2 WCF spike] LastResultBufferLength: {orchestrator.LastResultBufferLength}");
|
|
// Contract-safe: LastErrorBufferDescription is DescribeNativeError's STRUCTURED formatting of the
|
|
// 5-byte native error buffer ("type=N code=M (0xHEX)" / "<short>") — never freeform server text,
|
|
// FQDN, SPN, or credentials. The code value (e.g. 76 / 85) is the RED-case signal this spike exists
|
|
// to capture, so it is dumped in full rather than reduced to a length.
|
|
_output.WriteLine($"[C2 WCF spike] LastErrorBufferDescription: {orchestrator.LastErrorBufferDescription}");
|
|
_output.WriteLine($"[C2 WCF spike] window days: {days}");
|
|
|
|
// Diagnostic: NEVER fails the suite. GREEN = observed > 0; RED = observed == 0 (read the output).
|
|
Assert.True(observed >= 0, "diagnostic always passes; the GREEN/RED signal is the printed count");
|
|
}
|
|
}
|