6218512365
v2-ci / build (push) Failing after 46s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Live verification on a Windows VM surfaced a crash loop: TcpFrameServer.EnsureListening assigned _listener = new TcpListener(...) BEFORE calling Start(). When Start() throws — e.g. the port is in a Windows excluded/reserved range (WSAEACCES) or already in use — the field was left non-null-but-unstarted, so the `if (_listener is not null) return` guard permanently skipped re-Start() and every subsequent AcceptTcpClientAsync() threw the misleading InvalidOperationException "Not listening" → 20 failures → exit 2 → NSSM restart → loop. Now _listener is assigned only after Start() succeeds, so a transient bind failure is retried and a permanent one surfaces the real bind error each iteration. Adds a regression test that forces a bind conflict and asserts the SocketException persists.
298 lines
14 KiB
C#
298 lines
14 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Net.Security;
|
|
using System.Net.Sockets;
|
|
using System.Security.Authentication;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Round-trip tests for <see cref="TcpFrameServer"/> added with the TCP transport. Each
|
|
/// scenario binds the server on <c>127.0.0.1:0</c>, connects a real <see cref="TcpClient"/>,
|
|
/// performs the Hello handshake, and exercises a request/reply over the wire framing — both
|
|
/// plaintext and over TLS. These target net48 and run on Windows in CI; on the macOS dev box
|
|
/// they only compile.
|
|
/// </summary>
|
|
public sealed class TcpRoundTripTests
|
|
{
|
|
private static readonly ILogger Quiet = Logger.None;
|
|
|
|
// Generous timeout so the deterministic tests don't hang CI if the server misbehaves.
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
|
|
|
/// <summary>
|
|
/// Fake handler that echoes a fixed <see cref="ReadRawReply"/> when it sees a
|
|
/// <see cref="MessageKind.ReadRawRequest"/>, mirroring the client correlation id.
|
|
/// </summary>
|
|
private sealed class EchoHandler : IFrameHandler
|
|
{
|
|
public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
|
{
|
|
if (kind != MessageKind.ReadRawRequest)
|
|
return Task.CompletedTask;
|
|
|
|
var request = MessagePackSerializer.Deserialize<ReadRawRequest>(body);
|
|
var reply = new ReadRawReply
|
|
{
|
|
CorrelationId = request.CorrelationId,
|
|
Success = true,
|
|
Samples = new[]
|
|
{
|
|
new HistorianSampleDto
|
|
{
|
|
ValueBytes = MessagePackSerializer.Serialize(42.0),
|
|
Quality = 192,
|
|
TimestampUtcTicks = new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc).Ticks,
|
|
},
|
|
},
|
|
};
|
|
return writer.WriteAsync(MessageKind.ReadRawReply, reply, ct);
|
|
}
|
|
}
|
|
|
|
/// <summary>Generates an in-memory self-signed RSA cert with a serverAuth EKU and a private key.</summary>
|
|
private static X509Certificate2 MakeSelfSignedCert()
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
var req = new CertificateRequest("CN=otopcua-historian-sidecar-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
req.CertificateExtensions.Add(
|
|
new X509EnhancedKeyUsageExtension(
|
|
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") /* serverAuth */ }, critical: false));
|
|
using var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
|
// Round-trip through a PFX so the returned cert carries an exportable private key on net48.
|
|
var pfx = ephemeral.Export(X509ContentType.Pfx, "pw");
|
|
return new X509Certificate2(pfx, "pw", X509KeyStorageFlags.Exportable);
|
|
}
|
|
|
|
/// <summary>Performs the Hello handshake on the given stream and returns the deserialized ack.</summary>
|
|
private static async Task<HelloAck> HelloAsync(Stream stream, string secret, CancellationToken ct)
|
|
{
|
|
using var writer = new FrameWriter(stream, leaveOpen: true);
|
|
using var reader = new FrameReader(stream, leaveOpen: true);
|
|
|
|
await writer.WriteAsync(MessageKind.Hello,
|
|
new Hello { ProtocolMajor = Hello.CurrentMajor, PeerName = "test-client", SharedSecret = secret }, ct);
|
|
|
|
var ackFrame = await reader.ReadFrameAsync(ct);
|
|
ackFrame.ShouldNotBeNull();
|
|
ackFrame!.Value.Kind.ShouldBe(MessageKind.HelloAck);
|
|
return MessagePackSerializer.Deserialize<HelloAck>(ackFrame.Value.Body);
|
|
}
|
|
|
|
/// <summary>Wraps a connected client socket stream in an SslStream that pins the server cert thumbprint.</summary>
|
|
private static async Task<SslStream> ClientTlsAsync(NetworkStream inner, string expectedThumbprint, CancellationToken ct)
|
|
{
|
|
var ssl = new SslStream(inner, leaveInnerStreamOpen: false,
|
|
userCertificateValidationCallback: (_, cert, _, _) =>
|
|
cert is not null &&
|
|
string.Equals(
|
|
cert.GetCertHashString(),
|
|
expectedThumbprint,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null,
|
|
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false);
|
|
return ssl;
|
|
}
|
|
|
|
/// <summary>Plaintext: Hello (good secret) is accepted and a ReadRaw request is echoed back.</summary>
|
|
[Fact]
|
|
public async Task Plaintext_RoundTrip_HelloAcceptedAndRequestEchoed()
|
|
{
|
|
using var cts = new CancellationTokenSource(Timeout);
|
|
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet);
|
|
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
|
|
|
|
using var client = new TcpClient();
|
|
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
|
var stream = client.GetStream();
|
|
|
|
var ack = await HelloAsync(stream, "shh", cts.Token);
|
|
ack.Accepted.ShouldBeTrue();
|
|
|
|
using var writer = new FrameWriter(stream, leaveOpen: true);
|
|
using var reader = new FrameReader(stream, leaveOpen: true);
|
|
|
|
await writer.WriteAsync(MessageKind.ReadRawRequest,
|
|
new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "corr-1" }, cts.Token);
|
|
|
|
var replyFrame = await reader.ReadFrameAsync(cts.Token);
|
|
replyFrame.ShouldNotBeNull();
|
|
replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply);
|
|
var reply = MessagePackSerializer.Deserialize<ReadRawReply>(replyFrame.Value.Body);
|
|
reply.Success.ShouldBeTrue();
|
|
reply.CorrelationId.ShouldBe("corr-1");
|
|
reply.Samples.Length.ShouldBe(1);
|
|
MessagePackSerializer.Deserialize<double>(reply.Samples[0].ValueBytes!).ShouldBe(42.0);
|
|
|
|
client.Close();
|
|
await serverTask;
|
|
}
|
|
|
|
/// <summary>TLS: a self-signed server cert; the client pins its thumbprint; same exchange succeeds.</summary>
|
|
[Fact]
|
|
public async Task Tls_RoundTrip_HelloAcceptedAndRequestEchoed()
|
|
{
|
|
using var cts = new CancellationTokenSource(Timeout);
|
|
using var cert = MakeSelfSignedCert();
|
|
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet);
|
|
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
|
|
|
|
using var client = new TcpClient();
|
|
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
|
using var ssl = await ClientTlsAsync(client.GetStream(), cert.Thumbprint, cts.Token);
|
|
|
|
var ack = await HelloAsync(ssl, "shh", cts.Token);
|
|
ack.Accepted.ShouldBeTrue();
|
|
|
|
using var writer = new FrameWriter(ssl, leaveOpen: true);
|
|
using var reader = new FrameReader(ssl, leaveOpen: true);
|
|
|
|
await writer.WriteAsync(MessageKind.ReadRawRequest,
|
|
new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "tls-1" }, cts.Token);
|
|
|
|
var replyFrame = await reader.ReadFrameAsync(cts.Token);
|
|
replyFrame.ShouldNotBeNull();
|
|
replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply);
|
|
var reply = MessagePackSerializer.Deserialize<ReadRawReply>(replyFrame.Value.Body);
|
|
reply.Success.ShouldBeTrue();
|
|
reply.CorrelationId.ShouldBe("tls-1");
|
|
|
|
client.Close();
|
|
await serverTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// TLS: when the client pins a wrong thumbprint the validation callback returns false,
|
|
/// causing <see cref="SslStream.AuthenticateAsClientAsync"/> to throw
|
|
/// <see cref="AuthenticationException"/> before any Hello is exchanged.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Tls_BadThumbprint_AuthenticationFails()
|
|
{
|
|
using var cts = new CancellationTokenSource(Timeout);
|
|
using var cert = MakeSelfSignedCert();
|
|
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet);
|
|
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
|
|
|
|
using var client = new TcpClient();
|
|
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
|
|
|
// Deliberately pin the wrong thumbprint — all zeros.
|
|
const string wrongThumbprint = "0000000000000000000000000000000000000000";
|
|
var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false,
|
|
userCertificateValidationCallback: (_, serverCert, _, _) =>
|
|
serverCert is not null &&
|
|
string.Equals(serverCert.GetCertHashString(), wrongThumbprint, StringComparison.OrdinalIgnoreCase));
|
|
|
|
await Should.ThrowAsync<AuthenticationException>(async () =>
|
|
await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null,
|
|
enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false));
|
|
|
|
ssl.Dispose();
|
|
// Server will see the broken TLS handshake and end the connection; let it finish.
|
|
try { await serverTask; } catch { /* server may throw on the aborted TLS */ }
|
|
}
|
|
|
|
/// <summary>Bad secret: Hello is rejected with Accepted=false and the shared-secret-mismatch reason.</summary>
|
|
[Fact]
|
|
public async Task BadSecret_HelloRejected()
|
|
{
|
|
using var cts = new CancellationTokenSource(Timeout);
|
|
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "right-secret", tlsCert: null, Quiet);
|
|
var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token);
|
|
|
|
using var client = new TcpClient();
|
|
await client.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
|
|
|
var ack = await HelloAsync(client.GetStream(), "wrong-secret", cts.Token);
|
|
ack.Accepted.ShouldBeFalse();
|
|
ack.RejectReason.ShouldBe("shared-secret-mismatch");
|
|
|
|
client.Close();
|
|
await serverTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Single-active serial accept: while client A is connected (Hello done), client B's
|
|
/// Hello does not complete until A disconnects. The server only accepts one connection
|
|
/// per <see cref="TcpFrameServer.RunOneConnectionAsync"/>, so B's handshake is served by
|
|
/// the second loop iteration that runs only after A's connection ends.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SingleActive_SecondClientHelloCompletesOnlyAfterFirstCloses()
|
|
{
|
|
using var cts = new CancellationTokenSource(Timeout);
|
|
using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet);
|
|
|
|
// Run the server loop: it accepts one connection at a time, serially.
|
|
var serverLoop = server.RunAsync(new EchoHandler(), cts.Token);
|
|
|
|
// Client A connects and completes its Hello — it now owns the single active slot.
|
|
using var clientA = new TcpClient();
|
|
await clientA.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
|
var ackA = await HelloAsync(clientA.GetStream(), "shh", cts.Token);
|
|
ackA.Accepted.ShouldBeTrue();
|
|
|
|
// Client B connects. The TCP connect may complete (OS backlog) but the server is still
|
|
// busy with A, so B's Hello round-trip must NOT complete yet.
|
|
using var clientB = new TcpClient();
|
|
await clientB.ConnectAsync(IPAddress.Loopback, server.BoundPort);
|
|
var bHelloTask = HelloAsync(clientB.GetStream(), "shh", cts.Token);
|
|
|
|
// Give B a chance to (wrongly) complete — it must remain pending while A is connected.
|
|
var earlyWinner = await Task.WhenAny(bHelloTask, Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token));
|
|
earlyWinner.ShouldNotBe(bHelloTask, "client B's Hello completed while client A was still connected");
|
|
|
|
// Now disconnect A. The server's next loop iteration accepts B and serves its Hello.
|
|
clientA.Close();
|
|
|
|
var ackB = await bHelloTask;
|
|
ackB.Accepted.ShouldBeTrue();
|
|
|
|
// Tear down: cancel the loop and let it unwind.
|
|
cts.Cancel();
|
|
try { await serverLoop; } catch (OperationCanceledException) { /* expected */ }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BindFailure_SurfacesBindError_NotPermanentNotListening()
|
|
{
|
|
// Regression (live-caught 2026-06-12): when TcpFrameServer's listener bind fails (port in a
|
|
// Windows excluded range → WSAEACCES, or already in use), the failure must surface as the
|
|
// bind SocketException on EVERY accept attempt — NOT a one-time bind error followed by a
|
|
// permanent InvalidOperationException "Not listening". The latter is the assign-before-Start
|
|
// wedge: a non-null-but-unstarted listener that EnsureListening's guard never re-Starts,
|
|
// which crash-looped the live sidecar on the reserved port 32569.
|
|
using var cts = new CancellationTokenSource(Timeout);
|
|
|
|
// Occupy a loopback port exclusively so the server's Start() bind is forbidden.
|
|
var blocker = new TcpListener(IPAddress.Loopback, 0) { ExclusiveAddressUse = true };
|
|
blocker.Start();
|
|
try
|
|
{
|
|
var takenPort = ((IPEndPoint)blocker.LocalEndpoint).Port;
|
|
using var server = new TcpFrameServer(IPAddress.Loopback, takenPort, "shh", tlsCert: null, Quiet);
|
|
|
|
// First accept attempt: the bind fails with a SocketException.
|
|
await Should.ThrowAsync<SocketException>(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token));
|
|
|
|
// Second attempt MUST also be the bind SocketException — not InvalidOperationException
|
|
// "Not listening". This is the assertion that fails against the assign-before-Start bug.
|
|
var second = await Should.ThrowAsync<Exception>(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token));
|
|
second.ShouldBeOfType<SocketException>();
|
|
}
|
|
finally { blocker.Stop(); }
|
|
}
|
|
}
|