parallel-ssh 2.13.0__py3-none-any.whl → 2.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: parallel-ssh
3
- Version: 2.13.0
3
+ Version: 2.14.0
4
4
  Summary: Asynchronous parallel SSH library
5
5
  Home-page: https://github.com/ParallelSSH/parallel-ssh
6
6
  Author: Panos Kittenis
@@ -9,19 +9,19 @@ pssh/clients/__init__.py,sha256=QzNb1FcbjQyxEPtRbog-ZOidMRu0zA718PkRz21SMmQ,836
9
9
  pssh/clients/common.py,sha256=fd_jJj8ezDjwr3peN3LonLff5EYSNkMEre0yjlnRjBU,1375
10
10
  pssh/clients/reader.py,sha256=9boQMv9xUBGLY53OybJmWvfa6qsA6mudfdfXk4sF2Oc,3092
11
11
  pssh/clients/base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- pssh/clients/base/parallel.py,sha256=cNirNYL0tGw3usWD7XMm0BWVbT_9PNrUjjEOxHLREhw,25010
13
- pssh/clients/base/single.py,sha256=9MxJwz6eq8UY2mBksDIyg7YZyRkmO2yAQle4Y2jSJjA,26132
12
+ pssh/clients/base/parallel.py,sha256=GZEYNpg-sOCfKcM-jaAaxdW2Ln4_A_f77qhmCbsdll8,24595
13
+ pssh/clients/base/single.py,sha256=_Txr04S11LADbRekTvpS2xBf4C9UV3rTEZ22tPOTdfo,27013
14
14
  pssh/clients/native/__init__.py,sha256=dydX5Fae9U9gRBT8koPTM8H9u4t0rIgD1q9XammFYSs,865
15
15
  pssh/clients/native/parallel.py,sha256=AjOJoLdhyyUKWp4Leda08cXC-SsyT16XK4PExiOl3-c,24119
16
- pssh/clients/native/single.py,sha256=zXbMzYxY7_hkVOS2b2tHujqHq4iFWYgXV4KIL2MVFz8,32118
17
- pssh/clients/native/tunnel.py,sha256=294UpCYOYlhI9sgoZNvjxr5I199GNOmHevz1lbNVjQI,9309
16
+ pssh/clients/native/single.py,sha256=AQWmq_uBUFhZDkX6Cn0jR21BVd8twcYu_UhwMJYex9U,34388
17
+ pssh/clients/native/tunnel.py,sha256=xMfn75O32E-ZzVTFkA7DeHZOO0MXFSGn4aqGHNIb2lc,10650
18
18
  pssh/clients/ssh/__init__.py,sha256=HgYZkVJ5QOgaVFp5snwyLZvQbp0Tbdxj8Gbyic2_Ibo,857
19
19
  pssh/clients/ssh/parallel.py,sha256=2X47tgPklubHbb7RjUk75IZjidbbOdJq3VstH3KU6KM,11397
20
- pssh/clients/ssh/single.py,sha256=oSolU4_D-38rkT1eRqOl33js0CmR727tn9wj9T5qXmI,13617
21
- parallel_ssh-2.13.0.dist-info/COPYING,sha256=ZA2Q9u5AEkH_YoNNDRsz-DBJ6ZuL_foE7RsKFjXd4-c,18093
22
- parallel_ssh-2.13.0.dist-info/COPYING.LESSER,sha256=AKibDRiqzUEU3s95Ei24e_Nb3a8rxQ44PJyfTCYzkLI,24486
23
- parallel_ssh-2.13.0.dist-info/LICENSE,sha256=m4cqigcLitMpxL04D7G_AAD1ZMdQI-yOHmgD8VNkuek,26461
24
- parallel_ssh-2.13.0.dist-info/METADATA,sha256=W1GHoeJx-l4YwoL5TyWjAlHIwP4VlmAuMrNgu4XfY0Y,10730
25
- parallel_ssh-2.13.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
26
- parallel_ssh-2.13.0.dist-info/top_level.txt,sha256=s8P6ZHOwt2BYgDc62Cpd2z7i-rebGzIhhnO09pger0U,5
27
- parallel_ssh-2.13.0.dist-info/RECORD,,
20
+ pssh/clients/ssh/single.py,sha256=z9_OQKYyxOMnTixycZLuBoU2GjQYO_I-VL_L3GddwKo,14068
21
+ parallel_ssh-2.14.0.dist-info/COPYING,sha256=ZA2Q9u5AEkH_YoNNDRsz-DBJ6ZuL_foE7RsKFjXd4-c,18093
22
+ parallel_ssh-2.14.0.dist-info/COPYING.LESSER,sha256=AKibDRiqzUEU3s95Ei24e_Nb3a8rxQ44PJyfTCYzkLI,24486
23
+ parallel_ssh-2.14.0.dist-info/LICENSE,sha256=m4cqigcLitMpxL04D7G_AAD1ZMdQI-yOHmgD8VNkuek,26461
24
+ parallel_ssh-2.14.0.dist-info/METADATA,sha256=Y2Vjl44EdOIuyRpZx2Feav-OLD_6yiiB3u6ly5LgVGc,10730
25
+ parallel_ssh-2.14.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
26
+ parallel_ssh-2.14.0.dist-info/top_level.txt,sha256=s8P6ZHOwt2BYgDc62Cpd2z7i-rebGzIhhnO09pger0U,5
27
+ parallel_ssh-2.14.0.dist-info/RECORD,,
@@ -114,20 +114,6 @@ class BaseParallelSSHClient(object):
114
114
  self._host_clients.pop((i, host), None)
115
115
  self._hosts = _hosts
116
116
 
117
- def __del__(self):
118
- self.disconnect()
119
-
120
- def disconnect(self):
121
- """Disconnect all clients."""
122
- if not hasattr(self, '_host_clients'):
123
- return
124
- for s_client in self._host_clients.values():
125
- try:
126
- s_client.disconnect()
127
- except Exception as ex:
128
- logger.debug("Client disconnect failed with %s", ex)
129
- pass
130
-
131
117
  def _check_host_config(self):
132
118
  if self.host_config is None:
133
119
  return
@@ -19,10 +19,13 @@ import logging
19
19
  import os
20
20
  from getpass import getuser
21
21
  from socket import gaierror as sock_gaierror, error as sock_error
22
+ from warnings import warn
22
23
 
23
24
  from gevent import sleep, socket, Timeout as GTimeout
24
25
  from gevent.hub import Hub
25
26
  from gevent.select import poll, POLLIN, POLLOUT
27
+ from gevent.socket import SHUT_RDWR
28
+ from gevent.pool import Pool
26
29
  from ssh2.exceptions import AgentConnectionError, AgentListIdentitiesError, \
27
30
  AgentAuthenticationError, AgentGetIdentityError
28
31
  from ssh2.utils import find_eol
@@ -39,6 +42,61 @@ host_logger = logging.getLogger('pssh.host_logger')
39
42
  logger = logging.getLogger(__name__)
40
43
 
41
44
 
45
+ class PollMixIn(object):
46
+ """MixIn for co-operative socket polling functionality.
47
+
48
+ """
49
+ __slots__ = ('sock',)
50
+
51
+ def __init__(self, sock=None):
52
+ self.sock = sock
53
+
54
+ def poll(self, timeout=None):
55
+ raise NotImplementedError
56
+
57
+ def eagain(self, func, *args, **kwargs):
58
+ raise NotImplementedError
59
+
60
+ def eagain_write(self, write_func, data):
61
+ raise NotImplementedError
62
+
63
+ def _poll_errcodes(self, directions_func, inbound, outbound):
64
+ directions = directions_func()
65
+ if directions == 0:
66
+ return
67
+ events = 0
68
+ if directions & inbound:
69
+ events = POLLIN
70
+ if directions & outbound:
71
+ events |= POLLOUT
72
+ self._poll_socket(events)
73
+
74
+ def _poll_socket(self, events):
75
+ if self.sock is None:
76
+ return
77
+ poller = poll()
78
+ poller.register(self.sock, eventmask=events)
79
+ poller.poll(timeout=1)
80
+
81
+ def _eagain_errcode(self, func, eagain, *args, **kwargs):
82
+ ret = func(*args, **kwargs)
83
+ while ret == eagain:
84
+ self.poll()
85
+ ret = func(*args, **kwargs)
86
+ sleep()
87
+ return ret
88
+
89
+ def _eagain_write_errcode(self, write_func, data, eagain):
90
+ data_len = len(data)
91
+ total_written = 0
92
+ while total_written < data_len:
93
+ rc, bytes_written = write_func(data[total_written:])
94
+ total_written += bytes_written
95
+ if rc == eagain:
96
+ self.poll()
97
+ sleep()
98
+
99
+
42
100
  class Stdin(object):
43
101
  """Stdin stream for a channel.
44
102
 
@@ -64,13 +122,13 @@ class Stdin(object):
64
122
  :param data: Data to write.
65
123
  :type data: str
66
124
  """
67
- return self._client._eagain(self._channel.write, data)
125
+ return self._client.eagain(self._channel.write, data)
68
126
 
69
127
  def flush(self):
70
128
  """Flush pending data written to stdin."""
71
129
  if not hasattr(self._channel, "flush"):
72
130
  return
73
- return self._client._eagain(self._channel.flush)
131
+ return self._client.eagain(self._channel.flush)
74
132
 
75
133
 
76
134
  class InteractiveShell(object):
@@ -132,7 +190,7 @@ class InteractiveShell(object):
132
190
  """Wait for shell to finish executing and close channel."""
133
191
  if self._chan is None:
134
192
  return
135
- self._client._eagain(self._chan.send_eof)
193
+ self._client.eagain(self._chan.send_eof)
136
194
  self._client.wait_finished(self.output)
137
195
  return self
138
196
 
@@ -144,10 +202,10 @@ class InteractiveShell(object):
144
202
  :type cmd: str
145
203
  """
146
204
  cmd = cmd.encode(self._encoding) + self._EOL
147
- self._client._eagain_write(self._chan.write, cmd)
205
+ self._client.eagain_write(self._chan.write, cmd)
148
206
 
149
207
 
150
- class BaseSSHClient(object):
208
+ class BaseSSHClient(PollMixIn):
151
209
 
152
210
  IDENTITIES = (
153
211
  os.path.expanduser('~/.ssh/id_rsa'),
@@ -169,6 +227,7 @@ class BaseSSHClient(object):
169
227
  identity_auth=True,
170
228
  ipv6_only=False,
171
229
  ):
230
+ super(PollMixIn, self).__init__()
172
231
  self._auth_thread_pool = _auth_thread_pool
173
232
  self.host = host
174
233
  self.alias = alias
@@ -176,7 +235,6 @@ class BaseSSHClient(object):
176
235
  self.password = password
177
236
  self.port = port if port else 22
178
237
  self.num_retries = num_retries
179
- self.sock = None
180
238
  self.timeout = timeout if timeout else None
181
239
  self.retry_delay = retry_delay
182
240
  self.allow_agent = allow_agent
@@ -187,6 +245,7 @@ class BaseSSHClient(object):
187
245
  self.identity_auth = identity_auth
188
246
  self._keepalive_greenlet = None
189
247
  self.ipv6_only = ipv6_only
248
+ self._pool = Pool()
190
249
  self._init()
191
250
 
192
251
  def _pkey_from_memory(self, pkey_data):
@@ -212,11 +271,15 @@ class BaseSSHClient(object):
212
271
  raise AuthenticationError(msg, self.host, self.port, ex, retries, self.num_retries)
213
272
 
214
273
  def disconnect(self):
274
+ """Deprecated and a no-op. Disconnections handled by client de-allocation."""
275
+ warn("Deprecated and a no-op - to be removed in future releases.", DeprecationWarning)
276
+
277
+ def _disconnect(self):
215
278
  raise NotImplementedError
216
279
 
217
280
  def __del__(self):
218
281
  try:
219
- self.disconnect()
282
+ self._disconnect()
220
283
  except Exception:
221
284
  pass
222
285
 
@@ -224,7 +287,7 @@ class BaseSSHClient(object):
224
287
  return self
225
288
 
226
289
  def __exit__(self, *args):
227
- self.disconnect()
290
+ self._disconnect()
228
291
 
229
292
  def open_shell(self, encoding='utf-8', read_timeout=None):
230
293
  """Open interactive shell on new channel.
@@ -244,7 +307,8 @@ class BaseSSHClient(object):
244
307
  raise NotImplementedError
245
308
 
246
309
  def _disconnect_eagain(self):
247
- self._eagain(self.session.disconnect)
310
+ if self.session is not None and self.sock is not None and not self.sock.closed:
311
+ self.eagain(self.session.disconnect)
248
312
 
249
313
  def _connect_init_session_retry(self, retries):
250
314
  try:
@@ -254,9 +318,11 @@ class BaseSSHClient(object):
254
318
  self.session = None
255
319
  if not self.sock.closed:
256
320
  try:
257
- self.sock.close()
321
+ self.sock.shutdown(SHUT_RDWR)
322
+ self.sock.detach()
258
323
  except Exception:
259
324
  pass
325
+ self.sock = None
260
326
  sleep(self.retry_delay)
261
327
  self._connect(self._host, self._port, retries=retries)
262
328
  return self._init_session(retries=retries)
@@ -420,6 +486,13 @@ class BaseSSHClient(object):
420
486
  raise NotImplementedError
421
487
 
422
488
  def execute(self, cmd, use_pty=False, channel=None):
489
+ """
490
+ Deprecated - use ``run_command`` instead which returns a ``HostOutput`` object.
491
+ """
492
+ warn("Deprecated - use run_command instead.", DeprecationWarning)
493
+ return self._execute(cmd, use_pty=use_pty, channel=channel)
494
+
495
+ def _execute(self, cmd, use_pty=False, channel=None):
423
496
  raise NotImplementedError
424
497
 
425
498
  def read_stderr(self, stderr_buffer, timeout=None):
@@ -554,37 +627,11 @@ class BaseSSHClient(object):
554
627
  _command += "%s '%s'" % (_shell, command,)
555
628
  _command = _command.encode(encoding)
556
629
  with GTimeout(seconds=self.timeout):
557
- channel = self.execute(_command, use_pty=use_pty)
630
+ channel = self._execute(_command, use_pty=use_pty)
558
631
  _timeout = read_timeout if read_timeout else timeout
559
632
  host_out = self._make_host_output(channel, encoding, _timeout)
560
633
  return host_out
561
634
 
562
- def _eagain_write_errcode(self, write_func, data, eagain):
563
- data_len = len(data)
564
- total_written = 0
565
- while total_written < data_len:
566
- rc, bytes_written = write_func(data[total_written:])
567
- total_written += bytes_written
568
- if rc == eagain:
569
- self.poll()
570
- sleep()
571
-
572
- def _eagain_errcode(self, func, eagain, *args, **kwargs):
573
- timeout = kwargs.pop('timeout', self.timeout)
574
- with GTimeout(seconds=timeout, exception=Timeout):
575
- ret = func(*args, **kwargs)
576
- while ret == eagain:
577
- self.poll()
578
- ret = func(*args, **kwargs)
579
- sleep()
580
- return ret
581
-
582
- def _eagain_write(self, write_func, data):
583
- raise NotImplementedError
584
-
585
- def _eagain(self, func, *args, **kwargs):
586
- raise NotImplementedError
587
-
588
635
  def _make_sftp(self):
589
636
  raise NotImplementedError
590
637
 
@@ -692,24 +739,3 @@ class BaseSSHClient(object):
692
739
  _sep = file_path.rfind('/')
693
740
  if _sep > 0:
694
741
  return file_path[:_sep]
695
-
696
- def poll(self):
697
- raise NotImplementedError
698
-
699
- def _poll_socket(self, events):
700
- if self.sock is None:
701
- return
702
- poller = poll()
703
- poller.register(self.sock, eventmask=events)
704
- poller.poll(timeout=1)
705
-
706
- def _poll_errcodes(self, directions_func, inbound, outbound):
707
- directions = directions_func()
708
- if directions == 0:
709
- return
710
- events = 0
711
- if directions & inbound:
712
- events = POLLIN
713
- if directions & outbound:
714
- events |= POLLOUT
715
- self._poll_socket(events)
@@ -15,12 +15,15 @@
15
15
  # License along with this library; if not, write to the Free Software
16
16
  # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
17
 
18
- import logging
19
- import os
20
18
  from collections import deque
21
19
 
22
- from gevent import sleep, spawn, get_hub
20
+ import logging
21
+ import os
22
+ from gevent import sleep, get_hub
23
23
  from gevent.lock import RLock
24
+ from gevent.pool import Pool
25
+ from gevent.socket import SHUT_RDWR
26
+ from gevent.timeout import Timeout as GTimeout
24
27
  from ssh2.error_codes import LIBSSH2_ERROR_EAGAIN
25
28
  from ssh2.exceptions import SFTPHandleError, SFTPProtocolError, \
26
29
  Timeout as SSH2Timeout
@@ -31,7 +34,7 @@ from ssh2.sftp import LIBSSH2_FXF_READ, LIBSSH2_FXF_CREAT, LIBSSH2_FXF_WRITE, \
31
34
  LIBSSH2_SFTP_S_IXGRP, LIBSSH2_SFTP_S_IXOTH
32
35
 
33
36
  from .tunnel import FORWARDER
34
- from ..base.single import BaseSSHClient
37
+ from ..base.single import BaseSSHClient, PollMixIn
35
38
  from ...constants import DEFAULT_RETRIES, RETRY_DELAY
36
39
  from ...exceptions import SessionError, SFTPError, \
37
40
  SFTPIOError, Timeout, SCPError, ProxyError
@@ -41,6 +44,51 @@ logger = logging.getLogger(__name__)
41
44
  THREAD_POOL = get_hub().threadpool
42
45
 
43
46
 
47
+ class KeepAlive(PollMixIn):
48
+ """Class for handling SSHClient keepalive functionality.
49
+
50
+ Spawns a greenlet in its own pool for sending keepalives to a given session.
51
+ """
52
+ __slots__ = ('sock', 'session', '_let', '_pool')
53
+
54
+ def __init__(self, sock, session):
55
+ """
56
+ :param sock: The socket session is using to communicate.
57
+ :type sock: :py:class:`gevent.socket.socket`
58
+ :param session: The session keepalive is configured on.
59
+ :type session: :py:class:`ssh2.session.Session`
60
+ """
61
+ super(PollMixIn, self).__init__()
62
+ self._pool = Pool(1)
63
+ self.sock = sock
64
+ self.session = session
65
+ self._let = self._pool.spawn(self._send_keepalive)
66
+ self._let.start()
67
+
68
+ def _send_keepalive(self):
69
+ while True:
70
+ if self.session is None or self.sock is None or self.sock.closed:
71
+ return
72
+ sleep_for = self.eagain(self.session.keepalive_send)
73
+ sleep(sleep_for)
74
+
75
+ def poll(self, timeout=None):
76
+ """Perform co-operative gevent poll on ssh2 session socket.
77
+
78
+ Blocks current greenlet only if socket has pending read or write operations
79
+ in the appropriate direction.
80
+ :param timeout: Deprecated and unused - to be removed.
81
+ """
82
+ self._poll_errcodes(
83
+ self.session.block_directions,
84
+ LIBSSH2_SESSION_BLOCK_INBOUND,
85
+ LIBSSH2_SESSION_BLOCK_OUTBOUND,
86
+ )
87
+
88
+ def eagain(self, func, *args, **kwargs):
89
+ return self._eagain_errcode(func, LIBSSH2_ERROR_EAGAIN, *args, **kwargs)
90
+
91
+
44
92
  class SSHClient(BaseSSHClient):
45
93
  """ssh2-python (libssh2) based non-blocking SSH client."""
46
94
  # 2MB buffer
@@ -58,7 +106,8 @@ class SSHClient(BaseSSHClient):
58
106
  proxy_pkey=None,
59
107
  proxy_user=None,
60
108
  proxy_password=None,
61
- _auth_thread_pool=True, keepalive_seconds=60,
109
+ _auth_thread_pool=True,
110
+ keepalive_seconds=60,
62
111
  identity_auth=True,
63
112
  ipv6_only=False,
64
113
  ):
@@ -85,7 +134,9 @@ class SSHClient(BaseSSHClient):
85
134
  to :py:class:`pssh.constants.RETRY_DELAY`
86
135
  :type retry_delay: int or float
87
136
  :param timeout: SSH session timeout setting in seconds. This controls
88
- timeout setting of authenticated SSH sessions.
137
+ timeout setting of authenticated SSH sessions for each individual SSH operation.
138
+ Also currently sets socket as well as per function timeout in some cases, see
139
+ function descriptions.
89
140
  :type timeout: int or float
90
141
  :param allow_agent: (Optional) set to False to disable connecting to
91
142
  the system's SSH agent
@@ -146,7 +197,7 @@ class SSHClient(BaseSSHClient):
146
197
  )
147
198
 
148
199
  def _shell(self, channel):
149
- return self._eagain(channel.shell)
200
+ return self.eagain(channel.shell)
150
201
 
151
202
  def _connect_proxy(self, proxy_host, proxy_port, proxy_pkey,
152
203
  user=None, password=None, alias=None,
@@ -178,42 +229,36 @@ class SSHClient(BaseSSHClient):
178
229
  proxy_local_port = FORWARDER.out_q.get()
179
230
  return proxy_local_port
180
231
 
181
- def disconnect(self):
232
+ def _disconnect(self):
182
233
  """Attempt to disconnect session.
183
234
 
184
235
  Any errors on calling disconnect are suppressed by this function.
236
+
237
+ Does not need to be called directly - called when client object is de-allocated.
185
238
  """
186
239
  self._keepalive_greenlet = None
187
- if self.session is not None:
240
+ if self.session is not None and self.sock is not None and not self.sock.closed:
188
241
  try:
189
242
  self._disconnect_eagain()
190
243
  except Exception:
191
244
  pass
192
- self.session = None
245
+ self.session = None
246
+ # To allow for file descriptor reuse, which is part of gevent, shutdown but do not close socket here.
247
+ # Done by gevent when file descriptor is closed.
248
+ if self.sock is not None and not self.sock.closed:
249
+ try:
250
+ self.sock.shutdown(SHUT_RDWR)
251
+ self.sock.detach()
252
+ except Exception:
253
+ pass
254
+ self.sock = None
255
+ # Notify forwarder that proxy tunnel server can be shutdown
193
256
  if isinstance(self._proxy_client, SSHClient):
194
- # Don't disconnect proxy client here - let the TunnelServer do it at the time that
195
- # _wait_send_receive_lets ends. The cleanup_server call here triggers the TunnelServer
196
- # to stop.
197
257
  FORWARDER.cleanup_server(self._proxy_client)
198
258
 
199
- # I wanted to clean up all the sockets here to avoid a ResourceWarning from unittest,
200
- # but unfortunately closing this socket here causes a segfault, not sure why yet.
201
- # self.sock.close()
202
- else:
203
- self.sock.close()
204
- self.sock = None
205
-
206
- def spawn_send_keepalive(self):
207
- """Spawns a new greenlet that sends keep alive messages every
208
- self.keepalive_seconds"""
209
- return spawn(self._send_keepalive)
210
-
211
- def _send_keepalive(self):
212
- while True:
213
- sleep(self._eagain(self.session.keepalive_send))
214
-
215
259
  def configure_keepalive(self):
216
260
  """Configures keepalive on the server for `self.keepalive_seconds`."""
261
+ # Configure keepalives without a reply.
217
262
  self.session.keepalive_config(False, self.keepalive_seconds)
218
263
 
219
264
  def _init_session(self, retries=1):
@@ -234,7 +279,11 @@ class SSHClient(BaseSSHClient):
234
279
  msg = "Error connecting to host %s:%s - %s"
235
280
  logger.error(msg, self.host, self.port, ex)
236
281
  if not self.sock.closed:
237
- self.sock.close()
282
+ try:
283
+ self.sock.shutdown(SHUT_RDWR)
284
+ self.sock.detach()
285
+ except Exception:
286
+ pass
238
287
  if isinstance(ex, SSH2Timeout):
239
288
  raise Timeout(msg, self.host, self.port, ex)
240
289
  raise
@@ -242,7 +291,7 @@ class SSHClient(BaseSSHClient):
242
291
  def _keepalive(self):
243
292
  if self.keepalive_seconds:
244
293
  self.configure_keepalive()
245
- self._keepalive_greenlet = self.spawn_send_keepalive()
294
+ self._keepalive_greenlet = KeepAlive(self.sock, self.session)
246
295
 
247
296
  def _agent_auth(self):
248
297
  self.session.agent_auth(self.user)
@@ -264,7 +313,7 @@ class SSHClient(BaseSSHClient):
264
313
  self.session.userauth_password(self.user, self.password)
265
314
 
266
315
  def _open_session(self):
267
- chan = self._eagain(self.session.open_session)
316
+ chan = self.eagain(self.session.open_session)
268
317
  return chan
269
318
 
270
319
  def open_session(self):
@@ -282,14 +331,18 @@ class SSHClient(BaseSSHClient):
282
331
  return chan
283
332
 
284
333
  def _make_output_readers(self, channel, stdout_buffer, stderr_buffer):
285
- _stdout_reader = spawn(
334
+ # TODO: These greenlets need to be outside client scope or we create a reader <-> client cyclical reference
335
+ _stdout_reader = self._pool.spawn(
286
336
  self._read_output_to_buffer, channel.read, stdout_buffer)
287
- _stderr_reader = spawn(
337
+ _stderr_reader = self._pool.spawn(
288
338
  self._read_output_to_buffer, channel.read_stderr, stderr_buffer)
289
339
  return _stdout_reader, _stderr_reader
290
340
 
291
- def execute(self, cmd, use_pty=False, channel=None):
292
- """Execute command on remote server.
341
+ def _execute(self, cmd, use_pty=False, channel=None):
342
+ """
343
+ Use ``run_command`` which returns a ``HostOutput`` object rather than this function directly.
344
+
345
+ Execute command on remote server.
293
346
 
294
347
  :param cmd: Command to execute.
295
348
  :type cmd: str
@@ -298,12 +351,14 @@ class SSHClient(BaseSSHClient):
298
351
  :param channel: Use provided channel for execute rather than creating
299
352
  a new one.
300
353
  :type channel: :py:class:`ssh2.channel.Channel`
354
+
355
+ :rtype: :py:class:`ssh2.channel.Channel`
301
356
  """
302
357
  channel = self.open_session() if channel is None else channel
303
358
  if use_pty:
304
- self._eagain(channel.pty)
359
+ self.eagain(channel.pty)
305
360
  logger.debug("Executing command '%s'", cmd)
306
- self._eagain(channel.execute, cmd)
361
+ self.eagain(channel.execute, cmd)
307
362
  return channel
308
363
 
309
364
  def _read_output_to_buffer(self, read_func, _buffer, is_stderr=False):
@@ -341,20 +396,23 @@ class SSHClient(BaseSSHClient):
341
396
  channel = host_output.channel
342
397
  if channel is None:
343
398
  return
344
- self._eagain(channel.wait_eof, timeout=timeout)
345
- # Close channel to indicate no more commands will be sent over it
346
- self.close_channel(channel)
399
+ with GTimeout(seconds=timeout, exception=Timeout):
400
+ self.eagain(channel.wait_eof)
401
+ # Close channel to indicate no more commands will be sent over it
402
+ self.close_channel(channel)
347
403
 
348
404
  def close_channel(self, channel):
405
+ """Close given channel, handling EAGAIN."""
349
406
  with self._chan_stdout_lock, self._chan_stderr_lock:
350
407
  logger.debug("Closing channel")
351
- self._eagain(channel.close)
408
+ self.eagain(channel.close)
352
409
 
353
- def _eagain(self, func, *args, **kwargs):
410
+ def eagain(self, func, *args, **kwargs):
411
+ """Handle EAGAIN and call given function with any args, polling for as long as there is data to receive."""
354
412
  return self._eagain_errcode(func, LIBSSH2_ERROR_EAGAIN, *args, **kwargs)
355
413
 
356
414
  def _make_sftp_eagain(self):
357
- return self._eagain(self.session.sftp_init)
415
+ return self.eagain(self.session.sftp_init)
358
416
 
359
417
  def _make_sftp(self):
360
418
  try:
@@ -381,7 +439,7 @@ class SSHClient(BaseSSHClient):
381
439
  LIBSSH2_SFTP_S_IXGRP | \
382
440
  LIBSSH2_SFTP_S_IXOTH
383
441
  try:
384
- self._eagain(sftp.mkdir, directory, mode)
442
+ self.eagain(sftp.mkdir, directory, mode)
385
443
  except SFTPProtocolError as error:
386
444
  msg = "Error occured creating directory %s on host %s - %s"
387
445
  logger.error(msg, directory, self.host, error)
@@ -419,7 +477,7 @@ class SSHClient(BaseSSHClient):
419
477
  destination = self._remote_paths_split(remote_file)
420
478
  if destination is not None:
421
479
  try:
422
- self._eagain(sftp.stat, destination)
480
+ self.eagain(sftp.stat, destination)
423
481
  except (SFTPHandleError, SFTPProtocolError):
424
482
  self.mkdir(sftp, destination)
425
483
  self.sftp_put(sftp, local_file, remote_file)
@@ -482,7 +540,7 @@ class SSHClient(BaseSSHClient):
482
540
  cur_dir = _paths_to_create.popleft()
483
541
  cwd = '/'.join([cwd, cur_dir])
484
542
  try:
485
- self._eagain(sftp.stat, cwd)
543
+ self.eagain(sftp.stat, cwd)
486
544
  except (SFTPHandleError, SFTPProtocolError) as ex:
487
545
  logger.debug("Stat for %s failed with %s", cwd, ex)
488
546
  self._mkdir(sftp, cwd)
@@ -514,7 +572,7 @@ class SSHClient(BaseSSHClient):
514
572
  """
515
573
  sftp = self._make_sftp() if sftp is None else sftp
516
574
  try:
517
- self._eagain(sftp.stat, remote_file)
575
+ self.eagain(sftp.stat, remote_file)
518
576
  except (SFTPHandleError, SFTPProtocolError):
519
577
  msg = "Remote file or directory %s on host %s does not exist"
520
578
  logger.error(msg, remote_file, self.host)
@@ -541,7 +599,7 @@ class SSHClient(BaseSSHClient):
541
599
 
542
600
  def _scp_recv_recursive(self, remote_file, local_file, sftp, encoding='utf-8'):
543
601
  try:
544
- self._eagain(sftp.stat, remote_file)
602
+ self.eagain(sftp.stat, remote_file)
545
603
  except (SFTPHandleError, SFTPProtocolError):
546
604
  msg = "Remote file or directory %s does not exist"
547
605
  logger.error(msg, remote_file)
@@ -602,7 +660,7 @@ class SSHClient(BaseSSHClient):
602
660
 
603
661
  def _scp_recv(self, remote_file, local_file):
604
662
  try:
605
- (file_chan, fileinfo) = self._eagain(
663
+ (file_chan, fileinfo) = self.eagain(
606
664
  self.session.scp_recv2, remote_file)
607
665
  except Exception as ex:
608
666
  msg = "Error copying file %s from host %s - %s"
@@ -661,7 +719,7 @@ class SSHClient(BaseSSHClient):
661
719
  if destination is not None:
662
720
  sftp = self._make_sftp() if sftp is None else sftp
663
721
  try:
664
- self._eagain(sftp.stat, destination)
722
+ self.eagain(sftp.stat, destination)
665
723
  except (SFTPHandleError, SFTPProtocolError):
666
724
  self.mkdir(sftp, destination)
667
725
  elif remote_file.endswith('/'):
@@ -674,7 +732,7 @@ class SSHClient(BaseSSHClient):
674
732
  def _scp_send(self, local_file, remote_file):
675
733
  fileinfo = os.stat(local_file)
676
734
  try:
677
- chan = self._eagain(
735
+ chan = self.eagain(
678
736
  self.session.scp_send64,
679
737
  remote_file, fileinfo.st_mode & 0o777, fileinfo.st_size,
680
738
  fileinfo.st_mtime, fileinfo.st_atime)
@@ -693,14 +751,14 @@ class SSHClient(BaseSSHClient):
693
751
  logger.error(msg, remote_file, self.host, ex)
694
752
  raise SCPError(msg, remote_file, self.host, ex)
695
753
  finally:
696
- self._eagain(chan.flush)
697
- self._eagain(chan.send_eof)
698
- self._eagain(chan.wait_eof)
699
- self._eagain(chan.wait_closed)
754
+ self.eagain(chan.flush)
755
+ self.eagain(chan.send_eof)
756
+ self.eagain(chan.wait_eof)
757
+ self.eagain(chan.wait_closed)
700
758
 
701
759
  def _sftp_openfh(self, open_func, remote_file, *args):
702
760
  try:
703
- fh = self._eagain(open_func, remote_file, *args)
761
+ fh = self.eagain(open_func, remote_file, *args)
704
762
  except Exception as ex:
705
763
  raise SFTPError(ex)
706
764
  return fh
@@ -758,12 +816,9 @@ class SSHClient(BaseSSHClient):
758
816
  LIBSSH2_SESSION_BLOCK_OUTBOUND,
759
817
  )
760
818
 
761
- def _eagain_write(self, write_func, data):
819
+ def eagain_write(self, write_func, data):
762
820
  """Write data with given write_func for an ssh2-python session while
763
821
  handling EAGAIN and resuming writes from last written byte on each call to
764
822
  write_func.
765
823
  """
766
824
  return self._eagain_write_errcode(write_func, data, LIBSSH2_ERROR_EAGAIN)
767
-
768
- def eagain_write(self, write_func, data):
769
- return self._eagain_write(write_func, data)
@@ -15,13 +15,14 @@
15
15
  # License along with this library; if not, write to the Free Software
16
16
  # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
17
 
18
+ import atexit
18
19
  import logging
19
- from queue import Queue
20
- from threading import Thread, Event
21
-
22
- from gevent import spawn, joinall, get_hub, sleep
20
+ from gevent import joinall, get_hub, sleep
21
+ from gevent.pool import Pool
23
22
  from gevent.server import StreamServer
23
+ from queue import Queue
24
24
  from ssh2.error_codes import LIBSSH2_ERROR_EAGAIN
25
+ from threading import Thread, Event
25
26
 
26
27
  from ...constants import DEFAULT_RETRIES
27
28
 
@@ -39,25 +40,39 @@ class LocalForwarder(Thread):
39
40
  and get port to connect to via ``out_q`` once a target has been put into the input queue.
40
41
 
41
42
  ``SSHClient`` is the client for the SSH host that will be proxying.
43
+
44
+ There can be as many LocalForwarder(s) as needed to scale.
45
+
46
+ Relationship of clients to forwarders to servers is:
47
+
48
+ One client -> One Forwarder -> Many Servers <-> Many proxy hosts -> one target host per proxy host
49
+
50
+ A Forwarder starts servers. Servers communicate with clients directly via Forwarder thread.
51
+
52
+ Multiple forwarder threads can be used to scale clients to more threads as number of clients increases causing
53
+ contention in forwarder threads handling proxy connections.
42
54
  """
43
- Thread.__init__(self)
55
+ Thread.__init__(self, daemon=True)
44
56
  self.in_q = Queue(1)
45
57
  self.out_q = Queue(1)
58
+ self._pool = None
46
59
  self._servers = {}
47
60
  self._hub = None
48
61
  self.started = Event()
49
62
  self._cleanup_let = None
63
+ self.shutdown_triggered = Event()
50
64
 
51
65
  def _start_server(self):
52
66
  client, host, port = self.in_q.get()
67
+ logger.debug("Starting server for %s:%s", host, port)
53
68
  server = TunnelServer(client, host, port)
69
+ self._servers[client] = server
54
70
  server.start()
55
- self._get_server_listen_port(client, server)
71
+ self._get_server_listen_port(server)
56
72
 
57
- def _get_server_listen_port(self, client, server):
73
+ def _get_server_listen_port(self, server):
58
74
  while not server.started:
59
75
  sleep(0.01)
60
- self._servers[client] = server
61
76
  local_port = server.listen_port
62
77
  self.out_q.put(local_port)
63
78
 
@@ -74,9 +89,20 @@ class LocalForwarder(Thread):
74
89
  self.in_q.put((client, host, port))
75
90
 
76
91
  def shutdown(self):
77
- """Stop all tunnel servers."""
92
+ """Stop all tunnel servers and shutdown LocalForwarder thread.
93
+
94
+ This function will join the current thread and wait for it to shutdown if needed.
95
+ """
78
96
  for client, server in self._servers.items():
79
97
  server.stop()
98
+ self._servers = {}
99
+ if self.started:
100
+ self.shutdown_triggered.set()
101
+ try:
102
+ self.join()
103
+ except RuntimeError:
104
+ # Re-entry protection
105
+ pass
80
106
 
81
107
  def _cleanup_servers_let(self):
82
108
  while True:
@@ -89,19 +115,22 @@ class LocalForwarder(Thread):
89
115
  self.cleanup_server(client)
90
116
 
91
117
  def run(self):
92
- """Thread runner ensures a non main hub has been created for all subsequent
118
+ """Thread runner ensures a non-main hub has been created for all subsequent
93
119
  greenlets and waits for (client, host, port) tuples to be put into self.in_q.
94
120
 
95
- A server is created once something is in the queue and the port to connect to
96
- is put into self.out_q.
121
+ A server is created once something is in the queue and only then is the port to connect to
122
+ put into self.out_q.
97
123
  """
98
124
  self._hub = get_hub()
99
125
  assert self._hub.main_hub is False
126
+ self._pool = Pool(1)
100
127
  self.started.set()
101
- self._cleanup_let = spawn(self._cleanup_servers_let)
128
+ self._cleanup_let = self._pool.spawn(self._cleanup_servers_let)
102
129
  logger.debug("Hub in server runner is main hub: %s", self._hub.main_hub)
103
130
  try:
104
131
  while True:
132
+ if self.shutdown_triggered.is_set():
133
+ return
105
134
  if self.in_q.empty():
106
135
  sleep(.01)
107
136
  continue
@@ -111,8 +140,9 @@ class LocalForwarder(Thread):
111
140
  self.shutdown()
112
141
 
113
142
  def cleanup_server(self, client):
114
- """The purpose of this function is for a proxied client to notify the LocalForwarder that it
115
- is shutting down and its corresponding server can also be shut down."""
143
+ """
144
+ Stop server for given proxy client and remove client from this forwarder.
145
+ """
116
146
  server = self._servers[client]
117
147
  server.stop()
118
148
  del self._servers[client]
@@ -123,16 +153,18 @@ class TunnelServer(StreamServer):
123
153
 
124
154
  Accepts connections on an available bind_address port once started and tunnels data
125
155
  to/from remote SSH host for each connection.
156
+
157
+ TunnelServer.listen_port will return listening port for server on given host once TunnelServer.started is True.
126
158
  """
127
159
 
128
160
  def __init__(self, client, host, port, bind_address='127.0.0.1',
129
161
  num_retries=DEFAULT_RETRIES):
130
- StreamServer.__init__(self, (bind_address, 0), self._read_rw)
131
162
  self.client = client
163
+ self._pool = Pool()
164
+ StreamServer.__init__(self, (bind_address, 0), self._read_rw, spawn=self._pool)
132
165
  self.host = host
133
166
  self.port = port
134
167
  self.session = client.session
135
- self._client = client
136
168
  self._retries = num_retries
137
169
  self.bind_address = bind_address
138
170
  self.exception = None
@@ -154,41 +186,40 @@ class TunnelServer(StreamServer):
154
186
  self.host, self.port, ex)
155
187
  self.exception = ex
156
188
  return
157
- source = spawn(self._read_forward_sock, socket, channel)
158
- dest = spawn(self._read_channel, socket, channel)
189
+ # Channel remains alive while this handler function is alive
190
+ source = self._pool.spawn(self._read_forward_sock, socket, channel)
191
+ dest = self._pool.spawn(self._read_channel, socket, channel)
159
192
  logger.debug("Waiting for read/write greenlets")
160
- self._source_let = source
161
- self._dest_let = dest
162
193
  self._wait_send_receive_lets(source, dest, channel)
163
194
 
164
195
  def _wait_send_receive_lets(self, source, dest, channel):
165
196
  try:
166
197
  joinall((source, dest), raise_error=True)
167
198
  finally:
168
- # Forward socket does not need to be closed here; StreamServer does it in do_close
169
- logger.debug("Closing channel")
170
- self._client.close_channel(channel)
171
-
172
- # Disconnect client here to make sure it happens AFTER close_channel
173
- self._client.disconnect()
199
+ logger.debug("Read/Write greenlets for tunnel target %s:%s finished, closing forwarding channel",
200
+ self.host, self.port)
201
+ self.client.close_channel(channel)
174
202
 
175
203
  def _read_forward_sock(self, forward_sock, channel):
176
204
  while True:
177
205
  if channel is None or channel.eof():
178
206
  logger.debug("Channel closed, tunnel forward socket reader exiting")
179
207
  return
208
+ if not forward_sock or forward_sock.closed:
209
+ logger.debug("Forward socket closed, tunnel forward socket reader exiting")
210
+ return
180
211
  try:
181
212
  data = forward_sock.recv(1024)
182
213
  except Exception as ex:
183
214
  logger.error("Forward socket read error: %s", ex)
184
215
  raise
185
216
  data_len = len(data)
186
- # logger.debug("Read %s data from forward socket", data_len,)
217
+ # logger.debug("Read %s data from forward socket", data_len)
187
218
  if data_len == 0:
188
219
  sleep(.01)
189
220
  continue
190
221
  try:
191
- self._client.eagain_write(channel.write, data)
222
+ self.client.eagain_write(channel.write, data)
192
223
  except Exception as ex:
193
224
  logger.error("Error writing data to channel - %s", ex)
194
225
  raise
@@ -204,9 +235,9 @@ class TunnelServer(StreamServer):
204
235
  except Exception as ex:
205
236
  logger.error("Error reading from channel - %s", ex)
206
237
  raise
207
- # logger.debug("Read %s data from channel" % (size,))
238
+ # logger.debug("Read %s data from channel", size)
208
239
  if size == LIBSSH2_ERROR_EAGAIN:
209
- self._client.poll()
240
+ self.client.poll()
210
241
  continue
211
242
  elif size == 0:
212
243
  sleep(.01)
@@ -224,7 +255,7 @@ class TunnelServer(StreamServer):
224
255
  fw_host, fw_port, self.bind_address,
225
256
  local_port)
226
257
  while channel == LIBSSH2_ERROR_EAGAIN:
227
- self._client.poll()
258
+ self.client.poll()
228
259
  channel = self.session.direct_tcpip_ex(
229
260
  fw_host, fw_port, self.bind_address,
230
261
  local_port)
@@ -249,4 +280,4 @@ class TunnelServer(StreamServer):
249
280
 
250
281
 
251
282
  FORWARDER = LocalForwarder()
252
- FORWARDER.daemon = True
283
+ atexit.register(FORWARDER.shutdown)
@@ -16,8 +16,8 @@
16
16
  # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
17
 
18
18
  import logging
19
-
20
19
  from gevent import sleep, spawn, Timeout as GTimeout, joinall
20
+ from gevent.socket import SHUT_RDWR
21
21
  from ssh import options
22
22
  from ssh.error_codes import SSH_AGAIN
23
23
  from ssh.exceptions import EOF
@@ -81,9 +81,11 @@ class SSHClient(BaseSSHClient):
81
81
  :type retry_delay: int or float
82
82
  :param timeout: (Optional) If provided, all commands will timeout after
83
83
  <timeout> number of seconds.
84
+ Also currently sets socket as well as per function timeout in some cases, see
85
+ function descriptions.
84
86
  :type timeout: int or float
85
87
  :param allow_agent: (Optional) set to False to disable connecting to
86
- the system's SSH agent. Currently unused.
88
+ the system's SSH agent.
87
89
  :type allow_agent: bool
88
90
  :param identity_auth: (Optional) set to False to disable attempting to
89
91
  authenticate with default identity files from
@@ -124,10 +126,18 @@ class SSHClient(BaseSSHClient):
124
126
  ipv6_only=ipv6_only,
125
127
  )
126
128
 
127
- def disconnect(self):
128
- """Close socket if needed."""
129
- if self.sock is not None and not self.sock.closed:
130
- self.sock.close()
129
+ def _disconnect(self):
130
+ """Shutdown socket if needed.
131
+
132
+ Does not need to be called directly - called when client object is de-allocated.
133
+ """
134
+ if self.session is not None and self.sock is not None and not self.sock.closed:
135
+ try:
136
+ self.sock.shutdown(SHUT_RDWR)
137
+ self.sock.detach()
138
+ except Exception:
139
+ pass
140
+ self.sock = None
131
141
 
132
142
  def _agent_auth(self):
133
143
  self.session.userauth_agent(self.user)
@@ -201,12 +211,12 @@ class SSHClient(BaseSSHClient):
201
211
  logger.debug("Imported certificate file %s for pkey %s", self.cert_file, self.pkey)
202
212
 
203
213
  def _shell(self, channel):
204
- return self._eagain(channel.request_shell)
214
+ return self.eagain(channel.request_shell)
205
215
 
206
216
  def _open_session(self):
207
217
  channel = self.session.channel_new()
208
218
  channel.set_blocking(0)
209
- self._eagain(channel.open_session)
219
+ self.eagain(channel.open_session)
210
220
  return channel
211
221
 
212
222
  def open_session(self):
@@ -225,7 +235,7 @@ class SSHClient(BaseSSHClient):
225
235
  self._read_output_to_buffer, channel, stderr_buffer, is_stderr=True)
226
236
  return _stdout_reader, _stderr_reader
227
237
 
228
- def execute(self, cmd, use_pty=False, channel=None):
238
+ def _execute(self, cmd, use_pty=False, channel=None):
229
239
  """Execute command on remote host.
230
240
 
231
241
  :param cmd: The command string to execute.
@@ -235,12 +245,13 @@ class SSHClient(BaseSSHClient):
235
245
  :type use_pty: bool
236
246
  :param channel: Channel to use. New channel is created if not provided.
237
247
  :type channel: :py:class:`ssh.channel.Channel`"""
238
- channel = self.open_session() if not channel else channel
239
- if use_pty:
240
- self._eagain(channel.request_pty, timeout=self.timeout)
241
- logger.debug("Executing command '%s'", cmd)
242
- self._eagain(channel.request_exec, cmd)
243
- return channel
248
+ with GTimeout(seconds=self.timeout, exception=Timeout):
249
+ channel = self.open_session() if not channel else channel
250
+ if use_pty:
251
+ self.eagain(channel.request_pty)
252
+ logger.debug("Executing command '%s'", cmd)
253
+ self.eagain(channel.request_exec, cmd)
254
+ return channel
244
255
 
245
256
  def _read_output_to_buffer(self, channel, _buffer, is_stderr=False):
246
257
  try:
@@ -276,7 +287,7 @@ class SSHClient(BaseSSHClient):
276
287
  if channel is None:
277
288
  return
278
289
  logger.debug("Sending EOF on channel %s", channel)
279
- self._eagain(channel.send_eof, timeout=self.timeout)
290
+ self.eagain(channel.send_eof)
280
291
  logger.debug("Waiting for readers, timeout %s", timeout)
281
292
  with GTimeout(seconds=timeout, exception=Timeout):
282
293
  joinall((host_output.buffers.stdout.reader, host_output.buffers.stderr.reader))
@@ -311,7 +322,7 @@ class SSHClient(BaseSSHClient):
311
322
  :type channel: :py:class:`ssh.channel.Channel`
312
323
  """
313
324
  logger.debug("Closing channel")
314
- self._eagain(channel.close)
325
+ self.eagain(channel.close)
315
326
 
316
327
  def poll(self, timeout=None):
317
328
  """ssh-python based co-operative gevent poll on session socket.
@@ -323,9 +334,9 @@ class SSHClient(BaseSSHClient):
323
334
  SSH_WRITE_PENDING,
324
335
  )
325
336
 
326
- def _eagain(self, func, *args, **kwargs):
337
+ def eagain(self, func, *args, **kwargs):
327
338
  """Run function given and handle EAGAIN for an ssh-python session"""
328
339
  return self._eagain_errcode(func, SSH_AGAIN, *args, **kwargs)
329
340
 
330
- def _eagain_write(self, write_func, data):
341
+ def eagain_write(self, write_func, data):
331
342
  return self._eagain_write_errcode(write_func, data, SSH_AGAIN)