diff options
-rw-r--r-- | paramiko/_version.py | 2 | ||||
-rw-r--r-- | paramiko/channel.py | 53 | ||||
-rw-r--r-- | paramiko/client.py | 61 | ||||
-rw-r--r-- | paramiko/message.py | 15 | ||||
-rw-r--r-- | paramiko/transport.py | 14 | ||||
-rw-r--r-- | sites/www/changelog.rst | 32 | ||||
-rw-r--r-- | tests/test_client.py | 120 |
7 files changed, 243 insertions, 54 deletions
diff --git a/paramiko/_version.py b/paramiko/_version.py index 93214f6d..4350dbd0 100644 --- a/paramiko/_version.py +++ b/paramiko/_version.py @@ -1,2 +1,2 @@ -__version_info__ = (2, 0, 8) +__version_info__ = (2, 1, 5) __version__ = ".".join(map(str, __version_info__)) diff --git a/paramiko/channel.py b/paramiko/channel.py index 13ab7dcf..b2e8edd1 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -306,6 +306,59 @@ class Channel(ClosingContextManager): m.add_int(height_pixels) self.transport._send_user_message(m) + @open_only + def update_environment(self, environment): + """ + Updates this channel's remote shell environment. + + .. note:: + This operation is additive - i.e. the current environment is not + reset before the given environment variables are set. + + .. warning:: + Servers may silently reject some environment variables; see the + warning in `set_environment_variable` for details. + + :param dict environment: + a dictionary containing the name and respective values to set + :raises: + `.SSHException` -- if any of the environment variables was rejected + by the server or the channel was closed + """ + for name, value in environment.items(): + try: + self.set_environment_variable(name, value) + except SSHException as e: + err = 'Failed to set environment variable "{0}".' + raise SSHException(err.format(name), e) + + @open_only + def set_environment_variable(self, name, value): + """ + Set the value of an environment variable. + + .. warning:: + The server may reject this request depending on its ``AcceptEnv`` + setting; such rejections will fail silently (which is common client + practice for this particular request type). Make sure you + understand your server's configuration before using! + + :param str name: name of the environment variable + :param str value: value of the environment variable + + :raises: + `.SSHException` -- if the request was rejected or the channel was + closed + """ + m = Message() + m.add_byte(cMSG_CHANNEL_REQUEST) + m.add_int(self.remote_chanid) + m.add_string("env") + m.add_boolean(False) + m.add_string(name) + m.add_string(value) + self.transport._send_user_message(m) + def exit_status_ready(self): """ Return true if the remote process has exited and returned an exit diff --git a/paramiko/client.py b/paramiko/client.py index 15e2de09..bb6c7ff4 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -345,38 +345,40 @@ class SSHClient(ClosingContextManager): t.set_log_channel(self._log_channel) if banner_timeout is not None: t.banner_timeout = banner_timeout - t.start_client() - - server_key = t.get_remote_server_key() - keytype = server_key.get_name() if port == SSH_PORT: server_hostkey_name = hostname else: server_hostkey_name = "[%s]:%d" % (hostname, port) + our_server_keys = None + + our_server_keys = self._system_host_keys.get(server_hostkey_name) + if our_server_keys is None: + our_server_keys = self._host_keys.get(server_hostkey_name) + if our_server_keys is not None: + keytype = our_server_keys.keys()[0] + sec_opts = t.get_security_options() + other_types = [x for x in sec_opts.key_types if x != keytype] + sec_opts.key_types = [keytype] + other_types + + t.start_client(timeout=timeout) # If GSS-API Key Exchange is performed we are not required to check the # host key, because the host is authenticated via GSS-API / SSPI as # well as our client. if not self._transport.gss_kex_used: - our_server_key = self._system_host_keys.get( - server_hostkey_name, {} - ).get(keytype) - if our_server_key is None: - our_server_key = self._host_keys.get( - server_hostkey_name, {} - ).get(keytype, None) - if our_server_key is None: - # will raise exception if the key is rejected; - # let that fall out + server_key = t.get_remote_server_key() + if our_server_keys is None: + # will raise exception if the key is rejected self._policy.missing_host_key( self, server_hostkey_name, server_key ) - # if the callback returns, assume the key is ok - our_server_key = server_key - - if server_key != our_server_key: - raise BadHostKeyException(hostname, server_key, our_server_key) + else: + our_key = our_server_keys.get(server_key.get_name()) + if our_key != server_key: + if our_key is None: + our_key = list(our_server_keys.values())[0] + raise BadHostKeyException(hostname, server_key, our_key) if username is None: username = getpass.getuser() @@ -421,7 +423,14 @@ class SSHClient(ClosingContextManager): self._agent.close() self._agent = None - def exec_command(self, command, bufsize=-1, timeout=None, get_pty=False): + def exec_command( + self, + command, + bufsize=-1, + timeout=None, + get_pty=False, + environment=None, + ): """ Execute a command on the SSH server. A new `.Channel` is opened and the requested command is executed. The command's input and output @@ -434,6 +443,14 @@ class SSHClient(ClosingContextManager): Python :param int timeout: set command's channel timeout. See `.Channel.settimeout` + :param dict environment: + a dict of shell environment variables, to be merged into the + default environment that the remote command executes within. + + .. warning:: + Servers may silently reject some environment variables; see the + warning in `.Channel.set_environment_variable` for details. + :return: the stdin, stdout, and stderr of the executing command, as a 3-tuple @@ -444,6 +461,8 @@ class SSHClient(ClosingContextManager): if get_pty: chan.get_pty() chan.settimeout(timeout) + if environment: + chan.update_environment(environment) chan.exec_command(command) stdin = chan.makefile("wb", bufsize) stdout = chan.makefile("r", bufsize) @@ -457,6 +476,7 @@ class SSHClient(ClosingContextManager): height=24, width_pixels=0, height_pixels=0, + environment=None, ): """ Start an interactive shell session on the SSH server. A new `.Channel` @@ -469,6 +489,7 @@ class SSHClient(ClosingContextManager): :param int height: the height (in characters) of the terminal window :param int width_pixels: the width (in pixels) of the terminal window :param int height_pixels: the height (in pixels) of the terminal window + :param dict environment: the command's environment :return: a new `.Channel` connected to the remote shell :raises: `.SSHException` -- if the server fails to invoke a shell diff --git a/paramiko/message.py b/paramiko/message.py index 8181b9ec..869ac6c6 100644 --- a/paramiko/message.py +++ b/paramiko/message.py @@ -144,9 +144,6 @@ class Message(object): def get_int(self): """ Fetch an int from the stream. - - @return: a 32-bit unsigned integer. - @rtype: int """ return struct.unpack(">I", self.get_bytes(4))[0] @@ -176,23 +173,15 @@ class Message(object): def get_text(self): """ - Fetch a string from the stream. This could be a byte string and may - contain unprintable characters. (It's not unheard of for a string to - contain another byte-stream Message.) - - @return: a string. - @rtype: string + Fetch a Unicode string from the stream. """ - return u(self.get_bytes(self.get_int())) + return u(self.get_string()) def get_binary(self): """ Fetch a string from the stream. This could be a byte string and may contain unprintable characters. (It's not unheard of for a string to contain another byte-stream Message.) - - @return: a string. - @rtype: string """ return self.get_bytes(self.get_int()) diff --git a/paramiko/transport.py b/paramiko/transport.py index 82914559..6465a784 100644 --- a/paramiko/transport.py +++ b/paramiko/transport.py @@ -1,4 +1,5 @@ # Copyright (C) 2003-2007 Robey Pointer <robeypointer@gmail.com> +# Copyright (C) 2003-2007 Robey Pointer <robeypointer@gmail.com> # # This file is part of paramiko. # @@ -484,7 +485,7 @@ class Transport(threading.Thread, ClosingContextManager): # We need the FQDN to get this working with SSPI self.gss_host = socket.getfqdn(gss_host) - def start_client(self, event=None): + def start_client(self, event=None, timeout=None): """ Negotiate a new SSH2 session as a client. This is the first step after creating a new `.Transport`. A separate thread is created for protocol @@ -495,7 +496,7 @@ class Transport(threading.Thread, ClosingContextManager): be triggered. On failure, `is_active` will return ``False``. (Since 1.4) If ``event`` is ``None``, this method will not return until - negotation is done. On success, the method returns normally. + negotiation is done. On success, the method returns normally. Otherwise an SSHException is raised. After a successful negotiation, you will usually want to authenticate, @@ -512,6 +513,9 @@ class Transport(threading.Thread, ClosingContextManager): :param .threading.Event event: an event to trigger when negotiation is complete (optional) + :param float timeout: + a timeout, in seconds, for SSH2 session negotiation (optional) + :raises: `.SSHException` -- if negotiation fails (and no ``event`` was passed in) @@ -526,6 +530,7 @@ class Transport(threading.Thread, ClosingContextManager): # synchronous, wait for a result self.completion_event = event = threading.Event() self.start() + max_time = time.time() + timeout if timeout is not None else None while True: event.wait(0.1) if not self.active: @@ -533,7 +538,9 @@ class Transport(threading.Thread, ClosingContextManager): if e is not None: raise e raise SSHException("Negotiation failed.") - if event.is_set(): + if event.is_set() or ( + timeout is not None and time.time() >= max_time + ): break def start_server(self, event=None, server=None): @@ -2231,6 +2238,7 @@ class Transport(threading.Thread, ClosingContextManager): raise SSHException( "Incompatible ssh peer (can't match requested host key type)" ) # noqa + self._log_agreement("HostKey", agreed_keys[0], agreed_keys[0]) if self.server_mode: agreed_local_ciphers = list( diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index d6845e8e..5a13957d 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -37,6 +37,7 @@ Changelog ``black`` code formatter (both of which previously only existed in the 2.4 branch and above) to everything 2.0 and newer. This makes back/forward porting bugfixes significantly easier. +- :release:`2.1.5 <2018-03-12>` - :release:`2.0.8 <2018-03-12>` - :release:`1.18.5 <2018-03-12>` - :release:`1.17.6 <2018-03-12>` @@ -49,6 +50,7 @@ Changelog ``async``) so that we're compatible with the upcoming Python 3.7 release (where ``async`` is a new keyword.) Thanks to ``@vEpiphyte`` for the report. - :support:`- backported` Include LICENSE file in wheel archives. +- :release:`2.1.4 <2017-09-18>` - :release:`2.0.7 <2017-09-18>` - :release:`1.18.4 <2017-09-18>` - :bug:`1061` Clean up GSSAPI authentication procedures so they do not prevent @@ -59,18 +61,23 @@ Changelog - :bug:`1060` Fix key exchange (kex) algorithm list for GSSAPI authentication; previously, the list used solely out-of-date algorithms, and now contains newer ones listed preferentially before the old. Credit: Anselm Kruis. +- :bug:`1055 (1.17+)` (also :issue:`1056`, :issue:`1057`, :issue:`1058`, + :issue:`1059`) Fix up host-key checking in our GSSAPI support, which was + previously using an incorrect API call. Thanks to Anselm Kruis for the + patches. - :bug:`945 (1.18+)` (backport of :issue:`910` and re: :issue:`865`) SSHClient now requests the type of host key it has (e.g. from known_hosts) and does not consider a different type to be a "Missing" host key. This fixes a common case where an ECDSA key is in known_hosts and the server also has an RSA host key. Thanks to Pierce Lopez. -- :bug:`1055 (1.17+)` (also :issue:`1056`, :issue:`1057`, :issue:`1058`, - :issue:`1059`) Fix up host-key checking in our GSSAPI support, which was - previously using an incorrect API call. Thanks to Anselm Kruis for the - patches. +- :release:`2.1.3 <2017-06-09>` - :release:`2.0.6 <2017-06-09>` - :release:`1.18.3 <2017-06-09>` - :release:`1.17.5 <2017-06-09>` +- :bug:`865` SSHClient now requests the type of host key it has (e.g. from + known_hosts) and does not consider a different type to be a "Missing" host + key. This fixes a common case where an ECDSA key is in known_hosts and the + server also has an RSA host key. Thanks to Pierce Lopez. - :support:`906 (1.18+)` Clean up a handful of outdated imports and related tweaks. Thanks to Pierce Lopez. - :bug:`984` Enhance default cipher preference order such that @@ -124,6 +131,7 @@ Changelog Pierce Lopez for assistance. - :bug:`683 (1.17+)` Make ``util.log_to_file`` append instead of replace. Thanks to ``@vlcinsky`` for the report. +- :release:`2.1.2 <2017-02-20>` - :release:`2.0.5 <2017-02-20>` - :release:`1.18.2 <2017-02-20>` - :release:`1.17.4 <2017-02-20>` @@ -152,6 +160,7 @@ Changelog test-related file we don't support, and add PyPy to Travis-CI config. Thanks to Pierce Lopez for the final patch and Pedro Rodrigues for an earlier edition. +- :release:`2.1.1 <2016-12-12>` - :release:`2.0.4 <2016-12-12>` - :release:`1.18.1 <2016-12-12>` - :bug:`859 (1.18+)` (via :issue:`860`) A tweak to the original patch @@ -163,6 +172,11 @@ Changelog features (breaking `~paramiko.client.SSHClient.invoke_shell` with an ``AttributeError``.) The offending code has been stripped out of the 2.0.x line (but of course, remains in 2.1.x and above.) +- :bug:`859` (via :issue:`860`) A tweak to the original patch implementing + :issue:`398` was not fully applied, causing calls to + `~paramiko.client.SSHClient.invoke_shell` to fail with ``AttributeError``. + This has been fixed. Patch credit: Kirk Byers. +- :release:`2.1.0 <2016-12-09>` - :release:`2.0.3 <2016-12-09>` - :release:`1.18.0 <2016-12-09>` - :release:`1.17.3 <2016-12-09>` @@ -206,6 +220,16 @@ Changelog signature. Caught by ``@Score_Under``. - :bug:`681 (1.17+)` Fix a Python3-specific bug re: the handling of read buffers when using ``ProxyCommand``. Thanks to Paul Kapp for catch & patch. +- :feature:`398 (1.18+)` Add an ``environment`` dict argument to + `Client.exec_command <paramiko.client.SSHClient.exec_command>` (plus the + lower level `Channel.update_environment + <paramiko.channel.Channel.update_environment>` and + `Channel.set_environment_variable + <paramiko.channel.Channel.set_environment_variable>` methods) which + implements the ``env`` SSH message type. This means the remote shell + environment can be set without the use of ``VARNAME=value`` shell tricks, + provided the server's ``AcceptEnv`` lists the variables you need to set. + Thanks to Philip Lorenz for the pull request. - :support:`819 backported (>=1.15,<2.0)` Document how lacking ``gmp`` headers at install time can cause a significant performance hit if you build PyCrypto from source. (Most system-distributed packages already have this enabled.) diff --git a/tests/test_client.py b/tests/test_client.py index 03b163fc..fed38791 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -83,6 +83,16 @@ class NullServer(paramiko.ServerInterface): return False return True + def check_channel_env_request(self, channel, name, value): + if name == "INVALID_ENV": + return False + + if not hasattr(channel, "env"): + setattr(channel, "env", {}) + + channel.env[name] = value + return True + class SSHClientTest(unittest.TestCase): def setUp(self): @@ -108,9 +118,11 @@ class SSHClientTest(unittest.TestCase): allowed_keys = FINGERPRINTS.keys() self.socks, addr = self.sockl.accept() self.ts = paramiko.Transport(self.socks) - host_key = paramiko.RSAKey.from_private_key_file( - _support("test_rsa.key") - ) + keypath = _support("test_rsa.key") + host_key = paramiko.RSAKey.from_private_key_file(keypath) + self.ts.add_server_key(host_key) + keypath = _support("test_ecdsa_256.key") + host_key = paramiko.ECDSAKey.from_private_key_file(keypath) self.ts.add_server_key(host_key) server = NullServer(allowed_keys=allowed_keys) if delay: @@ -240,10 +252,9 @@ class SSHClientTest(unittest.TestCase): verify that SSHClient's AutoAddPolicy works. """ threading.Thread(target=self._run).start() - host_key = paramiko.RSAKey.from_private_key_file( - _support("test_rsa.key") - ) - public_host_key = paramiko.RSAKey(data=host_key.asbytes()) + hostname = "[%s]:%d" % (self.addr, self.port) + key_file = _support("test_ecdsa_256.key") + public_host_key = paramiko.ECDSAKey.from_private_key_file(key_file) self.tc = paramiko.SSHClient() self.tc.set_missing_host_key_policy(paramiko.AutoAddPolicy()) @@ -256,12 +267,8 @@ class SSHClientTest(unittest.TestCase): self.assertEqual("slowdive", self.ts.get_username()) self.assertEqual(True, self.ts.is_authenticated()) self.assertEqual(1, len(self.tc.get_host_keys())) - self.assertEqual( - public_host_key, - self.tc.get_host_keys()["[%s]:%d" % (self.addr, self.port)][ - "ssh-rsa" - ], - ) + new_host_key = list(self.tc.get_host_keys()[hostname].values())[0] + self.assertEqual(public_host_key, new_host_key) def test_5_save_host_keys(self): """ @@ -440,3 +447,90 @@ class SSHClientTest(unittest.TestCase): gss_kex=True, **self.connect_kwargs ) + + def _client_host_key_bad(self, host_key): + threading.Thread(target=self._run).start() + hostname = "[%s]:%d" % (self.addr, self.port) + + self.tc = paramiko.SSHClient() + self.tc.set_missing_host_key_policy(paramiko.WarningPolicy()) + known_hosts = self.tc.get_host_keys() + known_hosts.add(hostname, host_key.get_name(), host_key) + + self.assertRaises( + paramiko.BadHostKeyException, + self.tc.connect, + password="pygmalion", + **self.connect_kwargs + ) + + def _client_host_key_good(self, ktype, kfile): + threading.Thread(target=self._run).start() + hostname = "[%s]:%d" % (self.addr, self.port) + + self.tc = paramiko.SSHClient() + self.tc.set_missing_host_key_policy(paramiko.RejectPolicy()) + host_key = ktype.from_private_key_file(_support(kfile)) + known_hosts = self.tc.get_host_keys() + known_hosts.add(hostname, host_key.get_name(), host_key) + + self.tc.connect(password="pygmalion", **self.connect_kwargs) + self.event.wait(1.0) + self.assertTrue(self.event.is_set()) + self.assertTrue(self.ts.is_active()) + self.assertEqual(True, self.ts.is_authenticated()) + + def test_host_key_negotiation_1(self): + host_key = paramiko.ECDSAKey.generate() + self._client_host_key_bad(host_key) + + def test_host_key_negotiation_2(self): + host_key = paramiko.RSAKey.generate(2048) + self._client_host_key_bad(host_key) + + def test_host_key_negotiation_3(self): + self._client_host_key_good(paramiko.ECDSAKey, "test_ecdsa_256.key") + + def test_host_key_negotiation_4(self): + self._client_host_key_good(paramiko.RSAKey, "test_rsa.key") + + def test_update_environment(self): + """ + Verify that environment variables can be set by the client. + """ + threading.Thread(target=self._run).start() + + self.tc = paramiko.SSHClient() + self.tc.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.assertEqual(0, len(self.tc.get_host_keys())) + self.tc.connect( + self.addr, self.port, username="slowdive", password="pygmalion" + ) + + self.event.wait(1.0) + self.assertTrue(self.event.isSet()) + self.assertTrue(self.ts.is_active()) + + target_env = {b"A": b"B", b"C": b"d"} + + self.tc.exec_command("yes", environment=target_env) + schan = self.ts.accept(1.0) + self.assertEqual(target_env, getattr(schan, "env", {})) + schan.close() + + # Cannot use assertRaises in context manager mode as it is not supported + # in Python 2.6. + try: + # Verify that a rejection by the server can be detected + self.tc.exec_command("yes", environment={b"INVALID_ENV": b""}) + except SSHException as e: + self.assertTrue( + "INVALID_ENV" in str(e), + "Expected variable name in error message", + ) + self.assertTrue( + isinstance(e.args[1], SSHException), + "Expected original SSHException in exception", + ) + else: + self.assertFalse(False, "SSHException was not thrown.") |