• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pahaz / sshtunnel / de7c8152-75ef-47b6-8dd3-b99923e44a5b

07 Mar 2026 03:22PM UTC coverage: 91.231% (-0.4%) from 91.667%
de7c8152-75ef-47b6-8dd3-b99923e44a5b

Pull #313

circleci

bmos
remove type syntax from test_check_address_string
Pull Request #313: modernized test suite

593 of 650 relevant lines covered (91.23%)

0.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.23
/sshtunnel.py
1
#!/usr/bin/env python
2
# -*- coding: utf-8 -*-
3
"""
1✔
4
*sshtunnel* - Initiate SSH tunnels via a remote gateway.
5

6
``sshtunnel`` works by opening a port forwarding SSH connection in the
7
background, using threads.
8

9
The connection(s) are closed when explicitly calling the
10
:meth:`SSHTunnelForwarder.stop` method or using it as a context.
11

12
"""
13

14
import os
1✔
15
import random
1✔
16
import string
1✔
17
import sys
1✔
18
import socket
1✔
19
import getpass
1✔
20
import logging
1✔
21
import argparse
1✔
22
import warnings
1✔
23
import threading
1✔
24
from select import select
1✔
25
from binascii import hexlify
1✔
26

27
import paramiko
1✔
28

29
if sys.version_info[0] < 3:  # pragma: no cover
30
    import Queue as queue
31
    import SocketServer as socketserver
32
    string_types = basestring,  # noqa
33
    input_ = raw_input  # noqa
34
else:  # pragma: no cover
35
    import queue
36
    import socketserver
37
    string_types = str
38
    input_ = input
39

40

41
__version__ = '0.4.0'
1✔
42
__author__ = 'pahaz'
1✔
43

44

45
#: Timeout (seconds) for transport socket (``socket.settimeout``)
46
SSH_TIMEOUT = 0.1  # ``None`` may cause a block of transport thread
1✔
47
#: Timeout (seconds) for tunnel connection (open_channel timeout)
48
TUNNEL_TIMEOUT = 10.0
1✔
49

50
_DAEMON = True  #: Use daemon threads in connections
1✔
51
_CONNECTION_COUNTER = 1
1✔
52
_DEPRECATIONS = {
1✔
53
    'ssh_address': 'ssh_address_or_host',
54
    'ssh_host': 'ssh_address_or_host',
55
    'ssh_private_key': 'ssh_pkey',
56
    'raise_exception_if_any_forwarder_have_a_problem': 'mute_exceptions'
57
}
58

59
# logging
60
DEFAULT_LOGLEVEL = logging.ERROR  #: default level if no logger passed (ERROR)
1✔
61
TRACE_LEVEL = 1
1✔
62
logging.addLevelName(TRACE_LEVEL, 'TRACE')
1✔
63
DEFAULT_SSH_DIRECTORY = '~/.ssh'
1✔
64

65
_StreamServer = socketserver.UnixStreamServer if os.name == 'posix' \
1✔
66
    else socketserver.TCPServer
67

68
#: Path of optional ssh configuration file
69
DEFAULT_SSH_DIRECTORY = '~/.ssh'
1✔
70
SSH_CONFIG_FILE = os.path.join(DEFAULT_SSH_DIRECTORY, 'config')
1✔
71

72
########################
73
#                      #
74
#       Utils          #
75
#                      #
76
########################
77

78

79
def check_host(host):
1✔
80
    assert isinstance(host, string_types), 'IP is not a string ({0})'.format(
1✔
81
        type(host).__name__
82
    )
83

84

85
def check_port(port):
1✔
86
    assert isinstance(port, int), 'PORT is not a number'
1✔
87
    assert port >= 0, 'PORT < 0 ({0})'.format(port)
1✔
88

89

90
def check_address(address):
1✔
91
    """
92
    Check if the format of the address is correct
93

94
    Arguments:
95
        address (tuple):
96
            (``str``, ``int``) representing an IP address and port,
97
            respectively
98

99
            .. note::
100
                alternatively a local ``address`` can be a ``str`` when working
101
                with UNIX domain sockets, if supported by the platform
102
    Raises:
103
        ValueError:
104
            raised when address has an incorrect format
105

106
    Example:
107
        >>> check_address(('127.0.0.1', 22))
108
    """
109
    if isinstance(address, tuple):
1✔
110
        check_host(address[0])
1✔
111
        check_port(address[1])
1✔
112
    elif isinstance(address, string_types):
1✔
113
        if os.name != 'posix':
1✔
114
            raise ValueError('Platform does not support UNIX domain sockets')
×
115
        if not (os.path.exists(address) or
1✔
116
                os.access(os.path.dirname(address), os.W_OK)):
117
            raise ValueError('ADDRESS not a valid socket domain socket ({0})'
1✔
118
                             .format(address))
119
    else:
120
        raise ValueError('ADDRESS is not a tuple, string, or character buffer '
1✔
121
                         '({0})'.format(type(address).__name__))
122

123

124
def check_addresses(address_list, is_remote=False):
1✔
125
    """
126
    Check if the format of the addresses is correct
127

128
    Arguments:
129
        address_list (list[tuple]):
130
            Sequence of (``str``, ``int``) pairs, each representing an IP
131
            address and port respectively
132

133
            .. note::
134
                when supported by the platform, one or more of the elements in
135
                the list can be of type ``str``, representing a valid UNIX
136
                domain socket
137

138
        is_remote (boolean):
139
            Whether or not the address list
140
    Raises:
141
        AssertionError:
142
            raised when ``address_list`` contains an invalid element
143
        ValueError:
144
            raised when any address in the list has an incorrect format
145

146
    Example:
147

148
        >>> check_addresses([('127.0.0.1', 22), ('127.0.0.1', 2222)])
149
    """
150
    assert all(isinstance(x, (tuple, string_types)) for x in address_list)
1✔
151
    if (is_remote and any(isinstance(x, string_types) for x in address_list)):
1✔
152
        raise AssertionError('UNIX domain sockets not allowed for remote'
1✔
153
                             'addresses')
154

155
    for address in address_list:
1✔
156
        check_address(address)
1✔
157

158

159
def create_logger(logger=None,
1✔
160
                  loglevel=None,
161
                  capture_warnings=True,
162
                  add_paramiko_handler=True):
163
    """
164
    Attach or create a new logger and add a console handler if not present
165

166
    Arguments:
167

168
        logger (Optional[logging.Logger]):
169
            :class:`logging.Logger` instance; a new one is created if this
170
            argument is empty
171

172
        loglevel (Optional[str or int]):
173
            :class:`logging.Logger`'s level, either as a string (i.e.
174
            ``ERROR``) or in numeric format (10 == ``DEBUG``)
175

176
            .. note:: a value of 1 == ``TRACE`` enables Tracing mode
177

178
        capture_warnings (boolean):
179
            Enable/disable capturing the events logged by the warnings module
180
            into ``logger``'s handlers
181

182
            Default: True
183

184
            .. note:: ignored in python 2.6
185

186
        add_paramiko_handler (boolean):
187
            Whether or not add a console handler for ``paramiko.transport``'s
188
            logger if no handler present
189

190
            Default: True
191
    Return:
192
        :class:`logging.Logger`
193
    """
194
    logger = logger or logging.getLogger(
1✔
195
        'sshtunnel.SSHTunnelForwarder'
196
    )
197
    if not any(isinstance(x, logging.Handler) for x in logger.handlers):
1✔
198
        logger.setLevel(loglevel or DEFAULT_LOGLEVEL)
1✔
199
        console_handler = logging.StreamHandler()
1✔
200
        _add_handler(logger,
1✔
201
                     handler=console_handler,
202
                     loglevel=loglevel or DEFAULT_LOGLEVEL)
203
    if loglevel:  # override if loglevel was set
1✔
204
        logger.setLevel(loglevel)
1✔
205
        for handler in logger.handlers:
1✔
206
            handler.setLevel(loglevel)
1✔
207

208
    if add_paramiko_handler:
1✔
209
        _check_paramiko_handlers(logger=logger)
1✔
210

211
    if capture_warnings and sys.version_info >= (2, 7):
1✔
212
        logging.captureWarnings(True)
1✔
213
        pywarnings = logging.getLogger('py.warnings')
1✔
214
        pywarnings.handlers.extend(logger.handlers)
1✔
215
    return logger
1✔
216

217

218
def _add_handler(logger, handler=None, loglevel=None):
1✔
219
    """
220
    Add a handler to an existing logging.Logger object
221
    """
222
    handler.setLevel(loglevel or DEFAULT_LOGLEVEL)
1✔
223
    if handler.level <= logging.DEBUG:
1✔
224
        _fmt = '%(asctime)s| %(levelname)-4.3s|%(threadName)10.9s/' \
1✔
225
               '%(lineno)04d@%(module)-10.9s| %(message)s'
226
        handler.setFormatter(logging.Formatter(_fmt))
1✔
227
    else:
228
        handler.setFormatter(logging.Formatter(
1✔
229
            '%(asctime)s| %(levelname)-8s| %(message)s'
230
        ))
231
    logger.addHandler(handler)
1✔
232

233

234
def _check_paramiko_handlers(logger=None):
1✔
235
    """
236
    Add a console handler for paramiko.transport's logger if not present
237
    """
238
    paramiko_logger = logging.getLogger('paramiko.transport')
1✔
239
    if not paramiko_logger.handlers:
1✔
240
        if logger:
1✔
241
            paramiko_logger.handlers = logger.handlers
1✔
242
        else:
243
            console_handler = logging.StreamHandler()
×
244
            console_handler.setFormatter(
×
245
                logging.Formatter('%(asctime)s | %(levelname)-8s| PARAMIKO: '
246
                                  '%(lineno)03d@%(module)-10s| %(message)s')
247
            )
248
            paramiko_logger.addHandler(console_handler)
×
249

250

251
def address_to_str(address):
1✔
252
    if isinstance(address, tuple):
1✔
253
        return '{0[0]}:{0[1]}'.format(address)
1✔
254
    return str(address)
1✔
255

256

257
def _remove_none_values(dictionary):
1✔
258
    """ Remove dictionary keys whose value is None """
259
    return list(map(dictionary.pop,
1✔
260
                    [i for i in dictionary if dictionary[i] is None]))
261

262

263
def generate_random_string(length):
1✔
264
    letters = string.ascii_letters + string.digits
1✔
265
    return ''.join(random.choice(letters) for _ in range(length))
1✔
266

267
########################
268
#                      #
269
#       Errors         #
270
#                      #
271
########################
272

273

274
class BaseSSHTunnelForwarderError(Exception):
1✔
275
    """ Exception raised by :class:`SSHTunnelForwarder` errors """
276

277
    def __init__(self, *args, **kwargs):
1✔
278
        self.value = kwargs.pop('value', args[0] if args else '')
1✔
279

280
    def __str__(self):
1✔
281
        return self.value
1✔
282

283

284
class HandlerSSHTunnelForwarderError(BaseSSHTunnelForwarderError):
1✔
285
    """ Exception for Tunnel forwarder errors """
286
    pass
1✔
287

288

289
########################
290
#                      #
291
#       Handlers       #
292
#                      #
293
########################
294

295

296
class _ForwardHandler(socketserver.BaseRequestHandler):
1✔
297
    """ Base handler for tunnel connections """
298
    remote_address = None
1✔
299
    ssh_transport = None
1✔
300
    logger = None
1✔
301
    info = None
1✔
302

303
    def _redirect(self, chan):
1✔
304
        while chan.active:
1✔
305
            rqst, _, _ = select([self.request, chan], [], [], 5)
1✔
306
            if self.request in rqst:
1✔
307
                data = self.request.recv(16384)
1✔
308
                if not data:
1✔
309
                    self.logger.log(
1✔
310
                        TRACE_LEVEL,
311
                        '>>> OUT {0} recv empty data >>>'.format(self.info)
312
                    )
313
                    break
1✔
314
                if self.logger.isEnabledFor(TRACE_LEVEL):
1✔
315
                    self.logger.log(
1✔
316
                        TRACE_LEVEL,
317
                        '>>> OUT {0} send to {1}: {2} >>>'.format(
318
                            self.info,
319
                            self.remote_address,
320
                            hexlify(data)
321
                        )
322
                    )
323
                chan.sendall(data)
1✔
324
            if chan in rqst:  # else
1✔
325
                if not chan.recv_ready():
1✔
326
                    self.logger.log(
×
327
                        TRACE_LEVEL,
328
                        '<<< IN {0} recv is not ready <<<'.format(self.info)
329
                    )
330
                    break
×
331
                data = chan.recv(16384)
1✔
332
                if self.logger.isEnabledFor(TRACE_LEVEL):
1✔
333
                    hex_data = hexlify(data)
1✔
334
                    self.logger.log(
1✔
335
                        TRACE_LEVEL,
336
                        '<<< IN {0} recv: {1} <<<'.format(self.info, hex_data)
337
                    )
338
                self.request.sendall(data)
1✔
339

340
    def handle(self):
1✔
341
        uid = generate_random_string(5)
1✔
342
        self.info = '#{0} <-- {1}'.format(uid, self.client_address or
1✔
343
                                          self.server.local_address)
344
        src_address = self.request.getpeername()
1✔
345
        if not isinstance(src_address, tuple):
1✔
346
            src_address = ('dummy', 12345)
×
347
        try:
1✔
348
            chan = self.ssh_transport.open_channel(
1✔
349
                kind='direct-tcpip',
350
                dest_addr=self.remote_address,
351
                src_addr=src_address,
352
                timeout=TUNNEL_TIMEOUT
353
            )
354
        except Exception as e:  # pragma: no cover
355
            msg_tupe = 'ssh ' if isinstance(e, paramiko.SSHException) else ''
356
            exc_msg = 'open new channel {0}error: {1}'.format(msg_tupe, e)
357
            log_msg = '{0} {1}'.format(self.info, exc_msg)
358
            self.logger.log(TRACE_LEVEL, log_msg)
359
            raise HandlerSSHTunnelForwarderError(exc_msg)
360

361
        self.logger.log(TRACE_LEVEL, '{0} connected'.format(self.info))
1✔
362
        try:
1✔
363
            self._redirect(chan)
1✔
364
        except socket.error:
×
365
            # Sometimes a RST is sent and a socket error is raised, treat this
366
            # exception. It was seen that a 3way FIN is processed later on, so
367
            # no need to make an ordered close of the connection here or raise
368
            # the exception beyond this point...
369
            self.logger.log(TRACE_LEVEL, '{0} sending RST'.format(self.info))
×
370
        except Exception as e:
×
371
            self.logger.log(TRACE_LEVEL,
×
372
                            '{0} error: {1}'.format(self.info, repr(e)))
373
        finally:
374
            chan.close()
1✔
375
            self.request.close()
1✔
376
            self.logger.log(TRACE_LEVEL,
1✔
377
                            '{0} connection closed.'.format(self.info))
378

379

380
class _ForwardServer(socketserver.TCPServer):  # Not Threading
1✔
381
    """
382
    Non-threading version of the forward server
383
    """
384
    allow_reuse_address = True  # faster rebinding
1✔
385

386
    def __init__(self, *args, **kwargs):
1✔
387
        logger = kwargs.pop('logger', None)
1✔
388
        self.logger = logger or create_logger()
1✔
389
        self.tunnel_ok = queue.Queue(1)
1✔
390
        socketserver.TCPServer.__init__(self, *args, **kwargs)
1✔
391

392
    def handle_error(self, request, client_address):
1✔
393
        (exc_class, exc, tb) = sys.exc_info()
×
394
        local_side = request.getsockname()
×
395
        remote_side = self.remote_address
×
396
        self.logger.error('Could not establish connection from local {0} '
×
397
                          'to remote {1} side of the tunnel: {2}'
398
                          .format(local_side, remote_side, exc))
399
        try:
×
400
            self.tunnel_ok.put(False, block=False, timeout=0.1)
×
401
        except queue.Full:
×
402
            # wait untill tunnel_ok.get is called
403
            pass
×
404
        except exc:
×
405
            self.logger.error('unexpected internal error: {0}'.format(exc))
×
406

407
    @property
1✔
408
    def local_address(self):
409
        return self.server_address
1✔
410

411
    @property
1✔
412
    def local_host(self):
413
        return self.server_address[0]
1✔
414

415
    @property
1✔
416
    def local_port(self):
417
        return self.server_address[1]
1✔
418

419
    @property
1✔
420
    def remote_address(self):
421
        return self.RequestHandlerClass.remote_address
1✔
422

423
    @property
1✔
424
    def remote_host(self):
425
        return self.RequestHandlerClass.remote_address[0]
×
426

427
    @property
1✔
428
    def remote_port(self):
429
        return self.RequestHandlerClass.remote_address[1]
×
430

431

432
class _ThreadingForwardServer(socketserver.ThreadingMixIn, _ForwardServer):
1✔
433
    """
434
    Allow concurrent connections to each tunnel
435
    """
436
    # If True, cleanly stop threads created by ThreadingMixIn when quitting
437
    # This value is overrides by SSHTunnelForwarder.daemon_forward_servers
438
    daemon_threads = _DAEMON
1✔
439

440

441
class _StreamForwardServer(_StreamServer):
1✔
442
    """
443
    Serve over domain sockets (does not work on Windows)
444
    """
445

446
    def __init__(self, *args, **kwargs):
1✔
447
        logger = kwargs.pop('logger', None)
1✔
448
        self.logger = logger or create_logger()
1✔
449
        self.tunnel_ok = queue.Queue(1)
1✔
450
        _StreamServer.__init__(self, *args, **kwargs)
1✔
451

452
    @property
1✔
453
    def local_address(self):
454
        return self.server_address
1✔
455

456
    @property
1✔
457
    def local_host(self):
458
        return None
×
459

460
    @property
1✔
461
    def local_port(self):
462
        return None
1✔
463

464
    @property
1✔
465
    def remote_address(self):
466
        return self.RequestHandlerClass.remote_address
1✔
467

468
    @property
1✔
469
    def remote_host(self):
470
        return self.RequestHandlerClass.remote_address[0]
×
471

472
    @property
1✔
473
    def remote_port(self):
474
        return self.RequestHandlerClass.remote_address[1]
×
475

476

477
class _ThreadingStreamForwardServer(socketserver.ThreadingMixIn,
1✔
478
                                    _StreamForwardServer):
479
    """
480
    Allow concurrent connections to each tunnel
481
    """
482
    # If True, cleanly stop threads created by ThreadingMixIn when quitting
483
    # This value is overrides by SSHTunnelForwarder.daemon_forward_servers
484
    daemon_threads = _DAEMON
1✔
485

486

487
class SSHTunnelForwarder(object):
1✔
488
    """
489
    **SSH tunnel class**
490

491
        - Initialize a SSH tunnel to a remote host according to the input
492
          arguments
493

494
        - Optionally:
495
            + Read an SSH configuration file (typically ``~/.ssh/config``)
496
            + Load keys from a running SSH agent (i.e. Pageant, GNOME Keyring)
497

498
    Raises:
499

500
        :class:`.BaseSSHTunnelForwarderError`:
501
            raised by SSHTunnelForwarder class methods
502

503
        :class:`.HandlerSSHTunnelForwarderError`:
504
            raised by tunnel forwarder threads
505

506
            .. note::
507
                    Attributes ``mute_exceptions`` and
508
                    ``raise_exception_if_any_forwarder_have_a_problem``
509
                    (deprecated) may be used to silence most exceptions raised
510
                    from this class
511

512
    Keyword Arguments:
513

514
        ssh_address_or_host (tuple or str):
515
            IP or hostname of ``REMOTE GATEWAY``. It may be a two-element
516
            tuple (``str``, ``int``) representing IP and port respectively,
517
            or a ``str`` representing the IP address only
518

519
            .. versionadded:: 0.0.4
520

521
        ssh_config_file (str):
522
            SSH configuration file that will be read. If explicitly set to
523
            ``None``, parsing of this configuration is omitted
524

525
            Default: :const:`SSH_CONFIG_FILE`
526

527
            .. versionadded:: 0.0.4
528

529
        ssh_host_key (str):
530
            Representation of a line in an OpenSSH-style "known hosts"
531
            file.
532

533
            ``REMOTE GATEWAY``'s key fingerprint will be compared to this
534
            host key in order to prevent against SSH server spoofing.
535
            Important when using passwords in order not to accidentally
536
            do a login attempt to a wrong (perhaps an attacker's) machine
537

538
        ssh_username (str):
539
            Username to authenticate as in ``REMOTE SERVER``
540

541
            Default: current local user name
542

543
        ssh_password (str):
544
            Text representing the password used to connect to ``REMOTE
545
            SERVER`` or for unlocking a private key.
546

547
            .. note::
548
                Avoid coding secret password directly in the code, since this
549
                may be visible and make your service vulnerable to attacks
550

551
        ssh_port (int):
552
            Optional port number of the SSH service on ``REMOTE GATEWAY``,
553
            when `ssh_address_or_host`` is a ``str`` representing the
554
            IP part of ``REMOTE GATEWAY``'s address
555

556
            Default: 22
557

558
        ssh_pkey (str or paramiko.PKey):
559
            **Private** key file name (``str``) to obtain the public key
560
            from or a **public** key (:class:`paramiko.pkey.PKey`)
561

562
        ssh_private_key_password (str):
563
            Password for an encrypted ``ssh_pkey``
564

565
            .. note::
566
                Avoid coding secret password directly in the code, since this
567
                may be visible and make your service vulnerable to attacks
568

569
        ssh_proxy (socket-like object or tuple):
570
            Proxy where all SSH traffic will be passed through.
571
            It might be for example a :class:`paramiko.proxy.ProxyCommand`
572
            instance.
573
            See either the :class:`paramiko.transport.Transport`'s sock
574
            parameter documentation or ``ProxyCommand`` in ``ssh_config(5)``
575
            for more information.
576

577
            It is also possible to specify the proxy address as a tuple of
578
            type (``str``, ``int``) representing proxy's IP and port
579

580
            .. note::
581
                Ignored if ``ssh_proxy_enabled`` is False
582

583
            .. versionadded:: 0.0.5
584

585
        ssh_proxy_enabled (boolean):
586
            Enable/disable SSH proxy. If True and user's
587
            ``ssh_config_file`` contains a ``ProxyCommand`` directive
588
            that matches the specified ``ssh_address_or_host``,
589
            a :class:`paramiko.proxy.ProxyCommand` object will be created where
590
            all SSH traffic will be passed through
591

592
            Default: ``True``
593

594
            .. versionadded:: 0.0.4
595

596
        local_bind_address (tuple):
597
            Local tuple in the format (``str``, ``int``) representing the
598
            IP and port of the local side of the tunnel. Both elements in
599
            the tuple are optional so both ``('', 8000)`` and
600
            ``('10.0.0.1', )`` are valid values
601

602
            Default: ``('0.0.0.0', RANDOM_PORT)``
603

604
            .. versionchanged:: 0.0.8
605
                Added the ability to use a UNIX domain socket as local bind
606
                address
607

608
        local_bind_addresses (list[tuple]):
609
            In case more than one tunnel is established at once, a list
610
            of tuples (in the same format as ``local_bind_address``)
611
            can be specified, such as [(ip1, port_1), (ip_2, port2), ...]
612

613
            Default: ``[local_bind_address]``
614

615
            .. versionadded:: 0.0.4
616

617
        remote_bind_address (tuple):
618
            Remote tuple in the format (``str``, ``int``) representing the
619
            IP and port of the remote side of the tunnel.
620

621
        remote_bind_addresses (list[tuple]):
622
            In case more than one tunnel is established at once, a list
623
            of tuples (in the same format as ``remote_bind_address``)
624
            can be specified, such as [(ip1, port_1), (ip_2, port2), ...]
625

626
            Default: ``[remote_bind_address]``
627

628
            .. versionadded:: 0.0.4
629

630
        allow_agent (boolean):
631
            Enable/disable load of keys from an SSH agent
632

633
            Default: ``True``
634

635
            .. versionadded:: 0.0.8
636

637
        host_pkey_directories (list):
638
            Look for pkeys in folders on this list, for example ['~/.ssh'].
639

640
            Default: ``None`` (disabled)
641

642
            .. versionadded:: 0.1.4
643

644
        compression (boolean):
645
            Turn on/off transport compression. By default compression is
646
            disabled since it may negatively affect interactive sessions
647

648
            Default: ``False``
649

650
            .. versionadded:: 0.0.8
651

652
        logger (logging.Logger):
653
            logging instance for sshtunnel and paramiko
654

655
            Default: :class:`logging.Logger` instance with a single
656
            :class:`logging.StreamHandler` handler and
657
            :const:`DEFAULT_LOGLEVEL` level
658

659
            .. versionadded:: 0.0.3
660

661
        mute_exceptions (boolean):
662
            Allow silencing :class:`BaseSSHTunnelForwarderError` or
663
            :class:`HandlerSSHTunnelForwarderError` exceptions when enabled
664

665
            Default: ``False``
666

667
            .. versionadded:: 0.0.8
668

669
        set_keepalive (float):
670
            Interval in seconds defining the period in which, if no data
671
            was sent over the connection, a *'keepalive'* packet will be
672
            sent (and ignored by the remote host). This can be useful to
673
            keep connections alive over a NAT. You can set to 0.0 for
674
            disable keepalive.
675

676
            Default: 5.0 (no keepalive packets are sent)
677

678
            .. versionadded:: 0.0.7
679

680
        threaded (boolean):
681
            Allow concurrent connections over a single tunnel
682

683
            Default: ``True``
684

685
            .. versionadded:: 0.0.3
686

687
        ssh_address (str):
688
            Superseded by ``ssh_address_or_host``, tuple of type (str, int)
689
            representing the IP and port of ``REMOTE SERVER``
690

691
            .. deprecated:: 0.0.4
692

693
        ssh_host (str):
694
            Superseded by ``ssh_address_or_host``, tuple of type
695
            (str, int) representing the IP and port of ``REMOTE SERVER``
696

697
            .. deprecated:: 0.0.4
698

699
        ssh_private_key (str or paramiko.PKey):
700
            Superseded by ``ssh_pkey``, which can represent either a
701
            **private** key file name (``str``) or a **public** key
702
            (:class:`paramiko.pkey.PKey`)
703

704
            .. deprecated:: 0.0.8
705

706
        raise_exception_if_any_forwarder_have_a_problem (boolean):
707
            Allow silencing :class:`BaseSSHTunnelForwarderError` or
708
            :class:`HandlerSSHTunnelForwarderError` exceptions when set to
709
            False
710

711
            Default: ``True``
712

713
            .. versionadded:: 0.0.4
714

715
            .. deprecated:: 0.0.8 (use ``mute_exceptions`` instead)
716

717
    Attributes:
718

719
        tunnel_is_up (dict):
720
            Describe whether or not the other side of the tunnel was reported
721
            to be up (and we must close it) or not (skip shutting down that
722
            tunnel)
723

724
            .. note::
725
                This attribute should not be modified
726

727
            .. note::
728
                When :attr:`.skip_tunnel_checkup` is disabled or the local bind
729
                is a UNIX socket, the value will always be ``True``
730

731
            **Example**::
732

733
                {('127.0.0.1', 55550): True,   # this tunnel is up
734
                 ('127.0.0.1', 55551): False}  # this one isn't
735

736
            where 55550 and 55551 are the local bind ports
737

738
        skip_tunnel_checkup (boolean):
739
            Disable tunnel checkup (default for backwards compatibility).
740

741
            .. versionadded:: 0.1.0
742

743
    """
744
    skip_tunnel_checkup = True
1✔
745
    # This option affects the `ForwardServer` and all his threads
746
    daemon_forward_servers = _DAEMON  #: flag tunnel threads in daemon mode
1✔
747
    # This option affect only `Transport` thread
748
    daemon_transport = _DAEMON  #: flag SSH transport thread in daemon mode
1✔
749

750
    def local_is_up(self, target):
1✔
751
        """
752
        Check if a tunnel is up (remote target's host is reachable on TCP
753
        target's port)
754

755
        Arguments:
756
            target (tuple):
757
                tuple of type (``str``, ``int``) indicating the listen IP
758
                address and port
759
        Return:
760
            boolean
761

762
        .. deprecated:: 0.1.0
763
            Replaced by :meth:`.check_tunnels()` and :attr:`.tunnel_is_up`
764
        """
765
        try:
1✔
766
            check_address(target)
1✔
767
        except ValueError:
1✔
768
            self.logger.warning('Target must be a tuple (IP, port), where IP '
1✔
769
                                'is a string (i.e. "192.168.0.1") and port is '
770
                                'an integer (i.e. 40000). Alternatively '
771
                                'target can be a valid UNIX domain socket.')
772
            return False
1✔
773

774
        self.check_tunnels()
1✔
775
        return self.tunnel_is_up.get(target, True)
1✔
776

777
    def check_tunnels(self):
1✔
778
        """
779
        Check that if all tunnels are established and populates
780
        :attr:`.tunnel_is_up`
781
        """
782
        skip_tunnel_checkup = self.skip_tunnel_checkup
1✔
783
        try:
1✔
784
            # force tunnel check at this point
785
            self.skip_tunnel_checkup = False
1✔
786
            for _srv in self._server_list:
1✔
787
                self._check_tunnel(_srv)
1✔
788
        finally:
789
            self.skip_tunnel_checkup = skip_tunnel_checkup  # roll it back
1✔
790

791
    def _check_tunnel(self, _srv):
1✔
792
        """ Check if tunnel is already established """
793
        if self.skip_tunnel_checkup:
1✔
794
            self.tunnel_is_up[_srv.local_address] = True
1✔
795
            return
1✔
796
        self.logger.info('Checking tunnel to: {0}'.format(_srv.remote_address))
1✔
797
        if isinstance(_srv.local_address, string_types):  # UNIX stream
1✔
798
            s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
×
799
        else:
800
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1✔
801
        s.settimeout(TUNNEL_TIMEOUT)
1✔
802
        try:
1✔
803
            # Windows raises WinError 10049 if trying to connect to 0.0.0.0
804
            connect_to = ('127.0.0.1', _srv.local_port) \
1✔
805
                if _srv.local_host == '0.0.0.0' else _srv.local_address
806
            s.connect(connect_to)
1✔
807
            self.tunnel_is_up[_srv.local_address] = _srv.tunnel_ok.get(
1✔
808
                timeout=TUNNEL_TIMEOUT * 1.1
809
            )
810
            self.logger.debug(
×
811
                'Tunnel to {0} is DOWN'.format(_srv.remote_address)
812
            )
813
        except socket.error:
1✔
814
            self.logger.debug(
1✔
815
                'Tunnel to {0} is DOWN'.format(_srv.remote_address)
816
            )
817
            self.tunnel_is_up[_srv.local_address] = False
1✔
818

819
        except queue.Empty:
1✔
820
            self.logger.debug(
1✔
821
                'Tunnel to {0} is UP'.format(_srv.remote_address)
822
            )
823
            self.tunnel_is_up[_srv.local_address] = True
1✔
824
        finally:
825
            s.close()
1✔
826

827
    def _make_ssh_forward_handler_class(self, remote_address_):
1✔
828
        """
829
        Make SSH Handler class
830
        """
831
        class Handler(_ForwardHandler):
1✔
832
            remote_address = remote_address_
1✔
833
            ssh_transport = self._transport
1✔
834
            logger = self.logger
1✔
835
        return Handler
1✔
836

837
    def _make_ssh_forward_server_class(self, remote_address_):
1✔
838
        return _ThreadingForwardServer if self._threaded else _ForwardServer
1✔
839

840
    def _make_stream_ssh_forward_server_class(self, remote_address_):
1✔
841
        return _ThreadingStreamForwardServer if self._threaded \
1✔
842
            else _StreamForwardServer
843

844
    def _make_ssh_forward_server(self, remote_address, local_bind_address):
1✔
845
        """
846
        Make SSH forward proxy Server class
847
        """
848
        _Handler = self._make_ssh_forward_handler_class(remote_address)
1✔
849
        try:
1✔
850
            forward_maker_class = self._make_stream_ssh_forward_server_class \
1✔
851
                if isinstance(local_bind_address, string_types) \
852
                else self._make_ssh_forward_server_class
853
            _Server = forward_maker_class(remote_address)
1✔
854
            ssh_forward_server = _Server(
1✔
855
                local_bind_address,
856
                _Handler,
857
                logger=self.logger,
858
            )
859

860
            if ssh_forward_server:
1✔
861
                ssh_forward_server.daemon_threads = self.daemon_forward_servers
1✔
862
                self._server_list.append(ssh_forward_server)
1✔
863
                self.tunnel_is_up[ssh_forward_server.server_address] = False
1✔
864
            else:
865
                self._raise(
×
866
                    BaseSSHTunnelForwarderError,
867
                    'Problem setting up ssh {0} <> {1} forwarder. You can '
868
                    'suppress this exception by using the `mute_exceptions`'
869
                    'argument'.format(address_to_str(local_bind_address),
870
                                      address_to_str(remote_address))
871
                )
872
        except IOError:
×
873
            self._raise(
×
874
                BaseSSHTunnelForwarderError,
875
                "Couldn't open tunnel {0} <> {1} might be in use or "
876
                "destination not reachable".format(
877
                    address_to_str(local_bind_address),
878
                    address_to_str(remote_address)
879
                )
880
            )
881

882
    def __init__(
1✔
883
            self,
884
            ssh_address_or_host=None,
885
            ssh_config_file=SSH_CONFIG_FILE,
886
            ssh_host_key=None,
887
            ssh_password=None,
888
            ssh_pkey=None,
889
            ssh_private_key_password=None,
890
            ssh_proxy=None,
891
            ssh_proxy_enabled=True,
892
            ssh_username=None,
893
            local_bind_address=None,
894
            local_bind_addresses=None,
895
            logger=None,
896
            mute_exceptions=False,
897
            remote_bind_address=None,
898
            remote_bind_addresses=None,
899
            set_keepalive=5.0,
900
            threaded=True,  # old version False
901
            compression=None,
902
            allow_agent=True,  # look for keys from an SSH agent
903
            host_pkey_directories=None,  # look for keys in ~/.ssh
904
            *args,
905
            **kwargs  # for backwards compatibility
906
    ):
907
        self.logger = logger or create_logger()
1✔
908

909
        self.ssh_host_key = ssh_host_key
1✔
910
        self.set_keepalive = set_keepalive
1✔
911
        self._server_list = []  # reset server list
1✔
912
        self.tunnel_is_up = {}  # handle tunnel status
1✔
913
        self._threaded = threaded
1✔
914
        self.is_alive = False
1✔
915
        # Check if deprecated arguments ssh_address or ssh_host were used
916
        for deprecated_argument in ['ssh_address', 'ssh_host']:
1✔
917
            ssh_address_or_host = self._process_deprecated(ssh_address_or_host,
1✔
918
                                                           deprecated_argument,
919
                                                           kwargs)
920
        # other deprecated arguments
921
        ssh_pkey = self._process_deprecated(ssh_pkey,
1✔
922
                                            'ssh_private_key',
923
                                            kwargs)
924

925
        self._raise_fwd_exc = self._process_deprecated(
1✔
926
            None,
927
            'raise_exception_if_any_forwarder_have_a_problem',
928
            kwargs) or not mute_exceptions
929

930
        if isinstance(ssh_address_or_host, tuple):
1✔
931
            check_address(ssh_address_or_host)
1✔
932
            (ssh_host, ssh_port) = ssh_address_or_host
1✔
933
        else:
934
            ssh_host = ssh_address_or_host
1✔
935
            ssh_port = kwargs.pop('ssh_port', None)
1✔
936

937
        if kwargs:
1✔
938
            raise ValueError('Unknown arguments: {0}'.format(kwargs))
1✔
939

940
        # remote binds
941
        self._remote_binds = self._get_binds(remote_bind_address,
1✔
942
                                             remote_bind_addresses,
943
                                             is_remote=True)
944
        # local binds
945
        self._local_binds = self._get_binds(local_bind_address,
1✔
946
                                            local_bind_addresses)
947
        self._local_binds = self._consolidate_binds(self._local_binds,
1✔
948
                                                    self._remote_binds)
949

950
        (self.ssh_host,
1✔
951
         self.ssh_username,
952
         ssh_pkey,  # still needs to go through _consolidate_auth
953
         self.ssh_port,
954
         self.ssh_proxy,
955
         self.compression) = self._read_ssh_config(
956
             ssh_host,
957
             ssh_config_file,
958
             ssh_username,
959
             ssh_pkey,
960
             ssh_port,
961
             ssh_proxy if ssh_proxy_enabled else None,
962
             compression,
963
             self.logger
964
        )
965

966
        (self.ssh_password, self.ssh_pkeys) = self._consolidate_auth(
1✔
967
            ssh_password=ssh_password,
968
            ssh_pkey=ssh_pkey,
969
            ssh_pkey_password=ssh_private_key_password,
970
            allow_agent=allow_agent,
971
            host_pkey_directories=host_pkey_directories,
972
            logger=self.logger
973
        )
974

975
        check_host(self.ssh_host)
1✔
976
        check_port(self.ssh_port)
1✔
977

978
        self.logger.info("Connecting to gateway: {0}:{1} as user '{2}'"
1✔
979
                         .format(self.ssh_host,
980
                                 self.ssh_port,
981
                                 self.ssh_username))
982

983
        self.logger.debug('Concurrent connections allowed: {0}'
1✔
984
                          .format(self._threaded))
985

986
    @staticmethod
1✔
987
    def _read_ssh_config(ssh_host,
1✔
988
                         ssh_config_file,
989
                         ssh_username=None,
990
                         ssh_pkey=None,
991
                         ssh_port=None,
992
                         ssh_proxy=None,
993
                         compression=None,
994
                         logger=None):
995
        """
996
        Read ssh_config_file and tries to look for user (ssh_username),
997
        identityfile (ssh_pkey), port (ssh_port) and proxycommand
998
        (ssh_proxy) entries for ssh_host
999
        """
1000
        ssh_config = paramiko.SSHConfig()
1✔
1001
        if not ssh_config_file:  # handle case where it's an empty string
1✔
1002
            ssh_config_file = None
1✔
1003

1004
        # Try to read SSH_CONFIG_FILE
1005
        try:
1✔
1006
            # open the ssh config file
1007
            with open(os.path.expanduser(ssh_config_file), 'r') as f:
1✔
1008
                ssh_config.parse(f)
1✔
1009
            # looks for information for the destination system
1010
            hostname_info = ssh_config.lookup(ssh_host)
1✔
1011
            # gather settings for user, port and identity file
1012
            # last resort: use the 'login name' of the user
1013
            ssh_username = (
1✔
1014
                ssh_username or
1015
                hostname_info.get('user')
1016
            )
1017
            ssh_pkey = (
1✔
1018
                ssh_pkey or
1019
                hostname_info.get('identityfile', [None])[0]
1020
            )
1021
            ssh_host = hostname_info.get('hostname')
1✔
1022
            ssh_port = ssh_port or hostname_info.get('port')
1✔
1023

1024
            proxycommand = hostname_info.get('proxycommand')
1✔
1025
            ssh_proxy = ssh_proxy or (paramiko.ProxyCommand(proxycommand) if
1✔
1026
                                      proxycommand else None)
1027
            if compression is None:
1✔
1028
                compression = hostname_info.get('compression', '')
1✔
1029
                compression = True if compression.upper() == 'YES' else False
1✔
1030
        except IOError:
1✔
1031
            if logger:
1✔
1032
                logger.warning(
1✔
1033
                    'Could not read SSH configuration file: {0}'
1034
                    .format(ssh_config_file)
1035
                )
1036
        except (AttributeError, TypeError):  # ssh_config_file is None
1✔
1037
            if logger:
1✔
1038
                logger.info('Skipping loading of ssh configuration file')
1✔
1039
        finally:
1040
            return (ssh_host,
1✔
1041
                    ssh_username or getpass.getuser(),
1042
                    ssh_pkey,
1043
                    int(ssh_port) if ssh_port else 22,  # fallback value
1044
                    ssh_proxy,
1045
                    compression)
1046

1047
    @staticmethod
1✔
1048
    def get_agent_keys(logger=None):
1✔
1049
        """ Load public keys from any available SSH agent
1050

1051
        Arguments:
1052
            logger (Optional[logging.Logger])
1053

1054
        Return:
1055
            list
1056
        """
1057
        paramiko_agent = paramiko.Agent()
1✔
1058
        agent_keys = paramiko_agent.get_keys()
1✔
1059
        if logger:
1✔
1060
            logger.info('{0} keys loaded from agent'.format(len(agent_keys)))
1✔
1061
        return list(agent_keys)
1✔
1062

1063
    @staticmethod
1✔
1064
    def get_keys(logger=None, host_pkey_directories=None, allow_agent=False):
1✔
1065
        """
1066
        Load public keys from any available SSH agent or local
1067
        .ssh directory.
1068

1069
        Arguments:
1070
            logger (Optional[logging.Logger])
1071

1072
            host_pkey_directories (Optional[list[str]]):
1073
                List of local directories where host SSH pkeys in the format
1074
                "id_*" are searched. For example, ['~/.ssh']
1075

1076
                .. versionadded:: 0.1.0
1077

1078
            allow_agent (Optional[boolean]):
1079
                Whether or not load keys from agent
1080

1081
                Default: False
1082

1083
        Return:
1084
            list
1085
        """
1086
        keys = SSHTunnelForwarder.get_agent_keys(logger=logger) \
1✔
1087
            if allow_agent else []
1088

1089
        if host_pkey_directories is None:
1✔
1090
            host_pkey_directories = [DEFAULT_SSH_DIRECTORY]
1✔
1091

1092
        paramiko_key_types = {'rsa': paramiko.RSAKey,
1✔
1093
                              'dsa': paramiko.DSSKey,
1094
                              'ecdsa': paramiko.ECDSAKey}
1095
        if hasattr(paramiko, 'Ed25519Key'):
1✔
1096
            # NOQA: new in paramiko>=2.2: http://docs.paramiko.org/en/stable/api/keys.html#module-paramiko.ed25519key
1097
            paramiko_key_types['ed25519'] = paramiko.Ed25519Key
1✔
1098
        for directory in host_pkey_directories:
1✔
1099
            for keytype in paramiko_key_types.keys():
1✔
1100
                ssh_pkey_expanded = os.path.expanduser(
1✔
1101
                    os.path.join(directory, 'id_{}'.format(keytype))
1102
                )
1103
                try:
1✔
1104
                    if os.path.isfile(ssh_pkey_expanded):
1✔
1105
                        ssh_pkey = SSHTunnelForwarder.read_private_key_file(
1✔
1106
                            pkey_file=ssh_pkey_expanded,
1107
                            logger=logger,
1108
                            key_type=paramiko_key_types[keytype]
1109
                        )
1110
                        if ssh_pkey:
1✔
1111
                            keys.append(ssh_pkey)
1✔
1112
                except OSError as exc:
×
1113
                    if logger:
×
1114
                        logger.warning('Private key file {0} check error: {1}'
×
1115
                                       .format(ssh_pkey_expanded, exc))
1116
        if logger:
1✔
1117
            logger.info('{0} key(s) loaded'.format(len(keys)))
1✔
1118
        return keys
1✔
1119

1120
    @staticmethod
1✔
1121
    def _consolidate_binds(local_binds, remote_binds):
1122
        """
1123
        Fill local_binds with defaults when no value/s were specified,
1124
        leaving paramiko to decide in which local port the tunnel will be open
1125
        """
1126
        count = len(remote_binds) - len(local_binds)
1✔
1127
        if count < 0:
1✔
1128
            raise ValueError('Too many local bind addresses '
1✔
1129
                             '(local_bind_addresses > remote_bind_addresses)')
1130
        local_binds.extend([('0.0.0.0', 0) for x in range(count)])
1✔
1131
        return local_binds
1✔
1132

1133
    @staticmethod
1✔
1134
    def _consolidate_auth(ssh_password=None,
1✔
1135
                          ssh_pkey=None,
1136
                          ssh_pkey_password=None,
1137
                          allow_agent=True,
1138
                          host_pkey_directories=None,
1139
                          logger=None):
1140
        """
1141
        Get sure authentication information is in place.
1142
        ``ssh_pkey`` may be of classes:
1143
            - ``str`` - in this case it represents a private key file; public
1144
            key will be obtained from it
1145
            - ``paramiko.Pkey`` - it will be transparently added to loaded keys
1146

1147
        """
1148
        ssh_loaded_pkeys = SSHTunnelForwarder.get_keys(
1✔
1149
            logger=logger,
1150
            host_pkey_directories=host_pkey_directories,
1151
            allow_agent=allow_agent
1152
        )
1153

1154
        if isinstance(ssh_pkey, string_types):
1✔
1155
            ssh_pkey_expanded = os.path.expanduser(ssh_pkey)
1✔
1156
            if os.path.exists(ssh_pkey_expanded):
1✔
1157
                ssh_pkey = SSHTunnelForwarder.read_private_key_file(
1✔
1158
                    pkey_file=ssh_pkey_expanded,
1159
                    pkey_password=ssh_pkey_password or ssh_password,
1160
                    logger=logger
1161
                )
1162
            elif logger:
1✔
1163
                logger.warning('Private key file not found: {0}'
1✔
1164
                               .format(ssh_pkey))
1165
        if isinstance(ssh_pkey, paramiko.pkey.PKey):
1✔
1166
            ssh_loaded_pkeys.insert(0, ssh_pkey)
1✔
1167

1168
        if not ssh_password and not ssh_loaded_pkeys:
1✔
1169
            raise ValueError('No password or public key available!')
1✔
1170
        return (ssh_password, ssh_loaded_pkeys)
1✔
1171

1172
    def _raise(self, exception=BaseSSHTunnelForwarderError, reason=None):
1✔
1173
        if self._raise_fwd_exc:
1✔
1174
            raise exception(reason)
1✔
1175
        else:
1176
            self.logger.error(repr(exception(reason)))
1✔
1177

1178
    def _get_transport(self):
1✔
1179
        """ Return the SSH transport to the remote gateway """
1180
        if self.ssh_proxy:
1✔
1181
            if isinstance(self.ssh_proxy, paramiko.proxy.ProxyCommand):
×
1182
                proxy_repr = repr(self.ssh_proxy.cmd[1])
×
1183
            else:
1184
                proxy_repr = repr(self.ssh_proxy)
×
1185
            self.logger.debug('Connecting via proxy: {0}'.format(proxy_repr))
×
1186
            _socket = self.ssh_proxy
×
1187
        else:
1188
            _socket = (self.ssh_host, self.ssh_port)
1✔
1189
        if isinstance(_socket, socket.socket):
1✔
1190
            _socket.settimeout(SSH_TIMEOUT)
×
1191
            _socket.connect((self.ssh_host, self.ssh_port))
×
1192
        transport = paramiko.Transport(_socket)
1✔
1193
        sock = transport.sock
1✔
1194
        if isinstance(sock, socket.socket):
1✔
1195
            sock.settimeout(SSH_TIMEOUT)
1✔
1196
        transport.set_keepalive(self.set_keepalive)
1✔
1197
        transport.use_compression(compress=self.compression)
1✔
1198
        transport.daemon = self.daemon_transport
1✔
1199
        # try to solve https://github.com/paramiko/paramiko/issues/1181
1200
        # transport.banner_timeout = 200
1201
        if isinstance(sock, socket.socket):
1✔
1202
            sock_timeout = sock.gettimeout()
1✔
1203
            sock_info = repr((sock.family, sock.type, sock.proto))
1✔
1204
            self.logger.debug('Transport socket info: {0}, timeout={1}'
1✔
1205
                              .format(sock_info, sock_timeout))
1206
        return transport
1✔
1207

1208
    def _create_tunnels(self):
1✔
1209
        """
1210
        Create SSH tunnels on top of a transport to the remote gateway
1211
        """
1212
        if not self.is_active:
1✔
1213
            try:
1✔
1214
                self._connect_to_gateway()
1✔
1215
            except socket.gaierror:  # raised by paramiko.Transport
1✔
1216
                msg = 'Could not resolve IP address for {0}, aborting!' \
1✔
1217
                    .format(self.ssh_host)
1218
                self.logger.error(msg)
1✔
1219
                return
1✔
1220
            except (paramiko.SSHException, socket.error) as e:
1✔
1221
                template = 'Could not connect to gateway {0}:{1} : {2}'
1✔
1222
                msg = template.format(self.ssh_host, self.ssh_port, e.args[0])
1✔
1223
                self.logger.error(msg)
1✔
1224
                return
1✔
1225
        for (rem, loc) in zip(self._remote_binds, self._local_binds):
1✔
1226
            try:
1✔
1227
                self._make_ssh_forward_server(rem, loc)
1✔
1228
            except BaseSSHTunnelForwarderError as e:
×
1229
                msg = 'Problem setting SSH Forwarder up: {0}'.format(e.value)
×
1230
                self.logger.error(msg)
×
1231

1232
    @staticmethod
1✔
1233
    def _get_binds(bind_address, bind_addresses, is_remote=False):
1✔
1234
        addr_kind = 'remote' if is_remote else 'local'
1✔
1235

1236
        if not bind_address and not bind_addresses:
1✔
1237
            if is_remote:
1✔
1238
                raise ValueError("No {0} bind addresses specified. Use "
1✔
1239
                                 "'{0}_bind_address' or '{0}_bind_addresses'"
1240
                                 " argument".format(addr_kind))
1241
            else:
1242
                return []
1✔
1243
        elif bind_address and bind_addresses:
1✔
1244
            raise ValueError("You can't use both '{0}_bind_address' and "
1✔
1245
                             "'{0}_bind_addresses' arguments. Use one of "
1246
                             "them.".format(addr_kind))
1247
        if bind_address:
1✔
1248
            bind_addresses = [bind_address]
1✔
1249
        if not is_remote:
1✔
1250
            # Add random port if missing in local bind
1251
            for (i, local_bind) in enumerate(bind_addresses):
1✔
1252
                if isinstance(local_bind, tuple) and len(local_bind) == 1:
1✔
1253
                    bind_addresses[i] = (local_bind[0], 0)
1✔
1254
        check_addresses(bind_addresses, is_remote)
1✔
1255
        return bind_addresses
1✔
1256

1257
    @staticmethod
1✔
1258
    def _process_deprecated(attrib, deprecated_attrib, kwargs):
1259
        """
1260
        Processes optional deprecate arguments
1261
        """
1262
        if deprecated_attrib not in _DEPRECATIONS:
1✔
1263
            raise ValueError('{0} not included in deprecations list'
1✔
1264
                             .format(deprecated_attrib))
1265
        if deprecated_attrib in kwargs:
1✔
1266
            warnings.warn("'{0}' is DEPRECATED use '{1}' instead"
1✔
1267
                          .format(deprecated_attrib,
1268
                                  _DEPRECATIONS[deprecated_attrib]),
1269
                          DeprecationWarning)
1270
            if attrib:
1✔
1271
                raise ValueError("You can't use both '{0}' and '{1}'. "
1✔
1272
                                 "Please only use one of them"
1273
                                 .format(deprecated_attrib,
1274
                                         _DEPRECATIONS[deprecated_attrib]))
1275
            else:
1276
                return kwargs.pop(deprecated_attrib)
1✔
1277
        return attrib
1✔
1278

1279
    @staticmethod
1✔
1280
    def read_private_key_file(pkey_file,
1✔
1281
                              pkey_password=None,
1282
                              key_type=None,
1283
                              logger=None):
1284
        """
1285
        Get SSH Public key from a private key file, given an optional password
1286

1287
        Arguments:
1288
            pkey_file (str):
1289
                File containing a private key (RSA, DSS or ECDSA)
1290
        Keyword Arguments:
1291
            pkey_password (Optional[str]):
1292
                Password to decrypt the private key
1293
            logger (Optional[logging.Logger])
1294
        Return:
1295
            paramiko.Pkey
1296
        """
1297
        ssh_pkey = None
1✔
1298
        key_types = (paramiko.RSAKey, paramiko.DSSKey, paramiko.ECDSAKey)
1✔
1299
        if hasattr(paramiko, 'Ed25519Key'):
1✔
1300
            # NOQA: new in paramiko>=2.2: http://docs.paramiko.org/en/stable/api/keys.html#module-paramiko.ed25519key
1301
            key_types += (paramiko.Ed25519Key, )
1✔
1302
        for pkey_class in (key_type,) if key_type else key_types:
1✔
1303
            try:
1✔
1304
                ssh_pkey = pkey_class.from_private_key_file(
1✔
1305
                    pkey_file,
1306
                    password=pkey_password
1307
                )
1308
                if logger:
1✔
1309
                    logger.debug('Private key file ({0}, {1}) successfully '
1✔
1310
                                 'loaded'.format(pkey_file, pkey_class))
1311
                break
1✔
1312
            except paramiko.PasswordRequiredException:
1✔
1313
                if logger:
1✔
1314
                    logger.error('Password is required for key {0}'
1✔
1315
                                 .format(pkey_file))
1316
                break
1✔
1317
            except paramiko.SSHException:
1✔
1318
                if logger:
1✔
1319
                    logger.debug('Private key file ({0}) could not be loaded '
1✔
1320
                                 'as type {1} or bad password'
1321
                                 .format(pkey_file, pkey_class))
1322
        return ssh_pkey
1✔
1323

1324
    def start(self):
1✔
1325
        """ Start the SSH tunnels """
1326
        if self.is_alive:
1✔
1327
            self.logger.warning('Already started!')
1✔
1328
            return
1✔
1329
        self._create_tunnels()
1✔
1330
        if not self.is_active:
1✔
1331
            self._raise(BaseSSHTunnelForwarderError,
1✔
1332
                        reason='Could not establish session to SSH gateway')
1333
        for _srv in self._server_list:
1✔
1334
            thread = threading.Thread(
1✔
1335
                target=self._serve_forever_wrapper,
1336
                args=(_srv, ),
1337
                name='Srv-{0}'.format(address_to_str(_srv.local_port))
1338
            )
1339
            thread.daemon = self.daemon_forward_servers
1✔
1340
            thread.start()
1✔
1341
            self._check_tunnel(_srv)
1✔
1342
        self.is_alive = any(self.tunnel_is_up.values())
1✔
1343
        if not self.is_alive:
1✔
1344
            self._raise(HandlerSSHTunnelForwarderError,
×
1345
                        'An error occurred while opening tunnels.')
1346

1347
    def stop(self, force=False):
1✔
1348
        """
1349
        Shut the tunnel down. By default we are always waiting until closing
1350
        all connections. You can use `force=True` to force close connections
1351

1352
        Keyword Arguments:
1353
            force (bool):
1354
                Force close current connections
1355

1356
                Default: False
1357

1358
                .. versionadded:: 0.2.2
1359

1360
        .. note:: This **had** to be handled with care before ``0.1.0``:
1361

1362
            - if a port redirection is opened
1363
            - the destination is not reachable
1364
            - we attempt a connection to that tunnel (``SYN`` is sent and
1365
              acknowledged, then a ``FIN`` packet is sent and never
1366
              acknowledged... weird)
1367
            - we try to shutdown: it will not succeed until ``FIN_WAIT_2`` and
1368
              ``CLOSE_WAIT`` time out.
1369

1370
        .. note::
1371
            Handle these scenarios with :attr:`.tunnel_is_up`: if False, server
1372
            ``shutdown()`` will be skipped on that tunnel
1373
        """
1374
        self.logger.info('Closing all open connections...')
1✔
1375
        opened_address_text = ', '.join(
1✔
1376
            (address_to_str(k.local_address) for k in self._server_list)
1377
        ) or 'None'
1378
        self.logger.debug('Listening tunnels: ' + opened_address_text)
1✔
1379
        self._stop_transport(force=force)
1✔
1380
        self._server_list = []  # reset server list
1✔
1381
        self.tunnel_is_up = {}  # reset tunnel status
1✔
1382

1383
    def close(self):
1✔
1384
        """ Stop the an active tunnel, alias to :meth:`.stop` """
1385
        self.stop()
×
1386

1387
    def restart(self):
1✔
1388
        """ Restart connection to the gateway and tunnels """
1389
        self.stop()
×
1390
        self.start()
×
1391

1392
    def _connect_to_gateway(self):
1✔
1393
        """
1394
        Open connection to SSH gateway
1395
         - First try with all keys loaded from an SSH agent (if allowed)
1396
         - Then with those passed directly or read from ~/.ssh/config
1397
         - As last resort, try with a provided password
1398
        """
1399
        for key in self.ssh_pkeys:
1✔
1400
            self.logger.debug('Trying to log in with key: {0}'
1✔
1401
                              .format(hexlify(key.get_fingerprint())))
1402
            try:
1✔
1403
                self._transport = self._get_transport()
1✔
1404
                self._transport.connect(hostkey=self.ssh_host_key,
1✔
1405
                                        username=self.ssh_username,
1406
                                        pkey=key)
1407
                if self._transport.is_alive:
1✔
1408
                    return
1✔
1409
            except paramiko.AuthenticationException:
×
1410
                self.logger.debug('Authentication error')
×
1411
                self._stop_transport()
×
1412

1413
        if self.ssh_password:  # avoid conflict using both pass and pkey
1✔
1414
            self.logger.debug('Trying to log in with password: {0}'
1✔
1415
                              .format('*' * len(self.ssh_password)))
1416
            try:
1✔
1417
                self._transport = self._get_transport()
1✔
1418
                self._transport.connect(hostkey=self.ssh_host_key,
1✔
1419
                                        username=self.ssh_username,
1420
                                        password=self.ssh_password)
1421
                if self._transport.is_alive:
1✔
1422
                    return
1✔
1423
            except paramiko.AuthenticationException:
1✔
1424
                self.logger.debug('Authentication error')
1✔
1425
                self._stop_transport()
1✔
1426

1427
        self.logger.error('Could not open connection to gateway')
1✔
1428

1429
    def _serve_forever_wrapper(self, _srv, poll_interval=0.1):
1✔
1430
        """
1431
        Wrapper for the server created for a SSH forward
1432
        """
1433
        self.logger.info('Opening tunnel: {0} <> {1}'.format(
1✔
1434
            address_to_str(_srv.local_address),
1435
            address_to_str(_srv.remote_address))
1436
        )
1437
        _srv.serve_forever(poll_interval)  # blocks until finished
1✔
1438

1439
        self.logger.info('Tunnel: {0} <> {1} released'.format(
1✔
1440
            address_to_str(_srv.local_address),
1441
            address_to_str(_srv.remote_address))
1442
        )
1443

1444
    def _stop_transport(self, force=False):
1✔
1445
        """ Close the underlying transport when nothing more is needed """
1446
        try:
1✔
1447
            self._check_is_started()
1✔
1448
        except (BaseSSHTunnelForwarderError,
1✔
1449
                HandlerSSHTunnelForwarderError) as e:
1450
            self.logger.warning(e)
1✔
1451
        if force and self.is_active:
1✔
1452
            # don't wait connections
1453
            self.logger.info('Closing ssh transport')
1✔
1454
            self._transport.close()
1✔
1455
            self._transport.stop_thread()
1✔
1456
        for _srv in self._server_list:
1✔
1457
            status = 'up' if self.tunnel_is_up[_srv.local_address] else 'down'
1✔
1458
            self.logger.info('Shutting down tunnel: {0} <> {1} ({2})'.format(
1✔
1459
                address_to_str(_srv.local_address),
1460
                address_to_str(_srv.remote_address),
1461
                status
1462
            ))
1463
            _srv.shutdown()
1✔
1464
            _srv.server_close()
1✔
1465
            # clean up the UNIX domain socket if we're using one
1466
            if isinstance(_srv, _StreamForwardServer):
1✔
1467
                try:
1✔
1468
                    os.unlink(_srv.local_address)
1✔
1469
                except Exception as e:
×
1470
                    self.logger.error('Unable to unlink socket {0}: {1}'
×
1471
                                      .format(_srv.local_address, repr(e)))
1472
        self.is_alive = False
1✔
1473
        if self.is_active:
1✔
1474
            self.logger.info('Closing ssh transport')
1✔
1475
            self._transport.close()
1✔
1476
            self._transport.stop_thread()
1✔
1477
        self.logger.debug('Transport is closed')
1✔
1478

1479
    @property
1✔
1480
    def local_bind_port(self):
1481
        # BACKWARDS COMPATIBILITY
1482
        self._check_is_started()
1✔
1483
        if len(self._server_list) != 1:
1✔
1484
            raise BaseSSHTunnelForwarderError(
1✔
1485
                'Use .local_bind_ports property for more than one tunnel'
1486
            )
1487
        return self.local_bind_ports[0]
1✔
1488

1489
    @property
1✔
1490
    def local_bind_host(self):
1491
        # BACKWARDS COMPATIBILITY
1492
        self._check_is_started()
1✔
1493
        if len(self._server_list) != 1:
1✔
1494
            raise BaseSSHTunnelForwarderError(
1✔
1495
                'Use .local_bind_hosts property for more than one tunnel'
1496
            )
1497
        return self.local_bind_hosts[0]
1✔
1498

1499
    @property
1✔
1500
    def local_bind_address(self):
1501
        # BACKWARDS COMPATIBILITY
1502
        self._check_is_started()
1✔
1503
        if len(self._server_list) != 1:
1✔
1504
            raise BaseSSHTunnelForwarderError(
1✔
1505
                'Use .local_bind_addresses property for more than one tunnel'
1506
            )
1507
        return self.local_bind_addresses[0]
1✔
1508

1509
    @property
1✔
1510
    def local_bind_ports(self):
1511
        """
1512
        Return a list containing the ports of local side of the TCP tunnels
1513
        """
1514
        self._check_is_started()
1✔
1515
        return [_server.local_port for _server in self._server_list if
1✔
1516
                _server.local_port is not None]
1517

1518
    @property
1✔
1519
    def local_bind_hosts(self):
1520
        """
1521
        Return a list containing the IP addresses listening for the tunnels
1522
        """
1523
        self._check_is_started()
1✔
1524
        return [_server.local_host for _server in self._server_list if
1✔
1525
                _server.local_host is not None]
1526

1527
    @property
1✔
1528
    def local_bind_addresses(self):
1529
        """
1530
        Return a list of (IP, port) pairs for the local side of the tunnels
1531
        """
1532
        self._check_is_started()
1✔
1533
        return [_server.local_address for _server in self._server_list]
1✔
1534

1535
    @property
1✔
1536
    def tunnel_bindings(self):
1537
        """
1538
        Return a dictionary containing the active local<>remote tunnel_bindings
1539
        """
1540
        return dict((_server.remote_address, _server.local_address) for
1✔
1541
                    _server in self._server_list if
1542
                    self.tunnel_is_up[_server.local_address])
1543

1544
    @property
1✔
1545
    def is_active(self):
1546
        """ Return True if the underlying SSH transport is up """
1547
        if (
1✔
1548
            '_transport' in self.__dict__ and
1549
            self._transport.is_active()
1550
        ):
1551
            return True
1✔
1552
        return False
1✔
1553

1554
    def _check_is_started(self):
1✔
1555
        if not self.is_active:  # underlying transport not alive
1✔
1556
            msg = 'Server is not started. Please .start() first!'
1✔
1557
            raise BaseSSHTunnelForwarderError(msg)
1✔
1558
        if not self.is_alive:
1✔
1559
            msg = 'Tunnels are not started. Please .start() first!'
1✔
1560
            raise HandlerSSHTunnelForwarderError(msg)
1✔
1561

1562
    def __str__(self):
1✔
1563
        credentials = {
1✔
1564
            'password': self.ssh_password,
1565
            'pkeys': [(key.get_name(), hexlify(key.get_fingerprint()))
1566
                      for key in self.ssh_pkeys]
1567
            if any(self.ssh_pkeys) else None
1568
        }
1569
        _remove_none_values(credentials)
1✔
1570
        template = os.linesep.join(['{0} object',
1✔
1571
                                    'ssh gateway: {1}:{2}',
1572
                                    'proxy: {3}',
1573
                                    'username: {4}',
1574
                                    'authentication: {5}',
1575
                                    'hostkey: {6}',
1576
                                    'status: {7}started',
1577
                                    'keepalive messages: {8}',
1578
                                    'tunnel connection check: {9}',
1579
                                    'concurrent connections: {10}allowed',
1580
                                    'compression: {11}requested',
1581
                                    'logging level: {12}',
1582
                                    'local binds: {13}',
1583
                                    'remote binds: {14}'])
1584
        return (template.format(
1✔
1585
            self.__class__,
1586
            self.ssh_host,
1587
            self.ssh_port,
1588
            self.ssh_proxy.cmd[1] if self.ssh_proxy else 'no',
1589
            self.ssh_username,
1590
            credentials,
1591
            self.ssh_host_key if self.ssh_host_key else 'not checked',
1592
            '' if self.is_alive else 'not ',
1593
            'disabled' if not self.set_keepalive else
1594
            'every {0} sec'.format(self.set_keepalive),
1595
            'disabled' if self.skip_tunnel_checkup else 'enabled',
1596
            '' if self._threaded else 'not ',
1597
            '' if self.compression else 'not ',
1598
            logging.getLevelName(self.logger.level),
1599
            self._local_binds,
1600
            self._remote_binds,
1601
        ))
1602

1603
    def __repr__(self):
1✔
1604
        return self.__str__()
1✔
1605

1606
    def __enter__(self):
1✔
1607
        try:
1✔
1608
            self.start()
1✔
1609
            return self
1✔
1610
        except KeyboardInterrupt:
1✔
1611
            self.__exit__()
×
1612
            raise
×
1613

1614
    def __exit__(self, *args):
1✔
1615
        self.stop(force=True)
1✔
1616

1617
    def __del__(self):
1✔
1618
        if self.is_active or self.is_alive:
1✔
1619
            self.logger.warning(
×
1620
                "It looks like you didn't call the .stop() before "
1621
                "the SSHTunnelForwarder obj was collected by "
1622
                "the garbage collector! Running .stop(force=True)")
1623
            self.stop(force=True)
×
1624

1625

1626
def open_tunnel(*args, **kwargs):
1✔
1627
    """
1628
    Open an SSH Tunnel, wrapper for :class:`SSHTunnelForwarder`
1629

1630
    Arguments:
1631
        destination (Optional[tuple]):
1632
            SSH server's IP address and port in the format
1633
            (``ssh_address``, ``ssh_port``)
1634

1635
    Keyword Arguments:
1636
        debug_level (Optional[int or str]):
1637
            log level for :class:`logging.Logger` instance, i.e. ``DEBUG``
1638

1639
        skip_tunnel_checkup (boolean):
1640
            Enable/disable the local side check and populate
1641
            :attr:`~SSHTunnelForwarder.tunnel_is_up`
1642

1643
            Default: True
1644

1645
            .. versionadded:: 0.1.0
1646

1647
    .. note::
1648
        A value of ``debug_level`` set to 1 == ``TRACE`` enables tracing mode
1649
    .. note::
1650
        See :class:`SSHTunnelForwarder` for keyword arguments
1651

1652
    **Example**::
1653

1654
        from sshtunnel import open_tunnel
1655

1656
        with open_tunnel(SERVER,
1657
                         ssh_username=SSH_USER,
1658
                         ssh_port=22,
1659
                         ssh_password=SSH_PASSWORD,
1660
                         remote_bind_address=(REMOTE_HOST, REMOTE_PORT),
1661
                         local_bind_address=('', LOCAL_PORT)) as server:
1662
            def do_something(port):
1663
                pass
1664

1665
            print("LOCAL PORTS:", server.local_bind_port)
1666

1667
            do_something(server.local_bind_port)
1668
    """
1669
    # Attach a console handler to the logger or create one if not passed
1670
    loglevel = kwargs.pop('debug_level', None)
1✔
1671
    logger = kwargs.get('logger', None) or create_logger(loglevel=loglevel)
1✔
1672
    kwargs['logger'] = logger
1✔
1673

1674
    ssh_address_or_host = kwargs.pop('ssh_address_or_host', None)
1✔
1675
    # Check if deprecated arguments ssh_address or ssh_host were used
1676
    for deprecated_argument in ['ssh_address', 'ssh_host']:
1✔
1677
        ssh_address_or_host = SSHTunnelForwarder._process_deprecated(
1✔
1678
            ssh_address_or_host,
1679
            deprecated_argument,
1680
            kwargs
1681
        )
1682

1683
    ssh_port = kwargs.pop('ssh_port', 22)
1✔
1684
    skip_tunnel_checkup = kwargs.pop('skip_tunnel_checkup', True)
1✔
1685
    block_on_close = kwargs.pop('block_on_close', None)
1✔
1686
    if block_on_close:
1✔
1687
        warnings.warn("'block_on_close' is DEPRECATED. You should use either"
1✔
1688
                      " .stop() or .stop(force=True), depends on what you do"
1689
                      " with the active connections. This option has no"
1690
                      " affect since 0.3.0",
1691
                      DeprecationWarning)
1692
    if not args:
1✔
1693
        if isinstance(ssh_address_or_host, tuple):
1✔
1694
            args = (ssh_address_or_host, )
1✔
1695
        else:
1696
            args = ((ssh_address_or_host, ssh_port), )
1✔
1697
    forwarder = SSHTunnelForwarder(*args, **kwargs)
1✔
1698
    forwarder.skip_tunnel_checkup = skip_tunnel_checkup
1✔
1699
    return forwarder
1✔
1700

1701

1702
def _bindlist(input_str):
1✔
1703
    """ Define type of data expected for remote and local bind address lists
1704
        Returns a tuple (ip_address, port) whose elements are (str, int)
1705
    """
1706
    try:
1✔
1707
        ip_port = input_str.split(':')
1✔
1708
        if len(ip_port) == 1:
1✔
1709
            _ip = ip_port[0]
1✔
1710
            _port = None
1✔
1711
        else:
1712
            (_ip, _port) = ip_port
1✔
1713
        if not _ip and not _port:
1✔
1714
            raise AssertionError
1✔
1715
        elif not _port:
1✔
1716
            _port = '22'  # default port if not given
1✔
1717
        return _ip, int(_port)
1✔
1718
    except ValueError:
1✔
1719
        raise argparse.ArgumentTypeError(
1✔
1720
            'Address tuple must be of type IP_ADDRESS:PORT'
1721
        )
1722
    except AssertionError:
1✔
1723
        raise argparse.ArgumentTypeError("Both IP:PORT can't be missing!")
1✔
1724

1725

1726
def _parse_arguments(args=None):
1✔
1727
    """
1728
    Parse arguments directly passed from CLI
1729
    """
1730
    parser = argparse.ArgumentParser(
1✔
1731
        description='Pure python ssh tunnel utils\n'
1732
                    'Version {0}'.format(__version__),
1733
        formatter_class=argparse.RawTextHelpFormatter
1734
    )
1735

1736
    parser.add_argument(
1✔
1737
        'ssh_address',
1738
        type=str,
1739
        help='SSH server IP address (GW for SSH tunnels)\n'
1740
             'set with "-- ssh_address" if immediately after '
1741
             '-R or -L'
1742
    )
1743

1744
    parser.add_argument(
1✔
1745
        '-U', '--username',
1746
        type=str,
1747
        dest='ssh_username',
1748
        help='SSH server account username'
1749
    )
1750

1751
    parser.add_argument(
1✔
1752
        '-p', '--server_port',
1753
        type=int,
1754
        dest='ssh_port',
1755
        default=22,
1756
        help='SSH server TCP port (default: 22)'
1757
    )
1758

1759
    parser.add_argument(
1✔
1760
        '-P', '--password',
1761
        type=str,
1762
        dest='ssh_password',
1763
        help='SSH server account password'
1764
    )
1765

1766
    parser.add_argument(
1✔
1767
        '-R', '--remote_bind_address',
1768
        type=_bindlist,
1769
        nargs='+',
1770
        default=[],
1771
        metavar='IP:PORT',
1772
        required=True,
1773
        dest='remote_bind_addresses',
1774
        help='Remote bind address sequence: '
1775
             'ip_1:port_1 ip_2:port_2 ... ip_n:port_n\n'
1776
             'Equivalent to ssh -Lxxxx:IP_ADDRESS:PORT\n'
1777
             'If port is omitted, defaults to 22.\n'
1778
             'Example: -R 10.10.10.10: 10.10.10.10:5900'
1779
    )
1780

1781
    parser.add_argument(
1✔
1782
        '-L', '--local_bind_address',
1783
        type=_bindlist,
1784
        nargs='*',
1785
        dest='local_bind_addresses',
1786
        metavar='IP:PORT',
1787
        help='Local bind address sequence: '
1788
             'ip_1:port_1 ip_2:port_2 ... ip_n:port_n\n'
1789
             'Elements may also be valid UNIX socket domains: \n'
1790
             '/tmp/foo.sock /tmp/bar.sock ... /tmp/baz.sock\n'
1791
             'Equivalent to ssh -LPORT:xxxxxxxxx:xxxx, '
1792
             'being the local IP address optional.\n'
1793
             'By default it will listen in all interfaces '
1794
             '(0.0.0.0) and choose a random port.\n'
1795
             'Example: -L :40000'
1796
    )
1797

1798
    parser.add_argument(
1✔
1799
        '-k', '--ssh_host_key',
1800
        type=str,
1801
        help="Gateway's host key"
1802
    )
1803

1804
    parser.add_argument(
1✔
1805
        '-K', '--private_key_file',
1806
        dest='ssh_private_key',
1807
        metavar='KEY_FILE',
1808
        type=str,
1809
        help='RSA/DSS/ECDSA private key file'
1810
    )
1811

1812
    parser.add_argument(
1✔
1813
        '-S', '--private_key_password',
1814
        dest='ssh_private_key_password',
1815
        metavar='KEY_PASSWORD',
1816
        type=str,
1817
        help='RSA/DSS/ECDSA private key password'
1818
    )
1819

1820
    parser.add_argument(
1✔
1821
        '-t', '--threaded',
1822
        action='store_true',
1823
        help='Allow concurrent connections to each tunnel'
1824
    )
1825

1826
    parser.add_argument(
1✔
1827
        '-v', '--verbose',
1828
        action='count',
1829
        default=0,
1830
        help='Increase output verbosity (default: {0})'.format(
1831
            logging.getLevelName(DEFAULT_LOGLEVEL)
1832
        )
1833
    )
1834

1835
    parser.add_argument(
1✔
1836
        '-V', '--version',
1837
        action='version',
1838
        version='%(prog)s {version}'.format(version=__version__),
1839
        help='Show version number and quit'
1840
    )
1841

1842
    parser.add_argument(
1✔
1843
        '-x', '--proxy',
1844
        type=_bindlist,
1845
        dest='ssh_proxy',
1846
        metavar='IP:PORT',
1847
        help='IP and port of SSH proxy to destination'
1848
    )
1849

1850
    parser.add_argument(
1✔
1851
        '-c', '--config',
1852
        type=str,
1853
        default=SSH_CONFIG_FILE,
1854
        dest='ssh_config_file',
1855
        help='SSH configuration file, defaults to {0}'.format(SSH_CONFIG_FILE)
1856
    )
1857

1858
    parser.add_argument(
1✔
1859
        '-z', '--compress',
1860
        action='store_true',
1861
        dest='compression',
1862
        help='Request server for compression over SSH transport'
1863
    )
1864

1865
    parser.add_argument(
1✔
1866
        '-n', '--noagent',
1867
        action='store_false',
1868
        dest='allow_agent',
1869
        help='Disable looking for keys from an SSH agent'
1870
    )
1871

1872
    parser.add_argument(
1✔
1873
        '-d', '--host_pkey_directories',
1874
        nargs='*',
1875
        dest='host_pkey_directories',
1876
        metavar='FOLDER',
1877
        help='List of directories where SSH pkeys (in the format `id_*`) '
1878
             'may be found'
1879
    )
1880
    return vars(parser.parse_args(args))
1✔
1881

1882

1883
def _cli_main(args=None, **extras):
1✔
1884
    """ Pass input arguments to open_tunnel
1885

1886
        Mandatory: ssh_address, -R (remote bind address list)
1887

1888
        Optional:
1889
        -U (username) we may gather it from SSH_CONFIG_FILE or current username
1890
        -p (server_port), defaults to 22
1891
        -P (password)
1892
        -L (local_bind_address), default to 0.0.0.0:22
1893
        -k (ssh_host_key)
1894
        -K (private_key_file), may be gathered from SSH_CONFIG_FILE
1895
        -S (private_key_password)
1896
        -t (threaded), allow concurrent connections over tunnels
1897
        -v (verbose), up to 3 (-vvv) to raise loglevel from ERROR to DEBUG
1898
        -V (version)
1899
        -x (proxy), ProxyCommand's IP:PORT, may be gathered from config file
1900
        -c (ssh_config), ssh configuration file (defaults to SSH_CONFIG_FILE)
1901
        -z (compress)
1902
        -n (noagent), disable looking for keys from an Agent
1903
        -d (host_pkey_directories), look for keys on these folders
1904
    """
1905
    arguments = _parse_arguments(args)
1✔
1906
    # Remove all "None" input values
1907
    _remove_none_values(arguments)
1✔
1908
    verbosity = min(arguments.pop('verbose'), 4)
1✔
1909
    levels = [logging.ERROR,
1✔
1910
              logging.WARNING,
1911
              logging.INFO,
1912
              logging.DEBUG,
1913
              TRACE_LEVEL]
1914
    arguments.setdefault('debug_level', levels[verbosity])
1✔
1915
    # do this while supporting py27/py34 instead of merging dicts
1916
    for (extra, value) in extras.items():
1✔
1917
        arguments.setdefault(extra, value)
1✔
1918
    with open_tunnel(**arguments) as tunnel:
1✔
1919
        if tunnel.is_alive:
1✔
1920
            input_('''
1✔
1921

1922
            Press <Ctrl-C> or <Enter> to stop!
1923

1924
            ''')
1925

1926

1927
if __name__ == '__main__':  # pragma: no cover
1928
    _cli_main()
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc