Skip to content

Instantly share code, notes, and snippets.

@dch
Created September 28, 2026 08:22
Show Gist options
  • Select an option

  • Save dch/08d43f682c625a309bcb2f82fc944281 to your computer and use it in GitHub Desktop.

Select an option

Save dch/08d43f682c625a309bcb2f82fc944281 to your computer and use it in GitHub Desktop.
# 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