tobiko/tobiko/shell/ssh/_client.py

348 lines
12 KiB
Python

# Copyright (c) 2019 Red Hat, Inc.
#
# All Rights Reserved.
#
# 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.
from __future__ import absolute_import
import os
import socket
import time
import paramiko
from oslo_log import log
import tobiko
from tobiko.shell.ssh import _config
LOG = log.getLogger(__name__)
SSH_CONNECT_PARAMETERS = {
#: The server to connect to
'hostname': str,
#: The server port to connect to
'port': int,
#: The username to authenticate as (defaults to the current local username)
'username': str,
#: Used for password authentication; is also used for private key
#: decryption if passphrase is not given
'password': str,
#: Used for decrypting private keys
'passphrase': str,
#: Private key to be used for authentication
'pkey': str,
#: The filename, or list of filenames, of optional private key(s) and/or
#: certs to try for authentication
'key_filename': str,
#: An optional timeout (in seconds) for the TCP connect
'timeout': float,
#: Set to False to disable connecting to the SSH agent
'allow_agent': bool,
#: Set to False to disable searching for discoverable private key files in
#: ~/.ssh/
'look_for_keys': bool,
#: Set to True to turn on compression
'compress': bool,
#: True if you want to use GSS-API authentication
'gss_auth': bool,
#: Perform GSS-API Key Exchange and user authentication
'gss_kex': bool,
#: Delegate GSS-API client credentials or not
'gss_deleg_creds': bool,
#: The targets name in the kerberos database. default: hostname
'gss_host': str,
#: Indicates whether or not the DNS is trusted to securely canonicalize the
#: name of the host being connected to (default True).
'gss_trust_dns': bool,
#: An optional timeout (in seconds) to wait for the SSH banner to be
#: presented.
'banner_timeout': float,
#: An optional timeout (in seconds) to wait for an authentication response
'auth_timeout': float
}
class SSHConnectFailure(tobiko.TobikoException):
message = "Failed to login to {login}\n{cause}"
class SSHClientFixture(tobiko.SharedFixture):
host = None
username = None
port = 22
client = None
paramiko_conf = tobiko.required_setup_fixture(
_config.SSHParamikoConfFixture)
ssh_config = tobiko.required_setup_fixture(_config.SSHConfigFixture)
host_config = None
proxy_client = None
proxy_command = None
connect_parameters = None
def __init__(self, host=None, proxy_client=None, **connect_parameters):
super(SSHClientFixture, self).__init__()
if host:
self.host = host
if proxy_client:
self.proxy_client = proxy_client
invalid_parameters = sorted([
name
for name in connect_parameters
if name not in SSH_CONNECT_PARAMETERS])
if invalid_parameters:
message = "Invalid SSH connection parameters: {!s}".format(
', '.join(invalid_parameters))
raise ValueError(message)
self._connect_parameters = connect_parameters
def setup_fixture(self):
self.setup_host_config()
self.setup_connect_parameters()
self.setup_ssh_client()
def setup_host_config(self):
host = self.host
if not host:
message = 'Invalid host: {!r}'.format(host)
raise ValueError(message)
self.host_config = self.ssh_config.lookup(host)
def setup_connect_parameters(self):
"""Fill connect parameters dict
Get parameters values from below sources:
- parameters passed to class constructor
- parameters got from ~/.ssh/config and tobiko.conf
- parameters got from fixture object attributes
"""
# Get default parameter values from self object
self.connect_parameters = parameters = items_from_object(
schema=SSH_CONNECT_PARAMETERS, obj=self)
LOG.debug('Default parameters for host %r:\n%r', self.host,
parameters)
# Override parameters from host configuration files
parameters.update(
items_from_mapping(schema=SSH_CONNECT_PARAMETERS,
mapping=self.host_config.connect_parameters))
LOG.debug('Configured connect parameters for host %r:\n%r',
self.host, parameters)
# Override parameters with __init__ parameters
parameters.update(
items_from_mapping(schema=SSH_CONNECT_PARAMETERS,
mapping=self._connect_parameters))
LOG.debug('Resulting connect parameters for host %r:\n%r', self.host,
parameters)
# Validate hostname
hostname = parameters.get('hostname')
if not hostname:
message = "Invalid hostname: {!r}".format(hostname)
raise ValueError(message)
# Expand key_filename
key_filename = parameters.get('key_filename')
if key_filename:
key_filename = os.path.expanduser(key_filename)
if not os.path.exists(key_filename):
message = "key_filename {!r} doesn't exist".format(hostname)
raise ValueError(message)
parameters['key_filename'] = key_filename
# Validate connection timeout
timeout = parameters.get('timeout')
if not timeout or timeout < 0.:
message = "Invalid timeout: {!r}".format(timeout)
raise ValueError(message)
# Validate connection port
port = parameters.get('port')
if not port or port < 0 or port > 65535:
message = "Invalid timeout: {!r}".format(port)
raise ValueError(message)
def setup_ssh_client(self):
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.load_system_host_keys()
now = time.time()
parameters = dict(self.connect_parameters)
deadline = now + parameters.pop('timeout')
sleep_time = self.connect_sleep_time
login = self.connect_login
while True:
timeout = deadline - now
LOG.debug("Logging in to %r... (time left %d seconds)", login,
timeout)
try:
sock = self._open_proxy_sock()
client.connect(sock=sock, timeout=timeout, **parameters)
except (EOFError, socket.error, socket.timeout,
paramiko.SSHException) as ex:
now = time.time()
if now + sleep_time >= deadline:
raise SSHConnectFailure(login=login, cause=ex)
LOG.debug("Error logging in to %s (%s); retrying in %d "
"seconds...", login, ex, sleep_time)
time.sleep(sleep_time)
sleep_time += self.connect_sleep_time_increment
else:
self.client = client
self.addCleanup(client.close)
LOG.info("Successfully logged it to %s", login)
break
def _open_proxy_sock(self):
proxy_command = self.host_config.proxy_command or self.proxy_command
proxy_client = self.proxy_client
if proxy_client:
# I need a command to execute with proxy client
proxy_command = proxy_command or 'nc {hostname!r} {port!r}'
elif not proxy_command:
# Proxy sock is not required
return None
# Apply connect parameters to proxy command
parameters = self.connect_parameters
proxy_command = proxy_command.format(
hostname=parameters['hostname'],
port=parameters.get('port', 22))
LOG.debug("Using proxy command: %r", proxy_command)
if proxy_client:
if isinstance(proxy_client, SSHClientFixture):
# Connect to proxy server
proxy_client = tobiko.setup_fixture(proxy_client).client
# Open proxy channel
LOG.debug("Execute proxy command with proxy client %r: %r",
proxy_client, proxy_command)
proxy_sock = proxy_client.get_transport().open_session()
proxy_sock.exec_command(proxy_command)
else:
LOG.debug("Execute proxy command on local host: %r", proxy_command)
proxy_sock = paramiko.ProxyCommand(proxy_command)
self.addCleanup(proxy_sock.close)
return proxy_sock
@property
def connect_sleep_time(self):
return self.paramiko_conf.connect_sleep_time
@property
def connect_sleep_time_increment(self):
return self.paramiko_conf.connect_sleep_time_increment
@property
def connect_login(self):
login = self.connect_parameters['hostname']
port = self.connect_parameters.get('port', None)
if port:
login = ':'.join([login, str(port)])
username = self.connect_parameters.get('username', None)
if username:
login = "@".join([username, login])
return login
def items_from_mapping(schema, mapping):
return ((key, init(mapping.get(key)))
for key, init in schema.items()
if mapping.get(key) is not None)
def items_from_object(schema, obj):
return {key: init(getattr(obj, key, None))
for key, init in schema.items()
if getattr(obj, key, None) is not None}
class SSHClientManager(object):
ssh_config = tobiko.required_setup_fixture(_config.SSHConfigFixture)
paramiko_conf = tobiko.required_setup_fixture(
_config.SSHParamikoConfFixture)
def __init__(self):
self.clients = {}
def get_client(self, host, username=None, port=None, proxy_jump=None,
**connect_parameters):
host_config = self.ssh_config.lookup(host)
hostname = host_config.hostname
port = port or host_config.port
username = username or host_config.username
proxy_jump = proxy_jump or host_config.proxy_jump
host_key = hostname, port, username, proxy_jump
client = self.clients.get(host_key)
if not client:
proxy_client = None
if proxy_jump:
proxy_client = self.get_client(proxy_jump)
self.clients[host_key] = client = SSHClientFixture(
host=host, hostname=hostname, port=port, username=username,
proxy_client=proxy_client, **connect_parameters)
return client
@property
def proxy_client(self):
proxy_jump = self.paramiko_conf.proxy_jump
if proxy_jump:
return self.get_client(proxy_jump)
else:
return None
CLIENTS = SSHClientManager()
def ssh_client(host, port=None, username=None, proxy_jump=None,
manager=None, **connect_parameters):
manager = manager or CLIENTS
return manager.get_client(host=host, port=port, username=username,
proxy_jump=proxy_jump, **connect_parameters)
def ssh_proxy_client(manager=None):
manager = manager or CLIENTS
return manager.proxy_client