93 lines
4.0 KiB
C#
93 lines
4.0 KiB
C#
using System.Diagnostics;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
|
|
|
|
/// <summary>
|
|
/// TCP-connect probe for the <see cref="WonderwareHistorianClientOptions"/>-shaped driver
|
|
/// config. Opens a socket to the configured <c>Host:Port</c> (optionally performing the TLS
|
|
/// client handshake when <c>UseTls</c> is set, reusing the same pinned-thumbprint / CA-chain
|
|
/// validation as <see cref="FrameChannel.DefaultTcpConnectFactory"/>), then sends a
|
|
/// <see cref="Hello"/> with the configured shared secret and confirms the sidecar's
|
|
/// <see cref="HelloAck"/> is accepted — a true end-to-end reachability + auth check.
|
|
/// Surfaces a green tick + latency on success; a clear red message on timeout / connection
|
|
/// refused / TLS failure / rejected Hello.
|
|
/// </summary>
|
|
public sealed class WonderwareHistorianDriverProbe : IDriverProbe
|
|
{
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => "Historian.Wonderware";
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
WonderwareHistorianClientOptions? opts;
|
|
try { opts = JsonSerializer.Deserialize<WonderwareHistorianClientOptions>(configJson, _opts); }
|
|
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
|
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
|
|
|
if (string.IsNullOrWhiteSpace(opts.Host) || opts.Port <= 0)
|
|
return new(false, "Config has no host/port to probe.", null);
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
Stream? stream = null;
|
|
try
|
|
{
|
|
// Reuse the runtime connect factory so the probe exercises the exact TCP + TLS
|
|
// (pinned-thumbprint or CA-chain) path the client uses in production.
|
|
stream = await FrameChannel.DefaultTcpConnectFactory(opts, ct).ConfigureAwait(false);
|
|
|
|
using var reader = new FrameReader(stream, leaveOpen: true);
|
|
using var writer = new FrameWriter(stream, leaveOpen: true);
|
|
|
|
var hello = new Hello
|
|
{
|
|
ProtocolMajor = Hello.CurrentMajor,
|
|
ProtocolMinor = Hello.CurrentMinor,
|
|
PeerName = opts.PeerName,
|
|
SharedSecret = opts.SharedSecret,
|
|
};
|
|
await writer.WriteAsync(MessageKind.Hello, hello, ct).ConfigureAwait(false);
|
|
|
|
var ackFrame = await reader.ReadFrameAsync(ct).ConfigureAwait(false)
|
|
?? throw new EndOfStreamException("Sidecar closed connection before HelloAck.");
|
|
if (ackFrame.Kind != MessageKind.HelloAck)
|
|
return new(false, $"Sidecar replied to Hello with kind {ackFrame.Kind}; expected HelloAck.", null);
|
|
|
|
var ack = FrameReader.Deserialize<HelloAck>(ackFrame.Body);
|
|
if (!ack.Accepted)
|
|
return new(false, $"Sidecar rejected Hello: {ack.RejectReason ?? "<no reason>"}.", null);
|
|
|
|
sw.Stop();
|
|
return new(true, $"Connected to {opts.Host}:{opts.Port} (tls={opts.UseTls})", sw.Elapsed);
|
|
}
|
|
catch (SocketException ex)
|
|
{
|
|
return new(false, $"Connect failed: {ex.SocketErrorCode}", null);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new(false, ex.Message, null);
|
|
}
|
|
finally
|
|
{
|
|
if (stream is not null) await stream.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|