From 28c8be18c25e9d10f8e4759e489949f1e22d6346 Mon Sep 17 00:00:00 2001 From: Philip Lorenz Date: Sun, 21 Sep 2014 12:31:40 +0200 Subject: Support transmission of environment variables The SSH protocol allows the client to transmit environment variables to the server. This is particularly useful if the user wants to modify the environment of an executed command without having to reexecute the actual command from a shell. This patch extends the Client and Channel interface to allow the transmission of environment variables to the server side. In order to use this feature the SSH server must accept environment variables from the client (e.g. the AcceptEnv configuration directive of OpenSSH). FROM BITPROPHET: backport cherry-pick to 1.x line --- paramiko/channel.py | 41 +++++++++++++++++++++++++++++++++++++++++ paramiko/client.py | 9 +++++++-- tests/test_client.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/paramiko/channel.py b/paramiko/channel.py index 3a05bdc4..7735e1f1 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -283,6 +283,47 @@ class Channel (ClosingContextManager): m.add_int(height_pixels) self.transport._send_user_message(m) + @open_only + def update_environment_variables(self, environment): + """ + Updates this channel's environment. This operation is additive - i.e. + the current environment is not reset before the given environment + variables are set. + + :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: + raise SSHException("Failed to set environment variable \"%s\"." % name, e) + + @open_only + def set_environment_variable(self, name, value): + """ + Set the value of an environment variable. + + :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(True) + m.add_string(name) + m.add_string(value) + self._event_pending() + self.transport._send_user_message(m) + self._wait_for_event() + 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..681760cf 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -398,7 +398,8 @@ 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 +412,7 @@ class SSHClient (ClosingContextManager): Python :param int timeout: set command's channel timeout. See `Channel.settimeout`.settimeout + :param dict environment: the command's environment :return: the stdin, stdout, and stderr of the executing command, as a 3-tuple @@ -421,6 +423,7 @@ class SSHClient (ClosingContextManager): if get_pty: chan.get_pty() chan.settimeout(timeout) + chan.update_environment_variables(environment or {}) chan.exec_command(command) stdin = chan.makefile('wb', bufsize) stdout = chan.makefile('r', bufsize) @@ -428,7 +431,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 +443,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_variables(environment or {}) chan.invoke_shell() return chan diff --git a/tests/test_client.py b/tests/test_client.py index d39febac..e7ebbc6a 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.') -- cgit v1.2.3 From 4a2747fbcb817f8654c285838e5a37eb4dff02a9 Mon Sep 17 00:00:00 2001 From: Jeff Forcier Date: Wed, 30 Nov 2016 20:31:04 -0800 Subject: Cleanup / rename / doc updates / changelog, re #398 --- paramiko/channel.py | 17 ++++++++++------- paramiko/client.py | 17 +++++++++++++---- sites/www/changelog.rst | 9 +++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/paramiko/channel.py b/paramiko/channel.py index 7735e1f1..7689b266 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -284,14 +284,16 @@ class Channel (ClosingContextManager): self.transport._send_user_message(m) @open_only - def update_environment_variables(self, environment): + def update_environment(self, environment): """ - Updates this channel's environment. This operation is additive - i.e. - the current environment is not reset before the given environment - variables are set. + Updates this channel's remote shell environment. - :param dict environment: a dictionary containing the name and respective - values to set + .. note:: + This operation is additive - i.e. the current environment is not + reset before the given environment variables are set. + + :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 @@ -300,7 +302,8 @@ class Channel (ClosingContextManager): try: self.set_environment_variable(name, value) except SSHException as e: - raise SSHException("Failed to set environment variable \"%s\"." % name, e) + err = "Failed to set environment variable \"{0}\"." + raise SSHException(err.format(name), e) @open_only def set_environment_variable(self, name, value): diff --git a/paramiko/client.py b/paramiko/client.py index 681760cf..978bde51 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -398,8 +398,14 @@ class SSHClient (ClosingContextManager): self._agent.close() self._agent = None - def exec_command(self, command, bufsize=-1, timeout=None, get_pty=False, - environment=None): + 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 @@ -412,7 +418,9 @@ class SSHClient (ClosingContextManager): Python :param int timeout: set command's channel timeout. See `Channel.settimeout`.settimeout - :param dict environment: the command's environment + :param dict environment: + a dict of shell environment variables, to be merged into the + default environment that the remote command executes within. :return: the stdin, stdout, and stderr of the executing command, as a 3-tuple @@ -423,7 +431,8 @@ class SSHClient (ClosingContextManager): if get_pty: chan.get_pty() chan.settimeout(timeout) - chan.update_environment_variables(environment or {}) + if environment: + chan.update_environment(environment) chan.exec_command(command) stdin = chan.makefile('wb', bufsize) stdout = chan.makefile('r', bufsize) diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index e4555e32..496544ef 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -13,6 +13,15 @@ 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 ` (plus the + lower level `Channel.update_environment + ` and + `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. + 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.) -- cgit v1.2.3 From af455259c15edb38239083314a8b9e91cd0d3f06 Mon Sep 17 00:00:00 2001 From: Jeff Forcier Date: Thu, 1 Dec 2016 13:27:58 -0800 Subject: Add some warnings about AcceptEnv to env var setting bits. Re #398 --- paramiko/channel.py | 10 ++++++++++ paramiko/client.py | 5 +++++ sites/www/changelog.rst | 3 ++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/paramiko/channel.py b/paramiko/channel.py index 7689b266..3b6a59df 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -292,6 +292,10 @@ class Channel (ClosingContextManager): 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: @@ -310,6 +314,12 @@ class Channel (ClosingContextManager): """ 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 diff --git a/paramiko/client.py b/paramiko/client.py index 978bde51..40cd5cf2 100644 --- a/paramiko/client.py +++ b/paramiko/client.py @@ -421,6 +421,11 @@ class SSHClient (ClosingContextManager): :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 diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst index 496544ef..02421988 100644 --- a/sites/www/changelog.rst +++ b/sites/www/changelog.rst @@ -20,7 +20,8 @@ Changelog `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. + 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 -- cgit v1.2.3 From a506b2b3a12ce657011cce27784b24442c56c1ca Mon Sep 17 00:00:00 2001 From: Jeff Forcier Date: Thu, 1 Dec 2016 14:03:34 -0800 Subject: Don't expect reply to env-setting messages. Re #398 --- paramiko/channel.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/paramiko/channel.py b/paramiko/channel.py index 3b6a59df..52b5d849 100644 --- a/paramiko/channel.py +++ b/paramiko/channel.py @@ -330,12 +330,10 @@ class Channel (ClosingContextManager): m.add_byte(cMSG_CHANNEL_REQUEST) m.add_int(self.remote_chanid) m.add_string('env') - m.add_boolean(True) + m.add_boolean(False) m.add_string(name) m.add_string(value) - self._event_pending() self.transport._send_user_message(m) - self._wait_for_event() def exit_status_ready(self): """ -- cgit v1.2.3 From d3ad14d3cebda84a37be4baf6a018f1064e3a325 Mon Sep 17 00:00:00 2001 From: qqo Date: Wed, 13 Jul 2016 02:00:10 +0300 Subject: allow ~ expansion inside proxycommand --- paramiko/config.py | 1 + 1 file changed, 1 insertion(+) 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) -- cgit v1.2.3 From b788056982e415d4763d930a650fac437bd076ce Mon Sep 17 00:00:00 2001 From: qqo Date: Wed, 20 Jul 2016 16:06:22 +0300 Subject: Add tests for ~ expansion inside proxycommand --- tests/test_util.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_util.py b/tests/test_util.py index e25f0563..87624711 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -248,6 +248,19 @@ Host * val ) + def test_11_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_11_host_config_test_negation(self): test_config_file = """ Host www13.* !*.example.com -- cgit v1.2.3 From 2b06f22ed73906d780a1baa4f16c5f04007e97aa Mon Sep 17 00:00:00 2001 From: Jeff Forcier Date: Mon, 5 Dec 2016 19:02:24 -0800 Subject: Just get rid of the frickin' numbers. Every other merge screws them up anyway. --- tests/test_util.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/test_util.py b/tests/test_util.py index 87624711..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,7 @@ Host * val ) - def test_11_proxycommand_tilde_expansion(self): + def test_proxycommand_tilde_expansion(self): """ Tilde (~) should be expanded inside ProxyCommand """ @@ -261,7 +261,7 @@ Host test host_config('test', config)['proxycommand'] ) - def test_11_host_config_test_negation(self): + def test_host_config_test_negation(self): test_config_file = """ Host www13.* !*.example.com Port 22 @@ -283,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 @@ -311,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 @@ -341,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 """ @@ -357,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'])) -- cgit v1.2.3