diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs index 3d26c449..bf3bdee0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs @@ -1,15 +1,22 @@ +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; /// -/// Driver probe for the -shaped driver config. -/// The Wonderware Historian client communicates over TCP, but a lightweight TCP-connect + -/// Hello-frame probe is not yet implemented. This probe always returns a well-formed -/// "not applicable" result so the AdminUI can display a meaningful message instead of a -/// red error. A full TCP connect + Hello-frame probe is a documented follow-up. +/// TCP-connect probe for the -shaped driver +/// config. Opens a socket to the configured Host:Port (optionally performing the TLS +/// client handshake when UseTls is set, reusing the same pinned-thumbprint / CA-chain +/// validation as ), then sends a +/// with the configured shared secret and confirms the sidecar's +/// 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. /// public sealed class WonderwareHistorianDriverProbe : IDriverProbe { @@ -23,23 +30,63 @@ public sealed class WonderwareHistorianDriverProbe : IDriverProbe public string DriverType => "Historian.Wonderware"; /// - public Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) + public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) { - // Validate the config JSON can at least be parsed — surface bad JSON immediately. WonderwareHistorianClientOptions? opts; try { opts = JsonSerializer.Deserialize(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(ackFrame.Body); + if (!ack.Accepted) + return new(false, $"Sidecar rejected Hello: {ack.RejectReason ?? ""}.", 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 Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null)); + return new(false, ex.Message, null); + } + finally + { + if (stream is not null) await stream.DisposeAsync().ConfigureAwait(false); } - if (opts is null) - return Task.FromResult(new DriverProbeResult(false, "Config JSON deserialized to null.", null)); - - // The Wonderware Historian sidecar communicates over TCP; a full TCP connect + - // Hello-frame probe is a documented follow-up. - return Task.FromResult(new DriverProbeResult( - false, - "Full TCP probe (connect + Hello handshake) is not yet implemented for this driver — it is a documented follow-up.", - null)); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor index 692ff12c..42c30d22 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor @@ -88,6 +88,19 @@ else placeholder="OtOpcUa" /> Sent in Hello for sidecar logging. Default: OtOpcUa. + + TLS + + + Use TLS + + Wrap the sidecar TCP stream in TLS before the Hello handshake. + + + Server cert thumbprint (TLS pin) + + SHA-1 thumbprint to pin; blank = validate CA chain. + @@ -214,7 +227,7 @@ else } private static WonderwareHistorianClientOptions CreateDefaultOptions() => - new(Host: "localhost", Port: 32569, SharedSecret: ""); + new(Host: "localhost", Port: 32569, SharedSecret: "") { UseTls = false, ServerCertThumbprint = null }; private async Task SubmitAsync() { @@ -321,6 +334,8 @@ else public int? ConnectTimeoutSeconds { get; set; } public int? CallTimeoutSeconds { get; set; } public int ProbeTimeoutSeconds { get; set; } = 15; + public bool UseTls { get; set; } + public string? ServerCertThumbprint { get; set; } public static WonderwareHistorianClientFormModel FromRecord(WonderwareHistorianClientOptions r) => new() { @@ -331,6 +346,8 @@ else ConnectTimeoutSeconds = r.ConnectTimeout.HasValue ? (int)r.ConnectTimeout.Value.TotalSeconds : null, CallTimeoutSeconds = r.CallTimeout.HasValue ? (int)r.CallTimeout.Value.TotalSeconds : null, ProbeTimeoutSeconds = r.ProbeTimeoutSeconds, + UseTls = r.UseTls, + ServerCertThumbprint = r.ServerCertThumbprint, }; public WonderwareHistorianClientOptions ToRecord() => new( @@ -342,6 +359,8 @@ else CallTimeout: CallTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(CallTimeoutSeconds.Value) : null) { ProbeTimeoutSeconds = ProbeTimeoutSeconds, + UseTls = UseTls, + ServerCertThumbprint = ServerCertThumbprint, }; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs index d0c31303..d99d6522 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs @@ -27,6 +27,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests CallTimeout: TimeSpan.FromSeconds(60)) { ProbeTimeoutSeconds = 25, + UseTls = true, + ServerCertThumbprint = "A1B2C3D4E5F60718293A4B5C6D7E8F9012345678", }; var json = JsonSerializer.Serialize(original, _opts); @@ -42,6 +44,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests back.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20)); back.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(60)); back.ProbeTimeoutSeconds.ShouldBe(25); + back.UseTls.ShouldBeTrue(); + back.ServerCertThumbprint.ShouldBe("A1B2C3D4E5F60718293A4B5C6D7E8F9012345678"); } [Fact] @@ -101,6 +105,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests CallTimeout: TimeSpan.FromSeconds(45)) { ProbeTimeoutSeconds = 30, + UseTls = true, + ServerCertThumbprint = "0011223344556677889AABBCCDDEEFF001122334", }; var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original); @@ -115,5 +121,7 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests result.EffectiveConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18)); result.EffectiveCallTimeout.ShouldBe(TimeSpan.FromSeconds(45)); result.ProbeTimeoutSeconds.ShouldBe(30); + result.UseTls.ShouldBeTrue(); + result.ServerCertThumbprint.ShouldBe("0011223344556677889AABBCCDDEEFF001122334"); } }