Created
September 28, 2026 08:22
-
-
Save dch/08d43f682c625a309bcb2f82fc944281 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Copyright (c) 2015-2025, Austin Hyde (@austinhyde) | |
| from __future__ import (absolute_import, division, print_function) | |
| import os | |
| import shlex | |
| from packaging import version | |
| from ansible import __version__ as ansible_version | |
| from ansible.errors import AnsibleError | |
| from ansible.plugins.connection.ssh import Connection as SSHConnection | |
| from ansible.module_utils._text import to_text | |
| from ansible.plugins.loader import get_shell_plugin | |
| from contextlib import contextmanager | |
| __metaclass__ = type | |
| MIN_ANSIBLE_VERSION = '2.11.3' | |
| DOCUMENTATION = ''' | |
| connection: sshjail | |
| short_description: connect via ssh client binary to jail | |
| description: | |
| - This connection plugin allows ansible to communicate to the target machines via normal ssh command line. | |
| author: Austin Hyde (@austinhyde) | |
| version_added: historical | |
| options: | |
| host: | |
| description: Hostname/ip to connect to. | |
| default: inventory_hostname | |
| vars: | |
| - name: inventory_hostname | |
| - name: ansible_host | |
| - name: 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 | |
| password_mechanism: | |
| description: Mechanism to use for handling ssh password prompt | |
| type: string | |
| default: ssh_askpass | |
| choices: | |
| - ssh_askpass | |
| - sshpass | |
| - disable | |
| version_added: '2.19' | |
| env: | |
| - name: ANSIBLE_SSH_PASSWORD_MECHANISM | |
| ini: | |
| - {key: password_mechanism, section: ssh_connection} | |
| vars: | |
| - name: ansible_ssh_password_mechanism | |
| 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' | |
| 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 | |
| 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' | |
| 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' | |
| 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' | |
| reconnection_retries: | |
| description: Number of attempts to connect. | |
| default: 0 | |
| 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 | |
| pipelining: | |
| default: ANSIBLE_PIPELINING | |
| description: | |
| - Pipelining reduces the number of SSH operations required to execute a module on the remote server, | |
| by executing many Ansible modules without actual file transfer. | |
| - This can result in a very significant performance improvement when enabled. | |
| - However this conflicts with privilege escalation (become). | |
| For example, when using sudo operations you must first disable 'requiretty' in the sudoers file for the target hosts, | |
| which is why this feature is disabled by default. | |
| env: | |
| - name: ANSIBLE_PIPELINING | |
| #- name: ANSIBLE_SSH_PIPELINING | |
| ini: | |
| - section: defaults | |
| key: pipelining | |
| #- section: ssh_connection | |
| # key: pipelining | |
| type: boolean | |
| 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 | |
| option: --private-key | |
| private_key: | |
| description: | |
| - Private key contents in PEM format. Requires the C(SSH_AGENT) configuration to be enabled. | |
| type: string | |
| env: | |
| - name: ANSIBLE_PRIVATE_KEY | |
| vars: | |
| - name: ansible_private_key | |
| - name: ansible_ssh_private_key | |
| version_added: '2.19' | |
| private_key_passphrase: | |
| description: | |
| - Private key passphrase, dependent on O(private_key). | |
| - This does NOT have any effect when used with O(private_key_file). | |
| type: string | |
| env: | |
| - name: ANSIBLE_PRIVATE_KEY_PASSPHRASE | |
| vars: | |
| - name: ansible_private_key_passphrase | |
| - name: ansible_ssh_private_key_passphrase | |
| version_added: '2.19' | |
| control_path: | |
| description: | |
| - This is the location to save ssh's ControlPath sockets, it uses ssh's variable substitution. | |
| - Since 2.3, if null, ansible will generate a unique hash. Use `%(directory)s` to indicate where to use the control dir path setting. | |
| 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} | |
| vars: | |
| - name: ansible_ssh_transfer_method | |
| version_added: '2.12' | |
| scp_if_ssh: | |
| default: smart | |
| description: | |
| - "Prefered 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' | |
| pkcs11_provider: | |
| version_added: '2.12' | |
| default: '' | |
| description: | |
| - PKCS11 SmartCard provider such as opensc, example: /usr/local/lib/opensc-pkcs11.so | |
| env: [{name: ANSIBLE_PKCS11_PROVIDER}] | |
| ini: | |
| - {key: pkcs11_provider, section: ssh_connection} | |
| vars: | |
| - name: ansible_ssh_pkcs11_provider | |
| 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 | |
| verbosity: | |
| version_added: '2.19' | |
| default: 0 | |
| type: int | |
| description: | |
| - Requested verbosity level for the SSH CLI. | |
| env: [{name: ANSIBLE_SSH_VERBOSITY}] | |
| ini: | |
| - {key: verbosity, section: ssh_connection} | |
| vars: | |
| - name: ansible_ssh_verbosity | |
| ''' | |
| try: | |
| from __main__ import display | |
| except ImportError: | |
| from ansible.utils.display import Display | |
| display = Display() | |
| # HACK: Ansible core does classname-based validation checks, to ensure connection plugins inherit directly from a class | |
| # named "ConnectionBase". This intermediate class works around this limitation. | |
| class ConnectionBase(SSHConnection): | |
| pass | |
| class Connection(ConnectionBase): | |
| ''' ssh based connections ''' | |
| transport = 'sshjail' | |
| def __init__(self, *args, **kwargs): | |
| super(Connection, self).__init__(*args, **kwargs) | |
| # self.host == jailname@jailhost | |
| self.inventory_hostname = self.host | |
| self.jailspec, self.host = self.host.split('@', 1) | |
| # self.jailspec == jailname | |
| # self.host == jailhost | |
| # this way SSHConnection parent class uses the jailhost as the SSH remote host | |
| # jail information loaded on first use by match_jail | |
| self.jid = None | |
| self.jname = None | |
| self.jpath = None | |
| self.connector = None | |
| if version.parse(ansible_version) < version.parse(MIN_ANSIBLE_VERSION): | |
| raise AnsibleError("sshjail needs at least ansible version " + MIN_ANSIBLE_VERSION) | |
| # logging.warning(self._play_context.connection) | |
| def match_jail(self): | |
| if self.jid is None: | |
| code, stdout, stderr = self._jailhost_command("jls -q jid name host.hostname path") | |
| if code != 0: | |
| display.vvv("JLS stdout: %s" % stdout) | |
| raise AnsibleError("jls returned non-zero!") | |
| lines = stdout.strip().split(b'\n') | |
| found = False | |
| for line in lines: | |
| if line.strip() == '': | |
| break | |
| jid, name, hostname, path = to_text(line).strip().split() | |
| if name == self.jailspec or hostname == self.jailspec: | |
| self.jid = jid | |
| self.jname = name | |
| self.jpath = path | |
| found = True | |
| break | |
| if not found: | |
| raise AnsibleError("failed to find a jail with name or hostname of '%s'" % self.jailspec) | |
| def get_jail_path(self): | |
| self.match_jail() | |
| return self.jpath | |
| def get_jail_id(self): | |
| self.match_jail() | |
| return self.jid | |
| def get_jail_connector(self): | |
| if self.connector is None: | |
| code, _, _ = self._jailhost_command("which -s jailme") | |
| if code != 0: | |
| self.connector = 'jexec' | |
| else: | |
| self.connector = 'jailme' | |
| return self.connector | |
| def _strip_sudo(self, executable, cmd): | |
| # cmd looks like: | |
| # sudo -H -S -n -u root /bin/sh -c 'echo BECOME-SUCCESS-hnjapeqklmbeniylaoledvqttgvyefbd ; test -e /usr/local/bin/python || ( pkg install -y python )' | |
| # or | |
| # /bin/sh -c 'sudo -H -S -n -u root /bin/sh -c '"'"'echo BECOME-SUCCESS-xuaumtxycchjfekyxlbykjbqxosupbpd ; /usr/local/bin/python2.7 /root/.ansible/tmp/ansible-tmp-1629053721.2637851-12131-255051228590176/AnsiballZ_pkgng.py'"'"'' | |
| # we need to extract just: | |
| # test -e /usr/local/bin/python || ( pkg install -y python ) | |
| # or | |
| # /usr/local/bin/python2.7 /root/.ansible/tmp/ansible-tmp-1629053721.2637851-12131-255051228590176/AnsiballZ_pkgng.py | |
| # to do this, we peel back successive command invocations | |
| words = shlex.split(cmd) | |
| while words[0] == executable or words[0] == 'sudo': | |
| cmd = words[-1] | |
| words = shlex.split(cmd) | |
| # after, cmd is: | |
| # echo BECOME-SUCCESS-hnjapeqklmbeniylaoledvqttgvyefbd ; test -e /usr/local/bin/python || ( pkg install -y python ) | |
| # or: | |
| # echo BECOME-SUCCESS-xuaumtxycchjfekyxlbykjbqxosupbpd ; /usr/local/bin/python2.7 /root/.ansible/tmp/ansible-tmp-1629053721.2637851-12131-255051228590176/AnsiballZ_pkgng.py | |
| # drop first command | |
| cmd = cmd.split(' ; ', 1)[1] | |
| return cmd | |
| def _strip_sleep(self, cmd): | |
| # Get the command without sleep | |
| cmd = cmd.split(' && sleep 0', 1)[0] | |
| # Add back trailing quote | |
| cmd = '%s%s' % (cmd, "'") | |
| return cmd | |
| def _jailhost_command(self, cmd): | |
| return super(Connection, self).exec_command(cmd, in_data=None, sudoable=True) | |
| def exec_command(self, cmd, in_data=None, executable='/bin/sh', sudoable=True): | |
| ''' run a command in the jail ''' | |
| slpcmd = False | |
| if '&& sleep 0' in cmd: | |
| slpcmd = True | |
| cmd = self._strip_sleep(cmd) | |
| if 'sudo' in cmd: | |
| cmd = self._strip_sudo(executable, cmd) | |
| self.set_option('host', self.host) | |
| cmd = ' '.join([executable, '-c', shlex.quote(cmd)]) | |
| if slpcmd: | |
| cmd = '%s %s %s %s' % (self.get_jail_connector(), self.get_jail_id(), cmd, '&& sleep 0') | |
| else: | |
| cmd = '%s %s %s' % (self.get_jail_connector(), self.get_jail_id(), cmd) | |
| if self._play_context.become: | |
| # display.debug("_low_level_execute_command(): using become for this command") | |
| plugin = self.become | |
| shell = get_shell_plugin(executable=executable) | |
| cmd = plugin.build_become_command(cmd, shell) | |
| # display.vvv("JAIL (%s) %s" % (local_cmd), host=self.host) | |
| return super(Connection, self).exec_command(cmd, in_data, True) | |
| def _normalize_path(self, path, prefix): | |
| if not path.startswith(os.path.sep): | |
| path = os.path.join(os.path.sep, path) | |
| normpath = os.path.normpath(path) | |
| return os.path.join(prefix, normpath[1:]) | |
| def _copy_file(self, from_file, to_file, executable='/bin/sh'): | |
| copycmd = ' '.join(['cp', from_file, to_file]) | |
| if self._play_context.become: | |
| plugin = self.become | |
| shell = get_shell_plugin(executable=executable) | |
| copycmd = plugin.build_become_command(copycmd, shell) | |
| display.vvv(u"REMOTE COPY {0} TO {1}".format(from_file, to_file), host=self.inventory_hostname) | |
| code, stdout, stderr = self._jailhost_command(copycmd) | |
| if code != 0: | |
| raise AnsibleError("failed to copy file from %s to %s:\n%s\n%s" % (from_file, to_file, stdout, stderr)) | |
| @contextmanager | |
| def tempfile(self): | |
| code, stdout, stderr = self._jailhost_command('mktemp') | |
| if code != 0: | |
| raise AnsibleError("failed to make temp file:\n%s\n%s" % (stdout, stderr)) | |
| tmp = to_text(stdout.strip().split(b'\n')[-1]) | |
| code, stdout, stderr = self._jailhost_command(' '.join(['chmod 0644', tmp])) | |
| if code != 0: | |
| raise AnsibleError("failed to make temp file %s world readable:\n%s\n%s" % (tmp, stdout, stderr)) | |
| yield tmp | |
| code, stdout, stderr = self._jailhost_command(' '.join(['rm', tmp])) | |
| if code != 0: | |
| raise AnsibleError("failed to remove temp file %s:\n%s\n%s" % (tmp, stdout, stderr)) | |
| def put_file(self, in_path, out_path): | |
| ''' transfer a file from local to remote jail ''' | |
| out_path = self._normalize_path(out_path, self.get_jail_path()) | |
| with self.tempfile() as tmp: | |
| super(Connection, self).put_file(in_path, tmp) | |
| self._copy_file(tmp, out_path) | |
| def fetch_file(self, in_path, out_path): | |
| ''' fetch a file from remote to local ''' | |
| in_path = self._normalize_path(in_path, self.get_jail_path()) | |
| with self.tempfile() as tmp: | |
| self._copy_file(in_path, tmp) | |
| super(Connection, self).fetch_file(tmp, out_path) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment