diff options
-rw-r--r-- | paramiko/_version.py | 2 | ||||
-rw-r--r-- | paramiko/channel.py | 52 | ||||
-rw-r--r-- | paramiko/client.py | 25 | ||||
-rw-r--r-- | paramiko/config.py | 1 | ||||
-rw-r--r-- | paramiko/transport.py | 11 | ||||
-rw-r--r-- | sites/www/changelog.rst | 24 | ||||
-rw-r--r-- | tests/test_client.py | 45 | ||||
-rw-r--r-- | tests/test_util.py | 43 |
8 files changed, 181 insertions, 22 deletions
diff --git a/paramiko/_version.py b/paramiko/_version.py index 7adb8add..f9bc8a8a 100644 --- a/paramiko/_version.py +++ b/paramiko/_version.py @@ -1,2 +1,2 @@ -__version_info__ = (1, 17, 3) +__version_info__ = (1, 18, 1) __version__ = '.'.join(map(str, __version_info__)) diff --git a/paramiko/channel.py b/paramiko/channel.py index 64c645d0..69db0107 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -283,6 +283,58 @@ 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 ebf21b08..53282c01 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -335,7 +335,7 @@ class SSHClient (ClosingContextManager): t.set_log_channel(self._log_channel) if banner_timeout is not None: t.banner_timeout = banner_timeout - t.start_client() + t.start_client(timeout=timeout) ResourceManager.register(self, t) server_key = t.get_remote_server_key() @@ -398,7 +398,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 @@ -411,6 +418,14 @@ class SSHClient (ClosingContextManager): Python :param int timeout: set command's channel timeout. See `Channel.settimeout`.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 @@ -421,6 +436,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) @@ -428,7 +445,7 @@ class SSHClient (ClosingContextManager): return stdin, stdout, stderr def invoke_shell(self, term='vt100', width=80, height=24, width_pixels=0, - height_pixels=0): + height_pixels=0, environment=None): """ Start an interactive shell session on the SSH server. A new `.Channel` is opened and connected to a pseudo-terminal using the requested @@ -440,12 +457,14 @@ 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 """ chan = self._transport.open_session() chan.get_pty(term, width, height, width_pixels, height_pixels) + chan.update_environment(environment or {}) chan.invoke_shell() return chan diff --git a/paramiko/config.py b/paramiko/config.py index 7374eb1a..42831fab 100644 --- a/paramiko/config.py +++ b/paramiko/config.py @@ -209,6 +209,7 @@ class SSHConfig (object): ], 'proxycommand': [ + ('~', homedir), ('%h', config['hostname']), ('%p', port), ('%r', remoteuser) diff --git a/paramiko/transport.py b/paramiko/transport.py index 120f9e84..8eaa094a 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. # @@ -444,7 +445,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 @@ -455,7 +456,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, @@ -472,6 +473,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) """ @@ -485,6 +489,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: @@ -492,7 +497,7 @@ 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): diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index 36ef443f..8286baa9 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -8,6 +8,12 @@ Changelog * :support:`866 backported` (also :issue:`838`) Remove an old 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:`1.18.1 <2016-12-12>` +* :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:`1.18.0 <2016-12-09>` * :release:`1.17.3 <2016-12-09>` * :bug:`802 (1.17+)` (via :issue:`804`) Update our vendored Windows API module to address errors of the form ``AttributeError: 'module' object has no @@ -21,6 +27,14 @@ Changelog Gabi Davar. * :support:`792 (1.17+)` Minor updates to the README and demos; thanks to Alan Yee. +* :feature:`780 (1.18+)` (also :issue:`779`, and may help users affected by + :issue:`520`) Add an optional ``timeout`` parameter to + `Transport.start_client <paramiko.transport.Transport.start_client>` (and + feed it the value of the configured connection timeout when used within + `SSHClient <paramiko.client.SSHClient>`.) This helps prevent situations where + network connectivity isn't timing out, but the remote server is otherwise + unable to service the connection in a timely manner. Credit to + ``@sanseihappa``. * :bug:`789` Add a missing ``.closed`` attribute (plus ``._closed`` because reasons) to `ProxyCommand <paramiko.proxy.ProxyCommand>` so the earlier partial fix for :issue:`520` works in situations where one is gatewaying via @@ -36,6 +50,16 @@ Changelog signature. Caught by ``@Score_Under``. * :bug:`681` 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 f775532e..49bdf528 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -79,6 +79,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): @@ -373,3 +383,38 @@ class SSHClientTest (unittest.TestCase): password='pygmalion', ) self._test_connection(**kwargs) + + 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.') diff --git a/tests/test_util.py b/tests/test_util.py index e25f0563..a31e4507 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -66,7 +66,7 @@ from paramiko import * class UtilTest(unittest.TestCase): - def test_1_import(self): + def test_import(self): """ verify that all the classes can be imported from paramiko. """ @@ -104,7 +104,7 @@ class UtilTest(unittest.TestCase): self.assertTrue('SSHConfig' in symbols) self.assertTrue('util' in symbols) - def test_2_parse_config(self): + def test_parse_config(self): global test_config_file f = StringIO(test_config_file) config = paramiko.util.parse_ssh_config(f) @@ -114,7 +114,7 @@ class UtilTest(unittest.TestCase): {'host': ['*'], 'config': {'crazy': 'something dumb'}}, {'host': ['spoo.example.com'], 'config': {'crazy': 'something else'}}]) - def test_3_host_config(self): + def test_host_config(self): global test_config_file f = StringIO(test_config_file) config = paramiko.util.parse_ssh_config(f) @@ -141,12 +141,12 @@ class UtilTest(unittest.TestCase): values ) - def test_4_generate_key_bytes(self): + def test_generate_key_bytes(self): x = paramiko.util.generate_key_bytes(sha1, b'ABCDEFGH', 'This is my secret passphrase.', 64) hex = ''.join(['%02x' % byte_ord(c) for c in x]) self.assertEqual(hex, '9110e2f6793b69363e58173e9436b13a5a4b339005741d5c680e505f57d871347b4239f14fb5c46e857d5e100424873ba849ac699cea98d729e57b3e84378e8b') - def test_5_host_keys(self): + def test_host_keys(self): with open('hostfile.temp', 'w') as f: f.write(test_hosts_file) try: @@ -159,7 +159,7 @@ class UtilTest(unittest.TestCase): finally: os.unlink('hostfile.temp') - def test_7_host_config_expose_issue_33(self): + def test_host_config_expose_issue_33(self): test_config_file = """ Host www13.* Port 22 @@ -178,7 +178,7 @@ Host * {'hostname': host, 'port': '22'} ) - def test_8_eintr_retry(self): + def test_eintr_retry(self): self.assertEqual('foo', paramiko.util.retry_on_signal(lambda: 'foo')) # Variables that are set by raises_intr @@ -203,7 +203,7 @@ Host * self.assertRaises(AssertionError, lambda: paramiko.util.retry_on_signal(raises_other_exception)) - def test_9_proxycommand_config_equals_parsing(self): + def test_proxycommand_config_equals_parsing(self): """ ProxyCommand should not split on equals signs within the value. """ @@ -222,7 +222,7 @@ Host equals-delimited 'foo bar=biz baz' ) - def test_10_proxycommand_interpolation(self): + def test_proxycommand_interpolation(self): """ ProxyCommand should perform interpolation on the value """ @@ -248,7 +248,20 @@ Host * val ) - def test_11_host_config_test_negation(self): + def test_proxycommand_tilde_expansion(self): + """ + Tilde (~) should be expanded inside ProxyCommand + """ + config = paramiko.util.parse_ssh_config(StringIO(""" +Host test + ProxyCommand ssh -F ~/.ssh/test_config bastion nc %h %p +""")) + self.assertEqual( + 'ssh -F %s/.ssh/test_config bastion nc test 22' % os.path.expanduser('~'), + host_config('test', config)['proxycommand'] + ) + + def test_host_config_test_negation(self): test_config_file = """ Host www13.* !*.example.com Port 22 @@ -270,7 +283,7 @@ Host * {'hostname': host, 'port': '8080'} ) - def test_12_host_config_test_proxycommand(self): + def test_host_config_test_proxycommand(self): test_config_file = """ Host proxy-with-equal-divisor-and-space ProxyCommand = foo=bar @@ -298,7 +311,7 @@ ProxyCommand foo=bar:%h-%p values ) - def test_11_host_config_test_identityfile(self): + def test_host_config_test_identityfile(self): test_config_file = """ IdentityFile id_dsa0 @@ -328,7 +341,7 @@ IdentityFile id_dsa22 values ) - def test_12_config_addressfamily_and_lazy_fqdn(self): + def test_config_addressfamily_and_lazy_fqdn(self): """ Ensure the code path honoring non-'all' AddressFamily doesn't asplode """ @@ -344,13 +357,13 @@ IdentityFile something_%l_using_fqdn self.assertEqual(32767, paramiko.util.clamp_value(32767, 32765, 32769)) self.assertEqual(32769, paramiko.util.clamp_value(32767, 32770, 32769)) - def test_13_config_dos_crlf_succeeds(self): + def test_config_dos_crlf_succeeds(self): config_file = StringIO("host abcqwerty\r\nHostName 127.0.0.1\r\n") config = paramiko.SSHConfig() config.parse(config_file) self.assertEqual(config.lookup("abcqwerty")["hostname"], "127.0.0.1") - def test_14_get_hostnames(self): + def test_get_hostnames(self): f = StringIO(test_config_file) config = paramiko.util.parse_ssh_config(f) self.assertEqual(config.get_hostnames(), set(['*', '*.example.com', 'spoo.example.com'])) |