From af6c42498b0dc27fcec1da013f34e737aad85fb3 Mon Sep 17 00:00:00 2001 From: Nathan Buckner Date: Tue, 24 Nov 2015 19:07:39 -0600 Subject: [PATCH] Create sshv2 client for compatibility with parallel runner Change-Id: I5e80177859be74a869dd3d908f516d751258ae7c --- cafe/plugins/sshv2/cafe/__init__.py | 14 + cafe/plugins/sshv2/cafe/engine/__init__.py | 0 .../sshv2/cafe/engine/sshv2/__init__.py | 0 .../sshv2/cafe/engine/sshv2/behaviors.py | 74 +++++ .../plugins/sshv2/cafe/engine/sshv2/client.py | 306 ++++++++++++++++++ .../plugins/sshv2/cafe/engine/sshv2/common.py | 94 ++++++ .../sshv2/cafe/engine/sshv2/composites.py | 50 +++ .../plugins/sshv2/cafe/engine/sshv2/config.py | 123 +++++++ .../plugins/sshv2/cafe/engine/sshv2/models.py | 30 ++ cafe/plugins/sshv2/cafe/engine/sshv2/proxy.py | 103 ++++++ cafe/plugins/sshv2/setup.py | 29 ++ setup.py | 2 +- 12 files changed, 824 insertions(+), 1 deletion(-) create mode 100644 cafe/plugins/sshv2/cafe/__init__.py create mode 100644 cafe/plugins/sshv2/cafe/engine/__init__.py create mode 100644 cafe/plugins/sshv2/cafe/engine/sshv2/__init__.py create mode 100644 cafe/plugins/sshv2/cafe/engine/sshv2/behaviors.py create mode 100644 cafe/plugins/sshv2/cafe/engine/sshv2/client.py create mode 100644 cafe/plugins/sshv2/cafe/engine/sshv2/common.py create mode 100644 cafe/plugins/sshv2/cafe/engine/sshv2/composites.py create mode 100644 cafe/plugins/sshv2/cafe/engine/sshv2/config.py create mode 100644 cafe/plugins/sshv2/cafe/engine/sshv2/models.py create mode 100644 cafe/plugins/sshv2/cafe/engine/sshv2/proxy.py create mode 100644 cafe/plugins/sshv2/setup.py diff --git a/cafe/plugins/sshv2/cafe/__init__.py b/cafe/plugins/sshv2/cafe/__init__.py new file mode 100644 index 0000000..b232471 --- /dev/null +++ b/cafe/plugins/sshv2/cafe/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2015 Rackspace +# 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. +"""Declares namespace, required for Opencafe plugins""" +__import__('pkg_resources').declare_namespace(__name__) diff --git a/cafe/plugins/sshv2/cafe/engine/__init__.py b/cafe/plugins/sshv2/cafe/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cafe/plugins/sshv2/cafe/engine/sshv2/__init__.py b/cafe/plugins/sshv2/cafe/engine/sshv2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cafe/plugins/sshv2/cafe/engine/sshv2/behaviors.py b/cafe/plugins/sshv2/cafe/engine/sshv2/behaviors.py new file mode 100644 index 0000000..8c4a32f --- /dev/null +++ b/cafe/plugins/sshv2/cafe/engine/sshv2/behaviors.py @@ -0,0 +1,74 @@ +# Copyright 2015 Rackspace +# 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 Crypto.PublicKey import RSA +import os + +from cafe.engine.config import EngineConfig +from cafe.engine.sshv2.common import BaseSSHClass +from cafe.engine.sshv2.models import SSHKeyResponse + +ENGINE_CONFIG = EngineConfig() + + +class SSHBehavior(BaseSSHClass): + + @classmethod + def generate_rsa_ssh_keys(cls, key_size=None, pass_phrase=None): + key_size = key_size or 2048 + pass_phrase = pass_phrase or "" + + try: + private_key = RSA.generate(key_size) + public_key = private_key.publickey() + except ValueError as exception: + cls._log.error("Key Generate exception: \n {0}".format(exception)) + raise exception + + return SSHKeyResponse( + public_key=public_key.exportKey(passphrase=pass_phrase), + private_key=private_key.exportKey(passphrase=pass_phrase)) + + @classmethod + def write_secure_keys_local( + cls, private_key, public_key=None, path=None, file_name=None): + if path is None: + path = ENGINE_CONFIG.temp_directory + if file_name is None: + file_name = "id_rsa" + + try: + os.makedirs(path) + except OSError: + pass + + key_path = os.path.join(path, file_name) + cls.write_file_with_permissions(key_path, private_key, 0o600) + key_path = "{0}.pub".format(key_path) + cls.write_file_with_permissions(key_path, public_key, 0o600) + + @staticmethod + def write_file_with_permissions(file_path, string=None, permissions=0o600): + if string is None: + return + with open(file_path, "w") as file_: + file_.write(string) + os.chmod(file_path, permissions) + + @classmethod + def generate_and_write_files( + cls, path=None, file_name=None, key_size=None, passphrase=None): + keys = cls.generate_rsa_ssh_keys(key_size, passphrase) + cls.write_secure_keys_local( + private_key=keys.private_key, public_key=keys.public_key, + path=path, file_name=file_name) diff --git a/cafe/plugins/sshv2/cafe/engine/sshv2/client.py b/cafe/plugins/sshv2/cafe/engine/sshv2/client.py new file mode 100644 index 0000000..bc01529 --- /dev/null +++ b/cafe/plugins/sshv2/cafe/engine/sshv2/client.py @@ -0,0 +1,306 @@ +# Copyright 2015 Rackspace +# 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 socks import socket, create_connection +from StringIO import StringIO +from uuid import uuid4 +import io +import time + +from paramiko import AutoAddPolicy, RSAKey +from paramiko.client import SSHClient as ParamikoSSHClient + +from cafe.engine.sshv2.common import ( + BaseSSHClass, _SSHLogger, DEFAULT_TIMEOUT, POLLING_RATE, CHANNEL_KEEPALIVE) +from cafe.engine.sshv2.models import ExecResponse + + +class ProxyTypes(object): + SOCKS5 = 2 + SOCKS4 = 1 + + +class ExtendedParamikoSSHClient(ParamikoSSHClient): + def execute_command( + self, command, bufsize=-1, timeout=None, stdin_str="", stdin_file=None, + raise_exceptions=False): + timeout = timeout or DEFAULT_TIMEOUT + chan = self._transport.open_session() + chan.settimeout(POLLING_RATE) + chan.exec_command(command) + stdin_str = stdin_str if stdin_file is None else stdin_file.read() + stdin = chan.makefile("wb", bufsize) + stdout = chan.makefile("rb", bufsize) + stderr = chan.makefile_stderr("rb", bufsize) + stdin.write(stdin_str) + stdin.close() + stdout_str = stderr_str = "" + exit_status = None + max_time = time.time() + timeout + while not chan.exit_status_ready(): + stderr_str += self._read_channel(stderr) + stdout_str += self._read_channel(stdout) + if max_time < time.time(): + raise socket.timeout( + "Command timed out\nSTDOUT:{0}\nSTDERR:{1}\n".format( + stdout_str, stderr_str)) + exit_status = chan.recv_exit_status() + stdout_str += self._read_channel(stdout) + stderr_str += self._read_channel(stderr) + chan.close() + return stdin_str, stdout_str, stderr_str, exit_status + + def _read_channel(self, chan): + read = "" + try: + read += chan.read() + except socket.timeout: + pass + return read + + +class SSHClient(BaseSSHClass): + def __init__( + self, hostname=None, port=22, username=None, password=None, + accept_missing_host_key=True, timeout=None, compress=True, pkey=None, + look_for_keys=False, allow_agent=False, key_filename=None, + proxy_type=None, proxy_ip=None, proxy_port=None, sock=None): + super(SSHClient, self).__init__() + self.connect_kwargs = {} + self.accept_missing_host_key = accept_missing_host_key + self.proxy_port = proxy_port + self.proxy_ip = proxy_ip + self.proxy_type = proxy_type + self.connect_kwargs["timeout"] = timeout or DEFAULT_TIMEOUT + self.connect_kwargs["hostname"] = hostname + self.connect_kwargs["port"] = int(port) + self.connect_kwargs["username"] = username + self.connect_kwargs["password"] = password + self.connect_kwargs["compress"] = compress + self.connect_kwargs["pkey"] = pkey + self.connect_kwargs["look_for_keys"] = look_for_keys + self.connect_kwargs["allow_agent"] = allow_agent + self.connect_kwargs["key_filename"] = key_filename + self.connect_kwargs["sock"] = sock + + @property + def timeout(self): + return self.connect_kwargs.get("timeout") + + @timeout.setter + def timeout(self, value): + self.connect_kwargs["timeout"] = value + + @timeout.deleter + def timeout(self): + if "timeout" in self.connect_kwargs: + del self.connect_kwargs["timeout"] + + def _connect( + self, hostname=None, port=None, username=None, password=None, + accept_missing_host_key=None, timeout=None, compress=None, pkey=None, + look_for_keys=None, allow_agent=None, key_filename=None, + proxy_type=None, proxy_ip=None, proxy_port=None, sock=None): + connect_kwargs = dict(self.connect_kwargs) + connect_kwargs.update({ + k: locals().get(k) for k in self.connect_kwargs + if locals().get(k) is not None}) + connect_kwargs["port"] = int(connect_kwargs.get("port")) + + ssh = ExtendedParamikoSSHClient() + + if bool(self.accept_missing_host_key or accept_missing_host_key): + ssh.set_missing_host_key_policy(AutoAddPolicy()) + + if connect_kwargs.get("pkey") is not None: + connect_kwargs["pkey"] = RSAKey.from_private_key( + io.StringIO(unicode(connect_kwargs["pkey"]))) + + proxy_type = proxy_type or self.proxy_type + proxy_ip = proxy_ip or self.proxy_ip + proxy_port = proxy_port or self.proxy_port + if connect_kwargs.get("sock") is not None: + pass + elif all([proxy_type, proxy_ip, proxy_port]): + connect_kwargs["sock"] = create_connection( + (connect_kwargs.get("hostname"), connect_kwargs.get("port")), + proxy_type, proxy_ip, int(proxy_port)) + + ssh.connect(**connect_kwargs) + return ssh + + @_SSHLogger + def execute_command( + self, command, bufsize=-1, get_pty=False, + stdin_str="", stdin_file=None, **connect_kwargs): + ssh_client = self._connect(**connect_kwargs) + stdin, stdout, stderr, exit_status = ssh_client.execute_command( + timeout=self._get_timeout(connect_kwargs.get("timeout")), + command=command, bufsize=bufsize) + ssh_client.close() + del ssh_client + return ExecResponse( + stdin=stdin, stdout=stdout, stderr=stderr, exit_status=exit_status) + + def _get_timeout(self, timeout=None): + return timeout if timeout is not None else self.timeout + + @_SSHLogger + def create_shell(self, keepalive=None, **connect_kwargs): + connection = self._connect(**connect_kwargs) + return SSHShell( + connection, self._get_timeout(connect_kwargs.get("timeout")), + keepalive) + + @_SSHLogger + def create_sftp(self, keepalive=None, **connect_kwargs): + connection = self._connect(**connect_kwargs) + return SFTPShell(connection, keepalive) + + +class SFTPShell(BaseSSHClass): + def __init__(self, connection=None, keepalive=None): + super(SFTPShell, self).__init__() + self.connection = connection + self.sftp = connection.open_sftp() + self.sftp.get_channel().get_transport().set_keepalive( + keepalive or CHANNEL_KEEPALIVE) + self.chdir(".") + + def __getattribute__(self, name): + if name in [ + "chdir", "chmod", "chown", "file", "get", "getcwd", "getfo", + "listdir", "listdir_attr", "listdir_iter", "lstat", "mkdir", + "normalize", "open", "put", "putfo", "readlink", "remove", + "rename", "rmdir", "stat", "symlink", "truncate", "unlink", + "utime"]: + return self.sftp.__getattribute__(name) + else: + return super(SFTPShell, self).__getattribute__(name) + + def exists(self, path): + ret_val = False + try: + self.sftp.stat(path) + ret_val = True + except IOError, e: + if e[0] != 2: + raise + ret_val = False + return ret_val + + def get_file(self, remote_path): + ret_val = StringIO() + self.getfo(ret_val, remote_path) + return ret_val.getvalue() + + def write_file(self, data, remote_path): + return self.putfo(StringIO(data), remote_path) + + def close(self): + if hasattr(self, "sftp"): + self.sftp.close() + del self.sftp + if hasattr(self, "connection"): + self.connection.close() + del self.connection + + +class SSHShell(BaseSSHClass): + RAISE = "RAISE" + RAISE_DISCONNECT = "RAISE_DISCONNECT" + + def __init__(self, connection, timeout, keepalive=None): + super(SSHShell, self).__init__() + self.connection = connection + self.timeout = timeout + self.channel = self._create_channel() + self.channel.settimeout(POLLING_RATE) + self._clear_channel() + self.channel.get_transport().set_keepalive( + keepalive or CHANNEL_KEEPALIVE) + + def close(self): + if hasattr(self, "channel"): + self.channel.close() + del self.channel + if hasattr(self, "connection"): + self.connection.close() + del self.connection + + @_SSHLogger + def execute_shell_command( + self, cmd, timeout=None, timeout_action=RAISE_DISCONNECT, + exception_on_timeout=True): + max_time = time.time() + self._get_timeout(timeout) + uuid = str(uuid4()).replace("-", "") + cmd = "echo {1}\n{0}\necho {1} $?\n".format(cmd.strip(), uuid) + try: + self._clear_channel() + self._wait_for_active_shell(max_time) + self.channel.send(cmd) + response = self._read_shell_response(uuid, max_time) + except socket.timeout as e: + if timeout_action == self.RAISE_DISCONNECT: + self.close() + raise e + return response + + def _create_channel(self): + chan = self.connection._transport.open_session() + chan.invoke_shell() + return chan + + def _wait_for_active_shell(self, max_time): + while not self.channel.send_ready(): + time.sleep(POLLING_RATE) + if max_time < time.time(): + raise socket.timeout("Timed out waiting for active shell") + + def _read_shell_response(self, uuid, max_time): + stdout = stderr = "" + exit_status = None + while max_time > time.time(): + stdout += self._read_channel(self.channel.recv) + stderr += self._read_channel(self.channel.recv_stderr) + if stdout.count(uuid) == 2: + list_ = stdout.split(uuid) + stdout = list_[1] + try: + exit_status = int(list_[2]) + except (ValueError, TypeError): + exit_status = None + break + else: + raise socket.timeout( + "Command timed out\nSTDOUT:{0}\nSTDERR:{1}\n".format( + stdout, stderr)) + response = ExecResponse( + stdin=None, stdout=stdout.strip(), stderr=stderr, + exit_status=exit_status) + return response + + def _read_channel(self, read_func, buffsize=1024): + read = "" + try: + read += read_func(buffsize) + except socket.timeout: + pass + return read + + def _clear_channel(self): + self._read_channel(self.channel.recv) + self._read_channel(self.channel.recv_stderr) + + def _get_timeout(self, timeout=None): + return timeout if timeout is not None else self.timeout diff --git a/cafe/plugins/sshv2/cafe/engine/sshv2/common.py b/cafe/plugins/sshv2/cafe/engine/sshv2/common.py new file mode 100644 index 0000000..61cf13f --- /dev/null +++ b/cafe/plugins/sshv2/cafe/engine/sshv2/common.py @@ -0,0 +1,94 @@ +# Copyright 2015 Rackspace +# 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. + +import functools +import time + +from cafe.common.reporting import cclogging +from cafe.engine.sshv2.models import ExecResponse +CHANNEL_KEEPALIVE = 45 +DEFAULT_TIMEOUT = 60 +POLLING_RATE = 0.01 + + +def _SSHLogger(func): + DASH_WIDTH = 42 + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + message = ( + "{equals}\nCALL\n{dash}\n" + "{name} args..........: {args}\n" + "{name} kwargs........: {kwargs}\n" + "{dash}\n").format( + dash="-" * DASH_WIDTH, equals="=" * DASH_WIDTH, name=func.__name__, + args=args, kwargs=kwargs) + self._log.info(message) + + start = time.time() + try: + resp = func(self, *args, **kwargs) + except Exception as e: + self._log.critical(e) + raise + + elapsed = time.time() - start + if isinstance(resp, ExecResponse): + message = ( + "{equals}\nRESPONSE\n{dash}\n" + "response stdout......: {stdout}\n" + "response stderr......: {stderr}\n" + "response exit_status.: {exit_status}\n" + "response elapsed.....: {elapsed}\n" + "{dash}\n").format( + dash="-" * 42, equals="=" * 42, elapsed=elapsed, + stdout=resp.stdout.rstrip("\n"), + stderr=resp.stderr.rstrip("\n"), exit_status=resp.exit_status) + self._log.info(message) + return resp + return wrapper + + +class ClassPropertyDescriptor(object): + + def __init__(self, fget, fset=None): + self.fget = fget + self.fset = fset + + def __get__(self, obj, klass=None): + if klass is None: + klass = type(obj) + return self.fget.__get__(obj, klass)() + + +def classproperty(func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + return ClassPropertyDescriptor(func) + + +class BaseSSHClass(object): + @classproperty + def _log(cls): + return cclogging.getLogger(cclogging.get_object_namespace(cls)) + + def __enter__(self): + return self + + def __exit__(self, exception_type, exception_value, traceback): + if getattr(self, "close", False): + self.close() + + def __del__(self): + if getattr(self, "close", False): + self.close() diff --git a/cafe/plugins/sshv2/cafe/engine/sshv2/composites.py b/cafe/plugins/sshv2/cafe/engine/sshv2/composites.py new file mode 100644 index 0000000..ce860f8 --- /dev/null +++ b/cafe/plugins/sshv2/cafe/engine/sshv2/composites.py @@ -0,0 +1,50 @@ +# Copyright 2015 Rackspace +# 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 cafe.engine.sshv2.behaviors import SSHBehavior +from cafe.engine.sshv2.client import SSHClient +from cafe.engine.sshv2.common import BaseSSHClass +from cafe.engine.sshv2.config import ProxyConfig, SSHClientConfig +from cafe.engine.sshv2.proxy import SSHProxy + + +class SSHComposite(BaseSSHClass): + def __init__(self, ssh_config=None, proxy_config=None): + self.proxy_config = proxy_config or ProxyConfig() + self.ssh_config = ssh_config or SSHClientConfig() + + self.proxy_client = SSHProxy( + hostname=self.proxy_config.hostname, + port=self.proxy_config.port, + username=self.proxy_config.username, + compress=self.proxy_config.compress, + look_for_keys=self.proxy_config.look_for_keys, + key_filename=self.proxy_config.key_filename) + + self.client = SSHClient( + hostname=self.ssh_config.hostname, + port=self.ssh_config.port, + username=self.ssh_config.username, + password=self.ssh_config.password, + accept_missing_host_key=self.ssh_config.accept_missing_host_key, + timeout=self.ssh_config.timeout, + compress=self.ssh_config.compress, + pkey=self.ssh_config.pkey, + look_for_keys=self.ssh_config.look_for_keys, + allow_agent=self.ssh_config.allow_agent, + key_filename=self.ssh_config.key_filename, + proxy_type=self.ssh_config.proxy_type, + proxy_ip=self.ssh_config.proxy_ip, + proxy_port=self.ssh_config.proxy_port) + + self.behavior = SSHBehavior diff --git a/cafe/plugins/sshv2/cafe/engine/sshv2/config.py b/cafe/plugins/sshv2/cafe/engine/sshv2/config.py new file mode 100644 index 0000000..4f41b0c --- /dev/null +++ b/cafe/plugins/sshv2/cafe/engine/sshv2/config.py @@ -0,0 +1,123 @@ +# Copyright 2015 Rackspace +# 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 cafe.engine.models.data_interfaces import ConfigSectionInterface +from cafe.engine.sshv2.common import DEFAULT_TIMEOUT + + +class SSHClientConfig(ConfigSectionInterface): + SECTION_NAME = "ssh_client" + + @property + def hostname(self): + return self.get("hostname", None) + + @property + def port(self): + return self.get("port", 22) + + @property + def username(self): + return self.get("username", None) + + @property + def password(self): + return self.get_raw("password") + + @property + def accept_missing_host_key(self): + return self.get_boolean("accept_missing_host_key", True) + + @property + def timeout(self): + return self.get("timeout", DEFAULT_TIMEOUT) + + @property + def compress(self): + return self.get_boolean("compress", True) + + @property + def pkey(self): + return self.get("pkey", None) + + @property + def look_for_keys(self): + return self.get_boolean("look_for_keys", False) + + @property + def allow_agent(self): + return self.get_boolean("allow_agent", False) + + @property + def key_filename(self): + return self.get("key_filename") + + @property + def proxy_type(self): + return self.get("proxy_type") + + @property + def proxy_ip(self): + return self.get("proxy_ip") + + @property + def proxy_port(self): + return self.get("proxy_port") + + +class ProxyConfig(ConfigSectionInterface): + SECTION_NAME = "proxy" + + @property + def hostname(self): + return self.get("hostname", None) + + @property + def port(self): + return self.get("port", 22) + + @property + def username(self): + return self.get("username", None) + + @property + def compress(self): + return self.get_boolean("compress", True) + + @property + def look_for_keys(self): + return self.get_boolean("look_for_keys", False) + + @property + def key_filename(self): + return self.get("key_filename") + + +class ProxyForward(ConfigSectionInterface): + SECTION_NAME = "port_forward" + + @property + def bind_port(self): + return self.get("bind_port", None) + + @property + def forward_hostname(self): + return self.get("forward_hostname", None) + + @property + def forward_port(self): + return self.get("forward_port", None) + + @property + def bind_address(self): + return self.get("bind_address", None) diff --git a/cafe/plugins/sshv2/cafe/engine/sshv2/models.py b/cafe/plugins/sshv2/cafe/engine/sshv2/models.py new file mode 100644 index 0000000..9930937 --- /dev/null +++ b/cafe/plugins/sshv2/cafe/engine/sshv2/models.py @@ -0,0 +1,30 @@ +# Copyright 2015 Rackspace +# 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 cafe.engine.models.base import BaseModel + + +class ExecResponse(BaseModel): + def __init__( + self, stdin=None, stdout=None, stderr=None, exit_status=None): + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + self.exit_status = exit_status + + +class SSHKeyResponse(BaseModel): + def __init__( + self, public_key=None, private_key=None, error=None): + self.public_key = public_key + self.private_key = private_key diff --git a/cafe/plugins/sshv2/cafe/engine/sshv2/proxy.py b/cafe/plugins/sshv2/cafe/engine/sshv2/proxy.py new file mode 100644 index 0000000..3148dc7 --- /dev/null +++ b/cafe/plugins/sshv2/cafe/engine/sshv2/proxy.py @@ -0,0 +1,103 @@ +# Copyright 2015 Rackspace +# 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. + +import os +import signal +import subprocess + +from cafe.engine.sshv2.common import BaseSSHClass + + +class SSHProxy(BaseSSHClass): + def __init__( + self, hostname=None, port=22, username=None, compress=True, + look_for_keys=False, key_filename=None): + super(SSHProxy, self).__init__() + for k, v in locals().items(): + if k != "self": + setattr(self, k, v) + + def _get_args( + self, hostname=None, port=None, username=None, compress=None, + look_for_keys=None, key_filename=None): + args = [ + "ssh", "-oUserKnownHostsFile=/dev/null", + "-oStrictHostKeyChecking=no", "-oExitOnForwardFailure=yes", "-N"] + hostname = hostname or self.hostname + username = username or self.username + compress = compress if compress is not None else self.compress + key_filename = key_filename or self.key_filename + look_for_keys = ( + look_for_keys if look_for_keys is not None + else self.look_for_keys) + + args.append("-P{0}".format(port or self.port)) + if compress: + args.append("-C") + + if look_for_keys is False: + args.append("-i{0}".format(key_filename)) + + if username: + args.append("{0}@{1}".format(username, hostname)) + else: + args.append(hostname) + return args + + def create_forward_port( + self, bind_port, forward_hostname, forward_port, bind_address=None, + **connect_kwargs): + args = self._get_args(**connect_kwargs) + if bind_address: + args.append("-L{0}:{1}:{2}:{3}".format( + bind_address, bind_port, forward_hostname, forward_port)) + else: + args.append("-L{0}:{1}:{2}".format( + bind_port, forward_hostname, forward_port)) + return PortForward( + subprocess.Popen(args).pid, "forward", bind_address or "localhost", + bind_port) + + def create_socks_proxy( + self, bind_port, bind_address=None, **connect_kwargs): + args = self._get_args(**connect_kwargs) + if bind_address: + args.append("-D{0}:{1}".format(bind_address, bind_port)) + else: + args.append("-D{0}:{1}:{2}".format(bind_port)) + return SocksProxy( + subprocess.Popen(args).pid, "forward", bind_address or "localhost", + bind_port) + + +class PortForward(BaseSSHClass): + def __init__(self, pid, type_, bind_address, bind_port): + for k, v in locals().items(): + if k != "self": + setattr(self, k, v) + self.name = None + + def set_name(self, name): + self.name = name + + def close(self): + try: + os.kill(self.pid, signal.SIGKILL) + except OSError as e: + self._log.warning( + "Close called more than once or process ended unexpectedly") + self._log.warning(e) + + +class SocksProxy(PortForward): + pass diff --git a/cafe/plugins/sshv2/setup.py b/cafe/plugins/sshv2/setup.py new file mode 100644 index 0000000..fcfd382 --- /dev/null +++ b/cafe/plugins/sshv2/setup.py @@ -0,0 +1,29 @@ +# Copyright 2015 Rackspace +# 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. +"""A setuptools based setup module. +See: +https://packaging.python.org/en/latest/distributing.html +""" +from setuptools import setup, find_packages + +setup( + name='cafe_sshv2_plugin', + version='0.0.1', + description='Paramiko based plugin for OpenCAFE', + author='Rackspace Cloud QE', + author_email='cloud-cafe@lists.rackspace.com', + url='http://rackspace.com', + packages=find_packages(), + namespace_packages=['cafe'], + install_requires=['paramiko', 'pysocks'], + zip_safe=False) diff --git a/setup.py b/setup.py index 0171ddf..f40f44b 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ setup( author='CafeHub', author_email='cloud-cafe@lists.rackspace.com', url='http://opencafe.readthedocs.org', - install_requires=['six'], + install_requires=['six', 'pysocks'], packages=find_packages(exclude=('tests*', 'docs')), package_data={'cafe': plugins}, license=open('LICENSE').read(),