Skip to content

Commit

Permalink
Support creating Shell(Stream) without PTY (#1419)
Browse files Browse the repository at this point in the history
* Support creating Shell(Stream) without PTY
Fixes #1418

* Add integration test for "PermitTTY no"

* Fix Integration Test

* Remove duplicate shell request

* Put common operations in a shared constructor. Update xml doc comments.

* Update comments and method overriding

* Update per code review

* Update integration tests

* Renaming

* Make `bufferSize` optional

* Try fix the test

* Update per code review

* try agian

* try again

* docs

* doc

---------

Co-authored-by: Rob Hague <rob.hague00@gmail.com>
scott-xu and Rob-Hague authored Jun 16, 2024
1 parent 830e504 commit 9dc7db9
Showing 9 changed files with 397 additions and 26 deletions.
11 changes: 11 additions & 0 deletions src/Renci.SshNet/IServiceFactory.cs
Original file line number Diff line number Diff line change
@@ -137,6 +137,17 @@ ShellStream CreateShellStream(ISession session,
IDictionary<TerminalModes, uint> terminalModeValues,
int bufferSize);

/// <summary>
/// Creates a shell stream without allocating a pseudo terminal.
/// </summary>
/// <param name="session">The SSH session.</param>
/// <param name="bufferSize">Size of the buffer.</param>
/// <returns>
/// The created <see cref="ShellStream"/> instance.
/// </returns>
/// <exception cref="SshConnectionException">Client is not connected.</exception>
ShellStream CreateShellStreamNoTerminal(ISession session, int bufferSize);

/// <summary>
/// Creates an <see cref="IRemotePathTransformation"/> that encloses a path in double quotes, and escapes
/// any embedded double quote with a backslash.
6 changes: 6 additions & 0 deletions src/Renci.SshNet/ServiceFactory.cs
Original file line number Diff line number Diff line change
@@ -206,6 +206,12 @@ public ShellStream CreateShellStream(ISession session, string terminalName, uint
return new ShellStream(session, terminalName, columns, rows, width, height, terminalModeValues, bufferSize);
}

/// <inheritdoc/>
public ShellStream CreateShellStreamNoTerminal(ISession session, int bufferSize)
{
return new ShellStream(session, bufferSize);
}

/// <summary>
/// Creates an <see cref="IRemotePathTransformation"/> that encloses a path in double quotes, and escapes
/// any embedded double quote with a backslash.
67 changes: 61 additions & 6 deletions src/Renci.SshNet/Shell.cs
Original file line number Diff line number Diff line change
@@ -14,6 +14,8 @@ namespace Renci.SshNet
/// </summary>
public class Shell : IDisposable
{
private const int DefaultBufferSize = 1024;

private readonly ISession _session;
private readonly string _terminalName;
private readonly uint _columns;
@@ -24,6 +26,7 @@ public class Shell : IDisposable
private readonly Stream _outputStream;
private readonly Stream _extendedOutputStream;
private readonly int _bufferSize;
private readonly bool _noTerminal;
private ManualResetEvent _dataReaderTaskCompleted;
private IChannelSession _channel;
private AutoResetEvent _channelClosedWaitHandle;
@@ -77,24 +80,66 @@ public class Shell : IDisposable
/// <param name="terminalModes">The terminal modes.</param>
/// <param name="bufferSize">Size of the buffer for output stream.</param>
internal Shell(ISession session, Stream input, Stream output, Stream extendedOutput, string terminalName, uint columns, uint rows, uint width, uint height, IDictionary<TerminalModes, uint> terminalModes, int bufferSize)
: this(session, input, output, extendedOutput, bufferSize, noTerminal: false)
{
_session = session;
_input = input;
_outputStream = output;
_extendedOutputStream = extendedOutput;
_terminalName = terminalName;
_columns = columns;
_rows = rows;
_width = width;
_height = height;
_terminalModes = terminalModes;
}

/// <summary>
/// Initializes a new instance of the <see cref="Shell"/> class.
/// </summary>
/// <param name="session">The session.</param>
/// <param name="input">The input.</param>
/// <param name="output">The output.</param>
/// <param name="extendedOutput">The extended output.</param>
/// <param name="bufferSize">Size of the buffer for output stream.</param>
internal Shell(ISession session, Stream input, Stream output, Stream extendedOutput, int bufferSize)
: this(session, input, output, extendedOutput, bufferSize, noTerminal: true)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="Shell"/> class.
/// </summary>
/// <param name="session">The session.</param>
/// <param name="input">The input.</param>
/// <param name="output">The output.</param>
/// <param name="extendedOutput">The extended output.</param>
/// <param name="bufferSize">Size of the buffer for output stream.</param>
/// <param name="noTerminal">Disables pseudo terminal allocation or not.</param>
private Shell(ISession session, Stream input, Stream output, Stream extendedOutput, int bufferSize, bool noTerminal)
{
if (bufferSize == -1)
{
bufferSize = DefaultBufferSize;
}
#if NET8_0_OR_GREATER
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize);
#else
if (bufferSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(bufferSize));
}
#endif
_session = session;
_input = input;
_outputStream = output;
_extendedOutputStream = extendedOutput;
_bufferSize = bufferSize;
_noTerminal = noTerminal;
}

/// <summary>
/// Starts this shell.
/// </summary>
/// <exception cref="SshException">Shell is started.</exception>
/// <exception cref="SshException">The pseudo-terminal request was not accepted by the server.</exception>
/// <exception cref="SshException">The request to start a shell was not accepted by the server.</exception>
public void Start()
{
if (IsStarted)
@@ -112,8 +157,18 @@ public void Start()
_session.ErrorOccured += Session_ErrorOccured;

_channel.Open();
_ = _channel.SendPseudoTerminalRequest(_terminalName, _columns, _rows, _width, _height, _terminalModes);
_ = _channel.SendShellRequest();
if (!_noTerminal)
{
if (!_channel.SendPseudoTerminalRequest(_terminalName, _columns, _rows, _width, _height, _terminalModes))
{
throw new SshException("The pseudo-terminal request was not accepted by the server. Consult the server log for more information.");
}
}

if (!_channel.SendShellRequest())
{
throw new SshException("The request to start a shell was not accepted by the server. Consult the server log for more information.");
}

_channelClosedWaitHandle = new AutoResetEvent(initialState: false);

88 changes: 68 additions & 20 deletions src/Renci.SshNet/ShellStream.cs
Original file line number Diff line number Diff line change
@@ -20,6 +20,8 @@ namespace Renci.SshNet
/// </summary>
public class ShellStream : Stream
{
private const int DefaultBufferSize = 1024;

private readonly ISession _session;
private readonly Encoding _encoding;
private readonly IChannelSession _channel;
@@ -29,6 +31,7 @@ public class ShellStream : Stream
private readonly object _sync = new object();

private readonly byte[] _writeBuffer;
private readonly bool _noTerminal;
private int _writeLength; // The length of the data in _writeBuffer.

private byte[] _readBuffer;
@@ -95,7 +98,68 @@ private void AssertValid()
/// <exception cref="SshException">The pseudo-terminal request was not accepted by the server.</exception>
/// <exception cref="SshException">The request to start a shell was not accepted by the server.</exception>
internal ShellStream(ISession session, string terminalName, uint columns, uint rows, uint width, uint height, IDictionary<TerminalModes, uint> terminalModeValues, int bufferSize)
: this(session, bufferSize, noTerminal: false)
{
try
{
_channel.Open();

if (!_channel.SendPseudoTerminalRequest(terminalName, columns, rows, width, height, terminalModeValues))
{
throw new SshException("The pseudo-terminal request was not accepted by the server. Consult the server log for more information.");
}

if (!_channel.SendShellRequest())
{
throw new SshException("The request to start a shell was not accepted by the server. Consult the server log for more information.");
}
}
catch
{
Dispose();
throw;
}
}

/// <summary>
/// Initializes a new instance of the <see cref="ShellStream"/> class.
/// </summary>
/// <param name="session">The SSH session.</param>
/// <param name="bufferSize">The size of the buffer.</param>
/// <exception cref="SshException">The channel could not be opened.</exception>
/// <exception cref="SshException">The request to start a shell was not accepted by the server.</exception>
internal ShellStream(ISession session, int bufferSize)
: this(session, bufferSize, noTerminal: true)
{
try
{
_channel.Open();

if (!_channel.SendShellRequest())
{
throw new SshException("The request to start a shell was not accepted by the server. Consult the server log for more information.");
}
}
catch
{
Dispose();
throw;
}
}

/// <summary>
/// Initializes a new instance of the <see cref="ShellStream"/> class.
/// </summary>
/// <param name="session">The SSH session.</param>
/// <param name="bufferSize">The size of the buffer.</param>
/// <param name="noTerminal">Disables pseudo terminal allocation or not.</param>
/// <exception cref="SshException">The channel could not be opened.</exception>
private ShellStream(ISession session, int bufferSize, bool noTerminal)
{
if (bufferSize == -1)
{
bufferSize = DefaultBufferSize;
}
#if NET8_0_OR_GREATER
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize);
#else
@@ -119,25 +183,7 @@ internal ShellStream(ISession session, string terminalName, uint columns, uint r
_readBuffer = new byte[bufferSize];
_writeBuffer = new byte[bufferSize];

try
{
_channel.Open();

if (!_channel.SendPseudoTerminalRequest(terminalName, columns, rows, width, height, terminalModeValues))
{
throw new SshException("The pseudo-terminal request was not accepted by the server. Consult the server log for more information.");
}

if (!_channel.SendShellRequest())
{
throw new SshException("The request to start a shell was not accepted by the server. Consult the server log for more information.");
}
}
catch
{
Dispose();
throw;
}
_noTerminal = noTerminal;
}

/// <summary>
@@ -848,7 +894,9 @@ public override void Write(byte[] buffer, int offset, int count)
/// <exception cref="ObjectDisposedException">The stream is closed.</exception>
public void WriteLine(string line)
{
Write(line + "\r");
// By default, the terminal driver translates carriage return to line feed on input.
// See option ICRLF at https://www.man7.org/linux/man-pages/man3/termios.3.html.
Write(line + (_noTerminal ? "\n" : "\r"));
}

/// <inheritdoc/>
35 changes: 35 additions & 0 deletions src/Renci.SshNet/SshClient.cs
Original file line number Diff line number Diff line change
@@ -391,6 +391,25 @@ public Shell CreateShell(Encoding encoding, string input, Stream output, Stream
return CreateShell(encoding, input, output, extendedOutput, string.Empty, 0, 0, 0, 0, terminalModes: null, 1024);
}

/// <summary>
/// Creates the shell without allocating a pseudo terminal,
/// similar to the <c>ssh -T</c> option.
/// </summary>
/// <param name="input">The input.</param>
/// <param name="output">The output.</param>
/// <param name="extendedOutput">The extended output.</param>
/// <param name="bufferSize">Size of the internal read buffer.</param>
/// <returns>
/// Returns a representation of a <see cref="Shell" /> object.
/// </returns>
/// <exception cref="SshConnectionException">Client is not connected.</exception>
public Shell CreateShellNoTerminal(Stream input, Stream output, Stream extendedOutput, int bufferSize = -1)
{
EnsureSessionIsOpen();

return new Shell(Session, input, output, extendedOutput, bufferSize);
}

/// <summary>
/// Creates the shell stream.
/// </summary>
@@ -450,6 +469,22 @@ public ShellStream CreateShellStream(string terminalName, uint columns, uint row
return ServiceFactory.CreateShellStream(Session, terminalName, columns, rows, width, height, terminalModeValues, bufferSize);
}

/// <summary>
/// Creates the shell stream without allocating a pseudo terminal,
/// similar to the <c>ssh -T</c> option.
/// </summary>
/// <param name="bufferSize">The size of the buffer.</param>
/// <returns>
/// The created <see cref="ShellStream"/> instance.
/// </returns>
/// <exception cref="SshConnectionException">Client is not connected.</exception>
public ShellStream CreateShellStreamNoTerminal(int bufferSize = -1)
{
EnsureSessionIsOpen();

return ServiceFactory.CreateShellStreamNoTerminal(Session, bufferSize);
}

/// <summary>
/// Stops forwarded ports.
/// </summary>
13 changes: 13 additions & 0 deletions test/Renci.SshNet.IntegrationTests/RemoteSshdConfig.cs
Original file line number Diff line number Diff line change
@@ -69,6 +69,19 @@ public RemoteSshdConfig PrintMotd(bool? value = true)
return this;
}

/// <summary>
/// Specifies whether TTY is permitted.
/// </summary>
/// <param name="value"><see langword="true"/> to permit TTY.</param>
/// <returns>
/// The current <see cref="RemoteSshdConfig"/> instance.
/// </returns>
public RemoteSshdConfig PermitTTY(bool? value = true)
{
_config.PermitTTY = value;
return this;
}

/// <summary>
/// Specifies whether TCP forwarding is permitted.
/// </summary>
54 changes: 54 additions & 0 deletions test/Renci.SshNet.IntegrationTests/SshTests.cs
Original file line number Diff line number Diff line change
@@ -23,6 +23,7 @@ public void SetUp()

_remoteSshdConfig = new RemoteSshd(_adminConnectionInfoFactory).OpenConfig();
_remoteSshdConfig.AllowTcpForwarding()
.PermitTTY(true)
.PrintMotd(false)
.Update()
.Restart();
@@ -77,6 +78,24 @@ public void Ssh_ShellStream_Exit()
}
}

[TestMethod]
public void Ssh_CreateShellStreamNoTerminal()
{
using (var client = new SshClient(_connectionInfoFactory.Create()))
{
client.Connect();

using (var shellStream = client.CreateShellStreamNoTerminal(bufferSize: 1024))
{
var foo = new string('a', 90);
shellStream.WriteLine($"echo {foo}");
var line = shellStream.ReadLine(TimeSpan.FromSeconds(1));
Assert.IsNotNull(line);
Assert.IsTrue(line.EndsWith(foo), line);
}
}
}

/// <summary>
/// https://github.com/sshnet/SSH.NET/issues/63
/// </summary>
@@ -171,6 +190,41 @@ public void Ssh_CreateShell()
}
}

[TestMethod]
public void Ssh_CreateShellNoTerminal()
{
using (var client = new SshClient(_connectionInfoFactory.Create()))
{
client.Connect();

using (var input = new MemoryStream())
using (var output = new MemoryStream())
using (var extOutput = new MemoryStream())
{
var shell = client.CreateShellNoTerminal(input, output, extOutput, 1024);

shell.Start();

var inputWriter = new StreamWriter(input, Encoding.ASCII, 1024);
var foo = new string('a', 90);
inputWriter.WriteLine($"echo {foo}");
inputWriter.Flush();
input.Position = 0;

Thread.Sleep(1000);

output.Position = 0;
var outputReader = new StreamReader(output, Encoding.ASCII, false, 1024);
var outputString = outputReader.ReadLine();

Assert.IsNotNull(outputString);
Assert.IsTrue(outputString.EndsWith(foo), outputString);

shell.Stop();
}
}
}

[TestMethod]
public void Ssh_Command_IntermittentOutput_EndExecute()
{
132 changes: 132 additions & 0 deletions test/Renci.SshNet.IntegrationTests/SshTests_TTYDisabled.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using Renci.SshNet.Common;
using Renci.SshNet.IntegrationTests.Common;

namespace Renci.SshNet.IntegrationTests
{
[TestClass]
public class SshTests_TTYDisabled : TestBase
{
private IConnectionInfoFactory _connectionInfoFactory;
private IConnectionInfoFactory _adminConnectionInfoFactory;
private RemoteSshdConfig _remoteSshdConfig;

[TestInitialize]
public void SetUp()
{
_connectionInfoFactory = new LinuxVMConnectionFactory(SshServerHostName, SshServerPort);
_adminConnectionInfoFactory = new LinuxAdminConnectionFactory(SshServerHostName, SshServerPort);

_remoteSshdConfig = new RemoteSshd(_adminConnectionInfoFactory).OpenConfig();
_remoteSshdConfig.AllowTcpForwarding()
.PermitTTY(false)
.PrintMotd(false)
.Update()
.Restart();
}

[TestCleanup]
public void TearDown()
{
_remoteSshdConfig?.Reset();
}

[TestMethod]
public void Ssh_CreateShellStream()
{
using (var client = new SshClient(_connectionInfoFactory.Create()))
{
client.Connect();

try
{
client.CreateShellStream("xterm", 80, 24, 800, 600, 1024, null);
Assert.Fail("Should not be able to create ShellStream with pseudo-terminal settings when PermitTTY is no at server side.");
}
catch (SshException ex)
{
Assert.AreEqual("The pseudo-terminal request was not accepted by the server. Consult the server log for more information.", ex.Message);
}
}
}

[TestMethod]
public void Ssh_CreateShellStreamNoTerminal()
{
using (var client = new SshClient(_connectionInfoFactory.Create()))
{
client.Connect();

using (var shellStream = client.CreateShellStreamNoTerminal(bufferSize: 1024))
{
var foo = new string('a', 90);
shellStream.WriteLine($"echo {foo}");
var line = shellStream.ReadLine(TimeSpan.FromSeconds(1));
Assert.IsNotNull(line);
Assert.IsTrue(line.EndsWith(foo), line);
}
}
}


[TestMethod]
public void Ssh_CreateShell()
{
using (var client = new SshClient(_connectionInfoFactory.Create()))
{
client.Connect();

using (var input = new MemoryStream())
using (var output = new MemoryStream())
using (var extOutput = new MemoryStream())
{
var shell = client.CreateShell(input, output, extOutput, "xterm", 80, 24, 800, 600, null, 1024);

try
{
shell.Start();
Assert.Fail("Should not be able to create ShellStream with terminal settings when PermitTTY is no at server side.");
}
catch (SshException ex)
{
Assert.AreEqual("The pseudo-terminal request was not accepted by the server. Consult the server log for more information.", ex.Message);
}
}
}
}

[TestMethod]
public void Ssh_CreateShellNoTerminal()
{
using (var client = new SshClient(_connectionInfoFactory.Create()))
{
client.Connect();

using (var input = new MemoryStream())
using (var output = new MemoryStream())
using (var extOutput = new MemoryStream())
{
var shell = client.CreateShellNoTerminal(input, output, extOutput, 1024);

shell.Start();

var inputWriter = new StreamWriter(input, Encoding.ASCII, 1024);
var foo = new string('a', 90);
inputWriter.WriteLine($"echo {foo}");
inputWriter.Flush();
input.Position = 0;

Thread.Sleep(1000);

output.Position = 0;
var outputReader = new StreamReader(output, Encoding.ASCII, false, 1024);
var outputString = outputReader.ReadLine();

Assert.IsNotNull(outputString);
Assert.IsTrue(outputString.EndsWith(foo), outputString);

shell.Stop();
}
}
}
}
}
17 changes: 17 additions & 0 deletions test/Renci.SshNet.TestTools.OpenSSH/SshdConfig.cs
Original file line number Diff line number Diff line change
@@ -135,6 +135,15 @@ private SshdConfig()
/// </value>
public string Protocol { get; set; }

/// <summary>
/// Gets or sets a value indicating whether TTY is permitted.
/// </summary>
/// <value>
/// <see langword="true"/> to permit and <see langword="false"/> to not permit TTY,
/// or <see langword="null"/> if this option is not configured.
/// </value>
public bool? PermitTTY { get; set; }

/// <summary>
/// Gets or sets a value indicating whether TCP forwarding is allowed.
/// </summary>
@@ -238,6 +247,11 @@ public void SaveTo(TextWriter writer)
writer.WriteLine("KbdInteractiveAuthentication " + _booleanFormatter.Format(KeyboardInteractiveAuthentication.Value));
}

if (PermitTTY is not null)
{
writer.WriteLine("PermitTTY " + _booleanFormatter.Format(PermitTTY.Value));
}

if (AllowTcpForwarding is not null)
{
writer.WriteLine("AllowTcpForwarding " + _booleanFormatter.Format(AllowTcpForwarding.Value));
@@ -364,6 +378,9 @@ private static void ProcessGlobalOption(SshdConfig sshdConfig, string line)
case "Protocol":
sshdConfig.Protocol = value;
break;
case "PermitTTY":
sshdConfig.PermitTTY = ToBool(value);
break;
case "AllowTcpForwarding":
sshdConfig.AllowTcpForwarding = ToBool(value);
break;

0 comments on commit 9dc7db9

Please sign in to comment.