diff --git a/README.md b/README.md index b8055d0..e034a00 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ connection_plugins = /path/to/connection_plugins/lxc_ssh Then, modify your `hosts` file to use the `lxc_ssh` transport: ``` -container ansible_host=server ansible_connection=lxc_ssh ansible_ssh_extra_args=container +container ansible_host=server ansible_connection=lxc_ssh lxc_host=container ``` @@ -47,7 +47,7 @@ The following is an extract from a Playbook which creates a container. First the ``` [containers] -web ansible_host=physical.host ansible_ssh_extra_args=web +web ansible_host=physical.host lxc_host=web ``` The Playbook: @@ -83,9 +83,3 @@ The Playbook: ``` The actual container creation is redirected to the `ansible_host`, also fact gathering is turned off because the container is not yet live. It might be a good idea to create the containers one by one, hence the serialization. In my case I also setup ssh access and hostname resolution during the container setup - this does not work well when run in parallel for multiple containers. - - -## notes - -* I haven't found any proper method to access the 'inventory_name' from the connection plugin, so I used 'ansible_ssh_extra_args' to store the name of the container. - diff --git a/lxc_ssh.py b/lxc_ssh.py index dd6c801..771ae79 100644 --- a/lxc_ssh.py +++ b/lxc_ssh.py @@ -19,6 +19,338 @@ # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . # + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: lxc_ssh + short_description: connect via ssh and lxc to remote lxc guest + description: + - This connection plugin allows ansible to communicate to the target machines via normal ssh and lxc cli. + - Ansible does not expose a channel to allow communication between the user and the ssh process to accept + a password manually to decrypt an ssh key when using this connection plugin (which is the default). The + use of ``ssh-agent`` is highly recommended. + author: Pierre Chifflier + notes: + - Many options default to 'None' here but that only means we don't override the ssh tool's defaults and/or configuration. + For example, if you specify the port in this plugin it will override any C(Port) entry in your C(.ssh/config). + options: + host: + description: Hostname/ip to connect to. + vars: + - name: inventory_hostname + - name: ansible_host + - name: ansible_ssh_host + - name: delegated_vars['ansible_host'] + - name: delegated_vars['ansible_ssh_host'] + host_key_checking: + description: Determines if ssh should check host keys + type: boolean + ini: + - section: defaults + key: 'host_key_checking' + - section: ssh_connection + key: 'host_key_checking' + version_added: '2.5' + env: + - name: ANSIBLE_HOST_KEY_CHECKING + - name: ANSIBLE_SSH_HOST_KEY_CHECKING + version_added: '2.5' + vars: + - name: ansible_host_key_checking + version_added: '2.5' + - name: ansible_ssh_host_key_checking + version_added: '2.5' + password: + description: Authentication password for the C(remote_user). Can be supplied as CLI option. + vars: + - name: ansible_password + - name: ansible_ssh_pass + - name: ansible_ssh_password + sshpass_prompt: + description: Password prompt that sshpass should search for. Supported by sshpass 1.06 and up. + default: '' + ini: + - section: 'ssh_connection' + key: 'sshpass_prompt' + env: + - name: ANSIBLE_SSHPASS_PROMPT + vars: + - name: ansible_sshpass_prompt + version_added: '2.10' + ssh_args: + description: Arguments to pass to all ssh cli tools + default: '-C -o ControlMaster=auto -o ControlPersist=60s' + ini: + - section: 'ssh_connection' + key: 'ssh_args' + env: + - name: ANSIBLE_SSH_ARGS + vars: + - name: ansible_ssh_args + version_added: '2.7' + cli: + - name: ssh_args + ssh_common_args: + description: Common extra args for all ssh CLI tools + ini: + - section: 'ssh_connection' + key: 'ssh_common_args' + version_added: '2.7' + env: + - name: ANSIBLE_SSH_COMMON_ARGS + version_added: '2.7' + vars: + - name: ansible_ssh_common_args + cli: + - name: ssh_common_args + ssh_executable: + default: ssh + description: + - This defines the location of the ssh binary. It defaults to ``ssh`` which will use the first ssh binary available in $PATH. + - This option is usually not required, it might be useful when access to system ssh is restricted, + or when using ssh wrappers to connect to remote hosts. + env: [{name: ANSIBLE_SSH_EXECUTABLE}] + ini: + - {key: ssh_executable, section: ssh_connection} + #const: ANSIBLE_SSH_EXECUTABLE + version_added: "2.2" + vars: + - name: ansible_ssh_executable + version_added: '2.7' + sftp_executable: + default: sftp + description: + - This defines the location of the sftp binary. It defaults to ``sftp`` which will use the first binary available in $PATH. + env: [{name: ANSIBLE_SFTP_EXECUTABLE}] + ini: + - {key: sftp_executable, section: ssh_connection} + version_added: "2.6" + vars: + - name: ansible_sftp_executable + version_added: '2.7' + scp_executable: + default: scp + description: + - This defines the location of the scp binary. It defaults to `scp` which will use the first binary available in $PATH. + env: [{name: ANSIBLE_SCP_EXECUTABLE}] + ini: + - {key: scp_executable, section: ssh_connection} + version_added: "2.6" + vars: + - name: ansible_scp_executable + version_added: '2.7' + scp_extra_args: + description: Extra exclusive to the ``scp`` CLI + vars: + - name: ansible_scp_extra_args + env: + - name: ANSIBLE_SCP_EXTRA_ARGS + version_added: '2.7' + ini: + - key: scp_extra_args + section: ssh_connection + version_added: '2.7' + cli: + - name: scp_extra_args + sftp_extra_args: + description: Extra exclusive to the ``sftp`` CLI + vars: + - name: ansible_sftp_extra_args + env: + - name: ANSIBLE_SFTP_EXTRA_ARGS + version_added: '2.7' + ini: + - key: sftp_extra_args + section: ssh_connection + version_added: '2.7' + cli: + - name: sftp_extra_args + ssh_extra_args: + description: Extra exclusive to the 'ssh' CLI + vars: + - name: ansible_ssh_extra_args + env: + - name: ANSIBLE_SSH_EXTRA_ARGS + version_added: '2.7' + ini: + - key: ssh_extra_args + section: ssh_connection + version_added: '2.7' + cli: + - name: ssh_extra_args + retries: + description: Number of attempts to connect. + default: 3 + type: integer + env: + - name: ANSIBLE_SSH_RETRIES + ini: + - section: connection + key: retries + - section: ssh_connection + key: retries + vars: + - name: ansible_ssh_retries + version_added: '2.7' + port: + description: Remote port to connect to. + type: int + ini: + - section: defaults + key: remote_port + env: + - name: ANSIBLE_REMOTE_PORT + vars: + - name: ansible_port + - name: ansible_ssh_port + remote_user: + description: + - User name with which to login to the remote server, normally set by the remote_user keyword. + - If no user is supplied, Ansible will let the ssh client binary choose the user as it normally + ini: + - section: defaults + key: remote_user + env: + - name: ANSIBLE_REMOTE_USER + vars: + - name: ansible_user + - name: ansible_ssh_user + cli: + - name: user + pipelining: + env: + - name: ANSIBLE_PIPELINING + - name: ANSIBLE_SSH_PIPELINING + ini: + - section: connection + key: pipelining + - section: ssh_connection + key: pipelining + vars: + - name: ansible_pipelining + - name: ansible_ssh_pipelining + + private_key_file: + description: + - Path to private key file to use for authentication + ini: + - section: defaults + key: private_key_file + env: + - name: ANSIBLE_PRIVATE_KEY_FILE + vars: + - name: ansible_private_key_file + - name: ansible_ssh_private_key_file + cli: + - name: private_key_file + + control_path: + description: + - This is the location to save ssh's ControlPath sockets, it uses ssh's variable substitution. + - Since 2.3, if null (default), ansible will generate a unique hash. Use `%(directory)s` to indicate where to use the control dir path setting. + - Before 2.3 it defaulted to `control_path=%(directory)s/ansible-ssh-%%h-%%p-%%r`. + - Be aware that this setting is ignored if `-o ControlPath` is set in ssh args. + env: + - name: ANSIBLE_SSH_CONTROL_PATH + ini: + - key: control_path + section: ssh_connection + vars: + - name: ansible_control_path + version_added: '2.7' + control_path_dir: + default: ~/.ansible/cp + description: + - This sets the directory to use for ssh control path if the control path setting is null. + - Also, provides the `%(directory)s` variable for the control path setting. + env: + - name: ANSIBLE_SSH_CONTROL_PATH_DIR + ini: + - section: ssh_connection + key: control_path_dir + vars: + - name: ansible_control_path_dir + version_added: '2.7' + sftp_batch_mode: + default: 'yes' + description: 'TODO: write it' + env: [{name: ANSIBLE_SFTP_BATCH_MODE}] + ini: + - {key: sftp_batch_mode, section: ssh_connection} + type: bool + vars: + - name: ansible_sftp_batch_mode + version_added: '2.7' + ssh_transfer_method: + default: smart + description: + - "Preferred method to use when transferring files over ssh" + - Setting to 'smart' (default) will try them in order, until one succeeds or they all fail + - Using 'piped' creates an ssh pipe with ``dd`` on either side to copy the data + choices: ['sftp', 'scp', 'piped', 'smart'] + env: [{name: ANSIBLE_SSH_TRANSFER_METHOD}] + ini: + - {key: transfer_method, section: ssh_connection} + scp_if_ssh: + default: smart + description: + - "Preferred method to use when transfering files over ssh" + - When set to smart, Ansible will try them until one succeeds or they all fail + - If set to True, it will force 'scp', if False it will use 'sftp' + env: [{name: ANSIBLE_SCP_IF_SSH}] + ini: + - {key: scp_if_ssh, section: ssh_connection} + vars: + - name: ansible_scp_if_ssh + version_added: '2.7' + use_tty: + version_added: '2.5' + default: 'yes' + description: add -tt to ssh commands to force tty allocation + env: [{name: ANSIBLE_SSH_USETTY}] + ini: + - {key: usetty, section: ssh_connection} + type: bool + vars: + - name: ansible_ssh_use_tty + version_added: '2.7' + timeout: + default: 10 + description: + - This is the default ammount of time we will wait while establishing an ssh connection + - It also controls how long we can wait to access reading the connection once established (select on the socket) + env: + - name: ANSIBLE_TIMEOUT + - name: ANSIBLE_SSH_TIMEOUT + version_added: '2.11' + ini: + - key: timeout + section: defaults + - key: timeout + section: ssh_connection + version_added: '2.11' + vars: + - name: ansible_ssh_timeout + version_added: '2.11' + cli: + - name: timeout + type: integer + lxc_host: + description: + The lxc host to connect to. + env: + - name: LXC_HOST + ini: + - key: lxc_host + section: lxc_ssh_connection + vars: + - name: lxc_host + cli: + - name: lxc_host +''' + import errno import fcntl import hashlib @@ -71,7 +403,7 @@ def _ssh_retry(func): """ @wraps(func) def wrapped(self, *args, **kwargs): - remaining_tries = int(C.ANSIBLE_SSH_RETRIES) + 1 + remaining_tries = int(self.get_option('retries')) + 1 cmd_summary = "%s..." % args[0] for attempt in range(remaining_tries): cmd = args[0] @@ -129,8 +461,8 @@ def __init__(self, play_context, new_stdin, *args, **kwargs): self.host = self._play_context.remote_addr self.port = self._play_context.port self.user = self._play_context.remote_user - self.control_path = C.ANSIBLE_SSH_CONTROL_PATH - self.control_path_dir = C.ANSIBLE_SSH_CONTROL_PATH_DIR + self.control_path = None + self.control_path_dir = None self.lxc_version = None # LXC v1 uses 'lxc-info', 'lxc-attach' and so on @@ -155,10 +487,7 @@ def _connect(self): ''' connect to the lxc; nothing to do here ''' display.vvv('XXX connect') super(Connection, self)._connect() - #self.container_name = self.ssh._play_context.remote_addr - self.container_name = self._play_context.ssh_extra_args # XXX - #self.container = None - + self.container_name = self.get_option('lxc_host') # only used from Ansible version 2.3 on forward @staticmethod @@ -223,69 +552,132 @@ def _add_args(self, b_command, b_args, explanation): display.vvvvv(u'SSH: %s: (%s)' % (explanation, ')('.join(to_text(a) for a in b_args)), host=self._play_context.remote_addr) b_command += b_args + def _build_command(self, binary, subsystem, *other_args): + ''' + Takes a executable (ssh, scp, sftp or wrapper) and optional extra arguments and returns the remote command + wrapped in local ssh shell commands and ready for execution. + + :arg binary: actual executable to use to execute command. + :arg subsystem: type of executable provided, ssh/sftp/scp, needed because wrappers for ssh might have diff names. + :arg other_args: dict of, value pairs passed as arguments to the ssh binary + + ''' - def _build_command(self, binary, *other_args): b_command = [] - if binary == 'ssh': - b_command += [to_bytes(self._play_context.ssh_executable, errors='surrogate_or_strict')] - else: - b_command += [to_bytes(binary, errors='surrogate_or_strict')] + conn_password = self.get_option('password') or self._play_context.password + + # + # First, the command to invoke + # + + # If we want to use password authentication, we have to set up a pipe to + # write the password to sshpass. + + if conn_password: + if not self._sshpass_available(): + raise AnsibleError("to use the 'ssh' connection type with passwords, you must install the sshpass program") + + self.sshpass_pipe = os.pipe() + b_command += [b'sshpass', b'-d' + to_bytes(self.sshpass_pipe[0], nonstring='simplerepr', errors='surrogate_or_strict')] + + password_prompt = self.get_option('sshpass_prompt') + if password_prompt: + b_command += [b'-P', to_bytes(password_prompt, errors='surrogate_or_strict')] + + b_command += [to_bytes(binary, errors='surrogate_or_strict')] + + # + # Next, additional arguments based on the configuration. + # + + # sftp batch mode allows us to correctly catch failed transfers, but can + # be disabled if the client side doesn't support the option. However, + # sftp batch mode does not prompt for passwords so it must be disabled + # if not using controlpersist and using sshpass + if subsystem == 'sftp' and self.get_option('sftp_batch_mode'): + if conn_password: + b_args = [b'-o', b'BatchMode=no'] + self._add_args(b_command, b_args, u'disable batch mode for sshpass') + b_command += [b'-b', b'-'] + if self._play_context.verbosity > 3: b_command.append(b'-vvv') - # Next, we add [ssh_connection]ssh_args from ansible.cfg. - # - if self._play_context.ssh_args: + + # Next, we add ssh_args + ssh_args = self.get_option('ssh_args') + if ssh_args: b_args = [to_bytes(a, errors='surrogate_or_strict') for a in - self._split_args(self._play_context.ssh_args)] + self._split_ssh_args(ssh_args)] self._add_args(b_command, b_args, u"ansible.cfg set ssh_args") - # Now we add various arguments controlled by configuration file settings - # (e.g. host_key_checking) or inventory variables (ansible_ssh_port) or - # a combination thereof. - if not C.HOST_KEY_CHECKING: + # Now we add various arguments that have their own specific settings defined in docs above. + if not self.get_option('host_key_checking'): b_args = (b"-o", b"StrictHostKeyChecking=no") self._add_args(b_command, b_args, u"ANSIBLE_HOST_KEY_CHECKING/host_key_checking disabled") - if self._play_context.port is not None: - b_args = (b"-o", b"Port=" + to_bytes(self._play_context.port, nonstring='simplerepr', errors='surrogate_or_strict')) + + self.port = self.get_option('port') + if self.port is not None: + b_args = (b"-o", b"Port=" + to_bytes(self.port, nonstring='simplerepr', errors='surrogate_or_strict')) self._add_args(b_command, b_args, u"ANSIBLE_REMOTE_PORT/remote_port/ansible_port set") - key = self._play_context.private_key_file + + key = self.get_option('private_key_file') if key: b_args = (b"-o", b'IdentityFile="' + to_bytes(os.path.expanduser(key), errors='surrogate_or_strict') + b'"') self._add_args(b_command, b_args, u"ANSIBLE_PRIVATE_KEY_FILE/private_key_file/ansible_ssh_private_key_file set") - if not self._play_context.password: + + if not conn_password: self._add_args( b_command, ( b"-o", b"KbdInteractiveAuthentication=no", b"-o", b"PreferredAuthentications=publickey,gssapi-with-mic,gssapi-keyex,hostbased", b"-o", b"PasswordAuthentication=no" ), - u"ansible_password/ansible_ssh_pass not set" + u"ansible_password/ansible_ssh_password not set" ) - user = self._play_context.remote_user - if user: + + self.user = self.get_option('remote_user') + if self.user: self._add_args( b_command, - (b"-o", b"User=" + to_bytes(self._play_context.remote_user, errors='surrogate_or_strict')), + (b"-o", b'User="%s"' % to_bytes(self.user, errors='surrogate_or_strict')), u"ANSIBLE_REMOTE_USER/remote_user/ansible_user/user/-u set" ) + + timeout = self.get_option('timeout') self._add_args( b_command, - (b"-o", b"ConnectTimeout=" + to_bytes(self._play_context.timeout, errors='surrogate_or_strict', nonstring='simplerepr')), + (b"-o", b"ConnectTimeout=" + to_bytes(timeout, errors='surrogate_or_strict', nonstring='simplerepr')), u"ANSIBLE_TIMEOUT/timeout set" ) + + # Add in any common or binary-specific arguments from the PlayContext + # (i.e. inventory or task settings or overrides on the command line). + + for opt in (u'ssh_common_args', u'{0}_extra_args'.format(subsystem)): + attr = self.get_option(opt) + if attr is not None: + b_args = [to_bytes(a, errors='surrogate_or_strict') for a in self._split_ssh_args(attr)] + self._add_args(b_command, b_args, u"Set %s" % opt) + # Check if ControlPersist is enabled and add a ControlPath if one hasn't # already been set. + controlpersist, controlpath = self._persistence_controls(b_command) + if controlpersist: self._persistent = True + if not controlpath: + self.control_path_dir = self.get_option('control_path_dir') cpdir = unfrackpath(self.control_path_dir) b_cpdir = to_bytes(cpdir, errors='surrogate_or_strict') + # The directory must exist and be writable. makedirs_safe(b_cpdir, 0o700) if not os.access(b_cpdir, os.W_OK): raise AnsibleError("Cannot write to ControlPath %s" % to_native(cpdir)) + self.control_path = self.get_option('control_path') if not self.control_path: self.control_path = self._create_control_path( self.host, @@ -301,7 +693,6 @@ def _build_command(self, binary, *other_args): return b_command - def _send_initial_data(self, fh, in_data): ''' Writes initial data to the stdin filehandle of the subprocess and closes @@ -636,7 +1027,7 @@ def _bare_run(self, cmd, in_data, sudoable=True, checkrc=True): # completely (see also issue #848) stdin.close() - if C.HOST_KEY_CHECKING: + if self.get_option('host_key_checking'): if cmd[0] == b"sshpass" and p.returncode == 6: raise AnsibleError('Using a SSH password instead of a key is not possible because Host Key checking is enabled and sshpass does not support ' 'this. Please add this host\'s fingerprint to your known_hosts file to manage this host.') @@ -677,11 +1068,11 @@ def _exec_command(self, cmd, in_data=None, sudoable=True): # python interactive-mode but the modules are not compatible with the # interactive-mode ("unexpected indent" mainly because of empty lines) - ssh_executable = self._play_context.ssh_executable + ssh_executable = self.get_option('ssh_executable') if in_data: - cmd = self._build_command(ssh_executable, self.host, cmd) + cmd = self._build_command(ssh_executable, 'ssh', self.host, cmd) else: - cmd = self._build_command(ssh_executable, '-tt', self.host, cmd) + cmd = self._build_command(ssh_executable, 'ssh', '-tt', self.host, cmd) (returncode, stdout, stderr) = self._run(cmd, in_data, sudoable=sudoable) @@ -705,8 +1096,8 @@ def exec_command(self, cmd, in_data=None, sudoable=False): display.vvv('XXX exec_command: %s' % cmd) super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable) - ssh_executable = self._play_context.ssh_executable - ##print dir(self) + ssh_executable = self.get_option('ssh_executable') + ##print(dir(self)) ##print dir(self._play_context) ##print self._play_context._attributes #self.dir_print(self._play_context) @@ -723,9 +1114,9 @@ def exec_command(self, cmd, in_data=None, sudoable=False): % (pipes.quote(h), pipes.quote(cmd)) if in_data: - cmd = self._build_command(ssh_executable, self.host, lxc_cmd) + cmd = self._build_command(ssh_executable, 'ssh', self.host, lxc_cmd) else: - cmd = self._build_command(ssh_executable, '-tt', self.host, lxc_cmd) + cmd = self._build_command(ssh_executable, 'ssh', '-tt', self.host, lxc_cmd) #self.ssh.exec_command(lxc_cmd,in_data,sudoable) (returncode, stdout, stderr) = self._run(cmd, in_data, sudoable=sudoable) return (returncode, stdout, stderr) @@ -735,7 +1126,7 @@ def put_file(self, in_path, out_path): ''' transfer a file from local to lxc ''' super(Connection, self).put_file(in_path, out_path) display.vvv(u"PUT {0} TO {1}".format(in_path, out_path), host=self.host) - ssh_executable = self._play_context.ssh_executable + ssh_executable = self.get_option('ssh_executable') if not os.path.exists(to_bytes(in_path, errors='surrogate_or_strict')): raise AnsibleFileNotFound("file or module does not exist: {0}".format(to_native(in_path))) @@ -759,9 +1150,9 @@ def put_file(self, in_path, out_path): % (pipes.quote(h), pipes.quote(cmd)) if in_data: - cmd = self._build_command(ssh_executable, self.host, lxc_cmd) + cmd = self._build_command(ssh_executable, 'ssh', self.host, lxc_cmd) else: - cmd = self._build_command(ssh_executable, '-tt', self.host, lxc_cmd) + cmd = self._build_command(ssh_executable, 'ssh', '-tt', self.host, lxc_cmd) #self.ssh.exec_command(lxc_cmd,in_data,sudoable) (returncode, stdout, stderr) = self._run(cmd, in_data, sudoable=False) return (returncode, stdout, stderr) @@ -784,9 +1175,9 @@ def put_file(self, in_path, out_path): % (pipes.quote(h), pipes.quote(cmd)) if in_data: - cmd = self._build_command(ssh_executable, self.host, lxc_cmd) + cmd = self._build_command(ssh_executable, 'ssh', self.host, lxc_cmd) else: - cmd = self._build_command(ssh_executable, '-tt', self.host, lxc_cmd) + cmd = self._build_command(ssh_executable, 'ssh', '-tt', self.host, lxc_cmd) #self.ssh.exec_command(lxc_cmd,in_data,sudoable) (returncode, stdout, stderr) = self._run(cmd, in_data, sudoable=False) return (returncode, stdout, stderr) @@ -796,7 +1187,7 @@ def fetch_file(self, in_path, out_path): ''' fetch a file from lxc to local ''' super(Connection, self).fetch_file(in_path, out_path) display.vvv(u"FETCH {0} TO {1}".format(in_path, out_path), host=self.host) - ssh_executable = self._play_context.ssh_executable + ssh_executable = self.get_option('ssh_executable') cmd = ('cat < %s' % pipes.quote(in_path)) h = self.container_name @@ -809,7 +1200,7 @@ def fetch_file(self, in_path, out_path): % (pipes.quote(h), pipes.quote(cmd)) - cmd = self._build_command(ssh_executable, self.host, lxc_cmd) + cmd = self._build_command(ssh_executable, 'ssh', self.host, lxc_cmd) (returncode, stdout, stderr) = self._run(cmd, None, sudoable=False) if returncode != 0: @@ -828,7 +1219,7 @@ def fetch_file(self, in_path, out_path): # only used from Ansible version 2.3 on forward def reset(self): # If we have a persistent ssh connection (ControlPersist), we can ask it to stop listening. - cmd = self._build_command(self._play_context.ssh_executable, '-O', 'stop', self.host) + cmd = self._build_command(self.get_option('ssh_executable'), 'ssh', '-O', 'stop', self.host) controlpersist, controlpath = self._persistence_controls(cmd) if controlpersist: display.vvv(u'sending stop: %s' % cmd)