using System.Net; using System.Net.Sockets; using Mbproxy; using Mbproxy.Options; using Mbproxy.Proxy; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using NModbus; using Serilog; using Xunit; namespace Mbproxy.Tests.Proxy; /// /// End-to-end proxy forwarding tests. /// Each test: /// 1. Starts the proxy host in-process, configured with one PLC pointing at the simulator. /// 2. Connects NModbus to the proxy's listen port. /// 3. Asserts the proxy forwards bytes transparently (NoopPduPipeline — no BCD rewriting). /// [Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))] [Trait("Category", "E2E")] public sealed class ProxyForwardingTests { private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim; public ProxyForwardingTests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim) { _sim = sim; } // ── 1. FC03 read HR0 — expect 0xCAFE ─────────────────────────────────────────────── [Fact(Timeout = 5_000)] public async Task Forward_FC03_HR0_Returns_SimulatorRawValue_0xCAFE() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var (proxyPort, host, cts) = await StartProxyAsync(); await using var _ = new AsyncHostDispose(host, cts); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var master = new ModbusFactory().CreateMaster(client); ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 0, numberOfPoints: 1); Assert.Equal(0xCAFE, regs[0]); } // ── 2a. FC03 read HR1072 — with BCD configured → decoded 1234 ────────────────────── [Fact(Timeout = 5_000)] public async Task Forward_FC03_HR1072_Returns_Decoded_1234() { // BcdPduPipeline is active. When BCD tag 1072 (width=16) is configured, the proxy // decodes the raw 0x1234 nibbles and the client receives binary 1234. if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); int proxyPort = PickFreePort(); var config = new Dictionary { ["Mbproxy:AdminPort"] = "8080", [$"Mbproxy:Plcs:0:Name"] = "TestPLC", [$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(), [$"Mbproxy:Plcs:0:Host"] = _sim.Host, [$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(), ["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000", ["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000", // Configure address 1072 as a 16-bit BCD tag. ["Mbproxy:BcdTags:Global:0:Address"] = "1072", ["Mbproxy:BcdTags:Global:0:Width"] = "16", }; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var host = BuildBcdProxyHost(config); using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await host.StartAsync(startCts.Token); await using var _ = new AsyncHostDispose(host, cts); await Task.Delay(150, TestContext.Current.CancellationToken); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var master = new ModbusFactory().CreateMaster(client); ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1); // BCD decoded: 0x1234 → binary 1234. Assert.Equal(1234, regs[0]); } // ── 2b. FC03 read HR1072 — without BCD configured → raw 0x1234 ───────────────────── [Fact(Timeout = 5_000)] public async Task Forward_FC03_HR1072_AsRaw_WhenNotConfigured_Returns_0x1234() { // When no BCD tag is configured at address 1072, the proxy passes bytes through // unmodified. Client receives raw BCD nibbles 0x1234 (= 4660 decimal). if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var (proxyPort, host, cts) = await StartProxyAsync(); await using var _ = new AsyncHostDispose(host, cts); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var master = new ModbusFactory().CreateMaster(client); ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1); // No BCD tag configured: raw BCD nibbles pass through. Assert.Equal(0x1234, regs[0]); } // ── 3. FC06 write single register then read back ──────────────────────────────────── [Fact(Timeout = 5_000)] public async Task Forward_FC06_WriteHR200_ThenReadBack_RoundTrips() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var (proxyPort, host, cts) = await StartProxyAsync(); await using var _ = new AsyncHostDispose(host, cts); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var master = new ModbusFactory().CreateMaster(client); const ushort writeValue = 0xABCD; master.WriteSingleRegister(slaveAddress: 1, registerAddress: 200, value: writeValue); ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 200, numberOfPoints: 1); Assert.Equal(writeValue, regs[0]); } // ── 4. FC16 write multiple registers then read back ────────────────────────────────── [Fact(Timeout = 5_000)] public async Task Forward_FC16_WriteMultipleHR201_203_ThenReadBack_RoundTrips() { if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var (proxyPort, host, cts) = await StartProxyAsync(); await using var _ = new AsyncHostDispose(host, cts); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); var master = new ModbusFactory().CreateMaster(client); ushort[] writeValues = [0x0010, 0x0020, 0x0030]; master.WriteMultipleRegisters(slaveAddress: 1, startAddress: 201, data: writeValues); ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 201, numberOfPoints: 3); Assert.Equal(writeValues, regs); } // ── 5. MBAP TxId preserved end-to-end ──────────────────────────────────────────────── [Fact(Timeout = 5_000)] public async Task MbapTxId_IsPreservedEndToEnd() { // Issue 20 back-to-back FC03 reads with manually-incrementing TxIds (via raw sockets) // and verify every response carries the matching TxId. // This verifies no mid-stream frame split causes a parse failure under stress. if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason); var (proxyPort, host, cts) = await StartProxyAsync(); await using var _ = new AsyncHostDispose(host, cts); using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.NoDelay = true; await socket.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); const int count = 20; byte[] reqBuf = new byte[12]; // FC03 request frame byte[] rspBuf = new byte[260]; for (ushort txId = 1; txId <= count; txId++) { // Build FC03 request: read 1 register at address 0. // [TxId(2), ProtocolId(2)=0, Length(2)=6, UnitId=1, FC=03, Start(2)=0, Qty(2)=1] reqBuf[0] = (byte)(txId >> 8); reqBuf[1] = (byte)(txId & 0xFF); reqBuf[2] = 0x00; // ProtocolId high reqBuf[3] = 0x00; // ProtocolId low reqBuf[4] = 0x00; // Length high reqBuf[5] = 0x06; // Length low (6 bytes: UnitId + FC + 4 PDU bytes) reqBuf[6] = 0x01; // UnitId reqBuf[7] = 0x03; // FC03 reqBuf[8] = 0x00; // Start addr high reqBuf[9] = 0x00; // Start addr low reqBuf[10] = 0x00; // Qty high reqBuf[11] = 0x01; // Qty low await socket.SendAsync(reqBuf.AsMemory(), SocketFlags.None, TestContext.Current.CancellationToken); // Read response header (7 bytes), then body. int read = 0; while (read < 7) read += await socket.ReceiveAsync(rspBuf.AsMemory(read, 7 - read), SocketFlags.None, TestContext.Current.CancellationToken); // Parse response TxId. ushort rspTxId = (ushort)((rspBuf[0] << 8) | rspBuf[1]); ushort rspLength = (ushort)((rspBuf[4] << 8) | rspBuf[5]); Assert.Equal(txId, rspTxId); // Drain the response body. int bodyLen = rspLength - 1; // length covers UnitId + PDU; we already read UnitId if (bodyLen > 0) { int bodyRead = 0; while (bodyRead < bodyLen) bodyRead += await socket.ReceiveAsync(rspBuf.AsMemory(7 + bodyRead, bodyLen - bodyRead), SocketFlags.None, TestContext.Current.CancellationToken); } } } // ── 6. Backend connect failure — upstream socket closes cleanly ─────────────────────── [Fact(Timeout = 5_000)] public async Task BackendConnectFailure_ClosesUpstreamCleanly() { // Point the proxy at port 1 on loopback — guaranteed unreachable. // The multiplexer lazily connects to the backend on the first upstream PDU, so // we have to actually send a request before the proxy attempts the (failing) // backend connect that closes the upstream. const int badBackendPort = 1; const int backendTimeoutMs = 500; // short timeout for test speed int proxyPort = PickFreePort(); var config = new Dictionary { ["Mbproxy:AdminPort"] = "8080", [$"Mbproxy:Plcs:0:Name"] = "BadPLC", [$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(), [$"Mbproxy:Plcs:0:Host"] = "127.0.0.1", [$"Mbproxy:Plcs:0:Port"] = badBackendPort.ToString(), ["Mbproxy:Connection:BackendConnectTimeoutMs"] = backendTimeoutMs.ToString(), ["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000", }; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var host = BuildProxyHost(config); await host.StartAsync(cts.Token); // Give the proxy a moment to bind. await Task.Delay(150, TestContext.Current.CancellationToken); using var client = new TcpClient(); await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken); // Send a Modbus request so the multiplexer attempts the backend connect. byte[] req = [ 0x00, 0x01, // TxId 0x00, 0x00, // ProtocolId 0x00, 0x06, // Length 0x01, // UnitId 0x03, // FC03 0x00, 0x00, // Start 0x00, 0x01, // Qty ]; await client.GetStream().WriteAsync(req, TestContext.Current.CancellationToken); // Wait up to BackendConnectTimeoutMs + 600ms for the upstream socket to close. // Polly default retry adds extra time, so we account for it in the deadline. var deadline = DateTime.UtcNow.AddMilliseconds(backendTimeoutMs + 1500); bool closed = false; while (DateTime.UtcNow < deadline) { try { // A 0-byte receive returns 0 when the remote end closed the socket. var buf = new byte[1]; int n = await client.GetStream() .ReadAsync(buf.AsMemory(), TestContext.Current.CancellationToken); if (n == 0) { closed = true; break; } } catch { closed = true; break; } await Task.Delay(50, TestContext.Current.CancellationToken); } await host.StopAsync(cts.Token); Assert.True(closed, "Upstream socket should have been closed by the proxy after backend connect failure."); } // ── Helpers ────────────────────────────────────────────────────────────────────────── private async Task<(int proxyPort, IHost host, CancellationTokenSource cts)> StartProxyAsync() { int proxyPort = PickFreePort(); var config = new Dictionary { ["Mbproxy:AdminPort"] = "8080", [$"Mbproxy:Plcs:0:Name"] = "TestPLC", [$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(), [$"Mbproxy:Plcs:0:Host"] = _sim.Host, [$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(), ["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000", ["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000", }; using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var host = BuildProxyHost(config); await host.StartAsync(startCts.Token); // Give the proxy time to bind. await Task.Delay(150, TestContext.Current.CancellationToken); var runCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); return (proxyPort, host, runCts); } private static IHost BuildProxyHost(Dictionary config) { var builder = Host.CreateApplicationBuilder(); builder.Configuration.AddInMemoryCollection(config); // Suppress verbose logging in tests. builder.Services.AddSerilog( new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false); builder.AddMbproxyOptions(); // Tests in ProxyForwardingTests use NoopPduPipeline to verify raw passthrough // (baseline behaviour independent of BCD configuration). builder.Services.AddSingleton(); builder.Services.AddHostedService(); return builder.Build(); } private static IHost BuildBcdProxyHost(Dictionary config) { var builder = Host.CreateApplicationBuilder(); builder.Configuration.AddInMemoryCollection(config); builder.Services.AddSerilog( new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false); builder.AddMbproxyOptions(); // BCD rewriter pipeline — used by the BCD-decode tests in this file. builder.Services.AddSingleton(); builder.Services.AddHostedService(); return builder.Build(); } private static int PickFreePort() { var l = new TcpListener(IPAddress.Loopback, 0); l.Start(); int port = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return port; } /// Disposes the host and CTS when the test finishes. private sealed class AsyncHostDispose : IAsyncDisposable { private readonly IHost _host; private readonly CancellationTokenSource _cts; public AsyncHostDispose(IHost host, CancellationTokenSource cts) { _host = host; _cts = cts; } public async ValueTask DisposeAsync() { using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await _host.StopAsync(stopCts.Token); } catch { /* best effort */ } _host.Dispose(); _cts.Dispose(); } } }