From 36ec575f420ac07417f2862672abbafa5ed07762 Mon Sep 17 00:00:00 2001 From: Nikolai Norum Vinningland Date: Fri, 23 Oct 2026 10:42:45 +0200 Subject: [PATCH] Forward channel EOF to the connected host ChannelForwardedTcpip ignored SSH_MSG_CHANNEL_EOF, so the connected host never received end-of-stream when the client stopped sending. Bind stayed blocked in ReadContinuous until the host closed the connection itself, which a host waiting for the client to speak first only does on its own timeout. Shut down the send direction of the socket on EOF, matching ChannelDirectTcpip. The host can still send data back over the channel. --- .../Channels/ChannelForwardedTcpip.cs | 12 ++ ...elForwardedTcpipTest_ChannelEofReceived.cs | 182 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 test/Renci.SshNet.Tests/Classes/Channels/ChannelForwardedTcpipTest_ChannelEofReceived.cs diff --git a/src/Renci.SshNet/Channels/ChannelForwardedTcpip.cs b/src/Renci.SshNet/Channels/ChannelForwardedTcpip.cs index 1f536ff27..55be94a43 100644 --- a/src/Renci.SshNet/Channels/ChannelForwardedTcpip.cs +++ b/src/Renci.SshNet/Channels/ChannelForwardedTcpip.cs @@ -98,6 +98,18 @@ public void Bind(IPEndPoint remoteEndpoint, IForwardedPort forwardedPort) SocketAbstraction.ReadContinuous(_socket, buffer, 0, buffer.Length, SendData); } + /// + /// Called when channel has no more data to receive. + /// + protected override void OnEof() + { + base.OnEof(); + + // the server will not send any more data; shut down the send direction of the socket so the + // connected host receives end-of-stream, while it can still send data back over the channel + ShutdownSocket(SocketShutdown.Send); + } + protected override void OnErrorOccurred(Exception exp) { base.OnErrorOccurred(exp); diff --git a/test/Renci.SshNet.Tests/Classes/Channels/ChannelForwardedTcpipTest_ChannelEofReceived.cs b/test/Renci.SshNet.Tests/Classes/Channels/ChannelForwardedTcpipTest_ChannelEofReceived.cs new file mode 100644 index 000000000..bc81fcfea --- /dev/null +++ b/test/Renci.SshNet.Tests/Classes/Channels/ChannelForwardedTcpipTest_ChannelEofReceived.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Moq; + +using Renci.SshNet.Channels; +using Renci.SshNet.Messages; +using Renci.SshNet.Messages.Connection; + +namespace Renci.SshNet.Tests.Classes.Channels +{ + [TestClass] + public class ChannelForwardedTcpipTest_ChannelEofReceived + { + private Mock _sessionMock; + private Mock _forwardedPortMock; + private Mock _connectionInfoMock; + private ChannelForwardedTcpip _channel; + private uint _localChannelNumber; + private uint _localWindowSize; + private uint _localPacketSize; + private uint _remoteWindowSize; + private uint _remotePacketSize; + private uint _remoteChannelNumber; + private TimeSpan _channelCloseTimeout; + private TcpListener _remoteListener; + private Socket _remoteSocket; + private Exception _channelException; + private Thread _channelThread; + private List _dataSentToServer; + private ManualResetEvent _dataSentToServerWaitHandle; + private int _bytesReceivedAfterEof; + private byte[] _dataToSendAfterEof; + + [TestInitialize] + public void Initialize() + { + Arrange(); + Act(); + } + + [TestCleanup] + public void CleanUp() + { + _remoteSocket?.Dispose(); + _remoteSocket = null; + + _channelThread?.Join(TimeSpan.FromSeconds(5)); + _channelThread = null; + + _channel?.Dispose(); + _channel = null; + + _remoteListener?.Stop(); + _remoteListener = null; + + _dataSentToServerWaitHandle?.Dispose(); + _dataSentToServerWaitHandle = null; + } + + private void Arrange() + { + var random = new Random(); + _localChannelNumber = (uint)random.Next(0, int.MaxValue); + _localWindowSize = (uint)random.Next(2000, 3000); + _localPacketSize = (uint)random.Next(1000, 2000); + _remoteChannelNumber = (uint)random.Next(0, int.MaxValue); + _remoteWindowSize = (uint)random.Next(1000, int.MaxValue); + _remotePacketSize = (uint)random.Next(100, 200); + _channelCloseTimeout = TimeSpan.FromSeconds(random.Next(10, 20)); + _channelException = null; + _dataSentToServer = new List(); + _dataSentToServerWaitHandle = new ManualResetEvent(false); + _dataToSendAfterEof = new byte[] { 0x0a, 0x0b, 0x0c }; + + _sessionMock = new Mock(MockBehavior.Strict); + _connectionInfoMock = new Mock(MockBehavior.Strict); + _forwardedPortMock = new Mock(MockBehavior.Strict); + + _ = _sessionMock.Setup(p => p.SessionLoggerFactory).Returns(NullLoggerFactory.Instance); + _ = _sessionMock.Setup(p => p.IsConnected).Returns(true); + _ = _sessionMock.Setup(p => p.ConnectionInfo).Returns(_connectionInfoMock.Object); + _ = _connectionInfoMock.Setup(p => p.Timeout).Returns(TimeSpan.FromSeconds(5)); + _ = _connectionInfoMock.Setup(p => p.ChannelCloseTimeout).Returns(_channelCloseTimeout); + _ = _sessionMock.Setup(p => p.SendMessage(It.IsAny())); + _ = _sessionMock.Setup(p => p.SendMessage(It.IsAny())) + .Callback(m => + { + var dataMessage = (ChannelDataMessage)m; + lock (_dataSentToServer) + { + _dataSentToServer.AddRange(dataMessage.Data.Skip(dataMessage.Offset).Take(dataMessage.Size)); + } + + _ = _dataSentToServerWaitHandle.Set(); + }); + _ = _sessionMock.Setup(p => p.TrySendMessage(It.IsAny())) + .Returns(true); + _ = _sessionMock.Setup(p => p.TryWait(It.IsAny(), _channelCloseTimeout)) + .Callback((waitHandle, channelCloseTimeout) => + { + _sessionMock.Raise( + s => s.ChannelCloseReceived += null, + new MessageEventArgs(new ChannelCloseMessage(_localChannelNumber))); + _ = waitHandle.WaitOne(); + }) + .Returns(WaitResult.Success); + + _remoteListener = new TcpListener(IPAddress.Loopback, 0); + _remoteListener.Start(); + var remoteEndpoint = (IPEndPoint)_remoteListener.LocalEndpoint; + + _channel = new ChannelForwardedTcpip(_sessionMock.Object, + _localChannelNumber, + _localWindowSize, + _localPacketSize, + _remoteChannelNumber, + _remoteWindowSize, + _remotePacketSize); + + _channelThread = new Thread(() => + { + try + { + _channel.Bind(remoteEndpoint, _forwardedPortMock.Object); + } + catch (Exception ex) + { + _channelException = ex; + } + }); + _channelThread.Start(); + + _remoteSocket = _remoteListener.AcceptSocket(); + _remoteSocket.ReceiveTimeout = (int)TimeSpan.FromSeconds(5).TotalMilliseconds; + } + + private void Act() + { + _sessionMock.Raise(s => s.ChannelEofReceived += null, + new MessageEventArgs(new ChannelEofMessage(_localChannelNumber))); + + var buffer = new byte[16]; + _bytesReceivedAfterEof = _remoteSocket.Receive(buffer); + + // only the send direction of the socket should be shut down, so the connected host can still send data + _ = _remoteSocket.Send(_dataToSendAfterEof); + _ = _dataSentToServerWaitHandle.WaitOne(TimeSpan.FromSeconds(5)); + + _remoteSocket.Shutdown(SocketShutdown.Send); + } + + [TestMethod] + public void ConnectedHostShouldReceiveEndOfStream() + { + Assert.AreEqual(0, _bytesReceivedAfterEof); + } + + [TestMethod] + public void DataSentByConnectedHostAfterEofShouldBeForwardedToServer() + { + lock (_dataSentToServer) + { + CollectionAssert.AreEqual(_dataToSendAfterEof, _dataSentToServer); + } + } + + [TestMethod] + public void BindShouldFinishWithoutExceptionWhenConnectedHostShutsDownSocket() + { + Assert.IsTrue(_channelThread.Join(TimeSpan.FromSeconds(5))); + Assert.IsNull(_channelException, _channelException?.ToString()); + } + } +}