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:
Samuel Stavinoha
2014-03-10 22:42:43 -05:00
parent eb115508a2
commit f01eda74c2

384
satori/ssh.py Normal file
View 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__