#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;
}
}
}
}