# external import
from __future__ import print_function
import collections
import datetime
import errno
from io import StringIO
import logging
import os
import re
import select
import time
import paramiko
from . import util, exception
logger = logging.getLogger(__name__)
SSH_PORT = 22
[docs]class SSHSession(object):
r"""Establish SSH session with a remote host
:param host: name or ip of the remote host
:param username: user to be used for remote ssh session
:param proxy_transport:
:class:`paramiko.transport.Transport <paramiko.transport.Transport>` object for an SSH connection
used to establish ssh session between 2 remotes hosts
:param private_key_file: local path to a private key file to use if key needed for authentication
and not present in standard path (~/.ssh/)
:param port: port to connect to the remote host (default 22)
:param password: password to be used for authentication with remote host
:param missing_host_key_policy: set policy to use when connecting to servers without a known host key.
This parameter is a class **instance** of type
:class:`paramiko.client.MissingHostKeyPolicy <paramiko.client.MissingHostKeyPolicy>`, not a **class** itself
:param compress: set to True to turn on compression for this session
:param timeout: optional timeout opening SSH session, default 3600s (1h)
:param \**kwargs: any parameter taken by
:meth:`paramiko.client.SSHClient.connect <paramiko.client.SSHClient.connect>`
and not already explicitly covered by `SSHSession`
Usage::
>>> from jumpssh import SSHSession
>>> gateway_session = SSHSession('gateway.example.com', 'my_user', password='my_password')
"""
def __init__(
self,
host,
username,
proxy_transport=None,
private_key_file=None,
port=SSH_PORT,
password=None,
missing_host_key_policy=None,
compress=False,
timeout=None,
**kwargs
):
self.host = host
self.port = port
self.username = username
self.password = password
self.retry_nb = 0
self.proxy_transport = proxy_transport
self.private_key_file = private_key_file
self.compress = compress
self.timeout = timeout
# get input key/value parameters from user, they will be given to paramiko.client.SSHClient.connect
self.extra_parameters = kwargs
self.ssh_remote_sessions = {}
self.ssh_client = paramiko.client.SSHClient()
self.ssh_transport = None
# automatically accept unknown host keys by default
if not missing_host_key_policy:
missing_host_key_policy = paramiko.AutoAddPolicy()
self.ssh_client.set_missing_host_key_policy(missing_host_key_policy)
def __enter__(self):
self.open()
return self
def __exit__(self, *args):
self.close()
def __del__(self):
self.close()
def __repr__(self):
return '%s(host=%s, username=%s, port=%s, private_key_file=%s, proxy_transport=%s)' \
% (self.__class__.__name__, self.host, self.username, self.port,
self.private_key_file, repr(self.proxy_transport))
[docs] def is_active(self):
""" Check if connection with remote host is still active
An inactive SSHSession cannot run command on remote host
:return: True if current session is still active, else False
:rtype: bool
Usage::
>>> from jumpssh import SSHSession
>>> with SSHSession('gateway.example.com', 'my_user', password='my_password') as ssh_session:
>>> ... ssh_session.is_active()
True
>>> ssh_session.is_active()
False
"""
return self.ssh_client and self.ssh_client.get_transport() and self.ssh_client.get_transport().is_active()
[docs] def open(self, retry=0, retry_interval=10):
"""Open session with the remote host
:param retry: number of retry to establish connection with remote host (-1 for infinite retry)
:param retry_interval: number of seconds between each retry
:return: same SSHSession opened
Usage::
>>> from jumpssh import SSHSession
>>> ssh_session = SSHSession('gateway.example.com', 'my_user', password='my_password').open()
>>> ssh_session.is_active()
True
"""
# session is already active, nothing more to do
if self.is_active():
return
while True:
try:
# if `proxy_transport` is given it will open a remote ssh session from current ssh session
if self.proxy_transport:
# open a `direct-tcpip` channel passing
# the destination hostname:port and the local hostname:port
hostname = 'localhost'
port = SSH_PORT
ssh_channel = self.proxy_transport.open_channel(
kind="direct-tcpip",
dest_addr=(self.host, self.port),
src_addr=(hostname, port),
timeout=self.timeout,
)
# else it will be a direct ssh session from local machine
else:
hostname = self.host
port = self.port
ssh_channel = None
# update with existing default values from SSHSession
self.extra_parameters.update({
'hostname': hostname,
'port': port,
'username': self.username,
'compress': self.compress,
'key_filename': self.private_key_file,
'password': self.password,
'sock': ssh_channel,
'timeout': self.timeout,
})
# connect to the host
self.ssh_client.connect(**self.extra_parameters)
# no exception raised => connected to remote host
break
except Exception as ex:
# negative retry value means infinite retry
if retry < 0 or self.retry_nb < retry:
logger.warning("ssh to '%s:%s' still not possible (attempt %d): %s.\nKeep retrying..."
% (self.host, self.port, self.retry_nb, repr(ex)))
self.retry_nb += 1
time.sleep(retry_interval)
else:
raise exception.ConnectionError("Unable to connect to '%s:%s' with user '%s'"
% (self.host, self.port, self.username), original_exception=ex)
# Get the client's transport
self.ssh_transport = self.ssh_client.get_transport()
logger.info("Successfully connected to '%s:%s'" % (self.host, self.port))
return self
[docs] def close(self):
""" Close connection with remote host
Usage::
>>> from jumpssh import SSHSession
>>> ssh_session = SSHSession('gateway.example.com', 'my_user', password='my_password').open()
>>> ssh_session.is_active()
True
>>> ssh_session.close()
>>> ssh_session.is_active()
False
"""
if hasattr(self, 'ssh_remote_sessions') and self.ssh_remote_sessions:
for remote_session in self.ssh_remote_sessions.values():
remote_session.close()
if hasattr(self, 'ssh_client') and self.is_active():
# fix garbage collection order issue when close is called by __del__
# => https://github.com/AmadeusITGroup/JumpSSH/issues/109
if globals().get("logger"):
logger.debug("Closing connection to '%s:%s'..." % (self.host, self.port))
self.ssh_client.close()
# clear local host keys as they may not be valid for next connection
self.ssh_client.get_host_keys().clear()
[docs] def run_cmd(
self,
cmd,
username=None,
raise_if_error=True,
continuous_output=False,
silent=False,
timeout=None,
input_data=None,
success_exit_code=0,
retry=0,
retry_interval=5,
keep_retry_history=False
):
""" Run command on the remote host and return result locally
:param cmd: command to execute on remote host
cmd can be a str or a list of str
:param username: user used to execute the command (sudo privilege needed)
:param raise_if_error:
if True, raise SSHException when exit code of the command is different from 0
else just return exit code and command output
:param continuous_output: if True, print output all along the command is running
:param silent:
if True, does not log the command run (useful if sensitive information are used in command)
if parameter is a list, all strings of the command matching an item of the list will be concealed
in logs (regexp supported)
:param timeout: length in seconds after what a TimeoutError exception is raised
:param input_data:
key/value dictionary used when remote command expects input from user
when key is matching command output, value is sent
:param success_exit_code: integer or list of integer considered as a success exit code for command run
:param retry: number of retry until exit code is part of successful exit code list (-1 for infinite retry) or
RunCmdError exception is raised
:param retry_interval: number of seconds between each retry
:param keep_retry_history: if True, all retries results are kept and accessible in return result
default is False as we don't want to save by default all output for all retries especially for big output
:raises TimeoutError: if command run longer than the specified timeout
:raises TypeError: if `cmd` parameter is neither a string neither a list of string
:raises SSHException: if current SSHSession is already closed
:raises RunCmdError: if exit code of the command is different from 0 and raise_if_error is True
:return: a class inheriting from collections.namedtuple containing mainly `exit_code` and `output`
of the remotely executed command
:rtype: RunCmdResult
Usage::
>>> from jumpssh import SSHSession
>>> with SSHSession('gateway.example.com', 'my_user', password='my_password') as ssh_session:
>>> ... ssh_session.run_cmd('hostname')
RunSSHCmdResult(exit_code=0, output=u'gateway.example.com')
"""
user = self.username
# check type of command parameter is valid
try:
string_type = basestring
except NameError:
string_type = str
if isinstance(cmd, list):
cmd = " && ".join(cmd)
elif not isinstance(cmd, string_type):
raise TypeError("Invalid type for cmd argument '%s'" % type(cmd))
# success_exit_code must be int or list of int
if isinstance(success_exit_code, int):
success_exit_code = [success_exit_code]
elif not isinstance(success_exit_code, list):
raise TypeError("Invalid type for success_exit_code argument '%s'" % type(success_exit_code))
my_cmd = cmd
if username:
user = username
# need to run full command with shell to support shell builtins commands (source, ...)
my_cmd = 'sudo su - %s -c "%s"' % (user, cmd.replace('"', '\\"'))
# open session if not already the case
self.open()
# conceal text from command to be logged if requested with silent parameter
cmd_for_log = cmd
if isinstance(silent, list):
for pattern in silent:
cmd_for_log = re.sub(pattern=pattern, repl='XXXXXXX', string=cmd_for_log)
if silent is not True:
logger.debug("Running command '%s' on '%s' as %s..." % (cmd_for_log, self.host, user))
# keep track of all results for each run to make them available in response object
result_list = []
# retry command until exit_code in success code list or max retry nb reached
retry_nb = 0
while True:
channel = self.ssh_transport.open_session()
# raise error rather than blocking the call
channel.setblocking(0)
# Forward local agent if it is running and has some keys
# (If no agent is running, `get_keys` will return an empty tuple)
if paramiko.agent.Agent().get_keys():
paramiko.agent.AgentRequestHandler(channel)
# Commands executed after this point will see the forwarded agent on the remote end.
channel.set_combine_stderr(True)
channel.get_pty()
channel.exec_command(my_cmd)
# prepare timer for timeout
start = datetime.datetime.now()
start_secs = time.mktime(start.timetuple())
output = StringIO()
try:
# wait until command finished running or timeout is reached
while True:
got_chunk = False
readq, _, _ = select.select([channel], [], [], timeout)
for c in readq:
if c.recv_ready():
data = channel.recv(len(c.in_buffer)).decode('utf-8')
output.write(data)
got_chunk = True
# print output all along the command is running
if not silent and continuous_output and len(data) > 0:
print(data)
if input_data and channel.send_ready():
# We received a potential prompt.
for pattern in input_data.keys():
# pattern text matching current output => send input data
if re.search(pattern, data):
channel.send(input_data[pattern] + '\n')
# remote process has exited and returned an exit status
if not got_chunk and channel.exit_status_ready() and not channel.recv_ready():
channel.shutdown_read() # indicate that we're not going to read from this channel anymore
channel.close()
break # exit as remote side is finished and our buffers are empty
# Timeout check
if timeout:
now = datetime.datetime.now()
now_secs = time.mktime(now.timetuple())
et_secs = now_secs - start_secs
if et_secs > timeout:
raise exception.TimeoutError(
"Timeout of %ds reached when calling command '%s'. "
"Increase timeout if you think the command was still running successfully."
% (timeout, cmd_for_log))
except KeyboardInterrupt:
# if channel still active, forward Ctrl-C to remote host if requested by user
if self.is_active() and util.yes_no_query("Terminate remote command '%s'?" % cmd_for_log,
default=True,
interrupt=False):
# channel has been closed pending response from user, we don't have access to remote server anymore
if channel.closed:
exit_code = channel.recv_exit_status()
if exit_code == -1:
logger.warning("Unable to terminate remote command because channel is closed.")
else:
logger.info("Remote command execution already finished with exit code %s" % exit_code)
else:
# forward Ctrl-C to remote host
channel.send('\x03')
channel.close()
raise
exit_code = channel.recv_exit_status()
output_value = output.getvalue().strip()
# keep result of all runs is result, not only the last one
if keep_retry_history:
result_list.append(RunSSHCmdResult(exit_code=exit_code, output=output_value))
if exit_code in success_exit_code:
# command ran successfully, no retry needed
break
else:
if retry < 0 or retry_nb < retry:
retry_nb += 1
time.sleep(retry_interval)
continue
# max retry reached and exception must be raised
elif raise_if_error:
raise exception.RunCmdError(exit_code=exit_code,
success_exit_code=success_exit_code,
command=cmd_for_log,
error=output_value,
runs_nb=retry_nb+1)
else:
# command ended in error but max retry already reached and no exception must be raised
break
# return result of run command + all information used to run the command
return RunCmdResult(exit_code=exit_code,
output=output_value,
result_list=result_list,
command=cmd_for_log,
success_exit_code=success_exit_code,
runs_nb=retry_nb+1)
[docs] def get_cmd_output(self, cmd, **kwargs):
""" Return output of remotely executed command
Support same parameters than `run_cmd` method
:param cmd: remote command to execute
:return: output of remotely executed command
:rtype: str
Usage::
>>> from jumpssh import SSHSession
>>> with SSHSession('gateway.example.com', 'my_user', password='my_password') as ssh_session:
>>> ... ssh_session.get_cmd_output('hostname'))
u'gateway.example.com'
"""
return self.run_cmd(cmd=cmd, **kwargs).output
[docs] def get_exit_code(self, cmd, **kwargs):
""" Return exit code of remotely executed command
Support same parameters than `run_cmd` method
:param cmd: remote command to execute
:return: exit code of remotely executed command
:rtype: int
Usage::
>>> from jumpssh import SSHSession
>>> with SSHSession('gateway.example.com', 'my_user', password='my_password') as ssh_session:
>>> ... ssh_session.get_exit_code('ls')
0
>>> ... ssh_session.get_exit_code('dummy_command')
127
"""
return self.run_cmd(cmd=cmd, raise_if_error=False, **kwargs).exit_code
[docs] def get_remote_session(
self,
host,
username=None,
retry=0,
private_key_file=None,
port=SSH_PORT,
password=None,
retry_interval=10,
compress=False,
timeout=None,
**kwargs
):
r""" Establish connection with a remote host from current session
:param host: name or ip of the remote host
:param username: user to be used for remote ssh session
:param retry: retry number to establish connection with remote host (-1 for infinite retry)
:param private_key_file: local path to a private key file to use if key needed for authentication
:param port: port to connect to the remote host (default 22)
:param password: password to be used for authentication with remote host
:param retry_interval: number of seconds between each retry
:param compress: set to True to turn on compression for this session
:param timeout: optional timeout opening remote session, default 3600s (1h)
:param \**kwargs: any parameter taken by
:meth:`paramiko.client.SSHClient.connect <paramiko.client.SSHClient.connect>`
and not already explicitly covered by `SSHSession`
:return: session object of the remote host
:rtype: SSHSession
Usage::
# open session with remote host
>>> from jumpssh import SSHSession
>>> ssh_session = SSHSession('gateway.example.com', 'my_user', password='my_password').open()
# get remote session using same user than current session and same authentication method
>>> remote_session = ssh_session.get_remote_session('remote.example.com')
# get remote session with specific user and password
>>> remote_session = ssh_session.get_remote_session('remote.example.com',
... username='other_user',
... password='other_user_password')
# retry indefinitely to connect to remote host until success
>>> remote_session = ssh_session.get_remote_session('remote.example.com', retry=-1)
"""
# check session is still active before using it as a jump server, else try to open it
self.open()
# get user to be used for remote ssh session (default : same user than parent session)
user = self.username
if username:
user = username
# build remote session key to identify this session among others
session_key = ('%s_%s_%s' % (host, port, user)).lower()
remote_session = self.ssh_remote_sessions.get(session_key)
if remote_session:
# if same session already active, just return it
if remote_session.is_active():
return remote_session
else:
# if same session exists but not usable, cleanup object
del self.ssh_remote_sessions[session_key]
logger.info("Connecting to '%s:%s' through '%s' with user '%s'..." % (host, port, self.host, user))
remote_session = SSHSession(host=host,
username=user,
proxy_transport=self.ssh_transport,
private_key_file=private_key_file,
port=port,
password=password,
compress=compress,
timeout=timeout,
**kwargs).open(retry=retry,
retry_interval=retry_interval)
# keep reference to opened session, to be able to reuse it later
self.ssh_remote_sessions[session_key] = remote_session
return remote_session
[docs] def get_sftp_client(self):
""" See documentation for available methods on paramiko.sftp_client at :
http://docs.paramiko.org/en/latest/api/sftp.html
:return: paramiko SFTP client object.
:rtype: paramiko.sftp_client.SFTPClient
Usage::
# open session with remote host
>>> from jumpssh import SSHSession
>>> ssh_session = SSHSession('gateway.example.com', 'my_user', password='my_password').open()
# get sftp client
>>> sftp_client = ssh_session.get_sftp_client()
"""
return paramiko.sftp_client.SFTPClient.from_transport(self.ssh_transport)
[docs] def exists(
self,
path,
use_sudo=False
):
""" Check if path exists on the remote host
:param path: remote path to check for existence
:param use_sudo: if True, allow to check path current user doesn't have access by default
:return: True, if specified `path` exists on the remote host else False
:rtype: bool
Usage::
>>> with SSHSession('gateway.example.com', 'my_user', password='my_password') as ssh_session:
>>> ... ssh_session.exists('/path/to/remote/file')
False
>>> ... ssh_session.exists('/home/other_user/.ssh', use_sudo=True)
True
"""
# cannot use sftp as sudo is not possible
cmd = "ls %s" % path
if use_sudo:
cmd = 'sudo ' + cmd
return self.get_exit_code(cmd, silent=True) == 0
[docs] def put(self,
local_path,
remote_path,
use_sudo=False,
owner=None,
permissions=None,
username=None,
):
""" Upload a file to the remote host
:param local_path: path of the local file to upload
:param remote_path: destination folder in which to upload the local file
:param use_sudo: allow to upload a file in location with restricted permissions
:param owner: user that will own the copied file on the remote host
syntax : `user:group` or simply `user` if same than group
:param permissions: permissions to apply on the remote file (chmod format)
:param username: sudo user
:raise IOError: if local file `local_path` does not exist
Usage::
# copy local file on remote host
>>> ssh_session.put(local_path='/path/to/local/file', remote_path='/path/to/remote/file')
# copy local file on remote host in a remote path needing sudo permission
>>> ssh_session.put(local_path='/path/to/local/file', remote_path='/path/to/remote/file', use_sudo=True)
# copy local file on remote host with specific owner and permissions
>>> ssh_session.put(local_path='/path/to/local/file', remote_path='/path/to/remote/file',
... owner='root', permissions='600')
"""
if not os.path.isfile(local_path):
raise IOError(errno.ENOENT, "Local file '%s' does not exist" % local_path)
logger.debug("Copy local file '%s' on remote host '%s' in '%s' as '%s'"
% (local_path, self.host, remote_path, self.username))
# create file remotely
with open(local_path, 'rb') as local_file:
self.file(remote_path=remote_path, content=local_file.read(),
use_sudo=use_sudo, owner=owner, permissions=permissions, username=username, silent=True)
[docs] def get(self,
remote_path,
local_path,
use_sudo=False,
username=None,
):
"""Download a file from the remote host
:param remote_path: remote path of the file to download
:param local_path: local path where to download the file
:param use_sudo: allow to download a file from a location current user does not have access
:param username: sudo user
Usage::
# download remote file in local directory
>>> ssh_session.get(remote_path='/path/to/remote/file', local_path='/local/folder')
# donload remote file from a path not accessible by current user
>>> ssh_session.get(local_path='/path/to/local/file', remote_path='/path/to/remote/file', use_sudo=True)
"""
copy_path = remote_path
remote_filename = os.path.basename(remote_path)
sudo_username = username if username else 'root' if use_sudo else None
# copy first remote file in a temporary location accessible from current user
# making sure current user own this file
if use_sudo:
copy_path = "/tmp/%s" % util.id_generator(size=15)
self.run_cmd(["cp %s %s" % (remote_path, copy_path),
"chown $USER:$USER %s" % copy_path],
silent=True, username=sudo_username)
# if local download path is a directory, local filename will be same as remote
if os.path.isdir(local_path):
local_path = os.path.join(local_path, remote_filename)
sftp_client = self.get_sftp_client()
try:
with sftp_client.file(copy_path) as remote_file:
with open(local_path, mode='wb') as local_file:
local_file.write(remote_file.read())
finally:
if use_sudo:
# cleanup temporary file
self.run_cmd('rm %s' % copy_path, silent=True, username=sudo_username)
[docs] def file(
self,
remote_path,
content,
use_sudo=False,
owner=None,
permissions=None,
username=None,
silent=False
):
""" Method to create a remote file with the specified `content`
:param remote_path: destination folder in which to copy the local file
:param content: content of the file
:param use_sudo: allow to copy file in location with restricted permissions
:param owner: user that will own the file on the remote host
:param permissions: permissions to apply on the remote file (chmod format)
:param username: sudo user
:param silent: disable logging
Usage::
# create file on remote host and with specified content at the specified path
>>> ssh_session.file(remote_path='/path/to/remote/file', content='file content')
# create file on remote host and with specified content at the specified path needing sudo permissions
>>> ssh_session.file(remote_path='/path/to/remote/file', content='file content', use_sudo=True)
# create file on remote host and with specified content at the specified path
# with specified owner and permissions
>>> ssh_session.file(remote_path='/path/to/remote/file', content='file content',
... owner='other_user', permissions='700')
"""
if not silent:
logger.debug("Create file '%s' on remote host '%s' as '%s'" % (remote_path, self.host, self.username))
# ensure the connection is open
self.open()
sftp_client = self.get_sftp_client()
copy_path = remote_path
if use_sudo:
# copy local file on remote host in temporary dir
copy_path = "/tmp/%s" % util.id_generator(size=15)
# create file remotely
with sftp_client.file(copy_path, mode='w+') as remote_file:
remote_file.write(content)
# mv this file in the final destination
if use_sudo:
move_command = "mv %s %s" % (copy_path, remote_path)
self.run_cmd(move_command, silent=True, username=username or 'root')
# file will be owned by the specified user
if owner:
full_owner = owner
if ':' not in owner:
full_owner = '{0}:{0}'.format(owner)
self.run_cmd("sudo chown %s %s" % (full_owner, remote_path), silent=True)
if permissions:
self.run_cmd("sudo chmod %s %s" % (permissions, remote_path), silent=True)
RunSSHCmdResult = collections.namedtuple('RunSSHCmdResult', 'exit_code output')
[docs]class RunCmdResult(RunSSHCmdResult):
"""Result of a command run with SSHSession
:param exit_code: exit code of the run command (last run exit_code in case of retries)
:param output: output of the command run (last run output only in case of retries)
:param command: the command run
:param result_list: list of RunSSHCmdResult, 1 item for each retry
:param success_exit_code: list of integer considered as a success exit code for command run
:param runs_nb: number of times the command has been run
Usage::
>>> result = ssh_session.run_cmd('hostname')
# access to both exit_code and command output using tuple
>>> (exit_code, output) = result
# access directly to single attributes
>>> result.exit_code
0
>>> result.output
u'gateway.example.com'
>>> result.command
'hostname'
"""
def __new__(cls, exit_code, output, result_list, command, success_exit_code, runs_nb):
self = super(RunCmdResult, cls).__new__(cls, exit_code, output)
self.result_list = result_list
self.command = command
self.success_exit_code = success_exit_code
self.runs_nb = runs_nb
return self