feat(wcf): EventReadConnectionModeOverride + cross-platform/bounded C2 spike

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
This commit is contained in:
Joseph Doherty
2026-06-26 04:38:58 -04:00
parent 8777c0b816
commit de8d5e91ce
3 changed files with 59 additions and 10 deletions
@@ -64,6 +64,14 @@ public sealed class HistorianClientOptions
/// </summary> /// </summary>
public string? ConnectViaAddress { get; init; } public string? ConnectViaAddress { get; init; }
/// <summary>
/// Diagnostic override for the native OpenConnection mode the WCF event-read chain uses (default
/// <c>0x402</c>, read-only process). Set to e.g. <c>0x501</c> (event) or <c>0x401</c> (write-enabled)
/// to probe whether CM_EVENT registration / event-row retrieval needs a different connection type on a
/// 2023 R2 server. Null = the default read-only process mode. Intended for protocol investigation.
/// </summary>
public uint? EventReadConnectionModeOverride { get; init; }
/// <summary> /// <summary>
/// For <see cref="HistorianTransport.RemoteGrpc"/>: when true the channel uses TLS /// For <see cref="HistorianTransport.RemoteGrpc"/>: when true the channel uses TLS
/// (<c>https://</c>); when false it uses plaintext (<c>http://</c>). Matches the stock /// (<c>https://</c>); when false it uses plaintext (<c>http://</c>). Matches the stock
@@ -147,7 +147,7 @@ internal sealed class HistorianWcfEventOrchestrator
EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction); EndpointAddress transactionEndpoint = HistorianWcfBindingFactory.CreateAuxiliaryEndpointAddress(_options, HistorianWcfServiceNames.Transaction);
uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection( uint clientHandle = HistorianWcfAuthChainHelper.OpenAuthenticatedConnection(
_options, histBinding, histEndpoint, contextKey, cancellationToken, _options, histBinding, histEndpoint, contextKey, cancellationToken,
connectionMode: HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode, connectionMode: _options.EventReadConnectionModeOverride ?? HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
additionalSetup: (historyChannel, context) => additionalSetup: (historyChannel, context) =>
AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint)); AddCmEventTagViaAddT(historyChannel, context, auxBinding, statusEndpoint, transactionEndpoint, retrBinding, retrEndpoint));
return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, filter, cancellationToken); return RunEventQuery(retrBinding, retrEndpoint, clientHandle, startUtc, endUtc, filter, cancellationToken);
@@ -36,9 +36,14 @@ public sealed class WcfEventReadSpikeTests
public async Task WcfEventRead_DiagnosticDump_AgainstRemoteHistorian() public async Task WcfEventRead_DiagnosticDump_AgainstRemoteHistorian()
{ {
string? host = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_HOST"); string? host = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_HOST");
if (string.IsNullOrWhiteSpace(host) || !OperatingSystem.IsWindows()) 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; // inert without the dedicated gate / off Windows return;
} }
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PORT"), out int p) int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_PORT"), out int p)
@@ -50,8 +55,17 @@ public sealed class WcfEventReadSpikeTests
string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN"); string? spn = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_SPN");
string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID"); string? dnsId = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_DNSID");
string? via = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VIA"); string? via = Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_VIA");
bool certificate = string.Equals( bool bypassVersion = string.Equals(
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_TRANSPORT"), "certificate", StringComparison.OrdinalIgnoreCase); 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( bool allowUntrusted = string.Equals(
Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_ALLOW_UNTRUSTED"), "1", StringComparison.Ordinal); Environment.GetEnvironmentVariable("HISTORIAN_WCF_EVENT_ALLOW_UNTRUSTED"), "1", StringComparison.Ordinal);
// Certificate transport carries no Windows transport credential (cert-validated TLS channel); // Certificate transport carries no Windows transport credential (cert-validated TLS channel);
@@ -71,23 +85,50 @@ public sealed class WcfEventReadSpikeTests
ServerDnsIdentity = string.IsNullOrWhiteSpace(dnsId) ? null : dnsId, ServerDnsIdentity = string.IsNullOrWhiteSpace(dnsId) ? null : dnsId,
AllowUntrustedServerCertificate = allowUntrusted, AllowUntrustedServerCertificate = allowUntrusted,
ConnectViaAddress = string.IsNullOrWhiteSpace(via) ? null : via, ConnectViaAddress = string.IsNullOrWhiteSpace(via) ? null : via,
VerifyServerInterfaceVersion = !bypassVersion,
ConnectTimeout = TimeSpan.FromSeconds(timeoutSec),
RequestTimeout = TimeSpan.FromSeconds(timeoutSec),
EventReadConnectionModeOverride = connModeOverride,
}; };
HistorianWcfEventOrchestrator orchestrator = new(options); HistorianWcfEventOrchestrator orchestrator = new(options);
DateTime endUtc = DateTime.UtcNow; DateTime endUtc = DateTime.UtcNow;
DateTime startUtc = endUtc - TimeSpan.FromDays(days); 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; int observed = 0;
bool hasFirstEvent = false; bool hasFirstEvent = false;
string outcome = "completed"; string outcome = "completed";
try try
{ {
await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, CancellationToken.None)) // 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 () =>
{ {
observed++; int n = 0;
hasFirstEvent = true; await foreach (HistorianEvent evt in orchestrator.ReadEventsAsync(startUtc, endUtc, filter: null, budget.Token))
{
n++;
_ = evt; // event identity intentionally NOT logged (sanitized) _ = 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) catch (Exception ex)
{ {
@@ -100,7 +141,7 @@ public sealed class WcfEventReadSpikeTests
} }
// Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY. // Sanitized diagnostic dump — counts, native return codes, buffer lengths, sha256 ONLY.
_output.WriteLine($"[C2 WCF spike] transport: {options.Transport} (integratedSec={integrated}, allowUntrusted={allowUntrusted})"); _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] outcome: {outcome}");
_output.WriteLine($"[C2 WCF spike] events observed: {observed}"); _output.WriteLine($"[C2 WCF spike] events observed: {observed}");
_output.WriteLine($"[C2 WCF spike] hasFirstEvent: {hasFirstEvent}"); _output.WriteLine($"[C2 WCF spike] hasFirstEvent: {hasFirstEvent}");