BaseClient.cs 22 KB

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