codekingpro/portable-devtools
114k
1#!/usr/bin/env python2# -*- coding: utf-8 -*-3"""4*sshtunnel* - Initiate SSH tunnels via a remote gateway.5 6``sshtunnel`` works by opening a port forwarding SSH connection in the7background, using threads.8 9The connection(s) are closed when explicitly calling the10:meth:`SSHTunnelForwarder.stop` method or using it as a context.11 12"""13 14import os15import sys16import socket17import getpass18import logging19import argparse20import warnings21import threading22from select import select23from binascii import hexlify24 25import paramiko26 27if sys.version_info[0] < 3: # pragma: no cover28 import Queue as queue29 import SocketServer as socketserver30 string_types = basestring, # noqa31 input_ = raw_input # noqa32else: # pragma: no cover33 import queue34 import socketserver35 string_types = str36 input_ = input37 38 39__version__ = '0.4.0'40__author__ = 'pahaz'41 42 43#: Timeout (seconds) for transport socket (``socket.settimeout``)44SSH_TIMEOUT = 0.1 # ``None`` may cause a block of transport thread45#: Timeout (seconds) for tunnel connection (open_channel timeout)46TUNNEL_TIMEOUT = 10.047 48_DAEMON = True #: Use daemon threads in connections49_CONNECTION_COUNTER = 150_LOCK = threading.Lock()51_DEPRECATIONS = {52 'ssh_address': 'ssh_address_or_host',53 'ssh_host': 'ssh_address_or_host',54 'ssh_private_key': 'ssh_pkey',55 'raise_exception_if_any_forwarder_have_a_problem': 'mute_exceptions'56}57 58# logging59DEFAULT_LOGLEVEL = logging.ERROR #: default level if no logger passed (ERROR)60TRACE_LEVEL = 161logging.addLevelName(TRACE_LEVEL, 'TRACE')62DEFAULT_SSH_DIRECTORY = '~/.ssh'63 64_StreamServer = socketserver.UnixStreamServer if os.name == 'posix' \65 else socketserver.TCPServer66 67#: Path of optional ssh configuration file68DEFAULT_SSH_DIRECTORY = '~/.ssh'69SSH_CONFIG_FILE = os.path.join(DEFAULT_SSH_DIRECTORY, 'config')70 71########################72# #73# Utils #74# #75########################76 77 78def check_host(host):79 assert isinstance(host, string_types), 'IP is not a string ({0})'.format(80 type(host).__name__81 )82 83 84def check_port(port):85 assert isinstance(port, int), 'PORT is not a number'86 assert port >= 0, 'PORT < 0 ({0})'.format(port)87 88 89def check_address(address):90 """91 Check if the format of the address is correct92 93 Arguments:94 address (tuple):95 (``str``, ``int``) representing an IP address and port,96 respectively97 98 .. note::99 alternatively a local ``address`` can be a ``str`` when working100 with UNIX domain sockets, if supported by the platform101 Raises:102 ValueError:103 raised when address has an incorrect format104 105 Example:106 >>> check_address(('127.0.0.1', 22))107 """108 if isinstance(address, tuple):109 check_host(address[0])110 check_port(address[1])111 elif isinstance(address, string_types):112 if os.name != 'posix':113 raise ValueError('Platform does not support UNIX domain sockets')114 if not (os.path.exists(address) or115 os.access(os.path.dirname(address), os.W_OK)):116 raise ValueError('ADDRESS not a valid socket domain socket ({0})'117 .format(address))118 else:119 raise ValueError('ADDRESS is not a tuple, string, or character buffer '120 '({0})'.format(type(address).__name__))121 122 123def check_addresses(address_list, is_remote=False):124 """125 Check if the format of the addresses is correct126 127 Arguments:128 address_list (list[tuple]):129 Sequence of (``str``, ``int``) pairs, each representing an IP130 address and port respectively131 132 .. note::133 when supported by the platform, one or more of the elements in134 the list can be of type ``str``, representing a valid UNIX135 domain socket136 137 is_remote (boolean):138 Whether or not the address list139 Raises:140 AssertionError:141 raised when ``address_list`` contains an invalid element142 ValueError:143 raised when any address in the list has an incorrect format144 145 Example:146 147 >>> check_addresses([('127.0.0.1', 22), ('127.0.0.1', 2222)])148 """149 assert all(isinstance(x, (tuple, string_types)) for x in address_list)150 if (is_remote and any(isinstance(x, string_types) for x in address_list)):151 raise AssertionError('UNIX domain sockets not allowed for remote'152 'addresses')153 154 for address in address_list:155 check_address(address)156 157 158def create_logger(logger=None,159 loglevel=None,160 capture_warnings=True,161 add_paramiko_handler=True):162 """163 Attach or create a new logger and add a console handler if not present164 165 Arguments:166 167 logger (Optional[logging.Logger]):168 :class:`logging.Logger` instance; a new one is created if this169 argument is empty170 171 loglevel (Optional[str or int]):172 :class:`logging.Logger`'s level, either as a string (i.e.173 ``ERROR``) or in numeric format (10 == ``DEBUG``)174 175 .. note:: a value of 1 == ``TRACE`` enables Tracing mode176 177 capture_warnings (boolean):178 Enable/disable capturing the events logged by the warnings module179 into ``logger``'s handlers180 181 Default: True182 183 .. note:: ignored in python 2.6184 185 add_paramiko_handler (boolean):186 Whether or not add a console handler for ``paramiko.transport``'s187 logger if no handler present188 189 Default: True190 Return:191 :class:`logging.Logger`192 """193 logger = logger or logging.getLogger(194 'sshtunnel.SSHTunnelForwarder'195 )196 if not any(isinstance(x, logging.Handler) for x in logger.handlers):197 logger.setLevel(loglevel or DEFAULT_LOGLEVEL)198 console_handler = logging.StreamHandler()199 _add_handler(logger,200 handler=console_handler,201 loglevel=loglevel or DEFAULT_LOGLEVEL)202 if loglevel: # override if loglevel was set203 logger.setLevel(loglevel)204 for handler in logger.handlers:205 handler.setLevel(loglevel)206 207 if add_paramiko_handler:208 _check_paramiko_handlers(logger=logger)209 210 if capture_warnings and sys.version_info >= (2, 7):211 logging.captureWarnings(True)212 pywarnings = logging.getLogger('py.warnings')213 pywarnings.handlers.extend(logger.handlers)214 return logger215 216 217def _add_handler(logger, handler=None, loglevel=None):218 """219 Add a handler to an existing logging.Logger object220 """221 handler.setLevel(loglevel or DEFAULT_LOGLEVEL)222 if handler.level <= logging.DEBUG:223 _fmt = '%(asctime)s| %(levelname)-4.3s|%(threadName)10.9s/' \224 '%(lineno)04d@%(module)-10.9s| %(message)s'225 handler.setFormatter(logging.Formatter(_fmt))226 else:227 handler.setFormatter(logging.Formatter(228 '%(asctime)s| %(levelname)-8s| %(message)s'229 ))230 logger.addHandler(handler)231 232 233def _check_paramiko_handlers(logger=None):234 """235 Add a console handler for paramiko.transport's logger if not present236 """237 paramiko_logger = logging.getLogger('paramiko.transport')238 if not paramiko_logger.handlers:239 if logger:240 paramiko_logger.handlers = logger.handlers241 else:242 console_handler = logging.StreamHandler()243 console_handler.setFormatter(244 logging.Formatter('%(asctime)s | %(levelname)-8s| PARAMIKO: '245 '%(lineno)03d@%(module)-10s| %(message)s')246 )247 paramiko_logger.addHandler(console_handler)248 249 250def address_to_str(address):251 if isinstance(address, tuple):252 return '{0[0]}:{0[1]}'.format(address)253 return str(address)254 255 256def get_connection_id():257 global _CONNECTION_COUNTER258 with _LOCK:259 uid = _CONNECTION_COUNTER260 _CONNECTION_COUNTER += 1261 return uid262 263 264def _remove_none_values(dictionary):265 """ Remove dictionary keys whose value is None """266 return list(map(dictionary.pop,267 [i for i in dictionary if dictionary[i] is None]))268 269########################270# #271# Errors #272# #273########################274 275 276class BaseSSHTunnelForwarderError(Exception):277 """ Exception raised by :class:`SSHTunnelForwarder` errors """278 279 def __init__(self, *args, **kwargs):280 self.value = kwargs.pop('value', args[0] if args else '')281 282 def __str__(self):283 return self.value284 285 286class HandlerSSHTunnelForwarderError(BaseSSHTunnelForwarderError):287 """ Exception for Tunnel forwarder errors """288 pass289 290 291########################292# #293# Handlers #294# #295########################296 297 298class _ForwardHandler(socketserver.BaseRequestHandler):299 """ Base handler for tunnel connections """300 remote_address = None301 ssh_transport = None302 logger = None303 info = None304 305 def _redirect(self, chan):306 while chan.active:307 rqst, _, _ = select([self.request, chan], [], [], 5)308 if self.request in rqst:309 data = self.request.recv(1024)310 if not data:311 self.logger.log(312 TRACE_LEVEL,313 '>>> OUT {0} recv empty data >>>'.format(self.info)314 )315 break316 self.logger.log(317 TRACE_LEVEL,318 '>>> OUT {0} send to {1}: {2} >>>'.format(319 self.info,320 self.remote_address,321 hexlify(data)322 )323 )324 chan.sendall(data)325 if chan in rqst: # else326 if not chan.recv_ready():327 self.logger.log(328 TRACE_LEVEL,329 '<<< IN {0} recv is not ready <<<'.format(self.info)330 )331 break332 data = chan.recv(1024)333 self.logger.log(334 TRACE_LEVEL,335 '<<< IN {0} recv: {1} <<<'.format(self.info, hexlify(data))336 )337 self.request.sendall(data)338 339 def handle(self):340 uid = get_connection_id()341 self.info = '#{0} <-- {1}'.format(uid, self.client_address or342 self.server.local_address)343 src_address = self.request.getpeername()344 if not isinstance(src_address, tuple):345 src_address = ('dummy', 12345)346 try:347 chan = self.ssh_transport.open_channel(348 kind='direct-tcpip',349 dest_addr=self.remote_address,350 src_addr=src_address,351 timeout=TUNNEL_TIMEOUT352 )353 except Exception as e: # pragma: no cover354 msg_tupe = 'ssh ' if isinstance(e, paramiko.SSHException) else ''355 exc_msg = 'open new channel {0}error: {1}'.format(msg_tupe, e)356 log_msg = '{0} {1}'.format(self.info, exc_msg)357 self.logger.log(TRACE_LEVEL, log_msg)358 raise HandlerSSHTunnelForwarderError(exc_msg)359 360 self.logger.log(TRACE_LEVEL, '{0} connected'.format(self.info))361 try:362 self._redirect(chan)363 except socket.error:364 # Sometimes a RST is sent and a socket error is raised, treat this365 # exception. It was seen that a 3way FIN is processed later on, so366 # no need to make an ordered close of the connection here or raise367 # the exception beyond this point...368 self.logger.log(TRACE_LEVEL, '{0} sending RST'.format(self.info))369 except Exception as e:370 self.logger.log(TRACE_LEVEL,371 '{0} error: {1}'.format(self.info, repr(e)))372 finally:373 chan.close()374 self.request.close()375 self.logger.log(TRACE_LEVEL,376 '{0} connection closed.'.format(self.info))377 378 379class _ForwardServer(socketserver.TCPServer): # Not Threading380 """381 Non-threading version of the forward server382 """383 allow_reuse_address = True # faster rebinding384 385 def __init__(self, *args, **kwargs):386 self.logger = create_logger(kwargs.pop('logger', None))387 self.tunnel_ok = queue.Queue(1)388 socketserver.TCPServer.__init__(self, *args, **kwargs)389 390 def handle_error(self, request, client_address):391 (exc_class, exc, tb) = sys.exc_info()392 local_side = request.getsockname()393 remote_side = self.remote_address394 self.logger.error('Could not establish connection from local {0} '395 'to remote {1} side of the tunnel: {2}'396 .format(local_side, remote_side, exc))397 try:398 self.tunnel_ok.put(False, block=False, timeout=0.1)399 except queue.Full:400 # wait untill tunnel_ok.get is called401 pass402 except exc:403 self.logger.error('unexpected internal error: {0}'.format(exc))404 405 @property406 def local_address(self):407 return self.server_address408 409 @property410 def local_host(self):411 return self.server_address[0]412 413 @property414 def local_port(self):415 return self.server_address[1]416 417 @property418 def remote_address(self):419 return self.RequestHandlerClass.remote_address420 421 @property422 def remote_host(self):423 return self.RequestHandlerClass.remote_address[0]424 425 @property426 def remote_port(self):427 return self.RequestHandlerClass.remote_address[1]428 429 430class _ThreadingForwardServer(socketserver.ThreadingMixIn, _ForwardServer):431 """432 Allow concurrent connections to each tunnel433 """434 # If True, cleanly stop threads created by ThreadingMixIn when quitting435 # This value is overrides by SSHTunnelForwarder.daemon_forward_servers436 daemon_threads = _DAEMON437 438 439class _StreamForwardServer(_StreamServer):440 """441 Serve over domain sockets (does not work on Windows)442 """443 444 def __init__(self, *args, **kwargs):445 self.logger = create_logger(kwargs.pop('logger', None))446 self.tunnel_ok = queue.Queue(1)447 _StreamServer.__init__(self, *args, **kwargs)448 449 @property450 def local_address(self):451 return self.server_address452 453 @property454 def local_host(self):455 return None456 457 @property458 def local_port(self):459 return None460 461 @property462 def remote_address(self):463 return self.RequestHandlerClass.remote_address464 465 @property466 def remote_host(self):467 return self.RequestHandlerClass.remote_address[0]468 469 @property470 def remote_port(self):471 return self.RequestHandlerClass.remote_address[1]472 473 474class _ThreadingStreamForwardServer(socketserver.ThreadingMixIn,475 _StreamForwardServer):476 """477 Allow concurrent connections to each tunnel478 """479 # If True, cleanly stop threads created by ThreadingMixIn when quitting480 # This value is overrides by SSHTunnelForwarder.daemon_forward_servers481 daemon_threads = _DAEMON482 483 484class SSHTunnelForwarder(object):485 """486 **SSH tunnel class**487 488 - Initialize a SSH tunnel to a remote host according to the input489 arguments490 491 - Optionally:492 + Read an SSH configuration file (typically ``~/.ssh/config``)493 + Load keys from a running SSH agent (i.e. Pageant, GNOME Keyring)494 495 Raises:496 497 :class:`.BaseSSHTunnelForwarderError`:498 raised by SSHTunnelForwarder class methods499 500 :class:`.HandlerSSHTunnelForwarderError`:501 raised by tunnel forwarder threads502 503 .. note::504 Attributes ``mute_exceptions`` and505 ``raise_exception_if_any_forwarder_have_a_problem``506 (deprecated) may be used to silence most exceptions raised507 from this class508 509 Keyword Arguments:510 511 ssh_address_or_host (tuple or str):512 IP or hostname of ``REMOTE GATEWAY``. It may be a two-element513 tuple (``str``, ``int``) representing IP and port respectively,514 or a ``str`` representing the IP address only515 516 .. versionadded:: 0.0.4517 518 ssh_config_file (str):519 SSH configuration file that will be read. If explicitly set to520 ``None``, parsing of this configuration is omitted521 522 Default: :const:`SSH_CONFIG_FILE`523 524 .. versionadded:: 0.0.4525 526 ssh_host_key (str):527 Representation of a line in an OpenSSH-style "known hosts"528 file.529 530 ``REMOTE GATEWAY``'s key fingerprint will be compared to this531 host key in order to prevent against SSH server spoofing.532 Important when using passwords in order not to accidentally533 do a login attempt to a wrong (perhaps an attacker's) machine534 535 ssh_username (str):536 Username to authenticate as in ``REMOTE SERVER``537 538 Default: current local user name539 540 ssh_password (str):541 Text representing the password used to connect to ``REMOTE542 SERVER`` or for unlocking a private key.543 544 .. note::545 Avoid coding secret password directly in the code, since this546 may be visible and make your service vulnerable to attacks547 548 ssh_port (int):549 Optional port number of the SSH service on ``REMOTE GATEWAY``,550 when `ssh_address_or_host`` is a ``str`` representing the551 IP part of ``REMOTE GATEWAY``'s address552 553 Default: 22554 555 ssh_pkey (str or paramiko.PKey):556 **Private** key file name (``str``) to obtain the public key557 from or a **public** key (:class:`paramiko.pkey.PKey`)558 559 ssh_private_key_password (str):560 Password for an encrypted ``ssh_pkey``561 562 .. note::563 Avoid coding secret password directly in the code, since this564 may be visible and make your service vulnerable to attacks565 566 ssh_proxy (socket-like object or tuple):567 Proxy where all SSH traffic will be passed through.568 It might be for example a :class:`paramiko.proxy.ProxyCommand`569 instance.570 See either the :class:`paramiko.transport.Transport`'s sock571 parameter documentation or ``ProxyCommand`` in ``ssh_config(5)``572 for more information.573 574 It is also possible to specify the proxy address as a tuple of575 type (``str``, ``int``) representing proxy's IP and port576 577 .. note::578 Ignored if ``ssh_proxy_enabled`` is False579 580 .. versionadded:: 0.0.5581 582 ssh_proxy_enabled (boolean):583 Enable/disable SSH proxy. If True and user's584 ``ssh_config_file`` contains a ``ProxyCommand`` directive585 that matches the specified ``ssh_address_or_host``,586 a :class:`paramiko.proxy.ProxyCommand` object will be created where587 all SSH traffic will be passed through588 589 Default: ``True``590 591 .. versionadded:: 0.0.4592 593 local_bind_address (tuple):594 Local tuple in the format (``str``, ``int``) representing the595 IP and port of the local side of the tunnel. Both elements in596 the tuple are optional so both ``('', 8000)`` and597 ``('10.0.0.1', )`` are valid values598 599 Default: ``('0.0.0.0', RANDOM_PORT)``600 601 .. versionchanged:: 0.0.8602 Added the ability to use a UNIX domain socket as local bind603 address604 605 local_bind_addresses (list[tuple]):606 In case more than one tunnel is established at once, a list607 of tuples (in the same format as ``local_bind_address``)608 can be specified, such as [(ip1, port_1), (ip_2, port2), ...]609 610 Default: ``[local_bind_address]``611 612 .. versionadded:: 0.0.4613 614 remote_bind_address (tuple):615 Remote tuple in the format (``str``, ``int``) representing the616 IP and port of the remote side of the tunnel.617 618 remote_bind_addresses (list[tuple]):619 In case more than one tunnel is established at once, a list620 of tuples (in the same format as ``remote_bind_address``)621 can be specified, such as [(ip1, port_1), (ip_2, port2), ...]622 623 Default: ``[remote_bind_address]``624 625 .. versionadded:: 0.0.4626 627 allow_agent (boolean):628 Enable/disable load of keys from an SSH agent629 630 Default: ``True``631 632 .. versionadded:: 0.0.8633 634 host_pkey_directories (list):635 Look for pkeys in folders on this list, for example ['~/.ssh'].636 637 Default: ``None`` (disabled)638 639 .. versionadded:: 0.1.4640 641 compression (boolean):642 Turn on/off transport compression. By default compression is643 disabled since it may negatively affect interactive sessions644 645 Default: ``False``646 647 .. versionadded:: 0.0.8648 649 logger (logging.Logger):650 logging instance for sshtunnel and paramiko651 652 Default: :class:`logging.Logger` instance with a single653 :class:`logging.StreamHandler` handler and654 :const:`DEFAULT_LOGLEVEL` level655 656 .. versionadded:: 0.0.3657 658 mute_exceptions (boolean):659 Allow silencing :class:`BaseSSHTunnelForwarderError` or660 :class:`HandlerSSHTunnelForwarderError` exceptions when enabled661 662 Default: ``False``663 664 .. versionadded:: 0.0.8665 666 set_keepalive (float):667 Interval in seconds defining the period in which, if no data668 was sent over the connection, a *'keepalive'* packet will be669 sent (and ignored by the remote host). This can be useful to670 keep connections alive over a NAT. You can set to 0.0 for671 disable keepalive.672 673 Default: 5.0 (no keepalive packets are sent)674 675 .. versionadded:: 0.0.7676 677 threaded (boolean):678 Allow concurrent connections over a single tunnel679 680 Default: ``True``681 682 .. versionadded:: 0.0.3683 684 ssh_address (str):685 Superseded by ``ssh_address_or_host``, tuple of type (str, int)686 representing the IP and port of ``REMOTE SERVER``687 688 .. deprecated:: 0.0.4689 690 ssh_host (str):691 Superseded by ``ssh_address_or_host``, tuple of type692 (str, int) representing the IP and port of ``REMOTE SERVER``693 694 .. deprecated:: 0.0.4695 696 ssh_private_key (str or paramiko.PKey):697 Superseded by ``ssh_pkey``, which can represent either a698 **private** key file name (``str``) or a **public** key699 (:class:`paramiko.pkey.PKey`)700 701 .. deprecated:: 0.0.8702 703 raise_exception_if_any_forwarder_have_a_problem (boolean):704 Allow silencing :class:`BaseSSHTunnelForwarderError` or705 :class:`HandlerSSHTunnelForwarderError` exceptions when set to706 False707 708 Default: ``True``709 710 .. versionadded:: 0.0.4711 712 .. deprecated:: 0.0.8 (use ``mute_exceptions`` instead)713 714 Attributes:715 716 tunnel_is_up (dict):717 Describe whether or not the other side of the tunnel was reported718 to be up (and we must close it) or not (skip shutting down that719 tunnel)720 721 .. note::722 This attribute should not be modified723 724 .. note::725 When :attr:`.skip_tunnel_checkup` is disabled or the local bind726 is a UNIX socket, the value will always be ``True``727 728 **Example**::729 730 {('127.0.0.1', 55550): True, # this tunnel is up731 ('127.0.0.1', 55551): False} # this one isn't732 733 where 55550 and 55551 are the local bind ports734 735 skip_tunnel_checkup (boolean):736 Disable tunnel checkup (default for backwards compatibility).737 738 .. versionadded:: 0.1.0739 740 """741 skip_tunnel_checkup = True742 # This option affects the `ForwardServer` and all his threads743 daemon_forward_servers = _DAEMON #: flag tunnel threads in daemon mode744 # This option affect only `Transport` thread745 daemon_transport = _DAEMON #: flag SSH transport thread in daemon mode746 747 def local_is_up(self, target):748 """749 Check if a tunnel is up (remote target's host is reachable on TCP750 target's port)751 752 Arguments:753 target (tuple):754 tuple of type (``str``, ``int``) indicating the listen IP755 address and port756 Return:757 boolean758 759 .. deprecated:: 0.1.0760 Replaced by :meth:`.check_tunnels()` and :attr:`.tunnel_is_up`761 """762 try:763 check_address(target)764 except ValueError:765 self.logger.warning('Target must be a tuple (IP, port), where IP '766 'is a string (i.e. "192.168.0.1") and port is '767 'an integer (i.e. 40000). Alternatively '768 'target can be a valid UNIX domain socket.')769 return False770 771 self.check_tunnels()772 return self.tunnel_is_up.get(target, True)773 774 def check_tunnels(self):775 """776 Check that if all tunnels are established and populates777 :attr:`.tunnel_is_up`778 """779 skip_tunnel_checkup = self.skip_tunnel_checkup780 try:781 # force tunnel check at this point782 self.skip_tunnel_checkup = False783 for _srv in self._server_list:784 self._check_tunnel(_srv)785 finally:786 self.skip_tunnel_checkup = skip_tunnel_checkup # roll it back787 788 def _check_tunnel(self, _srv):789 """ Check if tunnel is already established """790 if self.skip_tunnel_checkup:791 self.tunnel_is_up[_srv.local_address] = True792 return793 self.logger.info('Checking tunnel to: {0}'.format(_srv.remote_address))794 if isinstance(_srv.local_address, string_types): # UNIX stream795 s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)796 else:797 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)798 s.settimeout(TUNNEL_TIMEOUT)799 try:800 # Windows raises WinError 10049 if trying to connect to 0.0.0.0801 connect_to = ('127.0.0.1', _srv.local_port) \802 if _srv.local_host == '0.0.0.0' else _srv.local_address803 s.connect(connect_to)804 self.tunnel_is_up[_srv.local_address] = _srv.tunnel_ok.get(805 timeout=TUNNEL_TIMEOUT * 1.1806 )807 self.logger.debug(808 'Tunnel to {0} is DOWN'.format(_srv.remote_address)809 )810 except socket.error:811 self.logger.debug(812 'Tunnel to {0} is DOWN'.format(_srv.remote_address)813 )814 self.tunnel_is_up[_srv.local_address] = False815 816 except queue.Empty:817 self.logger.debug(818 'Tunnel to {0} is UP'.format(_srv.remote_address)819 )820 self.tunnel_is_up[_srv.local_address] = True821 finally:822 s.close()823 824 def _make_ssh_forward_handler_class(self, remote_address_):825 """826 Make SSH Handler class827 """828 class Handler(_ForwardHandler):829 remote_address = remote_address_830 ssh_transport = self._transport831 logger = self.logger832 return Handler833 834 def _make_ssh_forward_server_class(self, remote_address_):835 return _ThreadingForwardServer if self._threaded else _ForwardServer836 837 def _make_stream_ssh_forward_server_class(self, remote_address_):838 return _ThreadingStreamForwardServer if self._threaded \839 else _StreamForwardServer840 841 def _make_ssh_forward_server(self, remote_address, local_bind_address):842 """843 Make SSH forward proxy Server class844 """845 _Handler = self._make_ssh_forward_handler_class(remote_address)846 try:847 forward_maker_class = self._make_stream_ssh_forward_server_class \848 if isinstance(local_bind_address, string_types) \849 else self._make_ssh_forward_server_class850 _Server = forward_maker_class(remote_address)851 ssh_forward_server = _Server(852 local_bind_address,853 _Handler,854 logger=self.logger,855 )856 857 if ssh_forward_server:858 ssh_forward_server.daemon_threads = self.daemon_forward_servers859 self._server_list.append(ssh_forward_server)860 self.tunnel_is_up[ssh_forward_server.server_address] = False861 else:862 self._raise(863 BaseSSHTunnelForwarderError,864 'Problem setting up ssh {0} <> {1} forwarder. You can '865 'suppress this exception by using the `mute_exceptions`'866 'argument'.format(address_to_str(local_bind_address),867 address_to_str(remote_address))868 )869 except IOError:870 self._raise(871 BaseSSHTunnelForwarderError,872 "Couldn't open tunnel {0} <> {1} might be in use or "873 "destination not reachable".format(874 address_to_str(local_bind_address),875 address_to_str(remote_address)876 )877 )878 879 def __init__(880 self,881 ssh_address_or_host=None,882 ssh_config_file=SSH_CONFIG_FILE,883 ssh_host_key=None,884 ssh_password=None,885 ssh_pkey=None,886 ssh_private_key_password=None,887 ssh_proxy=None,888 ssh_proxy_enabled=True,889 ssh_username=None,890 local_bind_address=None,891 local_bind_addresses=None,892 logger=None,893 mute_exceptions=False,894 remote_bind_address=None,895 remote_bind_addresses=None,896 set_keepalive=5.0,897 threaded=True, # old version False898 compression=None,899 allow_agent=True, # look for keys from an SSH agent900 host_pkey_directories=None, # look for keys in ~/.ssh901 *args,902 **kwargs # for backwards compatibility903 ):904 self.logger = logger or create_logger()905 906 # Ensure paramiko.transport has a console handler907 _check_paramiko_handlers(logger=logger)908 909 self.ssh_host_key = ssh_host_key910 self.set_keepalive = set_keepalive911 self._server_list = [] # reset server list912 self.tunnel_is_up = {} # handle tunnel status913 self._threaded = threaded914 self.is_alive = False915 # Check if deprecated arguments ssh_address or ssh_host were used916 for deprecated_argument in ['ssh_address', 'ssh_host']:917 ssh_address_or_host = self._process_deprecated(ssh_address_or_host,918 deprecated_argument,919 kwargs)920 # other deprecated arguments921 ssh_pkey = self._process_deprecated(ssh_pkey,922 'ssh_private_key',923 kwargs)924 925 self._raise_fwd_exc = self._process_deprecated(926 None,927 'raise_exception_if_any_forwarder_have_a_problem',928 kwargs) or not mute_exceptions929 930 if isinstance(ssh_address_or_host, tuple):931 check_address(ssh_address_or_host)932 (ssh_host, ssh_port) = ssh_address_or_host933 else:934 ssh_host = ssh_address_or_host935 ssh_port = kwargs.pop('ssh_port', None)936 937 if kwargs:938 raise ValueError('Unknown arguments: {0}'.format(kwargs))939 940 # remote binds941 self._remote_binds = self._get_binds(remote_bind_address,942 remote_bind_addresses,943 is_remote=True)944 # local binds945 self._local_binds = self._get_binds(local_bind_address,946 local_bind_addresses)947 self._local_binds = self._consolidate_binds(self._local_binds,948 self._remote_binds)949 950 (self.ssh_host,951 self.ssh_username,952 ssh_pkey, # still needs to go through _consolidate_auth953 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.logger964 )965 966 (self.ssh_password, self.ssh_pkeys) = self._consolidate_auth(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.logger973 )974 975 check_host(self.ssh_host)976 check_port(self.ssh_port)977 978 self.logger.info("Connecting to gateway: {0}:{1} as user '{2}'"979 .format(self.ssh_host,980 self.ssh_port,981 self.ssh_username))982 983 self.logger.debug('Concurrent connections allowed: {0}'984 .format(self._threaded))985 986 @staticmethod987 def _read_ssh_config(ssh_host,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 proxycommand998 (ssh_proxy) entries for ssh_host999 """1000 ssh_config = paramiko.SSHConfig()1001 if not ssh_config_file: # handle case where it's an empty string1002 ssh_config_file = None1003 1004 # Try to read SSH_CONFIG_FILE1005 try:1006 # open the ssh config file1007 with open(os.path.expanduser(ssh_config_file), 'r') as f:1008 ssh_config.parse(f)1009 # looks for information for the destination system1010 hostname_info = ssh_config.lookup(ssh_host)1011 # gather settings for user, port and identity file1012 # last resort: use the 'login name' of the user1013 ssh_username = (1014 ssh_username or1015 hostname_info.get('user')1016 )1017 ssh_pkey = (1018 ssh_pkey or1019 hostname_info.get('identityfile', [None])[0]1020 )1021 ssh_host = hostname_info.get('hostname')1022 ssh_port = ssh_port or hostname_info.get('port')1023 1024 proxycommand = hostname_info.get('proxycommand')1025 ssh_proxy = ssh_proxy or (paramiko.ProxyCommand(proxycommand) if1026 proxycommand else None)1027 if compression is None:1028 compression = hostname_info.get('compression', '')1029 compression = True if compression.upper() == 'YES' else False1030 except IOError:1031 if logger:1032 logger.warning(1033 'Could not read SSH configuration file: {0}'1034 .format(ssh_config_file)1035 )1036 except (AttributeError, TypeError): # ssh_config_file is None1037 if logger:1038 logger.info('Skipping loading of ssh configuration file')1039 finally:1040 return (ssh_host,1041 ssh_username or getpass.getuser(),1042 ssh_pkey,1043 int(ssh_port) if ssh_port else 22, # fallback value1044 ssh_proxy,1045 compression)1046 1047 @staticmethod1048 def get_agent_keys(logger=None):1049 """ Load public keys from any available SSH agent1050 1051 Arguments:1052 logger (Optional[logging.Logger])1053 1054 Return:1055 list1056 """1057 paramiko_agent = paramiko.Agent()1058 agent_keys = paramiko_agent.get_keys()1059 if logger:1060 logger.info('{0} keys loaded from agent'.format(len(agent_keys)))1061 return list(agent_keys)1062 1063 @staticmethod1064 def get_keys(logger=None, host_pkey_directories=None, allow_agent=False):1065 """1066 Load public keys from any available SSH agent or local1067 .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 format1074 "id_*" are searched. For example, ['~/.ssh']1075 1076 .. versionadded:: 0.1.01077 1078 allow_agent (Optional[boolean]):1079 Whether or not load keys from agent1080 1081 Default: False1082 1083 Return:1084 list1085 """1086 keys = SSHTunnelForwarder.get_agent_keys(logger=logger) \1087 if allow_agent else []1088 1089 if host_pkey_directories is None:1090 host_pkey_directories = [DEFAULT_SSH_DIRECTORY]1091 1092 paramiko_key_types = {'rsa': paramiko.RSAKey,1093 'dsa': paramiko.DSSKey,1094 'ecdsa': paramiko.ECDSAKey}1095 if hasattr(paramiko, 'Ed25519Key'):1096 # NOQA: new in paramiko>=2.2: http://docs.paramiko.org/en/stable/api/keys.html#module-paramiko.ed25519key1097 paramiko_key_types['ed25519'] = paramiko.Ed25519Key1098 for directory in host_pkey_directories:1099 for keytype in paramiko_key_types.keys():1100 ssh_pkey_expanded = os.path.expanduser(1101 os.path.join(directory, 'id_{}'.format(keytype))1102 )1103 try:1104 if os.path.isfile(ssh_pkey_expanded):1105 ssh_pkey = SSHTunnelForwarder.read_private_key_file(1106 pkey_file=ssh_pkey_expanded,1107 logger=logger,1108 key_type=paramiko_key_types[keytype]1109 )1110 if ssh_pkey:1111 keys.append(ssh_pkey)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:1117 logger.info('{0} key(s) loaded'.format(len(keys)))1118 return keys1119 1120 @staticmethod1121 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 open1125 """1126 count = len(remote_binds) - len(local_binds)1127 if count < 0:1128 raise ValueError('Too many local bind addresses '1129 '(local_bind_addresses > remote_bind_addresses)')1130 local_binds.extend([('0.0.0.0', 0) for x in range(count)])1131 return local_binds1132 1133 @staticmethod1134 def _consolidate_auth(ssh_password=None,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; public1144 key will be obtained from it1145 - ``paramiko.Pkey`` - it will be transparently added to loaded keys1146 1147 """1148 ssh_loaded_pkeys = SSHTunnelForwarder.get_keys(1149 logger=logger,1150 host_pkey_directories=host_pkey_directories,1151 allow_agent=allow_agent1152 )1153 1154 if isinstance(ssh_pkey, string_types):1155 ssh_pkey_expanded = os.path.expanduser(ssh_pkey)1156 if os.path.exists(ssh_pkey_expanded):1157 ssh_pkey = SSHTunnelForwarder.read_private_key_file(1158 pkey_file=ssh_pkey_expanded,1159 pkey_password=ssh_pkey_password or ssh_password,1160 logger=logger1161 )1162 elif logger:1163 logger.warning('Private key file not found: {0}'1164 .format(ssh_pkey))1165 if isinstance(ssh_pkey, paramiko.pkey.PKey):1166 ssh_loaded_pkeys.insert(0, ssh_pkey)1167 1168 if not ssh_password and not ssh_loaded_pkeys:1169 raise ValueError('No password or public key available!')1170 return (ssh_password, ssh_loaded_pkeys)1171 1172 def _raise(self, exception=BaseSSHTunnelForwarderError, reason=None):1173 if self._raise_fwd_exc:1174 raise exception(reason)1175 else:1176 self.logger.error(repr(exception(reason)))1177 1178 def _get_transport(self):1179 """ Return the SSH transport to the remote gateway """1180 if self.ssh_proxy: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_proxy1187 else:1188 _socket = (self.ssh_host, self.ssh_port)1189 if isinstance(_socket, socket.socket):1190 _socket.settimeout(SSH_TIMEOUT)1191 _socket.connect((self.ssh_host, self.ssh_port))1192 transport = paramiko.Transport(_socket)1193 sock = transport.sock1194 if isinstance(sock, socket.socket):1195 sock.settimeout(SSH_TIMEOUT)1196 transport.set_keepalive(self.set_keepalive)1197 transport.use_compression(compress=self.compression)1198 transport.daemon = self.daemon_transport1199 # try to solve https://github.com/paramiko/paramiko/issues/11811200 # transport.banner_timeout = 200