Implement an SSH module
A way to connect to remote machines and execute commands.
Supports proxying through a middle-machine to execute
commands on a target host.
To use:
from satori import ssh
client = connect("123.456.789.0")
output = client.remote_execute('uname -v')
# {'stdout': '#75-Ubuntu SMP Tue Jun 18 17:59:38 UTC 2013'}
To use with proxy:
proxy = connect("10.11.12.99")
client = connect("123.456.789.0", proxy=proxy)
output = client.remote_execute('uname -v')
# {'stdout': '#75-Ubuntu SMP Tue Jun 18 17:59:38 UTC 2013'}
Change-Id: I38abbc8910620f7055c790995ad5e1e5e8934337
Implements: blueprint ssh-module
This commit is contained in:
384
satori/ssh.py
Normal file
384
satori/ssh.py
Normal file
@@ -0,0 +1,384 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
#
|
||||
# pylint: disable=R0902, R0913
|
||||
"""SSH Module for connecting to and automating remote commands.
|
||||
|
||||
Supports proxying, as in `ssh -A`
|
||||
"""
|
||||
import ast
|
||||
import getpass
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import StringIO
|
||||
import time
|
||||
|
||||
import paramiko
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def connect(*args, **kwargs):
|
||||
"""Connect to a remote device over SSH."""
|
||||
try:
|
||||
return SSH.get_client(*args, **kwargs)
|
||||
except TypeError as exc:
|
||||
msg = "got an unexpected"
|
||||
if msg in exc.message:
|
||||
message = "%s " + exc.message[exc.message.index(msg):]
|
||||
raise exc.__class__(message % "connect()")
|
||||
raise
|
||||
|
||||
|
||||
class AcceptMissingHostKey(paramiko.client.MissingHostKeyPolicy):
|
||||
|
||||
"""Add missing host keys to the client.
|
||||
|
||||
Do not save in the known_hosts file since we can easily spin up servers
|
||||
that have recycled ip addresses
|
||||
"""
|
||||
|
||||
# pylint: disable=R0903
|
||||
def missing_host_key(self, client, hostname, key):
|
||||
"""Add missing host key."""
|
||||
# pylint: disable=W0212
|
||||
client._host_keys.add(hostname, key.get_name(), key)
|
||||
|
||||
|
||||
class SSH(paramiko.SSHClient):
|
||||
|
||||
"""Connects to devices via SSH to execute commands."""
|
||||
|
||||
def __init__(self, host, password=None, username=None,
|
||||
private_key=None, key_filename=None, port=22,
|
||||
timeout=20, proxy=None, options=None):
|
||||
"""Create an instance of the SSH class.
|
||||
|
||||
:param str host: The ip address or host name of the server
|
||||
to connect to
|
||||
:param str password: A password to use for authentication
|
||||
or for unlocking a private key
|
||||
:param username: The username to authenticate as
|
||||
:param private_key: Private SSH Key string to use
|
||||
(instead of using a filename)
|
||||
:param key_filename: a private key filename (path)
|
||||
:param port: tcp/ip port to use (defaults to 22)
|
||||
:param float timeout: an optional timeout (in seconds) for the
|
||||
TCP connection
|
||||
:param socket proxy: an existing SSH instance to use
|
||||
for proxying
|
||||
:param dict options: A dictionary used to set ssh options
|
||||
(when proxying).
|
||||
e.g. for `ssh -o BatchMode=yes`, you would
|
||||
provide (..., options={'BatchMode': 'yes'})
|
||||
Conversion of booleans is also supported,
|
||||
(, options={'BatchMode': True}) is equivalent.
|
||||
"""
|
||||
self.password = password
|
||||
self.host = host
|
||||
self.username = username or getpass.getuser()
|
||||
self.private_key = private_key
|
||||
self.key_filename = key_filename
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self._platform_info = None
|
||||
self.options = options
|
||||
if proxy:
|
||||
if not hasattr(proxy, 'host'):
|
||||
raise TypeError("Keyword 'proxy' requires a `host` attribute.")
|
||||
self.sock = self._get_proxy_socket(proxy)
|
||||
else:
|
||||
self.sock = None
|
||||
super(SSH, self).__init__()
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_ssh_key(p_key):
|
||||
"""Use paramiko primitives to validate ssh-key.
|
||||
|
||||
Returns PKey obj or reason(s) why it didn't (str).
|
||||
|
||||
"""
|
||||
key_classes = [paramiko.rsakey.RSAKey,
|
||||
paramiko.dsskey.DSSKey,
|
||||
paramiko.ecdsakey.ECDSAKey, ]
|
||||
|
||||
saved_excs = []
|
||||
f_p_key = StringIO.StringIO(p_key)
|
||||
for cls in key_classes:
|
||||
f_p_key.seek(0)
|
||||
try:
|
||||
pkey = cls.from_private_key(f_p_key)
|
||||
except paramiko.SSHException as exc:
|
||||
saved_excs.append(exc.message)
|
||||
continue
|
||||
else:
|
||||
keytype = cls
|
||||
LOG.info("Valid SSH Key provided (%s)", keytype.__name__)
|
||||
return pkey
|
||||
|
||||
return "; ".join(saved_excs)
|
||||
|
||||
@classmethod
|
||||
def get_client(cls, *args, **kwargs):
|
||||
"""Return an ssh client object from this module."""
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def platform_info(self):
|
||||
"""Return distro, version, architecture."""
|
||||
if not self._platform_info:
|
||||
command = ('python -c '
|
||||
'"""import sys,platform as p;'
|
||||
'plat=list(p.dist()+(p.machine(),));'
|
||||
'sys.stdout.write(str(plat))"""')
|
||||
|
||||
output = self.remote_execute(command)
|
||||
stdout = re.split('\n|\r\n', output['stdout'])[-1].strip()
|
||||
plat = ast.literal_eval(stdout)
|
||||
self._platform_info = {'dist': plat[0].lower(), 'version': plat[1],
|
||||
'arch': plat[3]}
|
||||
|
||||
LOG.debug("Remote platform info: %s", self._platform_info)
|
||||
return self._platform_info
|
||||
|
||||
def connect(self, use_password=False): # pylint: disable=W0221
|
||||
"""Attempt an SSH connection through paramiko.SSHClient.connect .
|
||||
|
||||
The order for authentication attempts is:
|
||||
- private_key
|
||||
- key_filename
|
||||
- any key discoverable in ~/.ssh/
|
||||
- username/password
|
||||
|
||||
:param use_password: Skip SSH keys when authenticating.
|
||||
"""
|
||||
self.load_system_host_keys()
|
||||
self.set_missing_host_key_policy(AcceptMissingHostKey())
|
||||
try:
|
||||
if self.private_key is not None and not use_password:
|
||||
pkey = self._is_valid_ssh_key(self.private_key)
|
||||
LOG.debug("Trying supplied private key string")
|
||||
super(SSH, self).connect(self.host, timeout=self.timeout,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
pkey=pkey,
|
||||
sock=self.sock)
|
||||
elif self.key_filename is not None and not use_password:
|
||||
LOG.debug("Trying key file: %s",
|
||||
os.path.expanduser(self.key_filename))
|
||||
super(SSH, self).connect(
|
||||
self.host, timeout=self.timeout, port=self.port,
|
||||
username=self.username,
|
||||
key_filename=os.path.expanduser(self.key_filename),
|
||||
sock=self.sock)
|
||||
else:
|
||||
super(SSH, self).connect(self.host, port=self.port,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
sock=self.sock)
|
||||
LOG.debug("Authentication for ssh://%s@%s:%d using "
|
||||
"password succeeded",
|
||||
self.username, self.host, self.port)
|
||||
LOG.debug("Connected to ssh://%s@%s:%d.",
|
||||
self.username, self.host, self.port)
|
||||
|
||||
except paramiko.PasswordRequiredException as exc:
|
||||
#Looks like we have cert issues, so try password auth if we can
|
||||
if self.password:
|
||||
LOG.debug("Retrying with password credentials")
|
||||
return self.connect(use_password=True)
|
||||
else:
|
||||
raise exc
|
||||
except paramiko.BadHostKeyException as exc:
|
||||
msg = (
|
||||
"ssh://%s@%s:%d failed: %s. You might have a bad key "
|
||||
"entry on your server, but this is a security issue and won't"
|
||||
" be handled automatically. To fix this you can remove the "
|
||||
"host entry for this host from the /.ssh/known_hosts file" % (
|
||||
self.username, self.host, self.port, exc)
|
||||
)
|
||||
LOG.info(msg)
|
||||
raise exc
|
||||
except Exception as exc:
|
||||
LOG.info('ssh://%s@%s:%d failed. %s',
|
||||
self.username, self.host, self.port, exc)
|
||||
raise exc
|
||||
|
||||
def test_connection(self):
|
||||
"""Connect to an ssh server and verify that it responds.
|
||||
|
||||
The order for authentication attempts is:
|
||||
(1) private_key
|
||||
(2) key_filename
|
||||
(3) any key discoverable in ~/.ssh/
|
||||
(4) username/password
|
||||
"""
|
||||
LOG.debug("Checking for a response from ssh://%s@%s:%d.",
|
||||
self.username, self.host, self.port)
|
||||
try:
|
||||
if self.sock and not self.get_transport():
|
||||
self.connect()
|
||||
if not self.sock:
|
||||
self.connect()
|
||||
LOG.debug("ssh://%s@%s:%d is up.",
|
||||
self.username, self.host, self.port)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
LOG.info("ssh://%s@%s:%d failed. %s",
|
||||
self.username, self.host, self.port, exc)
|
||||
return False
|
||||
finally:
|
||||
if not self.sock:
|
||||
self.close()
|
||||
|
||||
def remote_execute(self, command, with_exit_code=False, get_pty=False):
|
||||
"""Execute an ssh command on a remote host.
|
||||
|
||||
Tries cert auth first and falls back
|
||||
to password auth if password provided.
|
||||
|
||||
:param command: Shell command to be executed by this function.
|
||||
:param with_exit_code: Include the exit_code in the return body.
|
||||
:param get_pty: Request a pseudo-terminal from the server.
|
||||
|
||||
:returns: a dict with stdin, stdout,
|
||||
and (optionally) the exit code of the call.
|
||||
"""
|
||||
LOG.debug("Executing '%s' on ssh://%s@%s:%s.",
|
||||
command, self.username, self.host, self.port)
|
||||
try:
|
||||
if self.sock and not self.get_transport():
|
||||
self.connect()
|
||||
if not self.sock:
|
||||
self.connect()
|
||||
|
||||
results = None
|
||||
chan = self.get_transport().open_session()
|
||||
if get_pty:
|
||||
chan.get_pty()
|
||||
stdin = chan.makefile('wb')
|
||||
stdout = chan.makefile('rb')
|
||||
stderr = chan.makefile_stderr('rb')
|
||||
chan.exec_command(command)
|
||||
LOG.debug('ssh://%s@%s:%d responded.', self.username, self.host,
|
||||
self.port)
|
||||
|
||||
time.sleep(1)
|
||||
if not stdout.channel.closed:
|
||||
buflen = len(stdout.channel.in_buffer)
|
||||
# min and max determined from max username length
|
||||
# and a set of encountered linux password prompts
|
||||
if 8 < buflen < 64:
|
||||
prompt = stdout.channel.recv(buflen)
|
||||
if all(m in prompt.lower()
|
||||
for m in ['password', ':']):
|
||||
LOG.warning("%s@%s encountered prompt! of length "
|
||||
" [%s] {%s}",
|
||||
self.username, self.host, buflen, prompt)
|
||||
stdin.write("%s\n" % self.password)
|
||||
stdin.flush()
|
||||
time.sleep(1)
|
||||
else:
|
||||
LOG.warning("Nearly a False-Positive on "
|
||||
"password prompt detection. [%s] {%s}",
|
||||
buflen, prompt)
|
||||
results = {
|
||||
'stdout': prompt.strip(),
|
||||
'stderr': stderr.read()
|
||||
}
|
||||
|
||||
if not results:
|
||||
results = {
|
||||
'stdout': stdout.read().strip(),
|
||||
'stderr': stderr.read()
|
||||
}
|
||||
|
||||
exit_code = chan.recv_exit_status()
|
||||
|
||||
if with_exit_code:
|
||||
results.update({'exit_code': exit_code})
|
||||
chan.close()
|
||||
|
||||
tty_req = ["you must have a tty to run sudo",
|
||||
"is not a tty",
|
||||
"no tty present", ]
|
||||
if any(m in str(k) for m in tty_req for k in results.values()):
|
||||
LOG.info('%s requires TTY for sudo. Using TTY mode.',
|
||||
self.host)
|
||||
if get_pty is True: # if this is *already* True
|
||||
raise SystemError("Running command with get_pty=True "
|
||||
"FAILED: %s@%s:%d"
|
||||
% (self.username, self.host, self.port))
|
||||
return self.remote_execute(
|
||||
command, with_exit_code=with_exit_code, get_pty=True)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as exc:
|
||||
LOG.info("ssh://%s@%s:%d failed. %s", self.username, self.host,
|
||||
self.port, exc)
|
||||
raise
|
||||
finally:
|
||||
if not self.sock:
|
||||
self.close()
|
||||
|
||||
def _get_proxy_socket(self, proxy):
|
||||
"""Return a wrapped subprocess running ProxyCommand-driven programs.
|
||||
|
||||
Create a new CommandProxy instance.
|
||||
Can be created from an existing SSH instance.
|
||||
For proxy clients, please specify a private key filename.
|
||||
|
||||
To use an ssh proxy, you must use an SSH Key,
|
||||
since a ProxyCommand cannot be passed a password.
|
||||
"""
|
||||
if proxy.private_key and not proxy.key_filename:
|
||||
# TODO(samstav): Have satori delete this
|
||||
# file after every session
|
||||
temp = os.path.expanduser('~/.satori')
|
||||
keyfile = open(temp, 'w+')
|
||||
keyfile.write(proxy.private_key)
|
||||
proxy.key_filename = temp
|
||||
|
||||
pxd = {
|
||||
'bastion': proxy.host,
|
||||
'user': proxy.username,
|
||||
'port': '-p %s' % proxy.port,
|
||||
'options': ('-o StrictHostKeyChecking=no '
|
||||
'-o ConnectTimeout=%s ' % proxy.timeout),
|
||||
'target_host': self.host,
|
||||
'target_port': self.port,
|
||||
}
|
||||
proxycommand = "ssh {options} -A {user}@{bastion} "
|
||||
|
||||
if proxy.key_filename:
|
||||
pxd.update({'identity': '-i %s' % proxy.key_filename})
|
||||
proxycommand += "{identity} "
|
||||
|
||||
if proxy.options:
|
||||
for key, val in proxy.options.items():
|
||||
if isinstance(val, bool):
|
||||
# turns booleans into `ssh -o` compat "yes" or "no"
|
||||
if val is True:
|
||||
val = "yes"
|
||||
if val is False:
|
||||
val = "no"
|
||||
pxd['options'] += '%s=%s ' % (key, val)
|
||||
|
||||
proxycommand += "nc {target_host} {target_port}"
|
||||
return paramiko.ProxyCommand(proxycommand.format(**pxd))
|
||||
|
||||
# Share SSH.__init__'s docstring
|
||||
connect.__doc__ = SSH.__init__.__doc__
|
||||
SSH.get_client.__func__.__doc__ = SSH.__init__.__func__.__doc__
|
||||
Reference in New Issue
Block a user