GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Passed
Push — develop ( 8ec028...a6b0a6 )
by Plexxi
19:39 queued 10:44
created

ParamikoSSHClient._connect()   F

Complexity

Conditions 22

Size

Total Lines 112

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 22
c 1
b 0
f 0
dl 0
loc 112
rs 2

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like ParamikoSSHClient._connect() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
# Licensed to the Apache Software Foundation (ASF) under one or more
2
# contributor license agreements.  See the NOTICE file distributed with
3
# this work for additional information regarding copyright ownership.
4
# The ASF licenses this file to You under the Apache License, Version 2.0
5
# (the "License"); you may not use this file except in compliance with
6
# the License.  You may obtain a copy of the License at
7
#
8
#     http://www.apache.org/licenses/LICENSE-2.0
9
#
10
# Unless required by applicable law or agreed to in writing, software
11
# distributed under the License is distributed on an "AS IS" BASIS,
12
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
15
16
import os
17
import posixpath
18
from StringIO import StringIO
19
import time
20
21
import eventlet
22
from oslo_config import cfg
23
24
import paramiko
25
from paramiko.ssh_exception import SSHException
26
27
# Depending on your version of Paramiko, it may cause a deprecation
28
# warning on Python 2.6.
29
# Ref: https://bugs.launchpad.net/paramiko/+bug/392973
30
31
from st2common.log import logging
32
from st2common.util.misc import strip_shell_chars
33
from st2common.util.shell import quote_unix
34
from st2common.constants.runners import DEFAULT_SSH_PORT, REMOTE_RUNNER_PRIVATE_KEY_HEADER
35
36
__all__ = [
37
    'ParamikoSSHClient',
38
39
    'SSHCommandTimeoutError'
40
]
41
42
43
class SSHCommandTimeoutError(Exception):
44
    """
45
    Exception which is raised when an SSH command times out.
46
    """
47
48
    def __init__(self, cmd, timeout, stdout=None, stderr=None):
49
        """
50
        :param stdout: Stdout which was consumed until the timeout occured.
51
        :type stdout: ``str``
52
53
        :param stdout: Stderr which was consumed until the timeout occured.
54
        :type stderr: ``str``
55
        """
56
        self.cmd = cmd
57
        self.timeout = timeout
58
        self.stdout = stdout
59
        self.stderr = stderr
60
        message = 'Command didn\'t finish in %s seconds' % (timeout)
61
        super(SSHCommandTimeoutError, self).__init__(message)
62
63
    def __repr__(self):
64
        return ('<SSHCommandTimeoutError: cmd="%s",timeout=%s)>' %
65
                (self.cmd, self.timeout))
66
67
    def __str__(self):
68
        return self.message
69
70
71
class ParamikoSSHClient(object):
72
    """
73
    A SSH Client powered by Paramiko.
74
    """
75
76
    # Maximum number of bytes to read at once from a socket
77
    CHUNK_SIZE = 1024
78
79
    # How long to sleep while waiting for command to finish
80
    SLEEP_DELAY = 1.5
81
82
    # Connect socket timeout
83
    CONNECT_TIMEOUT = 60
84
85
    def __init__(self, hostname, port=DEFAULT_SSH_PORT, username=None, password=None,
86
                 bastion_host=None,
87
                 key_files=None, key_material=None, timeout=None, passphrase=None):
88
        """
89
        Authentication is always attempted in the following order:
90
91
        - The key passed in (if key is provided)
92
        - Any key we can find through an SSH agent (only if no password and
93
          key is provided)
94
        - Any "id_rsa" or "id_dsa" key discoverable in ~/.ssh/ (only if no
95
          password and key is provided)
96
        - Plain username/password auth, if a password was given (if password is
97
          provided)
98
        """
99
        self.hostname = hostname
100
        self.port = port
101
        self.username = username
102
        self.password = password
103
        self.key_files = key_files
104
        self.timeout = timeout or ParamikoSSHClient.CONNECT_TIMEOUT
105
        self.key_material = key_material
106
        self.bastion_host = bastion_host
107
        self.passphrase = passphrase
108
        self.ssh_config_file = os.path.expanduser(
109
            cfg.CONF.ssh_runner.ssh_config_file_path or
110
            '~/.ssh/config'
111
        )
112
        self.logger = logging.getLogger(__name__)
113
114
        self.client = None
115
        self.sftp_client = None
116
117
        self.bastion_client = None
118
        self.bastion_socket = None
119
120
    def connect(self):
121
        """
122
        Connect to the remote node over SSH.
123
124
        :return: True if the connection has been successfully established,
125
                 False otherwise.
126
        :rtype: ``bool``
127
        """
128
        if self.bastion_host:
129
            self.logger.debug('Bastion host specified, connecting')
130
            self.bastion_client = self._connect(host=self.bastion_host)
131
            transport = self.bastion_client.get_transport()
132
            real_addr = (self.hostname, self.port)
133
            # fabric uses ('', 0) for direct-tcpip, this duplicates that behaviour
134
            # see https://github.com/fabric/fabric/commit/c2a9bbfd50f560df6c6f9675603fb405c4071cad
135
            local_addr = ('', 0)
136
            self.bastion_socket = transport.open_channel('direct-tcpip', real_addr, local_addr)
137
138
        self.client = self._connect(host=self.hostname, socket=self.bastion_socket)
139
        return True
140
141
    def put(self, local_path, remote_path, mode=None, mirror_local_mode=False):
142
        """
143
        Upload a file to the remote node.
144
145
        :type local_path: ``st``
146
        :param local_path: File path on the local node.
147
148
        :type remote_path: ``str``
149
        :param remote_path: File path on the remote node.
150
151
        :type mode: ``int``
152
        :param mode: Permissions mode for the file. E.g. 0744.
153
154
        :type mirror_local_mode: ``int``
155
        :param mirror_local_mode: Should remote file mirror local mode.
156
157
        :return: Attributes of the remote file.
158
        :rtype: :class:`posix.stat_result` or ``None``
159
        """
160
161
        if not local_path or not remote_path:
162
            raise Exception('Need both local_path and remote_path. local: %s, remote: %s' %
163
                            local_path, remote_path)
164
        local_path = quote_unix(local_path)
165
        remote_path = quote_unix(remote_path)
166
167
        extra = {'_local_path': local_path, '_remote_path': remote_path, '_mode': mode,
168
                 '_mirror_local_mode': mirror_local_mode}
169
        self.logger.debug('Uploading file', extra=extra)
170
171
        if not os.path.exists(local_path):
172
            raise Exception('Path %s does not exist locally.' % local_path)
173
174
        rattrs = self.sftp.put(local_path, remote_path)
175
176
        if mode or mirror_local_mode:
177
            local_mode = mode
178
            if not mode or mirror_local_mode:
179
                local_mode = os.stat(local_path).st_mode
180
181
            # Cast to octal integer in case of string
182
            if isinstance(local_mode, basestring):
183
                local_mode = int(local_mode, 8)
184
            local_mode = local_mode & 07777
185
            remote_mode = rattrs.st_mode
186
            # Only bitshift if we actually got an remote_mode
187
            if remote_mode is not None:
188
                remote_mode = (remote_mode & 07777)
189
            if local_mode != remote_mode:
190
                self.sftp.chmod(remote_path, local_mode)
191
192
        return rattrs
193
194
    def put_dir(self, local_path, remote_path, mode=None, mirror_local_mode=False):
195
        """
196
        Upload a dir to the remote node.
197
198
        :type local_path: ``str``
199
        :param local_path: Dir path on the local node.
200
201
        :type remote_path: ``str``
202
        :param remote_path: Base dir path on the remote node.
203
204
        :type mode: ``int``
205
        :param mode: Permissions mode for the file. E.g. 0744.
206
207
        :type mirror_local_mode: ``int``
208
        :param mirror_local_mode: Should remote file mirror local mode.
209
210
        :return: List of files created on remote node.
211
        :rtype: ``list`` of ``str``
212
        """
213
214
        extra = {'_local_path': local_path, '_remote_path': remote_path, '_mode': mode,
215
                 '_mirror_local_mode': mirror_local_mode}
216
        self.logger.debug('Uploading dir', extra=extra)
217
218
        if os.path.basename(local_path):
219
            strip = os.path.dirname(local_path)
220
        else:
221
            strip = os.path.dirname(os.path.dirname(local_path))
222
223
        remote_paths = []
224
225
        for context, dirs, files in os.walk(local_path):
226
            rcontext = context.replace(strip, '', 1)
227
            # normalize pathname separators with POSIX separator
228
            rcontext = rcontext.replace(os.sep, '/')
229
            rcontext = rcontext.lstrip('/')
230
            rcontext = posixpath.join(remote_path, rcontext)
231
232
            if not self.exists(rcontext):
233
                self.sftp.mkdir(rcontext)
234
235
            for d in dirs:
236
                n = posixpath.join(rcontext, d)
237
                if not self.exists(n):
238
                    self.sftp.mkdir(n)
239
240
            for f in files:
241
                local_path = os.path.join(context, f)
242
                n = posixpath.join(rcontext, f)
243
                # Note that quote_unix is done by put anyways.
244
                p = self.put(local_path=local_path, remote_path=n,
245
                             mirror_local_mode=mirror_local_mode, mode=mode)
246
                remote_paths.append(p)
247
248
        return remote_paths
249
250
    def exists(self, remote_path):
251
        """
252
        Validate whether a remote file or directory exists.
253
254
        :param remote_path: Path to remote file.
255
        :type remote_path: ``str``
256
257
        :rtype: ``bool``
258
        """
259
        try:
260
            self.sftp.lstat(remote_path).st_mode
261
        except IOError:
262
            return False
263
264
        return True
265
266
    def mkdir(self, dir_path):
267
        """
268
        Create a directory on remote box.
269
270
        :param dir_path: Path to remote directory to be created.
271
        :type dir_path: ``str``
272
273
        :return: Returns nothing if successful else raises IOError exception.
274
275
        :rtype: ``None``
276
        """
277
278
        dir_path = quote_unix(dir_path)
279
        extra = {'_dir_path': dir_path}
280
        self.logger.debug('mkdir', extra=extra)
281
        return self.sftp.mkdir(dir_path)
282
283
    def delete_file(self, path):
284
        """
285
        Delete a file on remote box.
286
287
        :param path: Path to remote file to be deleted.
288
        :type path: ``str``
289
290
        :return: True if the file has been successfully deleted, False
291
                 otherwise.
292
        :rtype: ``bool``
293
        """
294
295
        path = quote_unix(path)
296
        extra = {'_path': path}
297
        self.logger.debug('Deleting file', extra=extra)
298
        self.sftp.unlink(path)
299
        return True
300
301
    def delete_dir(self, path, force=False, timeout=None):
302
        """
303
        Delete a dir on remote box.
304
305
        :param path: Path to remote dir to be deleted.
306
        :type path: ``str``
307
308
        :param force: Optional Forcefully remove dir.
309
        :type force: ``bool``
310
311
        :param timeout: Optional Time to wait for dir to be deleted. Only relevant for force.
312
        :type timeout: ``int``
313
314
        :return: True if the file has been successfully deleted, False
315
                 otherwise.
316
        :rtype: ``bool``
317
        """
318
319
        path = quote_unix(path)
320
        extra = {'_path': path}
321
        if force:
322
            command = 'rm -rf %s' % path
323
            extra['_command'] = command
324
            extra['_force'] = force
325
            self.logger.debug('Deleting dir', extra=extra)
326
            return self.run(command, timeout=timeout)
327
328
        self.logger.debug('Deleting dir', extra=extra)
329
        return self.sftp.rmdir(path)
330
331
    def run(self, cmd, timeout=None, quote=False):
332
        """
333
        Note: This function is based on paramiko's exec_command()
334
        method.
335
336
        :param timeout: How long to wait (in seconds) for the command to
337
                        finish (optional).
338
        :type timeout: ``float``
339
        """
340
341
        if quote:
342
            cmd = quote_unix(cmd)
343
344
        extra = {'_cmd': cmd}
345
        self.logger.info('Executing command', extra=extra)
346
347
        # Use the system default buffer size
348
        bufsize = -1
349
350
        transport = self.client.get_transport()
351
        chan = transport.open_session()
352
353
        start_time = time.time()
354
        if cmd.startswith('sudo'):
355
            # Note that fabric does this as well. If you set pty, stdout and stderr
356
            # streams will be combined into one.
357
            chan.get_pty()
358
        chan.exec_command(cmd)
359
360
        stdout = StringIO()
361
        stderr = StringIO()
362
363
        # Create a stdin file and immediately close it to prevent any
364
        # interactive script from hanging the process.
365
        stdin = chan.makefile('wb', bufsize)
366
        stdin.close()
367
368
        # Receive all the output
369
        # Note #1: This is used instead of chan.makefile approach to prevent
370
        # buffering issues and hanging if the executed command produces a lot
371
        # of output.
372
        #
373
        # Note #2: If you are going to remove "ready" checks inside the loop
374
        # you are going to have a bad time. Trying to consume from a channel
375
        # which is not ready will block for indefinitely.
376
        exit_status_ready = chan.exit_status_ready()
377
378
        if exit_status_ready:
379
            stdout.write(self._consume_stdout(chan).getvalue())
380
            stderr.write(self._consume_stderr(chan).getvalue())
381
382
        while not exit_status_ready:
383
            current_time = time.time()
384
            elapsed_time = (current_time - start_time)
385
386
            if timeout and (elapsed_time > timeout):
387
                # TODO: Is this the right way to clean up?
388
                chan.close()
389
390
                stdout = strip_shell_chars(stdout.getvalue())
391
                stderr = strip_shell_chars(stderr.getvalue())
392
                raise SSHCommandTimeoutError(cmd=cmd, timeout=timeout, stdout=stdout,
393
                                             stderr=stderr)
394
395
            stdout.write(self._consume_stdout(chan).getvalue())
396
            stderr.write(self._consume_stderr(chan).getvalue())
397
398
            # We need to check the exist status here, because the command could
399
            # print some output and exit during this sleep bellow.
400
            exit_status_ready = chan.exit_status_ready()
401
402
            if exit_status_ready:
403
                break
404
405
            # Short sleep to prevent busy waiting
406
            eventlet.sleep(self.SLEEP_DELAY)
407
        # print('Wait over. Channel must be ready for host: %s' % self.hostname)
408
409
        # Receive the exit status code of the command we ran.
410
        status = chan.recv_exit_status()
411
412
        stdout = strip_shell_chars(stdout.getvalue())
413
        stderr = strip_shell_chars(stderr.getvalue())
414
415
        extra = {'_status': status, '_stdout': stdout, '_stderr': stderr}
416
        self.logger.debug('Command finished', extra=extra)
417
418
        return [stdout, stderr, status]
419
420
    def close(self):
421
        self.logger.debug('Closing server connection')
422
423
        self.client.close()
424
425
        if self.sftp_client:
426
            self.sftp_client.close()
427
428
        if self.bastion_client:
429
            self.bastion_client.close()
430
431
        return True
432
433
    @property
434
    def sftp(self):
435
        """
436
        Method which lazily establishes SFTP connection if one is not established yet when this
437
        variable is accessed.
438
        """
439
        if not self.sftp_client:
440
            self.sftp_client = self.client.open_sftp()
441
442
        return self.sftp_client
443
444
    def _consume_stdout(self, chan):
445
        """
446
        Try to consume stdout data from chan if it's receive ready.
447
        """
448
449
        out = bytearray()
450
        stdout = StringIO()
451
        if chan.recv_ready():
452
            data = chan.recv(self.CHUNK_SIZE)
453
            out += data
454
455
            while data:
456
                ready = chan.recv_ready()
457
458
                if not ready:
459
                    break
460
461
                data = chan.recv(self.CHUNK_SIZE)
462
                out += data
463
464
        stdout.write(self._get_decoded_data(out))
465
        return stdout
466
467
    def _consume_stderr(self, chan):
468
        """
469
        Try to consume stderr data from chan if it's receive ready.
470
        """
471
472
        out = bytearray()
473
        stderr = StringIO()
474
        if chan.recv_stderr_ready():
475
            data = chan.recv_stderr(self.CHUNK_SIZE)
476
            out += data
477
478
            while data:
479
                ready = chan.recv_stderr_ready()
480
481
                if not ready:
482
                    break
483
484
                data = chan.recv_stderr(self.CHUNK_SIZE)
485
                out += data
486
487
        stderr.write(self._get_decoded_data(out))
488
        return stderr
489
490
    def _get_decoded_data(self, data):
491
        try:
492
            return data.decode('utf-8')
493
        except:
494
            self.logger.exception('Non UTF-8 character found in data: %s', data)
495
            raise
496
497
    def _get_pkey_object(self, key_material, passphrase):
498
        """
499
        Try to detect private key type and return paramiko.PKey object.
500
        """
501
502
        for cls in [paramiko.RSAKey, paramiko.DSSKey, paramiko.ECDSAKey]:
503
            try:
504
                key = cls.from_private_key(StringIO(key_material), password=passphrase)
505
            except paramiko.ssh_exception.SSHException:
506
                # Invalid key, try other key type
507
                pass
508
            else:
509
                return key
510
511
        # If a user passes in something which looks like file path we throw a more friendly
512
        # exception letting the user know we expect the contents a not a path.
513
        # Note: We do it here and not up the stack to avoid false positives.
514
        contains_header = REMOTE_RUNNER_PRIVATE_KEY_HEADER in key_material.lower()
515
        if not contains_header and (key_material.count('/') >= 1 or key_material.count('\\') >= 1):
516
            msg = ('"private_key" parameter needs to contain private key data / content and not '
517
                   'a path')
518
        elif passphrase:
519
            msg = 'Invalid passphrase or invalid/unsupported key type'
520
        else:
521
            msg = 'Invalid or unsupported key type'
522
523
        raise paramiko.ssh_exception.SSHException(msg)
524
525
    def _connect(self, host, socket=None):
526
        """
527
        Order of precedence for SSH connection parameters:
528
529
        1. If user supplies parameters via action parameters, we use them to connect.
530
        2. For parameters not supplied via action parameters, if there is an entry
531
           for host in SSH config file, we use those. Note that this is a merge operation.
532
        3. If user does not supply certain action parameters (username and key file location)
533
           and there is no entry for host in SSH config file, we use values supplied in
534
           st2 config file for those parameters.
535
536
        :type host: ``str``
537
        :param host: Host to connect to
538
539
        :type socket: :class:`paramiko.Channel` or an opened :class:`socket.socket`
540
        :param socket: If specified, won't open a socket for communication to the specified host
541
                       and will use this instead
542
543
        :return: A connected SSHClient
544
        :rtype: :class:`paramiko.SSHClient`
545
        """
546
547
        conninfo = {'hostname': host,
548
                    'allow_agent': False,
549
                    'look_for_keys': False,
550
                    'timeout': self.timeout}
551
552
        ssh_config_file_info = {}
553
        if cfg.CONF.ssh_runner.use_ssh_config:
554
            ssh_config_file_info = self._get_ssh_config_for_host(host)
555
556
        self.username = (self.username or ssh_config_file_info.get('user', None) or
557
                         cfg.CONF.system_user.user)
558
        self.port = self.port or ssh_config_file_info.get('port' or None) or DEFAULT_SSH_PORT
559
560
        # If both key file and key material are provided as action parameters,
561
        # throw an error informing user only one is required.
562
        if self.key_files and self.key_material:
563
            msg = ('key_files and key_material arguments are mutually exclusive. Supply only one.')
564
            raise ValueError(msg)
565
566
        # If neither key material nor password is provided, only then we look at key file and decide
567
        # if we want to use the user supplied one or the one in SSH config.
568
        if not self.key_material and not self.password:
569
            self.key_files = (self.key_files or ssh_config_file_info.get('identityfile', None) or
570
                              cfg.CONF.system_user.ssh_key_file)
571
572
        if self.passphrase and not (self.key_files or self.key_material):
573
            raise ValueError('passphrase should accompany private key material')
574
575
        credentials_provided = self.password or self.key_files or self.key_material
576
577
        if not credentials_provided:
578
            msg = ('Either password or key file location or key material should be supplied ' +
579
                   'for action. You can also add an entry for host %s in SSH config file %s.' %
580
                   (host, self.ssh_config_file))
581
            raise ValueError(msg)
582
583
        conninfo['username'] = self.username
584
        conninfo['port'] = self.port
585
586
        if self.password:
587
            conninfo['password'] = self.password
588
589
        if self.key_files:
590
            conninfo['key_filename'] = self.key_files
591
592
            passphrase_reqd = self._is_key_file_needs_passphrase(self.key_files)
593
            if passphrase_reqd and not self.passphrase:
594
                msg = ('Private key file %s is passphrase protected. Supply a passphrase.' %
595
                       self.key_files)
596
                raise paramiko.ssh_exception.PasswordRequiredException(msg)
597
598
            if self.passphrase:
599
                # Optional passphrase for unlocking the private key
600
                conninfo['password'] = self.passphrase
601
602
        if self.key_material:
603
            conninfo['pkey'] = self._get_pkey_object(key_material=self.key_material,
604
                                                     passphrase=self.passphrase)
605
606
        if not self.password and not (self.key_files or self.key_material):
607
            conninfo['allow_agent'] = True
608
            conninfo['look_for_keys'] = True
609
610
        extra = {'_hostname': host, '_port': self.port,
611
                 '_username': self.username, '_timeout': self.timeout}
612
        self.logger.debug('Connecting to server', extra=extra)
613
614
        socket = socket or ssh_config_file_info.get('sock', None)
615
        if socket:
616
            conninfo['sock'] = socket
617
618
        client = paramiko.SSHClient()
619
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
620
621
        extra = {'_conninfo': conninfo}
622
        self.logger.debug('Connection info', extra=extra)
623
        try:
624
            client.connect(**conninfo)
625
        except SSHException as e:
626
            paramiko_msg = e.message
627
628
            if conninfo.get('password', None):
629
                conninfo['password'] = '<redacted>'
630
631
            msg = ('Error connecting to host %s ' % host +
632
                   'with connection parameters %s.' % conninfo +
633
                   'Paramiko error: %s.' % paramiko_msg)
634
            raise SSHException(msg)
635
636
        return client
637
638
    def _get_ssh_config_for_host(self, host):
639
        ssh_config_info = {}
640
        ssh_config_parser = paramiko.SSHConfig()
641
642
        try:
643
            with open(self.ssh_config_file) as f:
644
                ssh_config_parser.parse(f)
645
        except IOError as e:
646
            raise Exception('Error accessing ssh config file %s. Code: %s Reason %s' %
647
                            (self.ssh_config_file, e.errno, e.strerror))
648
649
        ssh_config = ssh_config_parser.lookup(host)
650
        self.logger.info('Parsed SSH config file contents: %s', ssh_config)
651
        if ssh_config:
652
            for k in ('hostname', 'user', 'port'):
653
                if k in ssh_config:
654
                    ssh_config_info[k] = ssh_config[k]
655
656
            if 'identityfile' in ssh_config:
657
                key_file = ssh_config['identityfile']
658
                if type(key_file) is list:
659
                    key_file = key_file[0]
660
661
                ssh_config_info['identityfile'] = key_file
662
663
            if 'proxycommand' in ssh_config:
664
                ssh_config_info['sock'] = paramiko.ProxyCommand(ssh_config['proxycommand'])
665
666
        return ssh_config_info
667
668
    @staticmethod
669
    def _is_key_file_needs_passphrase(file):
670
        for cls in [paramiko.RSAKey, paramiko.DSSKey, paramiko.ECDSAKey]:
671
            try:
672
                cls.from_private_key_file(file, password=None)
673
            except paramiko.ssh_exception.PasswordRequiredException:
674
                return True
675
            except paramiko.ssh_exception.SSHException:
676
                continue
677
678
        return False
679
680
    def __repr__(self):
681
        return ('<ParamikoSSHClient hostname=%s,port=%s,username=%s,id=%s>' %
682
                (self.hostname, self.port, self.username, id(self)))
683