feat(adminui): historian TCP-connect probe + TLS form fields
This commit is contained in:
+64
-17
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Driver probe for the <see cref="WonderwareHistorianClientOptions"/>-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 <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
|
||||
{
|
||||
@@ -23,23 +30,63 @@ public sealed class WonderwareHistorianDriverProbe : IDriverProbe
|
||||
public string DriverType => "Historian.Wonderware";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
public async Task<DriverProbeResult> 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<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 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));
|
||||
}
|
||||
}
|
||||
|
||||
+20
-1
@@ -88,6 +88,19 @@ else
|
||||
placeholder="OtOpcUa" />
|
||||
<div class="form-text">Sent in Hello for sidecar logging. Default: OtOpcUa.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">TLS</label>
|
||||
<div class="form-check mt-1">
|
||||
<InputCheckbox @bind-Value="_form.Historian.UseTls" class="form-check-input" id="historianUseTls" />
|
||||
<label class="form-check-label" for="historianUseTls">Use TLS</label>
|
||||
</div>
|
||||
<div class="form-text">Wrap the sidecar TCP stream in TLS before the Hello handshake.</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Server cert thumbprint (TLS pin)</label>
|
||||
<InputText @bind-Value="_form.Historian.ServerCertThumbprint" class="form-control form-control-sm mono" />
|
||||
<div class="form-text">SHA-1 thumbprint to pin; blank = validate CA chain.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user