From 630a35adb0752dd9a4d74539b91b243bafb4c7d7 Mon Sep 17 00:00:00 2001 From: Matt Martz Date: Wed, 23 Dec 2015 14:57:24 -0600 Subject: [PATCH 1/5] Add ProxyCommand support to the paramiko connection plugin --- docsite/rst/intro_configuration.rst | 11 +++++++++++ lib/ansible/constants.py | 1 + .../plugins/connection/paramiko_ssh.py | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/docsite/rst/intro_configuration.rst b/docsite/rst/intro_configuration.rst index ccfb456ed9..7f21c2e1f6 100644 --- a/docsite/rst/intro_configuration.rst +++ b/docsite/rst/intro_configuration.rst @@ -739,6 +739,17 @@ instead. Setting it to False will improve performance and is recommended when h record_host_keys=True +.. _paramiko_proxy_command + +proxy_command +============= + +.. versionadded:: 2.1 + +Use an OpenSSH like ProxyCommand for proxying all Paramiko SSH connections through a bastion or jump host. Requires a minimum of Paramiko version 1.9.0. On Enterprise Linux 6 this is provided by ``python-paramiko1.10`` in the EPEL repository:: + + proxy_command = ssh -W "%h:%p" bastion + .. _openssh_settings: OpenSSH Specific Settings diff --git a/lib/ansible/constants.py b/lib/ansible/constants.py index 5df9602246..7d6a76a19e 100644 --- a/lib/ansible/constants.py +++ b/lib/ansible/constants.py @@ -244,6 +244,7 @@ ANSIBLE_SSH_CONTROL_PATH = get_config(p, 'ssh_connection', 'control_path', ANSIBLE_SSH_PIPELINING = get_config(p, 'ssh_connection', 'pipelining', 'ANSIBLE_SSH_PIPELINING', False, boolean=True) ANSIBLE_SSH_RETRIES = get_config(p, 'ssh_connection', 'retries', 'ANSIBLE_SSH_RETRIES', 0, integer=True) PARAMIKO_RECORD_HOST_KEYS = get_config(p, 'paramiko_connection', 'record_host_keys', 'ANSIBLE_PARAMIKO_RECORD_HOST_KEYS', True, boolean=True) +PARAMIKO_PROXY_COMMAND = get_config(p, 'paramiko_connection', 'proxy_command', 'ANSIBLE_PARAMIKO_PROXY_COMMAND', None) # obsolete -- will be formally removed diff --git a/lib/ansible/plugins/connection/paramiko_ssh.py b/lib/ansible/plugins/connection/paramiko_ssh.py index ab9ce90db9..ea6ca3809d 100644 --- a/lib/ansible/plugins/connection/paramiko_ssh.py +++ b/lib/ansible/plugins/connection/paramiko_ssh.py @@ -158,6 +158,24 @@ class Connection(ConnectionBase): pass # file was not found, but not required to function ssh.load_system_host_keys() + sock_kwarg = {} + if C.PARAMIKO_PROXY_COMMAND: + replacers = { + '%h': self._play_context.remote_addr, + '%p': port, + '%r': self._play_context.remote_user + } + proxy_command = C.PARAMIKO_PROXY_COMMAND + for find, replace in replacers.items(): + proxy_command = proxy_command.replace(find, str(replace)) + try: + sock_kwarg = {'sock': paramiko.ProxyCommand(proxy_command)} + display.vvv("CONFIGURE PROXY COMMAND FOR CONNECTION: %s" % proxy_command, host=self._play_context.remote_addr) + except AttributeError: + display.warning('Paramiko ProxyCommand support unavailable. ' + 'Please upgrade to Paramiko 1.9.0 or newer. ' + 'Not using configured ProxyCommand') + ssh.set_missing_host_key_policy(MyAddPolicy(self._new_stdin, self)) allow_agent = True @@ -179,6 +197,7 @@ class Connection(ConnectionBase): password=self._play_context.password, timeout=self._play_context.timeout, port=port, + **sock_kwarg ) except Exception as e: msg = str(e) From a8e0763d1ec003e5f83c1d848578f7a0a02c9df4 Mon Sep 17 00:00:00 2001 From: Matt Martz Date: Thu, 24 Dec 2015 15:00:53 -0600 Subject: [PATCH 2/5] Move _split_args from ssh.py to ConnectionBase so we can use it in other connection plugins --- lib/ansible/plugins/connection/__init__.py | 11 +++++++++++ lib/ansible/plugins/connection/ssh.py | 14 ++------------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/ansible/plugins/connection/__init__.py b/lib/ansible/plugins/connection/__init__.py index 06616bac4c..4b6c17dc32 100644 --- a/lib/ansible/plugins/connection/__init__.py +++ b/lib/ansible/plugins/connection/__init__.py @@ -23,6 +23,7 @@ __metaclass__ = type import fcntl import gettext import os +import shlex from abc import ABCMeta, abstractmethod, abstractproperty from functools import wraps @@ -31,6 +32,7 @@ from ansible.compat.six import with_metaclass from ansible import constants as C from ansible.errors import AnsibleError from ansible.plugins import shell_loader +from ansible.utils.unicode import to_bytes, to_unicode try: from __main__ import display @@ -112,6 +114,15 @@ class ConnectionBase(with_metaclass(ABCMeta, object)): ''' pass + @staticmethod + def _split_ssh_args(argstring): + """ + Takes a string like '-o Foo=1 -o Bar="foo bar"' and returns a + list ['-o', 'Foo=1', '-o', 'Bar=foo bar'] that can be added to + the argument list. The list will not contain any empty elements. + """ + return [to_unicode(x.strip()) for x in shlex.split(to_bytes(argstring)) if x.strip()] + @abstractproperty def transport(self): """String used to identify this Connection class from other classes""" diff --git a/lib/ansible/plugins/connection/ssh.py b/lib/ansible/plugins/connection/ssh.py index a2abcf20ae..3da701aa8e 100644 --- a/lib/ansible/plugins/connection/ssh.py +++ b/lib/ansible/plugins/connection/ssh.py @@ -24,7 +24,6 @@ import os import pipes import pty import select -import shlex import subprocess import time @@ -100,15 +99,6 @@ class Connection(ConnectionBase): return controlpersist, controlpath - @staticmethod - def _split_args(argstring): - """ - Takes a string like '-o Foo=1 -o Bar="foo bar"' and returns a - list ['-o', 'Foo=1', '-o', 'Bar=foo bar'] that can be added to - the argument list. The list will not contain any empty elements. - """ - return [to_unicode(x.strip()) for x in shlex.split(to_bytes(argstring)) if x.strip()] - def _add_args(self, explanation, args): """ Adds the given args to self._command and displays a caller-supplied @@ -157,7 +147,7 @@ class Connection(ConnectionBase): # Next, we add [ssh_connection]ssh_args from ansible.cfg. if self._play_context.ssh_args: - args = self._split_args(self._play_context.ssh_args) + args = self._split_ssh_args(self._play_context.ssh_args) self._add_args("ansible.cfg set ssh_args", args) # Now we add various arguments controlled by configuration file settings @@ -210,7 +200,7 @@ class Connection(ConnectionBase): for opt in ['ssh_common_args', binary + '_extra_args']: attr = getattr(self._play_context, opt, None) if attr is not None: - args = self._split_args(attr) + args = self._split_ssh_args(attr) self._add_args("PlayContext set %s" % opt, args) # Check if ControlPersist is enabled and add a ControlPath if one hasn't From 0296209bc139d00d696a9d0722bee01f3bf99c2d Mon Sep 17 00:00:00 2001 From: Matt Martz Date: Thu, 24 Dec 2015 15:01:41 -0600 Subject: [PATCH 3/5] Parse ansible_ssh_common_args looking for ProxyCommand, for use in paramiko --- .../plugins/connection/paramiko_ssh.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/ansible/plugins/connection/paramiko_ssh.py b/lib/ansible/plugins/connection/paramiko_ssh.py index ea6ca3809d..47028a60a5 100644 --- a/lib/ansible/plugins/connection/paramiko_ssh.py +++ b/lib/ansible/plugins/connection/paramiko_ssh.py @@ -32,6 +32,7 @@ import tempfile import traceback import fcntl import sys +import re from termios import tcflush, TCIFLUSH from binascii import hexlify @@ -55,6 +56,9 @@ The %s key fingerprint is %s. Are you sure you want to continue connecting (yes/no)? """ +# SSH Options Regex +SETTINGS_REGEX = re.compile(r'(\w+)(?:\s*=\s*|\s+)(.+)') + # prevent paramiko warning noise -- see http://stackoverflow.com/questions/3920502/ HAVE_PARAMIKO=False with warnings.catch_warnings(): @@ -158,14 +162,34 @@ class Connection(ConnectionBase): pass # file was not found, but not required to function ssh.load_system_host_keys() + proxy_command = None + # Parse ansible_ssh_common_args, specifically looking for ProxyCommand + ssh_common_args = getattr(self._play_context, 'ssh_common_args', None) + if ssh_common_args is not None: + args = self._split_ssh_args(ssh_common_args) + for i, arg in enumerate(args): + if arg.lower() == 'proxycommand': + # _split_ssh_args split ProxyCommand from the command itself + proxy_command = args[i + 1] + else: + # ProxyCommand and the command itself are a single string + match = SETTINGS_REGEX.match(arg) + if match: + if match.group(1).lower() == 'proxycommand': + proxy_command = match.group(2) + + if proxy_command: + break + + proxy_command = proxy_command or C.PARAMIKO_PROXY_COMMAND + sock_kwarg = {} - if C.PARAMIKO_PROXY_COMMAND: + if proxy_command: replacers = { '%h': self._play_context.remote_addr, '%p': port, '%r': self._play_context.remote_user } - proxy_command = C.PARAMIKO_PROXY_COMMAND for find, replace in replacers.items(): proxy_command = proxy_command.replace(find, str(replace)) try: From 2587edb4f31390f51678bfaa2764146a16ed2841 Mon Sep 17 00:00:00 2001 From: Matt Martz Date: Thu, 24 Dec 2015 15:10:42 -0600 Subject: [PATCH 4/5] Move proxycommand parsing into _parse_proxy_command --- .../plugins/connection/paramiko_ssh.py | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/lib/ansible/plugins/connection/paramiko_ssh.py b/lib/ansible/plugins/connection/paramiko_ssh.py index 47028a60a5..21dfe0c7bc 100644 --- a/lib/ansible/plugins/connection/paramiko_ssh.py +++ b/lib/ansible/plugins/connection/paramiko_ssh.py @@ -141,27 +141,7 @@ class Connection(ConnectionBase): self.ssh = SSH_CONNECTION_CACHE[cache_key] = self._connect_uncached() return self - def _connect_uncached(self): - ''' activates the connection object ''' - - if not HAVE_PARAMIKO: - raise AnsibleError("paramiko is not installed") - - port = self._play_context.port or 22 - display.vvv("ESTABLISH CONNECTION FOR USER: %s on PORT %s TO %s" % (self._play_context.remote_user, port, self._play_context.remote_addr), host=self._play_context.remote_addr) - - ssh = paramiko.SSHClient() - - self.keyfile = os.path.expanduser("~/.ssh/known_hosts") - - if C.HOST_KEY_CHECKING: - try: - #TODO: check if we need to look at several possible locations, possible for loop - ssh.load_system_host_keys("/etc/ssh/ssh_known_hosts") - except IOError: - pass # file was not found, but not required to function - ssh.load_system_host_keys() - + def _parse_proxy_command(self, port=22): proxy_command = None # Parse ansible_ssh_common_args, specifically looking for ProxyCommand ssh_common_args = getattr(self._play_context, 'ssh_common_args', None) @@ -200,6 +180,31 @@ class Connection(ConnectionBase): 'Please upgrade to Paramiko 1.9.0 or newer. ' 'Not using configured ProxyCommand') + return sock_kwarg + + def _connect_uncached(self): + ''' activates the connection object ''' + + if not HAVE_PARAMIKO: + raise AnsibleError("paramiko is not installed") + + port = self._play_context.port or 22 + display.vvv("ESTABLISH CONNECTION FOR USER: %s on PORT %s TO %s" % (self._play_context.remote_user, port, self._play_context.remote_addr), host=self._play_context.remote_addr) + + ssh = paramiko.SSHClient() + + self.keyfile = os.path.expanduser("~/.ssh/known_hosts") + + if C.HOST_KEY_CHECKING: + try: + #TODO: check if we need to look at several possible locations, possible for loop + ssh.load_system_host_keys("/etc/ssh/ssh_known_hosts") + except IOError: + pass # file was not found, but not required to function + ssh.load_system_host_keys() + + sock_kwarg = self._parse_proxy_command(port) + ssh.set_missing_host_key_policy(MyAddPolicy(self._new_stdin, self)) allow_agent = True From 45d9cfcc6fded4c17a28fe77d5f04c173a396332 Mon Sep 17 00:00:00 2001 From: Matt Martz Date: Mon, 11 Jan 2016 11:55:25 -0600 Subject: [PATCH 5/5] Coalesce forms of ssh_args in order of most specific to least --- lib/ansible/plugins/connection/paramiko_ssh.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/ansible/plugins/connection/paramiko_ssh.py b/lib/ansible/plugins/connection/paramiko_ssh.py index 21dfe0c7bc..dfac62b7a8 100644 --- a/lib/ansible/plugins/connection/paramiko_ssh.py +++ b/lib/ansible/plugins/connection/paramiko_ssh.py @@ -144,9 +144,13 @@ class Connection(ConnectionBase): def _parse_proxy_command(self, port=22): proxy_command = None # Parse ansible_ssh_common_args, specifically looking for ProxyCommand - ssh_common_args = getattr(self._play_context, 'ssh_common_args', None) + ssh_args = [ + getattr(self._play_context, 'ssh_extra_args', ''), + getattr(self._play_context, 'ssh_common_args', ''), + getattr(self._play_context, 'ssh_args', ''), + ] if ssh_common_args is not None: - args = self._split_ssh_args(ssh_common_args) + args = self._split_ssh_args(' '.join(ssh_args)) for i, arg in enumerate(args): if arg.lower() == 'proxycommand': # _split_ssh_args split ProxyCommand from the command itself