#nullable enable using System; using System.Diagnostics; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; using Renci.SshNet.Channels; using Renci.SshNet.Common; using Renci.SshNet.Messages.Connection; using Renci.SshNet.Messages.Transport; namespace Renci.SshNet { /// /// Represents an SSH command that can be executed. /// public class SshCommand : IDisposable { private readonly ISession _session; private readonly Encoding _encoding; private IChannelSession? _channel; private TaskCompletionSource? _tcs; private CancellationTokenSource? _cts; private CancellationTokenRegistration _tokenRegistration; private string? _stdOut; private string? _stdErr; private bool _hasError; private bool _isDisposed; private ChannelInputStream? _inputStream; private TimeSpan _commandTimeout; /// /// The token supplied as an argument to . /// private CancellationToken _userToken; /// /// Whether has been called /// (either by a token or manually). /// private bool _cancellationRequested; private int _exitStatus; private volatile bool _haveExitStatus; // volatile to prevent re-ordering of reads/writes of _exitStatus. /// /// Gets the command text. /// public string CommandText { get; private set; } /// /// Gets or sets the command timeout. /// /// /// The command timeout. /// public TimeSpan CommandTimeout { get { return _commandTimeout; } set { value.EnsureValidTimeout(nameof(CommandTimeout)); _commandTimeout = value; } } /// /// Gets the number representing the exit status of the command, if applicable, /// otherwise . /// /// /// The value is not when an exit status code has been returned /// from the server. If the command terminated due to a signal, /// may be not instead. /// /// public int? ExitStatus { get { return _haveExitStatus ? _exitStatus : null; } } /// /// Gets the name of the signal due to which the command /// terminated violently, if applicable, otherwise . /// /// /// The value (if it exists) is supplied by the server and is usually one of the /// following, as described in https://datatracker.ietf.org/doc/html/rfc4254#section-6.10: /// ABRT, ALRM, FPE, HUP, ILL, INT, KILL, PIPE, QUIT, SEGV, TER, USR1, USR2. /// public string? ExitSignal { get; private set; } /// /// Gets the output stream. /// public Stream OutputStream { get; private set; } /// /// Gets the extended output stream. /// public Stream ExtendedOutputStream { get; private set; } /// /// Creates and returns the input stream for the command. /// /// /// The stream that can be used to transfer data to the command's input stream. /// /// /// Callers should ensure that is called on the /// returned instance in order to notify the command that no more data will be sent. /// Failure to do so may result in the command executing indefinitely. /// /// /// This example shows how to stream some data to 'cat' and have the server echo it back. /// /// using (SshCommand command = mySshClient.CreateCommand("cat")) /// { /// Task executeTask = command.ExecuteAsync(CancellationToken.None); /// /// using (Stream inputStream = command.CreateInputStream()) /// { /// inputStream.Write("Hello World!"u8); /// } /// /// await executeTask; /// /// Console.WriteLine(command.ExitStatus); // 0 /// Console.WriteLine(command.Result); // "Hello World!" /// } /// /// public Stream CreateInputStream() { if (_channel == null) { throw new InvalidOperationException($"The input stream can be used only after calling BeginExecute and before calling EndExecute."); } if (_inputStream != null) { throw new InvalidOperationException($"The input stream already exists."); } _inputStream = new ChannelInputStream(_channel); return _inputStream; } /// /// Gets the standard output of the command by reading . /// public string Result { get { if (_stdOut is not null) { return _stdOut; } if (_tcs is null) { return string.Empty; } using (var sr = new StreamReader(OutputStream, _encoding)) { return _stdOut = sr.ReadToEnd(); } } } /// /// Gets the standard error of the command by reading , /// when extended data has been sent which has been marked as stderr. /// public string Error { get { if (_stdErr is not null) { return _stdErr; } if (_tcs is null || !_hasError) { return string.Empty; } using (var sr = new StreamReader(ExtendedOutputStream, _encoding)) { return _stdErr = sr.ReadToEnd(); } } } /// /// Initializes a new instance of the class. /// /// The session. /// The command text. /// The encoding to use for the results. /// Either , is . internal SshCommand(ISession session, string commandText, Encoding encoding) { ThrowHelper.ThrowIfNull(session); ThrowHelper.ThrowIfNull(commandText); ThrowHelper.ThrowIfNull(encoding); _session = session; CommandText = commandText; _encoding = encoding; CommandTimeout = Timeout.InfiniteTimeSpan; OutputStream = new PipeStream(); ExtendedOutputStream = new PipeStream(); _session.Disconnected += Session_Disconnected; _session.ErrorOccured += Session_ErrorOccured; } /// /// Executes the command asynchronously. /// /// /// The . When triggered, attempts to terminate the /// remote command by sending a signal. /// /// A representing the lifetime of the command. /// Command is already executing. Thrown synchronously. /// Instance has been disposed. Thrown synchronously. /// The has been cancelled. /// The command timed out according to . #pragma warning disable CA1849 // Call async methods when in an async method; PipeStream.DisposeAsync would complete synchronously anyway. public Task ExecuteAsync(CancellationToken cancellationToken = default) { ThrowHelper.ThrowObjectDisposedIf(_isDisposed, this); if (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } if (_tcs is not null) { if (!_tcs.Task.IsCompleted) { throw new InvalidOperationException("Asynchronous operation is already in progress."); } OutputStream.Dispose(); ExtendedOutputStream.Dispose(); // Initialize output streams. We already initialised them for the first // execution in the constructor (to allow passing them around before execution) // so we just need to reinitialise them for subsequent executions. OutputStream = new PipeStream(); ExtendedOutputStream = new PipeStream(); } _exitStatus = default; _haveExitStatus = false; ExitSignal = null; _stdOut = null; _stdErr = null; _hasError = false; _tokenRegistration.Dispose(); _tokenRegistration = default; _cts?.Dispose(); _cts = null; _cancellationRequested = false; _tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _userToken = cancellationToken; _channel = _session.CreateChannelSession(); _channel.DataReceived += Channel_DataReceived; _channel.ExtendedDataReceived += Channel_ExtendedDataReceived; _channel.RequestReceived += Channel_RequestReceived; _channel.Closed += Channel_Closed; _channel.Open(); _ = _channel.SendExecRequest(CommandText); if (CommandTimeout != Timeout.InfiniteTimeSpan) { _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _cts.CancelAfter(CommandTimeout); cancellationToken = _cts.Token; } if (cancellationToken.CanBeCanceled) { _tokenRegistration = cancellationToken.Register(static cmd => ((SshCommand)cmd!).CancelAsync(), this); } return _tcs.Task; } #pragma warning restore CA1849 /// /// Begins an asynchronous command execution. /// /// /// An that represents the asynchronous command execution, which could still be pending. /// /// Asynchronous operation is already in progress. /// Invalid operation. /// CommandText property is empty. /// Client is not connected. /// Operation has timed out. public IAsyncResult BeginExecute() { return BeginExecute(callback: null, state: null); } /// /// Begins an asynchronous command execution. /// /// An optional asynchronous callback, to be called when the command execution is complete. /// /// An that represents the asynchronous command execution, which could still be pending. /// /// Asynchronous operation is already in progress. /// Invalid operation. /// CommandText property is empty. /// Client is not connected. /// Operation has timed out. public IAsyncResult BeginExecute(AsyncCallback? callback) { return BeginExecute(callback, state: null); } /// /// Begins an asynchronous command execution. /// /// An optional asynchronous callback, to be called when the command execution is complete. /// A user-provided object that distinguishes this particular asynchronous read request from other requests. /// /// An that represents the asynchronous command execution, which could still be pending. /// /// Asynchronous operation is already in progress. /// Invalid operation. /// CommandText property is empty. /// Client is not connected. /// Operation has timed out. public IAsyncResult BeginExecute(AsyncCallback? callback, object? state) { return TaskToAsyncResult.Begin(ExecuteAsync(), callback, state); } /// /// Begins an asynchronous command execution. /// /// The command text. /// An optional asynchronous callback, to be called when the command execution is complete. /// A user-provided object that distinguishes this particular asynchronous read request from other requests. /// /// An that represents the asynchronous command execution, which could still be pending. /// /// Client is not connected. /// Operation has timed out. public IAsyncResult BeginExecute(string commandText, AsyncCallback? callback, object? state) { ThrowHelper.ThrowIfNull(commandText); CommandText = commandText; return BeginExecute(callback, state); } /// /// Waits for the pending asynchronous command execution to complete. /// /// The reference to the pending asynchronous request to finish. /// . /// does not correspond to the currently executing command. /// is . public string EndExecute(IAsyncResult asyncResult) { var executeTask = TaskToAsyncResult.Unwrap(asyncResult); if (executeTask != _tcs?.Task) { throw new ArgumentException("Argument does not correspond to the currently executing command.", nameof(asyncResult)); } executeTask.GetAwaiter().GetResult(); return Result; } /// /// Cancels a running command by sending a signal to the remote process. /// /// if true send SIGKILL instead of SIGTERM. /// Time to wait for the server to reply. /// /// /// This method stops the command running on the server by sending a SIGTERM /// (or SIGKILL, depending on ) signal to the remote /// process. When the server implements signals, it will send a response which /// populates with the signal with which the command terminated. /// /// /// When the server does not implement signals, it may send no response. As a fallback, /// this method waits up to for a response /// and then completes the object anyway if there was none. /// /// /// If the command has already finished (with or without cancellation), this method does /// nothing. /// /// /// Command has not been started. public void CancelAsync(bool forceKill = false, int millisecondsTimeout = 500) { if (_tcs is null) { throw new InvalidOperationException("Command has not been started."); } if (_tcs.Task.IsCompleted) { return; } _cancellationRequested = true; Interlocked.MemoryBarrier(); // ensure fresh read in SetAsyncComplete (possibly unnecessary) // Try to send the cancellation signal. if (_channel?.SendSignalRequest(forceKill ? "KILL" : "TERM") is null) { // Command has completed (in the meantime since the last check). return; } // Having sent the "signal" message, we expect to receive "exit-signal" // and then a close message. But since a server may not implement signals, // we can't guarantee that, so we wait a short time for that to happen and // if it doesn't, just complete the task ourselves to unblock waiters. try { if (_tcs.Task.Wait(millisecondsTimeout)) { return; } } catch (AggregateException) { // We expect to be here if the server implements signals. // But we don't want to propagate the exception on the task from here. return; } SetAsyncComplete(); } /// /// Executes the command specified by . /// /// . /// Client is not connected. /// Operation has timed out. public string Execute() { ExecuteAsync().GetAwaiter().GetResult(); return Result; } /// /// Executes the specified command. /// /// The command text. /// . /// Client is not connected. /// Operation has timed out. public string Execute(string commandText) { CommandText = commandText; return Execute(); } private void Session_Disconnected(object? sender, EventArgs e) { _ = _tcs?.TrySetException(new SshConnectionException("An established connection was aborted by the software in your host machine.", DisconnectReason.ConnectionLost)); SetAsyncComplete(setResult: false); } private void Session_ErrorOccured(object? sender, ExceptionEventArgs e) { _ = _tcs?.TrySetException(e.Exception); SetAsyncComplete(setResult: false); } private void SetAsyncComplete(bool setResult = true) { Interlocked.MemoryBarrier(); // ensure fresh read of _cancellationRequested (possibly unnecessary) if (setResult) { Debug.Assert(_tcs is not null, "Should only be completing the task if we've started one."); if (_userToken.IsCancellationRequested) { _ = _tcs.TrySetCanceled(_userToken); } else if (_cts?.Token.IsCancellationRequested == true) { _ = _tcs.TrySetException(new SshOperationTimeoutException($"Command '{CommandText}' timed out. ({nameof(CommandTimeout)}: {CommandTimeout}).")); } else if (_cancellationRequested) { _ = _tcs.TrySetCanceled(); } else { _ = _tcs.TrySetResult(null!); } } UnsubscribeFromEventsAndDisposeChannel(); OutputStream.Dispose(); ExtendedOutputStream.Dispose(); } private void Channel_Closed(object? sender, ChannelEventArgs e) { SetAsyncComplete(); } private void Channel_RequestReceived(object? sender, ChannelRequestEventArgs e) { if (e.Info is ExitStatusRequestInfo exitStatusInfo) { _exitStatus = (int)exitStatusInfo.ExitStatus; _haveExitStatus = true; Debug.Assert(!exitStatusInfo.WantReply, "exit-status is want_reply := false by definition."); } else if (e.Info is ExitSignalRequestInfo exitSignalInfo) { ExitSignal = exitSignalInfo.SignalName; Debug.Assert(!exitSignalInfo.WantReply, "exit-signal is want_reply := false by definition."); } else if (e.Info.WantReply && _channel?.RemoteChannelNumber is uint remoteChannelNumber) { var replyMessage = new ChannelFailureMessage(remoteChannelNumber); _session.SendMessage(replyMessage); } } private void Channel_ExtendedDataReceived(object? sender, ChannelExtendedDataEventArgs e) { ExtendedOutputStream.Write(e.Data, 0, e.Data.Length); if (e.DataTypeCode == 1) { _hasError = true; } } private void Channel_DataReceived(object? sender, ChannelDataEventArgs e) { OutputStream.Write(e.Data, 0, e.Data.Length); } /// /// Unsubscribes the current from channel events, and disposes /// the . /// private void UnsubscribeFromEventsAndDisposeChannel() { var channel = _channel; if (channel is null) { return; } _channel = null; // unsubscribe from events as we do not want to be signaled should these get fired // during the dispose of the channel channel.DataReceived -= Channel_DataReceived; channel.ExtendedDataReceived -= Channel_ExtendedDataReceived; channel.RequestReceived -= Channel_RequestReceived; channel.Closed -= Channel_Closed; // actually dispose the channel channel.Dispose(); } /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } /// /// Releases unmanaged and - optionally - managed resources. /// /// to release both managed and unmanaged resources; to release only unmanaged resources. protected virtual void Dispose(bool disposing) { if (_isDisposed) { return; } if (disposing) { // unsubscribe from session events to ensure other objects that we're going to dispose // are not accessed while disposing _session.Disconnected -= Session_Disconnected; _session.ErrorOccured -= Session_ErrorOccured; // unsubscribe from channel events to ensure other objects that we're going to dispose // are not accessed while disposing UnsubscribeFromEventsAndDisposeChannel(); _inputStream?.Dispose(); _inputStream = null; OutputStream.Dispose(); ExtendedOutputStream.Dispose(); _tokenRegistration.Dispose(); _tokenRegistration = default; _cts?.Dispose(); _cts = null; if (_tcs is { Task.IsCompleted: false } tcs) { // In case an operation is still running, try to complete it with an ObjectDisposedException. _ = tcs.TrySetException(new ObjectDisposedException(GetType().FullName)); } _isDisposed = true; } } } }