7a3cd9b76e
DelT and EnsT2 had two distinct silent-fail blockers; both now resolved live end-to-end. Read path's RetrievalMode mapping was missing 11 of 15 enum values (plus a latent Cyclic→4 bug). Investigation tooling kept as env-gated helpers. DelT silent fail: Open2 was using NativeIntegratedReadOnlyConnectionMode (0x402); server returned err 132 OperationNotEnabled silently. Added NativeIntegratedWriteEnabledConnectionMode (0x401) per HistorianAccessUtil.SetConnectionMode bit map (Process=1 | IntegratedSecurity=0x400). Write orchestrator now opens with write-enabled mode. EnsT2 silent fail: byte-by-byte comparison via inspector revealed two bugs in SerializeAnalogCTagMetadata. The original "146-byte byte-for-byte match" was misaligned — it omitted the leading 0x4E marker byte and treated WCF's `01 01 01` EndElement closing markers as if they were part of the InBuff payload. Real native InBuff is 144 bytes with 0x4E lead and 2-byte `FE 00` trailer. Golden test bytes corrected. EnsureTagAsync expansion: probed every analog data type via instrument-wcf-writemessage; byte 11 of CTagMetadata is the data-type discriminator (Float=0x01, Double=0x21, UInt2=0x09, UInt4=0x11, Int2=0x29, Int4=0x31). String/Int1/Int8/UInt8 fail at native AddTag — out of scope for this op. Range encoding decoded: defaults emit compact `1A 03`; non-default emit `1F 00` + 4 doubles in order MinEU/MaxEU/MinRaw/MaxRaw. MinRaw/MaxRaw sent on the wire but server mirrors them to MinEU/MaxEU when ApplyScaling=false (verified against native — server quirk, not SDK bug). RetrievalMode mapping: probed all 15 enum values; QueryType is just the native enum ordinal. Replaced the broken switch with `(uint)mode`. Existing SDK mapped Cyclic→4 (BestFit's value); Cyclic is actually 0. CLAUDE.md updated: stale "Active Protocol Blocker" rewritten as resolved-status block; SDK surface now reflects the read-blocker resolution and the new write ops; "Remaining gaps" punch list refreshed. Tools added (both env-gated, no runtime overhead unless flipped on): - HistorianWcfMessageCaptureBehavior — captures all WCF body bytes when AVEVA_HISTORIAN_SDK_WIRE_CAPTURE is set; used for byte-level diff vs native. - HistorianWcfHistAddressingBehavior — explicitly sets wsa:To header on the Hist channel for parity with native bytes (kept though not load-bearing). - WriteDiag in TagWriteOrchestrator — env-gated EnsT2/DelT response logging (AVEVA_HISTORIAN_DELT_DIAG). NativeTraceHarness CLI: added --write-min-eu/--write-max-eu/--write-min-raw/ --write-max-raw for capturing non-default-range EnsT2 payloads. Tests: 130 → 161 passing (+31). Includes 16-mode RetrievalMode mapping table, 4 per-data-type EnsT2 golden tests, NonDefaultRanges golden test, 6 live round-trip integration tests covering Float/Double/Int2/Int4/UInt4/FloatRanges, 3 live tests for previously-unmapped RetrievalMode values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
8.9 KiB
C#
214 lines
8.9 KiB
C#
using System.Buffers.Binary;
|
||
using System.Diagnostics;
|
||
using System.Runtime.Versioning;
|
||
using System.ServiceModel;
|
||
using System.ServiceModel.Channels;
|
||
using AVEVA.Historian.Client.Wcf.Contracts;
|
||
|
||
namespace AVEVA.Historian.Client.Wcf;
|
||
|
||
[SupportedOSPlatform("windows")]
|
||
internal static class HistorianWcfAuthChainHelper
|
||
{
|
||
private const int OpenConnection3MinResponseLength = 5;
|
||
private const int CredentialBlockSizeBytes = 1026;
|
||
private const int MaxValClRounds = 8;
|
||
private const string ClientNodeNameFallback = "AVEVA.Historian.Client";
|
||
private const string ClientDataSourceId = "2020.406.2652.2";
|
||
private const string ClientDllVersionString = "2020.406.2652.2";
|
||
private const byte NativeClientType = 4;
|
||
public const uint NativeIntegratedReadOnlyConnectionMode = 0x402;
|
||
public const uint NativeIntegratedEventConnectionMode = 0x501;
|
||
/// <summary>
|
||
/// Process + write-enabled + integrated security. Per native ilspy
|
||
/// (HistorianAccessUtil.SetConnectionMode): Process=1, OR 0x400 for integratedSecurity.
|
||
/// EnsT2 and DelT silently return false with err code 132 (OperationNotEnabled) when
|
||
/// Open2 is opened with 0x402 (read-only); 0x401 unlocks write capability.
|
||
/// </summary>
|
||
public const uint NativeIntegratedWriteEnabledConnectionMode = 0x401;
|
||
private const byte NativeClientCommonInfoFormatVersion = 4;
|
||
private const ushort NativeHcalVersion = 17;
|
||
private const uint NativeClientVersionInt = 999_999;
|
||
private const ushort NativeOpen2ClientVersion = 9;
|
||
|
||
/// <summary>
|
||
/// Runs Hist.GetV → Hist.ValCl × N → Hist.Open2 against the configured /Hist endpoint and
|
||
/// returns the transient /Retr client handle decoded from the OpenConnection3 response.
|
||
/// Caller is responsible for opening the matching /Retr channel.
|
||
/// </summary>
|
||
public static uint OpenAuthenticatedConnection(
|
||
HistorianClientOptions options,
|
||
Binding historyBinding,
|
||
EndpointAddress historyEndpoint,
|
||
Guid contextKey,
|
||
CancellationToken cancellationToken,
|
||
uint connectionMode = NativeIntegratedReadOnlyConnectionMode,
|
||
Action<IHistoryServiceContract2, OpenConnectionContext>? additionalSetup = null)
|
||
{
|
||
ChannelFactory<IHistoryServiceContract2> historyFactory = new(historyBinding, historyEndpoint);
|
||
historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfHistAddressingBehavior());
|
||
if (HistorianWcfMessageCaptureBehavior.IsEnabled)
|
||
{
|
||
historyFactory.Endpoint.EndpointBehaviors.Add(new HistorianWcfMessageCaptureBehavior());
|
||
}
|
||
|
||
try
|
||
{
|
||
IHistoryServiceContract2 historyChannel = historyFactory.CreateChannel();
|
||
ICommunicationObject historyChannelCo = (ICommunicationObject)historyChannel;
|
||
try
|
||
{
|
||
historyChannel.GetInterfaceVersion(out _);
|
||
RunValClRounds(historyChannel, contextKey, options, cancellationToken);
|
||
|
||
byte[] open2Request = BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
|
||
bool open2Success = historyChannel.OpenConnection2(ref open2Request, out byte[] open2Response, out byte[] open2Error);
|
||
open2Response ??= [];
|
||
open2Error ??= [];
|
||
if (!open2Success || open2Response.Length < OpenConnection3MinResponseLength)
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"Open2 failed (Success={open2Success}, ResponseLen={open2Response.Length}, ErrorLen={open2Error.Length}).");
|
||
}
|
||
|
||
uint clientHandle = BinaryPrimitives.ReadUInt32LittleEndian(open2Response.AsSpan(1, 4));
|
||
Guid storageSessionId = open2Response.Length >= 21
|
||
? new Guid(open2Response.AsSpan(5, 16))
|
||
: Guid.Empty;
|
||
|
||
if (additionalSetup is not null)
|
||
{
|
||
additionalSetup(historyChannel, new OpenConnectionContext(contextKey, clientHandle, storageSessionId));
|
||
}
|
||
|
||
return clientHandle;
|
||
}
|
||
finally
|
||
{
|
||
CloseChannelSafely(historyChannelCo);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
CloseFactorySafely(historyFactory);
|
||
}
|
||
}
|
||
|
||
public readonly record struct OpenConnectionContext(Guid ContextKey, uint ClientHandle, Guid StorageSessionId);
|
||
|
||
private static void RunValClRounds(IHistoryServiceContract2 channel, Guid contextKey, HistorianClientOptions options, CancellationToken cancellationToken)
|
||
{
|
||
using HistorianSspiClient sspi = options.IntegratedSecurity
|
||
? new HistorianSspiClient(options.TargetSpn)
|
||
: new HistorianSspiClient(options.TargetSpn, ParseDomain(options.UserName), ParseUserName(options.UserName), options.Password);
|
||
string handle = contextKey.ToString("D").ToUpperInvariant();
|
||
byte[] incoming = [];
|
||
|
||
for (int round = 0; round < MaxValClRounds; round++)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
|
||
HistorianSspiStepResult step = sspi.Next(incoming);
|
||
byte[] outgoing = step.Token;
|
||
HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(outgoing);
|
||
byte[] wrapped = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(round == 0, outgoing);
|
||
|
||
bool serverSuccess = channel.ValidateClientCredential(handle, wrapped, out byte[] serverOutput, out byte[] errorBuffer);
|
||
serverOutput ??= [];
|
||
errorBuffer ??= [];
|
||
|
||
if (!serverSuccess)
|
||
{
|
||
throw new InvalidOperationException($"ValCl round {round} rejected (errorLen={errorBuffer.Length}).");
|
||
}
|
||
|
||
ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput);
|
||
if (response is null || !response.Continue)
|
||
{
|
||
return;
|
||
}
|
||
|
||
incoming = response.Token;
|
||
if (step.IsCompleted && incoming.Length == 0)
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
|
||
throw new InvalidOperationException($"ValCl exceeded {MaxValClRounds} rounds without terminal success.");
|
||
}
|
||
|
||
private static string ParseDomain(string userName)
|
||
{
|
||
if (string.IsNullOrEmpty(userName)) return string.Empty;
|
||
int slash = userName.IndexOf('\\');
|
||
return slash > 0 ? userName[..slash] : string.Empty;
|
||
}
|
||
|
||
private static string ParseUserName(string userName)
|
||
{
|
||
if (string.IsNullOrEmpty(userName)) return string.Empty;
|
||
int slash = userName.IndexOf('\\');
|
||
return slash > 0 ? userName[(slash + 1)..] : userName;
|
||
}
|
||
|
||
private static byte[] BuildOpenConnection3Request(string host, Guid contextKey, uint connectionMode)
|
||
{
|
||
Process current = Process.GetCurrentProcess();
|
||
string machineName = Environment.MachineName;
|
||
string processName = string.IsNullOrEmpty(current.ProcessName) ? ClientNodeNameFallback : current.ProcessName;
|
||
_ = host; // host reserved for remote-orchestrator extension
|
||
|
||
HistorianOpen2Request open2 = new(
|
||
HostName: machineName,
|
||
ProcessName: string.Empty,
|
||
ProcessId: checked((uint)current.Id),
|
||
UserName: string.Empty,
|
||
Password: [],
|
||
ClientType: NativeClientType,
|
||
ClientVersion: NativeOpen2ClientVersion,
|
||
ConnectionMode: connectionMode,
|
||
MetadataNamespace: HistorianMetadataNamespace.Empty);
|
||
|
||
HistorianClientCommonInfo commonInfo = new(
|
||
FormatVersion: NativeClientCommonInfoFormatVersion,
|
||
ServerNodeName: machineName,
|
||
ClientNodeName: processName,
|
||
ProcessId: checked((uint)current.Id),
|
||
HcalVersion: NativeHcalVersion,
|
||
ProcessName: string.Empty,
|
||
Proxy: string.Empty,
|
||
DataSourceId: ClientDataSourceId,
|
||
ShardId: Guid.Empty,
|
||
ClientVersion: NativeClientVersionInt,
|
||
ClientTimestamp: (ulong)DateTime.UtcNow.ToFileTimeUtc(),
|
||
ClientDllVersion: ClientDllVersionString);
|
||
|
||
return HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
|
||
open2,
|
||
commonInfo,
|
||
contextKey,
|
||
credentialBlock: new byte[CredentialBlockSizeBytes]);
|
||
}
|
||
|
||
private static void CloseChannelSafely(ICommunicationObject channel)
|
||
{
|
||
try
|
||
{
|
||
if (channel.State == CommunicationState.Faulted) channel.Abort();
|
||
else channel.Close();
|
||
}
|
||
catch { try { channel.Abort(); } catch { } }
|
||
}
|
||
|
||
private static void CloseFactorySafely<TChannel>(ChannelFactory<TChannel> factory)
|
||
{
|
||
try
|
||
{
|
||
if (factory.State == CommunicationState.Faulted) factory.Abort();
|
||
else factory.Close();
|
||
}
|
||
catch { try { factory.Abort(); } catch { } }
|
||
}
|
||
}
|