BaseClient.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. #nullable enable
  2. using System;
  3. using System.Net.Sockets;
  4. using System.Threading;
  5. using System.Threading.Tasks;
  6. using Microsoft.Extensions.Logging;
  7. using Renci.SshNet.Common;
  8. using Renci.SshNet.Messages.Transport;
  9. namespace Renci.SshNet
  10. {
  11. /// <summary>
  12. /// Serves as base class for client implementations, provides common client functionality.
  13. /// </summary>
  14. public abstract class BaseClient : IBaseClient
  15. {
  16. /// <summary>
  17. /// Holds value indicating whether the connection info is owned by this client.
  18. /// </summary>
  19. private readonly bool _ownsConnectionInfo;
  20. private readonly ILogger _logger;
  21. private readonly IServiceFactory _serviceFactory;
  22. private readonly object _keepAliveLock = new object();
  23. private TimeSpan _keepAliveInterval;
  24. private Timer? _keepAliveTimer;
  25. private ConnectionInfo _connectionInfo;
  26. private bool _isDisposed;
  27. /// <summary>
  28. /// Gets the current session.
  29. /// </summary>
  30. /// <value>
  31. /// The current session.
  32. /// </value>
  33. internal ISession? Session { get; private set; }
  34. /// <summary>
  35. /// Gets the factory for creating new services.
  36. /// </summary>
  37. /// <value>
  38. /// The factory for creating new services.
  39. /// </value>
  40. internal IServiceFactory ServiceFactory
  41. {
  42. get { return _serviceFactory; }
  43. }
  44. /// <summary>
  45. /// Gets the connection info.
  46. /// </summary>
  47. /// <value>
  48. /// The connection info.
  49. /// </value>
  50. /// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
  51. public ConnectionInfo ConnectionInfo
  52. {
  53. get
  54. {
  55. CheckDisposed();
  56. return _connectionInfo;
  57. }
  58. private set
  59. {
  60. _connectionInfo = value;
  61. }
  62. }
  63. /// <summary>
  64. /// Gets a value indicating whether this client is connected to the server.
  65. /// </summary>
  66. /// <value>
  67. /// <see langword="true"/> if this client is connected; otherwise, <see langword="false"/>.
  68. /// </value>
  69. /// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
  70. public virtual bool IsConnected
  71. {
  72. get
  73. {
  74. CheckDisposed();
  75. return IsSessionConnected();
  76. }
  77. }
  78. /// <summary>
  79. /// Gets or sets the keep-alive interval.
  80. /// </summary>
  81. /// <value>
  82. /// The keep-alive interval. Specify negative one (-1) milliseconds to disable the
  83. /// keep-alive. This is the default value.
  84. /// </value>
  85. /// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
  86. public TimeSpan KeepAliveInterval
  87. {
  88. get
  89. {
  90. CheckDisposed();
  91. return _keepAliveInterval;
  92. }
  93. set
  94. {
  95. CheckDisposed();
  96. value.EnsureValidTimeout(nameof(KeepAliveInterval));
  97. if (value == _keepAliveInterval)
  98. {
  99. return;
  100. }
  101. if (value == Timeout.InfiniteTimeSpan)
  102. {
  103. // stop the timer when the value is -1 milliseconds
  104. StopKeepAliveTimer();
  105. }
  106. else
  107. {
  108. if (_keepAliveTimer != null)
  109. {
  110. // change the due time and interval of the timer if has already
  111. // been created (which means the client is connected)
  112. _ = _keepAliveTimer.Change(value, value);
  113. }
  114. else if (IsSessionConnected())
  115. {
  116. // if timer has not yet been created and the client is already connected,
  117. // then we need to create the timer now
  118. //
  119. // this means that - before connecting - the keep-alive interval was set to
  120. // negative one (-1) and as such we did not create the timer
  121. _keepAliveTimer = CreateKeepAliveTimer(value, value);
  122. }
  123. // note that if the client is not yet connected, then the timer will be created with the
  124. // new interval when Connect() is invoked
  125. }
  126. _keepAliveInterval = value;
  127. }
  128. }
  129. /// <summary>
  130. /// Occurs when an error occurred.
  131. /// </summary>
  132. public event EventHandler<ExceptionEventArgs>? ErrorOccurred;
  133. /// <summary>
  134. /// Occurs when host key received.
  135. /// </summary>
  136. public event EventHandler<HostKeyEventArgs>? HostKeyReceived;
  137. /// <summary>
  138. /// Occurs when server identification received.
  139. /// </summary>
  140. public event EventHandler<SshIdentificationEventArgs>? ServerIdentificationReceived;
  141. /// <summary>
  142. /// Initializes a new instance of the <see cref="BaseClient"/> class.
  143. /// </summary>
  144. /// <param name="connectionInfo">The connection info.</param>
  145. /// <param name="ownsConnectionInfo">Specified whether this instance owns the connection info.</param>
  146. /// <exception cref="ArgumentNullException"><paramref name="connectionInfo"/> is <see langword="null"/>.</exception>
  147. /// <remarks>
  148. /// If <paramref name="ownsConnectionInfo"/> is <see langword="true"/>, then the
  149. /// connection info will be disposed when this instance is disposed.
  150. /// </remarks>
  151. protected BaseClient(ConnectionInfo connectionInfo, bool ownsConnectionInfo)
  152. : this(connectionInfo, ownsConnectionInfo, new ServiceFactory())
  153. {
  154. }
  155. /// <summary>
  156. /// Initializes a new instance of the <see cref="BaseClient"/> class.
  157. /// </summary>
  158. /// <param name="connectionInfo">The connection info.</param>
  159. /// <param name="ownsConnectionInfo">Specified whether this instance owns the connection info.</param>
  160. /// <param name="serviceFactory">The factory to use for creating new services.</param>
  161. /// <exception cref="ArgumentNullException"><paramref name="connectionInfo"/> is <see langword="null"/>.</exception>
  162. /// <exception cref="ArgumentNullException"><paramref name="serviceFactory"/> is <see langword="null"/>.</exception>
  163. /// <remarks>
  164. /// If <paramref name="ownsConnectionInfo"/> is <see langword="true"/>, then the
  165. /// connection info will be disposed when this instance is disposed.
  166. /// </remarks>
  167. private protected BaseClient(ConnectionInfo connectionInfo, bool ownsConnectionInfo, IServiceFactory serviceFactory)
  168. {
  169. ThrowHelper.ThrowIfNull(connectionInfo);
  170. ThrowHelper.ThrowIfNull(serviceFactory);
  171. _connectionInfo = connectionInfo;
  172. _ownsConnectionInfo = ownsConnectionInfo;
  173. _serviceFactory = serviceFactory;
  174. _logger = SshNetLoggingConfiguration.LoggerFactory.CreateLogger(GetType());
  175. _keepAliveInterval = Timeout.InfiniteTimeSpan;
  176. }
  177. /// <summary>
  178. /// Connects client to the server.
  179. /// </summary>
  180. /// <exception cref="InvalidOperationException">The client is already connected.</exception>
  181. /// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
  182. /// <exception cref="SocketException">Socket connection to the SSH server or proxy server could not be established, or an error occurred while resolving the hostname.</exception>
  183. /// <exception cref="SshConnectionException">SSH session could not be established.</exception>
  184. /// <exception cref="SshAuthenticationException">Authentication of SSH session failed.</exception>
  185. /// <exception cref="ProxyException">Failed to establish proxy connection.</exception>
  186. public void Connect()
  187. {
  188. CheckDisposed();
  189. // TODO (see issue #1758):
  190. // we're not stopping the keep-alive timer and disposing the session here
  191. //
  192. // we could do this but there would still be side effects as concrete
  193. // implementations may still hang on to the original session
  194. //
  195. // therefore it would be better to actually invoke the Disconnect method
  196. // (and then the Dispose on the session) but even that would have side effects
  197. // eg. it would remove all forwarded ports from SshClient
  198. //
  199. // I think we should modify our concrete clients to better deal with a
  200. // disconnect. In case of SshClient this would mean not removing the
  201. // forwarded ports on disconnect (but only on dispose ?) and link a
  202. // forwarded port with a client instead of with a session
  203. //
  204. // To be discussed with Oleg (or whoever is interested)
  205. if (IsConnected)
  206. {
  207. throw new InvalidOperationException("The client is already connected.");
  208. }
  209. OnConnecting();
  210. // The session may already/still be connected here because e.g. in SftpClient, IsConnected also checks the internal SFTP session
  211. var session = Session;
  212. if (session is null || !session.IsConnected)
  213. {
  214. if (session is not null)
  215. {
  216. DisposeSession(session);
  217. }
  218. Session = CreateAndConnectSession();
  219. }
  220. try
  221. {
  222. // Even though the method we invoke makes you believe otherwise, at this point only
  223. // the SSH session itself is connected.
  224. OnConnected();
  225. }
  226. catch
  227. {
  228. // Only dispose the session as Disconnect() would have side-effects (such as remove forwarded
  229. // ports in SshClient).
  230. DisposeSession();
  231. throw;
  232. }
  233. StartKeepAliveTimer();
  234. }
  235. /// <summary>
  236. /// Asynchronously connects client to the server.
  237. /// </summary>
  238. /// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
  239. /// <returns>A <see cref="Task"/> that represents the asynchronous connect operation.
  240. /// </returns>
  241. /// <exception cref="InvalidOperationException">The client is already connected.</exception>
  242. /// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
  243. /// <exception cref="SocketException">Socket connection to the SSH server or proxy server could not be established, or an error occurred while resolving the hostname.</exception>
  244. /// <exception cref="SshConnectionException">SSH session could not be established.</exception>
  245. /// <exception cref="SshAuthenticationException">Authentication of SSH session failed.</exception>
  246. /// <exception cref="ProxyException">Failed to establish proxy connection.</exception>
  247. public async Task ConnectAsync(CancellationToken cancellationToken)
  248. {
  249. CheckDisposed();
  250. cancellationToken.ThrowIfCancellationRequested();
  251. // TODO (see issue #1758):
  252. // we're not stopping the keep-alive timer and disposing the session here
  253. //
  254. // we could do this but there would still be side effects as concrete
  255. // implementations may still hang on to the original session
  256. //
  257. // therefore it would be better to actually invoke the Disconnect method
  258. // (and then the Dispose on the session) but even that would have side effects
  259. // eg. it would remove all forwarded ports from SshClient
  260. //
  261. // I think we should modify our concrete clients to better deal with a
  262. // disconnect. In case of SshClient this would mean not removing the
  263. // forwarded ports on disconnect (but only on dispose ?) and link a
  264. // forwarded port with a client instead of with a session
  265. //
  266. // To be discussed with Oleg (or whoever is interested)
  267. if (IsConnected)
  268. {
  269. throw new InvalidOperationException("The client is already connected.");
  270. }
  271. OnConnecting();
  272. // The session may already/still be connected here because e.g. in SftpClient, IsConnected also checks the internal SFTP session
  273. var session = Session;
  274. if (session is null || !session.IsConnected)
  275. {
  276. if (session is not null)
  277. {
  278. DisposeSession(session);
  279. }
  280. using var timeoutCancellationTokenSource = new CancellationTokenSource(ConnectionInfo.Timeout);
  281. using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCancellationTokenSource.Token);
  282. try
  283. {
  284. Session = await CreateAndConnectSessionAsync(linkedCancellationTokenSource.Token).ConfigureAwait(false);
  285. }
  286. catch (OperationCanceledException ex) when (timeoutCancellationTokenSource.IsCancellationRequested)
  287. {
  288. throw new SshOperationTimeoutException("Connection has timed out.", ex);
  289. }
  290. }
  291. try
  292. {
  293. // Even though the method we invoke makes you believe otherwise, at this point only
  294. // the SSH session itself is connected.
  295. OnConnected();
  296. }
  297. catch
  298. {
  299. // Only dispose the session as Disconnect() would have side-effects (such as remove forwarded
  300. // ports in SshClient).
  301. DisposeSession();
  302. throw;
  303. }
  304. StartKeepAliveTimer();
  305. }
  306. /// <summary>
  307. /// Disconnects client from the server.
  308. /// </summary>
  309. /// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
  310. public void Disconnect()
  311. {
  312. _logger.LogInformation("Disconnecting client.");
  313. CheckDisposed();
  314. OnDisconnecting();
  315. // stop sending keep-alive messages before we close the session
  316. StopKeepAliveTimer();
  317. // dispose the SSH session
  318. DisposeSession();
  319. OnDisconnected();
  320. }
  321. /// <summary>
  322. /// Sends a keep-alive message to the server.
  323. /// </summary>
  324. /// <remarks>
  325. /// Use <see cref="KeepAliveInterval"/> to configure the client to send a keep-alive at regular
  326. /// intervals.
  327. /// </remarks>
  328. /// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
  329. #pragma warning disable S1133 // Deprecated code should be removed
  330. [Obsolete("Use KeepAliveInterval to send a keep-alive message at regular intervals.")]
  331. #pragma warning restore S1133 // Deprecated code should be removed
  332. public void SendKeepAlive()
  333. {
  334. CheckDisposed();
  335. SendKeepAliveMessage();
  336. }
  337. /// <summary>
  338. /// Called when client is connecting to the server.
  339. /// </summary>
  340. protected virtual void OnConnecting()
  341. {
  342. }
  343. /// <summary>
  344. /// Called when client is connected to the server.
  345. /// </summary>
  346. protected virtual void OnConnected()
  347. {
  348. }
  349. /// <summary>
  350. /// Called when client is disconnecting from the server.
  351. /// </summary>
  352. protected virtual void OnDisconnecting()
  353. {
  354. Session?.OnDisconnecting();
  355. }
  356. /// <summary>
  357. /// Called when client is disconnected from the server.
  358. /// </summary>
  359. protected virtual void OnDisconnected()
  360. {
  361. }
  362. private void Session_ErrorOccured(object? sender, ExceptionEventArgs e)
  363. {
  364. ErrorOccurred?.Invoke(this, e);
  365. }
  366. private void Session_HostKeyReceived(object? sender, HostKeyEventArgs e)
  367. {
  368. HostKeyReceived?.Invoke(this, e);
  369. }
  370. private void Session_ServerIdentificationReceived(object? sender, SshIdentificationEventArgs e)
  371. {
  372. ServerIdentificationReceived?.Invoke(this, e);
  373. }
  374. /// <summary>
  375. /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
  376. /// </summary>
  377. public void Dispose()
  378. {
  379. Dispose(disposing: true);
  380. GC.SuppressFinalize(this);
  381. }
  382. /// <summary>
  383. /// Releases unmanaged and - optionally - managed resources.
  384. /// </summary>
  385. /// <param name="disposing"><see langword="true"/> to release both managed and unmanaged resources; <see langword="false"/> to release only unmanaged resources.</param>
  386. protected virtual void Dispose(bool disposing)
  387. {
  388. if (_isDisposed)
  389. {
  390. return;
  391. }
  392. if (disposing)
  393. {
  394. _logger.LogDebug("Disposing client.");
  395. Disconnect();
  396. if (_ownsConnectionInfo)
  397. {
  398. if (_connectionInfo is IDisposable connectionInfoDisposable)
  399. {
  400. connectionInfoDisposable.Dispose();
  401. }
  402. }
  403. _isDisposed = true;
  404. }
  405. }
  406. /// <summary>
  407. /// Check if the current instance is disposed.
  408. /// </summary>
  409. /// <exception cref="ObjectDisposedException">The current instance is disposed.</exception>
  410. protected void CheckDisposed()
  411. {
  412. ThrowHelper.ThrowObjectDisposedIf(_isDisposed, this);
  413. }
  414. /// <summary>
  415. /// Stops the keep-alive timer, and waits until all timer callbacks have been
  416. /// executed.
  417. /// </summary>
  418. private void StopKeepAliveTimer()
  419. {
  420. if (_keepAliveTimer is null)
  421. {
  422. return;
  423. }
  424. _keepAliveTimer.Dispose();
  425. _keepAliveTimer = null;
  426. }
  427. private void SendKeepAliveMessage()
  428. {
  429. var session = Session;
  430. // do nothing if we have disposed or disconnected
  431. if (session is null)
  432. {
  433. return;
  434. }
  435. // do not send multiple keep-alive messages concurrently
  436. if (Monitor.TryEnter(_keepAliveLock))
  437. {
  438. try
  439. {
  440. _ = session.TrySendMessage(new IgnoreMessage());
  441. }
  442. catch (ObjectDisposedException)
  443. {
  444. // ignore
  445. }
  446. catch (Exception ex)
  447. {
  448. _logger.LogError(ex, "Error sending keepalive message");
  449. }
  450. finally
  451. {
  452. Monitor.Exit(_keepAliveLock);
  453. }
  454. }
  455. }
  456. /// <summary>
  457. /// Starts the keep-alive timer.
  458. /// </summary>
  459. /// <remarks>
  460. /// When <see cref="KeepAliveInterval"/> is negative one (-1) milliseconds, then
  461. /// the timer will not be started.
  462. /// </remarks>
  463. private void StartKeepAliveTimer()
  464. {
  465. if (_keepAliveInterval == Timeout.InfiniteTimeSpan)
  466. {
  467. return;
  468. }
  469. if (_keepAliveTimer != null)
  470. {
  471. // timer is already started
  472. return;
  473. }
  474. _keepAliveTimer = CreateKeepAliveTimer(_keepAliveInterval, _keepAliveInterval);
  475. }
  476. /// <summary>
  477. /// Creates a <see cref="Timer"/> with the specified due time and interval.
  478. /// </summary>
  479. /// <param name="dueTime">The amount of time to delay before the keep-alive message is first sent. Specify negative one (-1) milliseconds to prevent the timer from starting. Specify zero (0) to start the timer immediately.</param>
  480. /// <param name="period">The time interval between attempts to send a keep-alive message. Specify negative one (-1) milliseconds to disable periodic signaling.</param>
  481. /// <returns>
  482. /// A <see cref="Timer"/> with the specified due time and interval.
  483. /// </returns>
  484. private Timer CreateKeepAliveTimer(TimeSpan dueTime, TimeSpan period)
  485. {
  486. return new Timer(state => SendKeepAliveMessage(), Session, dueTime, period);
  487. }
  488. private ISession CreateAndConnectSession()
  489. {
  490. var session = _serviceFactory.CreateSession(ConnectionInfo, _serviceFactory.CreateSocketFactory());
  491. session.ServerIdentificationReceived += Session_ServerIdentificationReceived;
  492. session.HostKeyReceived += Session_HostKeyReceived;
  493. session.ErrorOccured += Session_ErrorOccured;
  494. try
  495. {
  496. session.Connect();
  497. return session;
  498. }
  499. catch
  500. {
  501. DisposeSession(session);
  502. throw;
  503. }
  504. }
  505. private async Task<ISession> CreateAndConnectSessionAsync(CancellationToken cancellationToken)
  506. {
  507. var session = _serviceFactory.CreateSession(ConnectionInfo, _serviceFactory.CreateSocketFactory());
  508. session.ServerIdentificationReceived += Session_ServerIdentificationReceived;
  509. session.HostKeyReceived += Session_HostKeyReceived;
  510. session.ErrorOccured += Session_ErrorOccured;
  511. try
  512. {
  513. await session.ConnectAsync(cancellationToken).ConfigureAwait(false);
  514. return session;
  515. }
  516. catch
  517. {
  518. DisposeSession(session);
  519. throw;
  520. }
  521. }
  522. private void DisposeSession(ISession session)
  523. {
  524. session.ErrorOccured -= Session_ErrorOccured;
  525. session.HostKeyReceived -= Session_HostKeyReceived;
  526. session.ServerIdentificationReceived -= Session_ServerIdentificationReceived;
  527. session.Dispose();
  528. }
  529. /// <summary>
  530. /// Disposes the SSH session, and assigns <see langword="null"/> to <see cref="Session"/>.
  531. /// </summary>
  532. private void DisposeSession()
  533. {
  534. var session = Session;
  535. if (session != null)
  536. {
  537. Session = null;
  538. DisposeSession(session);
  539. }
  540. }
  541. /// <summary>
  542. /// Returns a value indicating whether the SSH session is established.
  543. /// </summary>
  544. /// <returns>
  545. /// <see langword="true"/> if the SSH session is established; otherwise, <see langword="false"/>.
  546. /// </returns>
  547. private bool IsSessionConnected()
  548. {
  549. var session = Session;
  550. return session != null && session.IsConnected;
  551. }
  552. }
  553. }