diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs
index 47f0aa68..4e7c7257 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Contracts/WonderwareHistorianClientOptions.cs
@@ -15,13 +15,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
/// history router are expected to layer their own backoff on top.
///
///
-/// Named-pipe name the sidecar listens on (matches the sidecar's OTOPCUA_HISTORIAN_PIPE).
+/// Sidecar TCP host (DNS name or IP) the client dials.
+/// Sidecar TCP port (matches the sidecar's OTOPCUA_HISTORIAN_TCP_PORT).
/// Per-process shared secret the sidecar will verify in the Hello frame.
/// Diagnostic peer identifier sent in Hello — typically the OtOpcUa instance id.
-/// Cap on the named-pipe connect + Hello round trip on each (re)connect.
+/// Cap on the TCP connect + Hello round trip on each (re)connect.
/// Cap on a single read/write call once connected.
public sealed record WonderwareHistorianClientOptions(
- string PipeName,
+ string Host,
+ int Port,
string SharedSecret,
string PeerName = "OtOpcUa",
TimeSpan? ConnectTimeout = null,
@@ -41,12 +43,6 @@ public sealed record WonderwareHistorianClientOptions(
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 15;
- /// Sidecar TCP host (DNS name or IP). Required for the TCP transport.
- public string? Host { get; init; }
-
- /// Sidecar TCP port (matches the sidecar's OTOPCUA_HISTORIAN_TCP_PORT).
- public int Port { get; init; }
-
/// When true, the client wraps the TCP stream in TLS before the Hello handshake.
public bool UseTls { get; init; }
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs
index 0e27e482..98991f91 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Internal/FrameChannel.cs
@@ -1,4 +1,3 @@
-using System.IO.Pipes;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
@@ -31,22 +30,6 @@ internal sealed class FrameChannel : IAsyncDisposable
private FrameWriter? _writer;
private bool _disposed;
- ///
- /// Default factory: connects to a real by name.
- ///
- public static Func> DefaultNamedPipeConnectFactory =
- async (opts, ct) =>
- {
- var pipe = new NamedPipeClientStream(
- serverName: ".",
- pipeName: opts.PipeName,
- direction: PipeDirection.InOut,
- options: PipeOptions.Asynchronous);
-
- await pipe.ConnectAsync((int)opts.EffectiveConnectTimeout.TotalMilliseconds, ct).ConfigureAwait(false);
- return pipe;
- };
-
///
/// Default TCP factory: connects to the sidecar over TCP, optionally wrapping the stream
/// in TLS (server-auth; pinned-thumbprint or CA-chain validation). The Hello handshake +
@@ -63,7 +46,7 @@ internal sealed class FrameChannel : IAsyncDisposable
{
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
connectCts.CancelAfter(opts.EffectiveConnectTimeout);
- await tcp.ConnectAsync(opts.Host!, opts.Port, connectCts.Token).ConfigureAwait(false);
+ await tcp.ConnectAsync(opts.Host, opts.Port, connectCts.Token).ConfigureAwait(false);
}
catch
{
@@ -85,7 +68,7 @@ internal sealed class FrameChannel : IAsyncDisposable
});
try
{
- await ssl.AuthenticateAsClientAsync(opts.Host!).ConfigureAwait(false);
+ await ssl.AuthenticateAsClientAsync(opts.Host).ConfigureAwait(false);
}
catch
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs
index 3f49a6f3..481b55c8 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs
@@ -527,12 +527,12 @@ public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHist
///
/// Synchronous dispose required by on
- /// . The underlying channel's async cleanup runs
- /// teardown, which can block briefly
- /// on OS handle release — strictly speaking it is not non-blocking — but the
- /// GetAwaiter()/GetResult() bridge is deadlock-safe because the cleanup never
- /// awaits a captured nor takes any
- /// lock that the caller could hold. (Finding 010.)
+ /// . The underlying channel's async cleanup runs the
+ /// TCP socket teardown, which can block briefly on OS handle release — strictly speaking
+ /// it is not non-blocking — but the GetAwaiter()/GetResult() bridge is
+ /// deadlock-safe because the cleanup never awaits a captured
+ /// nor takes any lock that the
+ /// caller could hold. (Finding 010.)
///
public void Dispose() => _channel.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs
new file mode 100644
index 00000000..e00e606c
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/IFrameHandler.cs
@@ -0,0 +1,20 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
+
+///
+/// Strategy for handling each post-Hello frame the sidecar's
+/// reads. Implementations deserialize the body per the , dispatch
+/// to the historian, and write the corresponding reply through the supplied
+/// .
+///
+public interface IFrameHandler
+{
+ /// Handles a frame from the sidecar frame server.
+ /// The type of message being handled.
+ /// The serialized message body.
+ /// The frame writer to send responses.
+ /// Cancellation token for the operation.
+ Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeAcl.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeAcl.cs
deleted file mode 100644
index 4d6094f6..00000000
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeAcl.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System;
-using System.IO.Pipes;
-using System.Security.AccessControl;
-using System.Security.Principal;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
-
-///
-/// Builds a strict for the historian sidecar pipe — only the
-/// configured server-principal SID gets ReadWrite | Synchronize, LocalSystem is
-/// explicitly denied (unless it's the allowed principal itself), and the allowed SID owns
-/// the DACL. Mirrors the policy in Driver.Galaxy.Host's PipeAcl.
-///
-public static class PipeAcl
-{
- /// Creates a strict PipeSecurity for the historian sidecar pipe.
- /// The security identifier that should have read-write access to the pipe.
- /// A configured PipeSecurity object with strict access control.
- public static PipeSecurity Create(SecurityIdentifier allowedSid)
- {
- if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid));
-
- var security = new PipeSecurity();
-
- security.AddAccessRule(new PipeAccessRule(
- allowedSid,
- PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize,
- AccessControlType.Allow));
-
- var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
- if (allowedSid != localSystem)
- security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny));
-
- // Owner = allowed SID so the deny rules can't be removed without write-DACL rights.
- security.SetOwner(allowedSid);
-
- return security;
- }
-}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeServer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeServer.cs
deleted file mode 100644
index ffd9cb0c..00000000
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/PipeServer.cs
+++ /dev/null
@@ -1,258 +0,0 @@
-using System;
-using System.IO.Pipes;
-using System.Security.Principal;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using Serilog;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
-
-///
-/// Accepts one client connection at a time on a named pipe with the strict ACL from
-/// . Verifies the peer SID and the per-process shared secret before
-/// any frame is dispatched. Mirrors Driver.Galaxy.Host's PipeServer; the sidecar carries
-/// its own copy so the deletion of Galaxy.Host in PR 7.2 leaves the sidecar self-contained.
-///
-public sealed class PipeServer : IDisposable
-{
- private readonly string _pipeName;
- private readonly SecurityIdentifier _allowedSid;
- private readonly string _sharedSecret;
- private readonly ILogger _logger;
- private readonly CancellationTokenSource _cts = new();
- private readonly CallerVerifier _verifier;
- private NamedPipeServerStream? _current;
-
- ///
- /// Pluggable caller-verification seam. Default implementation calls
- /// ; tests can substitute one that ignores the pipe ACL
- /// to exercise the rejection paths.
- ///
- /// The named pipe server stream to verify.
- /// The allowed security identifier.
- /// The rejection reason if verification fails.
- internal delegate bool CallerVerifier(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason);
-
- /// Initializes a new instance of the class.
- /// The name of the named pipe.
- /// The security identifier allowed to connect.
- /// The shared secret for client authentication.
- /// The logger for diagnostic messages.
- public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
- : this(pipeName, allowedSid, sharedSecret, logger, DefaultVerifier) { }
-
- /// Initializes a new instance of the class with a custom verifier.
- /// The name of the named pipe.
- /// The security identifier allowed to connect.
- /// The shared secret for client authentication.
- /// The logger for diagnostic messages.
- /// The caller verification delegate.
- internal PipeServer(
- string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger,
- CallerVerifier verifier)
- {
- _pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName));
- _allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid));
- _sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- _verifier = verifier ?? throw new ArgumentNullException(nameof(verifier));
- }
-
- private static bool DefaultVerifier(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason)
- => VerifyCaller(pipe, allowedSid, out reason);
-
- ///
- /// Accepts one connection, performs Hello handshake, then dispatches frames to
- /// until EOF or cancel. Returns when the client disconnects.
- ///
- /// The frame handler to process frames.
- /// Cancellation token for the operation.
- public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
- {
- using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
- var acl = PipeAcl.Create(_allowedSid);
-
- _current = new NamedPipeServerStream(
- _pipeName,
- PipeDirection.InOut,
- maxNumberOfServerInstances: 1,
- PipeTransmissionMode.Byte,
- PipeOptions.Asynchronous,
- inBufferSize: 64 * 1024,
- outBufferSize: 64 * 1024,
- pipeSecurity: acl);
-
- try
- {
- await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false);
-
- using var reader = new FrameReader(_current, leaveOpen: true);
- using var writer = new FrameWriter(_current, leaveOpen: true);
-
- // First frame must be Hello with the correct shared secret. Reading it before
- // the caller-SID impersonation check satisfies Windows' ERROR_CANNOT_IMPERSONATE
- // rule — ImpersonateNamedPipeClient fails until at least one frame has been read.
- var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
- if (first is null || first.Value.Kind != MessageKind.Hello)
- {
- _logger.Warning("Sidecar IPC first frame was not Hello; dropping");
- return;
- }
-
- if (!_verifier(_current, _allowedSid, out var reason))
- {
- // Driver.Historian.Wonderware-007: send a rejecting HelloAck so the client
- // learns why instead of having to wait for its own read timeout. The reason
- // tag "caller-sid-mismatch" is symmetric with the shared-secret-mismatch and
- // major-version-mismatch acks the two other rejection paths emit below.
- await writer.WriteAsync(MessageKind.HelloAck,
- new HelloAck { Accepted = false, RejectReason = $"caller-sid-mismatch: {reason}" },
- linked.Token).ConfigureAwait(false);
- _logger.Warning("Sidecar IPC caller rejected: {Reason}", reason);
- _current.Disconnect();
- return;
- }
-
- var hello = MessagePackSerializer.Deserialize(first.Value.Body);
- if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
- {
- await writer.WriteAsync(MessageKind.HelloAck,
- new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" },
- linked.Token).ConfigureAwait(false);
- _logger.Warning("Sidecar IPC Hello rejected: shared-secret-mismatch");
- return;
- }
-
- if (hello.ProtocolMajor != Hello.CurrentMajor)
- {
- await writer.WriteAsync(MessageKind.HelloAck,
- new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" },
- linked.Token).ConfigureAwait(false);
- _logger.Warning("Sidecar IPC Hello rejected: major mismatch peer={Peer} server={Server}",
- hello.ProtocolMajor, Hello.CurrentMajor);
- return;
- }
-
- await writer.WriteAsync(MessageKind.HelloAck,
- new HelloAck { Accepted = true, HostName = Environment.MachineName },
- linked.Token).ConfigureAwait(false);
-
- while (!linked.Token.IsCancellationRequested)
- {
- var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
- if (frame is null) break;
-
- await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
- }
- }
- finally
- {
- _current.Dispose();
- _current = null;
- }
- }
-
- // Backoff sequence for consecutive RunOneConnection failures: 250 ms → 500 ms →
- // 1 000 ms → 2 000 ms → 4 000 ms → capped at 8 000 ms thereafter.
- private static readonly TimeSpan[] BackoffSteps =
- {
- TimeSpan.FromMilliseconds(250),
- TimeSpan.FromMilliseconds(500),
- TimeSpan.FromSeconds(1),
- TimeSpan.FromSeconds(2),
- TimeSpan.FromSeconds(4),
- TimeSpan.FromSeconds(8),
- };
-
- ///
- /// Maximum consecutive failures before the server gives up and lets the process exit
- /// so the supervisor (NSSM / SCM) can restart the sidecar cleanly.
- ///
- private const int MaxConsecutiveFailures = 20;
-
- ///
- /// Runs the server continuously, handling one connection at a time. When a connection
- /// ends (clean or error), waits with exponential backoff before accepting the next.
- /// If consecutive failures occur the method
- /// throws so the supervisor can restart the sidecar.
- ///
- /// The frame handler to process frames.
- /// Cancellation token for the operation.
- public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
- {
- var consecutiveFailures = 0;
-
- while (!ct.IsCancellationRequested)
- {
- try
- {
- await RunOneConnectionAsync(handler, ct).ConfigureAwait(false);
- consecutiveFailures = 0; // a clean connection (even a short-lived one) resets the counter
- }
- catch (OperationCanceledException) { break; }
- catch (Exception ex)
- {
- consecutiveFailures++;
-
- if (consecutiveFailures >= MaxConsecutiveFailures)
- {
- _logger.Fatal(ex,
- "Sidecar IPC connection loop failed {Count} consecutive times — giving up so supervisor can restart",
- consecutiveFailures);
- throw;
- }
-
- var delay = BackoffSteps[Math.Min(consecutiveFailures - 1, BackoffSteps.Length - 1)];
- _logger.Error(ex,
- "Sidecar IPC connection loop error (consecutive failure {Count}/{Max}) — retrying in {Delay}",
- consecutiveFailures, MaxConsecutiveFailures, delay);
-
- try { await Task.Delay(delay, ct).ConfigureAwait(false); }
- catch (OperationCanceledException) { break; }
- }
- }
- }
-
- private static bool VerifyCaller(NamedPipeServerStream pipe, SecurityIdentifier allowedSid, out string reason)
- {
- try
- {
- pipe.RunAsClient(() =>
- {
- using var wi = WindowsIdentity.GetCurrent();
- if (wi.User is null)
- throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller");
- if (wi.User != allowedSid)
- throw new UnauthorizedAccessException(
- $"caller SID {wi.User.Value} does not match allowed {allowedSid.Value}");
- });
- reason = string.Empty;
- return true;
- }
- catch (Exception ex) { reason = ex.Message; return false; }
- }
-
- /// Disposes the pipe server and cancels any pending operations.
- public void Dispose()
- {
- _cts.Cancel();
- _current?.Dispose();
- _cts.Dispose();
- }
-}
-
-///
-/// Strategy for handling each post-Hello frame the pipe server reads. Implementations
-/// deserialize the body per the , dispatch to the historian, and
-/// write the corresponding reply through the supplied .
-///
-public interface IFrameHandler
-{
- /// Handles a frame from the pipe server.
- /// The type of message being handled.
- /// The serialized message body.
- /// The frame writer to send responses.
- /// Cancellation token for the operation.
- Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
-}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs
index cdaa4919..a4c0c217 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/TcpFrameServer.cs
@@ -14,8 +14,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
///
/// Accepts one TCP client at a time, optionally over TLS, verifies the shared-secret
-/// Hello, then dispatches frames to . The TCP replacement for
-/// PipeServer; the Windows-SID ACL is replaced by TLS + the shared secret.
+/// Hello, then dispatches frames to . Authentication is the
+/// shared secret carried in the Hello frame, optionally over a TLS-protected channel.
///
public sealed class TcpFrameServer : IDisposable
{
@@ -125,7 +125,7 @@ public sealed class TcpFrameServer : IDisposable
}
}
- // ---- identical backoff/give-up policy to PipeServer (copy verbatim) ----
+ // ---- exponential backoff / give-up policy between accepted connections ----
private static readonly TimeSpan[] BackoffSteps =
{
TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1),
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 871415d5..333ba75b 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
@@ -66,11 +66,16 @@ else
Connection
-
-
-
-
Must match the sidecar's OTOPCUA_HISTORIAN_PIPE environment variable.
+
+
+
+
DNS name or IP the historian sidecar's TCP listener is reachable at.
+
+
+
+
+
Must match the sidecar's OTOPCUA_HISTORIAN_TCP_PORT.
@@ -209,7 +214,7 @@ else
}
private static WonderwareHistorianClientOptions CreateDefaultOptions() =>
- new(PipeName: "otopcua-historian", SharedSecret: "");
+ new(Host: "localhost", Port: 32569, SharedSecret: "");
private async Task SubmitAsync()
{
@@ -309,7 +314,8 @@ else
///
public sealed class WonderwareHistorianClientFormModel
{
- public string PipeName { get; set; } = "otopcua-historian";
+ public string Host { get; set; } = "localhost";
+ public int Port { get; set; } = 32569;
public string SharedSecret { get; set; } = "";
public string PeerName { get; set; } = "OtOpcUa";
public int? ConnectTimeoutSeconds { get; set; }
@@ -318,7 +324,8 @@ else
public static WonderwareHistorianClientFormModel FromRecord(WonderwareHistorianClientOptions r) => new()
{
- PipeName = r.PipeName,
+ Host = r.Host,
+ Port = r.Port,
SharedSecret = r.SharedSecret,
PeerName = r.PeerName,
ConnectTimeoutSeconds = r.ConnectTimeout.HasValue ? (int)r.ConnectTimeout.Value.TotalSeconds : null,
@@ -327,7 +334,8 @@ else
};
public WonderwareHistorianClientOptions ToRecord() => new(
- PipeName: PipeName,
+ Host: Host,
+ Port: Port,
SharedSecret: SharedSecret,
PeerName: PeerName,
ConnectTimeout: ConnectTimeoutSeconds.HasValue ? TimeSpan.FromSeconds(ConnectTimeoutSeconds.Value) : null,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index ed042f26..f2d36aea 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -88,15 +88,15 @@ if (hasDriver)
// Config-gated durable alarm-historian sink. When the AlarmHistorian section is enabled this
// overrides the NullAlarmHistorianSink default from AddOtOpcUaRuntime (last registration wins)
- // with a SqliteStoreAndForwardSink draining to the Wonderware named-pipe writer. The writer is
+ // with a SqliteStoreAndForwardSink draining to the Wonderware TCP writer. The writer is
// injected here because the Host is the only project that references the Wonderware client —
// Runtime owns the gating + Sqlite construction, the Host supplies the concrete downstream.
builder.Services.AddAlarmHistorian(
builder.Configuration,
(opts, sp) => new WonderwareHistorianClient(
- new WonderwareHistorianClientOptions(opts.PipeName, opts.SharedSecret)
+ new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret)
{
- Host = opts.Host, Port = opts.Port, UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint,
+ UseTls = opts.UseTls, ServerCertThumbprint = opts.ServerCertThumbprint,
},
sp.GetService>()));
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs
index aeaa33de..5bdb8096 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/AlarmHistorianOptions.cs
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
/// Binds the AlarmHistorian configuration section that gates the durable
/// store-and-forward alarm sink. When is true,
/// AddAlarmHistorian registers a SqliteStoreAndForwardSink (draining to the
-/// Wonderware named-pipe writer supplied by the Host) in place of the
+/// Wonderware TCP writer supplied by the Host) in place of the
/// NullAlarmHistorianSink default; otherwise the Null default survives.
///
public sealed class AlarmHistorianOptions
@@ -25,9 +25,6 @@ public sealed class AlarmHistorianOptions
/// Filesystem path to the local SQLite store-and-forward queue database.
public string DatabasePath { get; init; } = "alarm-historian.db";
- /// Named-pipe name the Wonderware historian sidecar listens on.
- public string PipeName { get; init; } = "OtOpcUaHistorian";
-
/// TCP hostname or IP address the Wonderware historian sidecar listens on.
public string Host { get; init; } = "localhost";
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs
index a85521dc..e9ba17ef 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/TcpConnectFactoryTests.cs
@@ -55,10 +55,8 @@ public sealed class TcpConnectFactoryTests
await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token);
}, cts.Token);
- var opts = new WonderwareHistorianClientOptions("pipe", "secret")
+ var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret")
{
- Host = "127.0.0.1",
- Port = boundPort,
UseTls = false,
};
@@ -94,10 +92,8 @@ public sealed class TcpConnectFactoryTests
ssl.Dispose();
}, cts.Token);
- var opts = new WonderwareHistorianClientOptions("pipe", "secret")
+ var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret")
{
- Host = "127.0.0.1",
- Port = boundPort,
UseTls = true,
ServerCertThumbprint = cert.GetCertHashString(),
};
@@ -137,10 +133,8 @@ public sealed class TcpConnectFactoryTests
}
}, cts.Token);
- var opts = new WonderwareHistorianClientOptions("pipe", "secret")
+ var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret")
{
- Host = "127.0.0.1",
- Port = boundPort,
UseTls = true,
ServerCertThumbprint = "00112233445566778899AABBCCDDEEFF00112233", // bogus
};
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs
index 6f56032c..8e8b8fb4 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientOptionsTests.cs
@@ -11,10 +11,8 @@ public sealed class WonderwareHistorianClientOptionsTests
[Fact]
public void TcpTlsFields_AreStoredCorrectly_WhenExplicitlySet()
{
- var opts = new WonderwareHistorianClientOptions("pipe", "secret")
+ var opts = new WonderwareHistorianClientOptions("h", 32569, "secret")
{
- Host = "h",
- Port = 32569,
UseTls = true,
ServerCertThumbprint = "AB"
};
@@ -28,10 +26,10 @@ public sealed class WonderwareHistorianClientOptionsTests
[Fact]
public void TcpTlsFields_HaveCorrectDefaults_WhenNotSet()
{
- var opts = new WonderwareHistorianClientOptions("pipe", "secret");
+ var opts = new WonderwareHistorianClientOptions("host", 32569, "secret");
- opts.Host.ShouldBeNull();
- opts.Port.ShouldBe(0);
+ opts.Host.ShouldBe("host");
+ opts.Port.ShouldBe(32569);
opts.UseTls.ShouldBeFalse();
opts.ServerCertThumbprint.ShouldBeNull();
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs
index bbb3c4e2..1522e091 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs
@@ -22,14 +22,13 @@ public sealed class WonderwareHistorianClientTests
private const string Secret = "test-secret-123";
private static WonderwareHistorianClientOptions OptsFor(FakeSidecarServer server) => new(
- PipeName: "",
+ Host: "127.0.0.1",
+ Port: server.BoundPort,
SharedSecret: Secret,
PeerName: "test",
ConnectTimeout: TimeSpan.FromSeconds(2),
CallTimeout: TimeSpan.FromSeconds(2))
{
- Host = "127.0.0.1",
- Port = server.BoundPort,
UseTls = false,
};
@@ -445,14 +444,13 @@ public sealed class WonderwareHistorianClientTests
await server.StartAsync();
var opts = new WonderwareHistorianClientOptions(
- PipeName: "",
+ Host: "127.0.0.1",
+ Port: server.BoundPort,
SharedSecret: Secret,
PeerName: "test",
ConnectTimeout: TimeSpan.FromSeconds(2),
CallTimeout: TimeSpan.FromMilliseconds(500)) // short timeout for test speed
{
- Host = "127.0.0.1",
- Port = server.BoundPort,
UseTls = false,
};
@@ -661,13 +659,12 @@ public sealed class WonderwareHistorianClientTests
// 3. Construct the client via the PUBLIC ctor (no ForTests factory).
var opts = new WonderwareHistorianClientOptions(
- PipeName: "ignored-pipe",
+ Host: "127.0.0.1",
+ Port: boundPort,
SharedSecret: Secret,
ConnectTimeout: TimeSpan.FromSeconds(5),
CallTimeout: TimeSpan.FromSeconds(5))
{
- Host = "127.0.0.1",
- Port = boundPort,
UseTls = false,
};
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeRoundTripTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeRoundTripTests.cs
deleted file mode 100644
index c62a5c07..00000000
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeRoundTripTests.cs
+++ /dev/null
@@ -1,348 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using Serilog;
-using Serilog.Core;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
-using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
-using SidecarHistorianEventDto = ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc.HistorianEventDto;
-using BackendHistorianEventDto = ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend.HistorianEventDto;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc;
-
-///
-/// Round-trip tests for the sidecar pipe contract added in PR 3.3. Each scenario serializes
-/// a Request through the wire framing, dispatches via
-/// against a fake historian, and asserts the returned Reply round-trips with the expected
-/// content. No real named pipe is opened — the framing is exercised over a back-to-back
-/// pair so tests stay fast and platform-independent.
-///
-public sealed class PipeRoundTripTests
-{
- private static readonly ILogger Quiet = Logger.None;
-
- private sealed class FakeHistorian : IHistorianDataSource
- {
- /// Gets or sets the raw samples to return from reads.
- public List RawSamples { get; set; } = new();
-
- /// Gets or sets the aggregate samples to return from reads.
- public List AggregateSamples { get; set; } = new();
-
- /// Gets or sets the at-time samples to return from reads.
- public List AtTimeSamples { get; set; } = new();
-
- /// Gets or sets the events to return from reads.
- public List Events { get; set; } = new();
-
- /// Gets or sets an exception to throw from read operations.
- public Exception? ThrowFromRead { get; set; }
-
- ///
- /// Reads raw samples from the fake historian or throws if configured.
- ///
- /// The tag name.
- /// The start time.
- /// The end time.
- /// The maximum number of values to return.
- /// Cancellation token.
- /// The raw samples.
- public Task> ReadRawAsync(string tagName, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct = default)
- {
- if (ThrowFromRead is not null) throw ThrowFromRead;
- return Task.FromResult(RawSamples);
- }
-
- ///
- /// Reads aggregate samples from the fake historian.
- ///
- /// The tag name.
- /// The start time.
- /// The end time.
- /// The interval in milliseconds.
- /// The aggregate column name.
- /// Cancellation token.
- /// The aggregate samples.
- public Task> ReadAggregateAsync(string tagName, DateTime startTime, DateTime endTime, double intervalMs, string aggregateColumn, CancellationToken ct = default)
- => Task.FromResult(AggregateSamples);
-
- ///
- /// Reads at-time samples from the fake historian.
- ///
- /// The tag name.
- /// The timestamps to read at.
- /// Cancellation token.
- /// The at-time samples.
- public Task> ReadAtTimeAsync(string tagName, DateTime[] timestamps, CancellationToken ct = default)
- => Task.FromResult(AtTimeSamples);
-
- ///
- /// Reads events from the fake historian.
- ///
- /// The event source name.
- /// The start time.
- /// The end time.
- /// The maximum number of events to return.
- /// Cancellation token.
- /// The events.
- public Task> ReadEventsAsync(string? sourceName, DateTime startTime, DateTime endTime, int maxEvents, CancellationToken ct = default)
- => Task.FromResult(Events);
-
- /// Gets a health snapshot of the fake historian.
- /// A health snapshot.
- public HistorianHealthSnapshot GetHealthSnapshot() => new();
-
- /// Disposes the fake historian.
- public void Dispose() { }
- }
-
- private sealed class FakeAlarmWriter : IAlarmEventWriter
- {
- /// Gets the events received by this writer.
- public List Received { get; } = new();
-
- /// Gets or sets a delegate that decides whether each event should be marked as successfully written.
- public Func Decide { get; set; } = _ => true;
-
- ///
- /// Writes alarm events to the fake writer and returns per-event status based on the delegate.
- ///
- /// The events to write.
- /// Cancellation token.
- /// An array of booleans indicating success for each event.
- public Task WriteAsync(AlarmHistorianEventDto[] events, CancellationToken cancellationToken)
- {
- Received.AddRange(events);
- var result = new bool[events.Length];
- for (var i = 0; i < events.Length; i++) result[i] = Decide(events[i]);
- return Task.FromResult(result);
- }
- }
-
- ///
- /// Drives one round trip: serialize , run the handler,
- /// read the reply frame, deserialize it. Returns the reply.
- ///
- private static async Task RoundTripAsync(
- MessageKind requestKind,
- MessageKind expectedReplyKind,
- TRequest request,
- IFrameHandler handler)
- {
- // Build the request body the same way FrameWriter would, but feed it directly into
- // the handler's Handle method (the pipe server has already read the kind + body
- // before handing them to the handler).
- var requestBody = MessagePackSerializer.Serialize(request);
-
- using var stream = new MemoryStream();
- using var writer = new FrameWriter(stream, leaveOpen: true);
-
- await handler.HandleAsync(requestKind, requestBody, writer, CancellationToken.None);
-
- stream.Position = 0;
- using var reader = new FrameReader(stream, leaveOpen: true);
- var frame = await reader.ReadFrameAsync(CancellationToken.None);
- frame.ShouldNotBeNull();
- frame!.Value.Kind.ShouldBe(expectedReplyKind);
- return MessagePackSerializer.Deserialize(frame.Value.Body);
- }
-
- /// Verifies that raw historian samples round-trip correctly through the frame handler.
- [Fact]
- public async Task ReadRaw_RoundTripsSamples()
- {
- var historian = new FakeHistorian();
- historian.RawSamples.Add(new HistorianSample { Value = 42.0, Quality = 192, TimestampUtc = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc) });
- historian.RawSamples.Add(new HistorianSample { Value = 43.5, Quality = 192, TimestampUtc = new DateTime(2026, 4, 29, 12, 0, 1, DateTimeKind.Utc) });
-
- var handler = new HistorianFrameHandler(historian, Quiet);
- var reply = await RoundTripAsync(
- MessageKind.ReadRawRequest, MessageKind.ReadRawReply,
- new ReadRawRequest
- {
- TagName = "Tank.Level",
- StartUtcTicks = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks,
- EndUtcTicks = new DateTime(2026, 4, 30, 0, 0, 0, DateTimeKind.Utc).Ticks,
- MaxValues = 100,
- CorrelationId = "corr-1",
- }, handler);
-
- reply.Success.ShouldBeTrue();
- reply.Error.ShouldBeNull();
- reply.CorrelationId.ShouldBe("corr-1");
- reply.Samples.Length.ShouldBe(2);
- reply.Samples[0].Quality.ShouldBe((byte)192);
- reply.Samples[0].TimestampUtcTicks.ShouldBe(new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc).Ticks);
- reply.Samples[0].ValueBytes.ShouldNotBeNull();
- MessagePackSerializer.Deserialize(reply.Samples[0].ValueBytes!).ShouldBe(42.0);
- }
-
- /// Verifies that read failures are properly surfaced as error replies.
- [Fact]
- public async Task ReadRaw_FailureSurfacesAsErrorReply()
- {
- var historian = new FakeHistorian { ThrowFromRead = new InvalidOperationException("boom") };
- var handler = new HistorianFrameHandler(historian, Quiet);
- var reply = await RoundTripAsync(
- MessageKind.ReadRawRequest, MessageKind.ReadRawReply,
- new ReadRawRequest { TagName = "Tag", CorrelationId = "fail-1" }, handler);
-
- reply.Success.ShouldBeFalse();
- reply.Error.ShouldBe("boom");
- reply.CorrelationId.ShouldBe("fail-1");
- reply.Samples.ShouldBeEmpty();
- }
-
- /// Verifies that processed (aggregate) historian samples round-trip correctly through the frame handler.
- [Fact]
- public async Task ReadProcessed_RoundTripsBuckets()
- {
- var historian = new FakeHistorian();
- historian.AggregateSamples.Add(new HistorianAggregateSample { Value = 50.0, TimestampUtc = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc) });
- historian.AggregateSamples.Add(new HistorianAggregateSample { Value = null, TimestampUtc = new DateTime(2026, 4, 29, 0, 1, 0, DateTimeKind.Utc) });
-
- var handler = new HistorianFrameHandler(historian, Quiet);
- var reply = await RoundTripAsync(
- MessageKind.ReadProcessedRequest, MessageKind.ReadProcessedReply,
- new ReadProcessedRequest { TagName = "Tank.Level", IntervalMs = 60000, AggregateColumn = "Average", CorrelationId = "p-1" },
- handler);
-
- reply.Success.ShouldBeTrue();
- reply.Buckets.Length.ShouldBe(2);
- reply.Buckets[0].Value.ShouldBe(50.0);
- reply.Buckets[1].Value.ShouldBeNull(); // unavailable bucket
- }
-
- /// Verifies that at-time historian samples round-trip correctly through the frame handler.
- [Fact]
- public async Task ReadAtTime_RoundTripsSamples()
- {
- var historian = new FakeHistorian();
- historian.AtTimeSamples.Add(new HistorianSample { Value = 7, Quality = 192, TimestampUtc = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc) });
-
- var handler = new HistorianFrameHandler(historian, Quiet);
- var reply = await RoundTripAsync(
- MessageKind.ReadAtTimeRequest, MessageKind.ReadAtTimeReply,
- new ReadAtTimeRequest
- {
- TagName = "Tank.Level",
- TimestampsUtcTicks = new[] { new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc).Ticks },
- CorrelationId = "t-1",
- }, handler);
-
- reply.Success.ShouldBeTrue();
- reply.Samples.Length.ShouldBe(1);
- }
-
- /// Verifies that historian events round-trip correctly through the frame handler.
- [Fact]
- public async Task ReadEvents_RoundTripsEvents()
- {
- var historian = new FakeHistorian();
- var eid = Guid.Parse("11111111-1111-1111-1111-111111111111");
- historian.Events.Add(new BackendHistorianEventDto
- {
- Id = eid,
- Source = "Tank.HiHi",
- EventTime = new DateTime(2026, 4, 29, 1, 0, 0, DateTimeKind.Utc),
- ReceivedTime = new DateTime(2026, 4, 29, 1, 0, 1, DateTimeKind.Utc),
- DisplayText = "Level high-high",
- Severity = 800,
- });
-
- var handler = new HistorianFrameHandler(historian, Quiet);
- var reply = await RoundTripAsync(
- MessageKind.ReadEventsRequest, MessageKind.ReadEventsReply,
- new ReadEventsRequest { SourceName = "Tank.HiHi", MaxEvents = 100, CorrelationId = "e-1" },
- handler);
-
- reply.Success.ShouldBeTrue();
- reply.Events.Length.ShouldBe(1);
- reply.Events[0].EventId.ShouldBe(eid.ToString());
- reply.Events[0].Source.ShouldBe("Tank.HiHi");
- reply.Events[0].DisplayText.ShouldBe("Level high-high");
- reply.Events[0].Severity.ShouldBe((ushort)800);
- }
-
- /// Verifies that alarm events are routed to the writer and per-event status is returned.
- [Fact]
- public async Task WriteAlarmEvents_RoutesToWriter_AndReturnsPerEventStatus()
- {
- var historian = new FakeHistorian();
- var alarmWriter = new FakeAlarmWriter
- {
- // Simulate "second event fails" to verify per-event status flows through.
- Decide = e => e.EventId != "ev-2",
- };
- var handler = new HistorianFrameHandler(historian, Quiet, alarmWriter);
-
- var request = new WriteAlarmEventsRequest
- {
- CorrelationId = "wa-1",
- Events = new[]
- {
- new AlarmHistorianEventDto { EventId = "ev-1", SourceName = "Tank.HiHi", AlarmType = "Active", Severity = 800, EventTimeUtcTicks = DateTime.UtcNow.Ticks },
- new AlarmHistorianEventDto { EventId = "ev-2", SourceName = "Tank.HiHi", AlarmType = "Acknowledged", Severity = 800, EventTimeUtcTicks = DateTime.UtcNow.Ticks },
- },
- };
-
- var reply = await RoundTripAsync(
- MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply,
- request, handler);
-
- reply.Success.ShouldBeTrue();
- reply.PerEventOk.Length.ShouldBe(2);
- reply.PerEventOk[0].ShouldBeTrue();
- reply.PerEventOk[1].ShouldBeFalse();
- alarmWriter.Received.Count.ShouldBe(2);
- }
-
- /// Verifies that writing alarm events fails cleanly when no writer is configured.
- [Fact]
- public async Task WriteAlarmEvents_FailsCleanly_WhenNoWriterConfigured()
- {
- var historian = new FakeHistorian();
- var handler = new HistorianFrameHandler(historian, Quiet, alarmWriter: null);
-
- var reply = await RoundTripAsync(
- MessageKind.WriteAlarmEventsRequest, MessageKind.WriteAlarmEventsReply,
- new WriteAlarmEventsRequest
- {
- CorrelationId = "wa-2",
- Events = new[] { new AlarmHistorianEventDto { EventId = "ev-1" } },
- }, handler);
-
- reply.Success.ShouldBeFalse();
- reply.Error.ShouldNotBeNull();
- reply.PerEventOk.Length.ShouldBe(1);
- reply.PerEventOk[0].ShouldBeFalse();
- }
-
- /// Verifies that frame reader and writer preserve message kind and body through a round trip.
- [Fact]
- public async Task FrameReader_FrameWriter_RoundTripPreservesKindAndBody()
- {
- // Pure framing-layer test — confirms the length-prefix + kind-byte + body protocol
- // is the same on both sides without any handler in the loop.
- using var stream = new MemoryStream();
- using var writer = new FrameWriter(stream, leaveOpen: true);
-
- var hello = new Hello { ProtocolMajor = 1, PeerName = "test-peer", SharedSecret = "secret" };
- await writer.WriteAsync(MessageKind.Hello, hello, CancellationToken.None);
-
- stream.Position = 0;
- using var reader = new FrameReader(stream, leaveOpen: true);
- var frame = await reader.ReadFrameAsync(CancellationToken.None);
-
- frame.ShouldNotBeNull();
- frame!.Value.Kind.ShouldBe(MessageKind.Hello);
- var decoded = MessagePackSerializer.Deserialize(frame.Value.Body);
- decoded.PeerName.ShouldBe("test-peer");
- decoded.SharedSecret.ShouldBe("secret");
- }
-}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeServerSidRejectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeServerSidRejectTests.cs
deleted file mode 100644
index 5381db78..00000000
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Ipc/PipeServerSidRejectTests.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using System;
-using System.IO.Pipes;
-using System.Security.Principal;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using Serilog;
-using Serilog.Core;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc;
-
-///
-/// Driver.Historian.Wonderware-007 regression. The two other rejection paths
-/// (shared-secret-mismatch and major-version-mismatch) both write a
-/// with Accepted=false before disconnecting; the caller-SID-mismatch path used to
-/// just disconnect abruptly, leaving the client to time out instead of learning why.
-/// The fix sends a symmetric caller-sid-mismatch ack before disconnecting.
-///
-/// The test uses the internal test-seam constructor so the verifier rejects without
-/// needing to actually relax the pipe ACL (which would block the test client itself).
-///
-public sealed class PipeServerSidRejectTests
-{
- private static readonly ILogger Quiet = Logger.None;
-
- /// Verifies that a caller SID mismatch sends HelloAck with reject reason before disconnect.
- [Fact]
- public async Task Caller_SID_mismatch_sends_HelloAck_with_reject_reason_before_disconnect()
- {
- // The pipe ACL must allow the current process to connect — so wire up the pipe
- // with the current user's SID. Then have the verifier seam simulate the SID
- // mismatch by returning false. This isolates the "what does the server do on a
- // rejected caller" question from the (separate) "is the ACL correct" question.
- var current = WindowsIdentity.GetCurrent().User
- ?? throw new InvalidOperationException("WindowsIdentity.GetCurrent().User was null — cannot run test");
-
- var pipeName = $"otopcua-hist-sidreject-test-{Guid.NewGuid():N}";
-
- PipeServer.CallerVerifier rejecting = (NamedPipeServerStream _, SecurityIdentifier _, out string reason) =>
- {
- reason = "synthetic-mismatch";
- return false;
- };
- using var server = new PipeServer(pipeName, current, "secret", Quiet, rejecting);
-
- var serverTask = Task.Run(() => server.RunOneConnectionAsync(new NoopHandler(), CancellationToken.None));
-
- using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
- await client.ConnectAsync(5_000);
-
- using var writer = new FrameWriter(client, leaveOpen: true);
- using var reader = new FrameReader(client, leaveOpen: true);
-
- var hello = new Hello { ProtocolMajor = Hello.CurrentMajor, PeerName = "test", SharedSecret = "secret" };
- await writer.WriteAsync(MessageKind.Hello, hello, CancellationToken.None);
-
- // Read the rejecting HelloAck the server is expected to send before disconnecting.
- var frame = await reader.ReadFrameAsync(CancellationToken.None);
- frame.ShouldNotBeNull("server must send a HelloAck on caller-SID rejection, not just disconnect");
- frame!.Value.Kind.ShouldBe(MessageKind.HelloAck);
-
- var ack = MessagePackSerializer.Deserialize(frame.Value.Body);
- ack.Accepted.ShouldBeFalse();
- ack.RejectReason.ShouldNotBeNullOrEmpty();
- ack.RejectReason!.ShouldContain("caller-sid-mismatch",
- Case.Insensitive,
- "reject reason must match the documented caller-sid-mismatch tag so clients can diagnose");
-
- await serverTask;
- }
-
- /// Handler that asserts it is never called — the connection must be rejected at Hello.
- private sealed class NoopHandler : IFrameHandler
- {
- /// Throws if called, as the connection should be rejected before reaching this handler.
- /// The message kind (unused).
- /// The message body (unused).
- /// The frame writer (unused).
- /// Cancellation token (unused).
- /// Never returns; always throws.
- public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
- {
- throw new InvalidOperationException(
- $"Handler must not be reached on a rejected caller; got frame {kind}");
- }
- }
-}
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 365a3e6f..d0c31303 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HistorianWonderwareDriverPageFormSerializationTests.cs
@@ -19,7 +19,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
public void RoundTrip_PreservesKnownFields()
{
var original = new WonderwareHistorianClientOptions(
- PipeName: "otopcua-historian-prod",
+ Host: "historian-prod.zb.local",
+ Port: 32569,
SharedSecret: "t0ps3cr3t",
PeerName: "OtOpcUa-Primary",
ConnectTimeout: TimeSpan.FromSeconds(20),
@@ -32,7 +33,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
var back = JsonSerializer.Deserialize(json, _opts);
back.ShouldNotBeNull();
- back.PipeName.ShouldBe("otopcua-historian-prod");
+ back.Host.ShouldBe("historian-prod.zb.local");
+ back.Port.ShouldBe(32569);
back.SharedSecret.ShouldBe("t0ps3cr3t");
back.PeerName.ShouldBe("OtOpcUa-Primary");
back.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(20));
@@ -46,7 +48,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
public void RoundTrip_NullTimeouts_UsesDefaults()
{
var original = new WonderwareHistorianClientOptions(
- PipeName: "otopcua-historian",
+ Host: "localhost",
+ Port: 32569,
SharedSecret: "secret");
var json = JsonSerializer.Serialize(original, _opts);
@@ -65,7 +68,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
var jsonWithExtra = """
{
"unknownField": "old-value",
- "pipeName": "otopcua-historian",
+ "host": "historian.zb.local",
+ "port": 32569,
"sharedSecret": "s3cr3t",
"probeTimeoutSeconds": 20
}
@@ -79,7 +83,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
var back = JsonSerializer.Deserialize(jsonWithExtra, optsWithSkip);
back.ShouldNotBeNull();
back.ProbeTimeoutSeconds.ShouldBe(20);
- back.PipeName.ShouldBe("otopcua-historian");
+ back.Host.ShouldBe("historian.zb.local");
+ back.Port.ShouldBe(32569);
}
[Fact]
@@ -88,7 +93,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
// Construct a record with non-default values for every property and verify
// that WonderwareHistorianClientFormModel.FromRecord → ToRecord is lossless.
var original = new WonderwareHistorianClientOptions(
- PipeName: "otopcua-historian-prod",
+ Host: "historian-prod.zb.local",
+ Port: 32570,
SharedSecret: "sup3rs3cr3t",
PeerName: "OtOpcUa-Redundant",
ConnectTimeout: TimeSpan.FromSeconds(18),
@@ -100,7 +106,8 @@ public sealed class HistorianWonderwareDriverPageFormSerializationTests
var form = HistorianWonderwareDriverPage.WonderwareHistorianClientFormModel.FromRecord(original);
var result = form.ToRecord();
- result.PipeName.ShouldBe("otopcua-historian-prod");
+ result.Host.ShouldBe("historian-prod.zb.local");
+ result.Port.ShouldBe(32570);
result.SharedSecret.ShouldBe("sup3rs3cr3t");
result.PeerName.ShouldBe("OtOpcUa-Redundant");
result.ConnectTimeout.ShouldBe(TimeSpan.FromSeconds(18));