BaseClient.cs 23 KB

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