476 lines
18 KiB
Python
476 lines
18 KiB
Python
# 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.
|
|
|
|
"""SSH Module for connecting to and automating remote commands.
|
|
|
|
Supports proxying, as in `ssh -A`
|
|
|
|
To control the behavior of the SSH client, use the specific connect_with_*
|
|
calls. The .connect() call behaves like the ssh command and attempts a number
|
|
of connection methods, including using the curent user's ssh keys.
|
|
|
|
If interactive is set to true, the module will also prompt for a password if no
|
|
other connection methods succeeded.
|
|
|
|
Note that test_connection() calls connect(). To test a connection and control
|
|
the authentication methods used, just call connect_with_* and catch any
|
|
exceptions instead of using test_connect().
|
|
"""
|
|
|
|
import ast
|
|
import getpass
|
|
import logging
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import time
|
|
|
|
import paramiko
|
|
import six
|
|
|
|
from satori import errors
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
MIN_PASSWORD_PROMPT_LEN = 8
|
|
MAX_PASSWORD_PROMPT_LEN = 64
|
|
TEMPFILE_PREFIX = ".satori.tmp.key."
|
|
TTY_REQUIRED = [
|
|
"you must have a tty to run sudo",
|
|
"is not a tty",
|
|
"no tty present",
|
|
]
|
|
|
|
|
|
def make_pkey(private_key):
|
|
"""Return a paramiko.pkey.PKey from private key string."""
|
|
key_classes = [paramiko.rsakey.RSAKey,
|
|
paramiko.dsskey.DSSKey,
|
|
paramiko.ecdsakey.ECDSAKey, ]
|
|
|
|
keyfile = six.StringIO(private_key)
|
|
for cls in key_classes:
|
|
keyfile.seek(0)
|
|
try:
|
|
pkey = cls.from_private_key(keyfile)
|
|
except paramiko.SSHException:
|
|
continue
|
|
else:
|
|
keytype = cls
|
|
LOG.info("Valid SSH Key provided (%s)", keytype.__name__)
|
|
return pkey
|
|
|
|
raise paramiko.SSHException("Is not a valid private key")
|
|
|
|
|
|
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 str(exc):
|
|
message = "%s " + str(exc)[str(exc).index(msg):]
|
|
raise exc.__class__(message % "connect()")
|
|
raise
|
|
|
|
|
|
class AcceptMissingHostKey(paramiko.client.MissingHostKeyPolicy):
|
|
|
|
"""Allow connections to hosts whose fingerprints are not on record."""
|
|
|
|
# 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): # pylint: disable=R0902
|
|
|
|
"""Connects to devices via SSH to execute commands."""
|
|
|
|
# pylint: disable=R0913
|
|
def __init__(self, host, password=None, username="root",
|
|
private_key=None, key_filename=None, port=22,
|
|
timeout=20, proxy=None, options=None, interactive=False):
|
|
"""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 StrictHostKeyChecking=no`,
|
|
you would provide
|
|
(.., options={'StrictHostKeyChecking': 'no'})
|
|
Conversion of booleans is also supported,
|
|
(.., options={'StrictHostKeyChecking': False})
|
|
is equivalent.
|
|
:keyword interactive: If true, prompt for password if missing.
|
|
"""
|
|
self.password = password
|
|
self.host = host
|
|
self.username = username
|
|
self.private_key = private_key
|
|
self.key_filename = key_filename
|
|
self.port = port
|
|
self.timeout = timeout
|
|
self._platform_info = None
|
|
self.options = options or {}
|
|
self.proxy = proxy
|
|
self.sock = None
|
|
self.interactive = interactive
|
|
|
|
if self.proxy:
|
|
if not isinstance(self.proxy, SSH):
|
|
raise TypeError("'proxy' must be a satori.ssh.SSH instance. "
|
|
"( instances of this type are returned by "
|
|
"satori.ssh.connect() )")
|
|
|
|
super(SSH, self).__init__()
|
|
|
|
@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_with_host_keys(self):
|
|
"""Try connecting with locally available keys (ex. ~/.ssh/id_rsa)."""
|
|
LOG.debug("Trying to connect with local host keys")
|
|
return self._connect(look_for_keys=True, allow_agent=False)
|
|
|
|
def connect_with_password(self):
|
|
"""Try connecting with password."""
|
|
LOG.debug("Trying to connect with password")
|
|
if self.interactive and not self.password:
|
|
LOG.debug("Prompting for password (interactive=%s)",
|
|
self.interactive)
|
|
try:
|
|
self.password = getpass.getpass("Enter password for %s:" %
|
|
self.username)
|
|
except KeyboardInterrupt:
|
|
LOG.debug("User cancelled at password prompt")
|
|
if not self.password:
|
|
raise paramiko.PasswordRequiredException("Password not provided")
|
|
return self._connect(
|
|
password=self.password,
|
|
look_for_keys=False,
|
|
allow_agent=False)
|
|
|
|
def connect_with_key_file(self):
|
|
"""Try connecting with key file."""
|
|
LOG.debug("Trying to connect with key file")
|
|
if not self.key_filename:
|
|
raise paramiko.AuthenticationException("No key file supplied")
|
|
return self._connect(
|
|
key_filename=os.path.expanduser(self.key_filename),
|
|
look_for_keys=False,
|
|
allow_agent=False)
|
|
|
|
def connect_with_key(self):
|
|
"""Try connecting with key string."""
|
|
LOG.debug("Trying to connect with private key string")
|
|
if not self.private_key:
|
|
raise paramiko.AuthenticationException("No key supplied")
|
|
pkey = make_pkey(self.private_key)
|
|
return self._connect(
|
|
pkey=pkey,
|
|
look_for_keys=False,
|
|
allow_agent=False)
|
|
|
|
def _connect(self, **kwargs):
|
|
"""Set up client and connect to target."""
|
|
self.load_system_host_keys()
|
|
|
|
if self.proxy:
|
|
# lazy load
|
|
self.sock = self._get_proxy_socket(self.proxy)
|
|
|
|
if self.options.get('StrictHostKeyChecking') in (False, "no"):
|
|
self.set_missing_host_key_policy(AcceptMissingHostKey())
|
|
|
|
return super(SSH, self).connect(
|
|
self.host,
|
|
timeout=kwargs.pop('timeout', self.timeout),
|
|
port=kwargs.pop('port', self.port),
|
|
username=kwargs.pop('username', self.username),
|
|
pkey=kwargs.pop('pkey', None),
|
|
sock=kwargs.pop('sock', self.sock),
|
|
**kwargs)
|
|
|
|
def connect(self): # 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 (will prompt if the password is not supplied and
|
|
interactive is true)
|
|
"""
|
|
if self.private_key:
|
|
try:
|
|
return self.connect_with_key()
|
|
except paramiko.SSHException:
|
|
pass # try next method
|
|
|
|
if self.key_filename:
|
|
try:
|
|
return self.connect_with_key_file()
|
|
except paramiko.SSHException:
|
|
pass # try next method
|
|
|
|
try:
|
|
return self.connect_with_host_keys()
|
|
except paramiko.SSHException:
|
|
pass # try next method
|
|
|
|
try:
|
|
return self.connect_with_password()
|
|
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")
|
|
LOG.info(msg, self.username, self.host, self.port, exc)
|
|
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:
|
|
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:
|
|
self.close()
|
|
|
|
def _handle_tty_required(self, results, get_pty):
|
|
"""Determine whether the result implies a tty request."""
|
|
if any(m in str(k) for m in TTY_REQUIRED 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 errors.GetPTYRetryFailure(
|
|
"Running command with get_pty=True FAILED: %s@%s:%d"
|
|
% (self.username, self.host, self.port))
|
|
else:
|
|
return True
|
|
return False
|
|
|
|
def _handle_password_prompt(self, stdin, stdout):
|
|
"""Determine whether the remote host is prompting for a password.
|
|
|
|
Respond to the prompt through stdin if applicable.
|
|
"""
|
|
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 MIN_PASSWORD_PROMPT_LEN < buflen < MAX_PASSWORD_PROMPT_LEN:
|
|
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()
|
|
return True
|
|
else:
|
|
LOG.warning("Nearly a False-Positive on "
|
|
"password prompt detection. [%s] {%s}",
|
|
buflen, prompt)
|
|
stdout.channel.send(prompt)
|
|
|
|
return False
|
|
|
|
def remote_execute(self, command, with_exit_code=False,
|
|
get_pty=False, wd=None):
|
|
"""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 wd: The child's current directory will be changed
|
|
to `wd` before it is executed. Note that this
|
|
directory is not considered when searching the
|
|
executable, so you can't specify the program's
|
|
path relative to this argument
|
|
:param get_pty: Request a pseudo-terminal from the server.
|
|
|
|
:returns: a dict with stdin, stdout,
|
|
and (optionally) the exit code of the call.
|
|
"""
|
|
if wd:
|
|
prefix = "cd %s && " % wd
|
|
command = prefix + command
|
|
|
|
LOG.debug("Executing '%s' on ssh://%s@%s:%s.",
|
|
command, self.username, self.host, self.port)
|
|
try:
|
|
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(.25)
|
|
self._handle_password_prompt(stdin, stdout)
|
|
|
|
results = {
|
|
'stdout': stdout.read().strip(),
|
|
'stderr': stderr.read()
|
|
}
|
|
|
|
LOG.debug("STDOUT from ssh://%s@%s:%d: %s",
|
|
self.username, self.host, self.port,
|
|
results['stdout'])
|
|
LOG.debug("STDERR from ssh://%s@%s:%d: %s",
|
|
self.username, self.host, self.port,
|
|
results['stderr'])
|
|
exit_code = chan.recv_exit_status()
|
|
|
|
if with_exit_code:
|
|
results.update({'exit_code': exit_code})
|
|
chan.close()
|
|
|
|
if self._handle_tty_required(results, get_pty):
|
|
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:
|
|
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.password:
|
|
LOG.warning("Proxying through a client which is authorized by "
|
|
"a password is not currently implemented. Please "
|
|
"use an ssh key.")
|
|
|
|
proxy.load_system_host_keys()
|
|
if proxy.options.get('StrictHostKeyChecking') in (False, "no"):
|
|
proxy.set_missing_host_key_policy(AcceptMissingHostKey())
|
|
|
|
if proxy.private_key and not proxy.key_filename:
|
|
tempkeyfile = tempfile.NamedTemporaryFile(
|
|
mode='w+', prefix=TEMPFILE_PREFIX,
|
|
dir=os.path.expanduser('~/'), delete=True)
|
|
tempkeyfile.write(proxy.private_key)
|
|
proxy.key_filename = tempkeyfile.name
|
|
|
|
pxd = {
|
|
'bastion': proxy.host,
|
|
'user': proxy.username,
|
|
'port': '-p %s' % proxy.port,
|
|
'options': ('-o ConnectTimeout=%s ' % proxy.timeout),
|
|
'target_host': self.host,
|
|
'target_port': self.port,
|
|
}
|
|
proxycommand = "ssh {options} -A {user}@{bastion} "
|
|
|
|
if proxy.key_filename:
|
|
proxy.key_filename = os.path.expanduser(proxy.key_filename)
|
|
proxy.key_filename = os.path.abspath(proxy.key_filename)
|
|
pxd.update({'identity': '-i %s' % proxy.key_filename})
|
|
proxycommand += "{identity} "
|
|
|
|
if proxy.options:
|
|
for key, val in sorted(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'] += '-o %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__
|
|
try:
|
|
SSH.__dict__['get_client'].__doc__ = SSH.__dict__['__init__'].__doc__
|
|
except AttributeError:
|
|
SSH.get_client.__func__.__doc__ = SSH.__init__.__doc__
|