348 lines
12 KiB
Python
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
|