import posixpath
import select
import stat
import threading
from interface_meta import override
from omniduct.errors import DuctAuthenticationError
from omniduct.filesystems.base import FileSystemFileDesc
from omniduct.remotes.base import RemoteClient
from omniduct.utils.debug import logger
from omniduct.utils.processes import SubprocessResults
# Python 2 compatibility imports
try:
import SocketServer
except ImportError:
import socketserver as SocketServer
try:
FileNotFoundError
except NameError:
FileNotFoundError = IOError
__all__ = ['ParamikoSSHClient']
[docs]class ParamikoSSHClient(RemoteClient):
"""
An experimental SSH client that uses a `paramiko` rather than command-line
SSH backend. This client has been fully implemented and should work as is,
but until it receives further testing, we recommend using the cli backed SSH
client.
"""
PROTOCOLS = ['ssh_paramiko']
DEFAULT_PORT = 22
@override
def _init(self):
logger.warning("The Paramiko SSH client is still under development, \
and is not ready for use as a daily driver.")
@override
def _connect(self):
import paramiko # Imported here due to relatively slow import
self.__client = paramiko.SSHClient()
self.__client.set_missing_host_key_policy(paramiko.client.AutoAddPolicy())
self.__client.load_system_host_keys()
try:
self.__client.connect(self.host, username=self.username)
self.__client_sftp = paramiko.SFTPClient.from_transport(self.__client.get_transport())
except paramiko.SSHException as e:
if len(e.args) == 1 and e.args[0] == 'No authentication methods available':
raise DuctAuthenticationError(e.args[0])
raise e
@override
def _is_connected(self):
try:
return self.__client.get_transport().is_active()
except:
return False
@override
def _disconnect(self):
try:
self.__client_sftp.close()
return self.__client.close()
except:
pass
@override
def _execute(self, cmd, **kwargs):
stdin, stdout, stderr = self.__client.exec_command(cmd)
returncode = stdout.channel.recv_exit_status()
return SubprocessResults(
returncode=returncode,
stdout=stdout.read(),
stderr=stderr.read()
)
@override
def _port_forward_start(self, local_port, remote_host, remote_port):
logger.debug('Now forwarding port {} to {}:{} ...'.format(local_port, remote_host, remote_port))
try:
server = forward_tunnel(local_port, remote_host, remote_port, self.__client.get_transport())
except KeyboardInterrupt:
print('C-c: Port forwarding stopped.')
return server
@override
def _port_forward_stop(self, local_port, remote_host, remote_port, connection):
connection.shutdown()
@override
def _is_port_bound(self, host, port):
return True
# Path properties and helpers
@override
def _path_home(self):
return self.execute('echo ~', skip_cwd=True).stdout.decode('utf-8').strip()
@override
def _path_separator(self):
return '/'
# File node properties
@override
def _exists(self, path):
try:
self.__client_sftp.stat(path)
return True
except FileNotFoundError:
return False
@override
def _isdir(self, path):
try:
return stat.S_ISDIR(self.__client_sftp.stat(path).st_mode)
except FileNotFoundError:
return False
@override
def _isfile(self, path):
try:
return not stat.S_ISDIR(self.__client_sftp.stat(path).st_mode)
except FileNotFoundError:
return False
# Directory handling and enumeration
@override
def _dir(self, path):
for attrs in self.__client_sftp.listdir_attr(path):
yield FileSystemFileDesc(
fs=self,
path=posixpath.join(path, attrs.filename),
name=attrs.filename,
type='directory' if stat.S_ISDIR(attrs.st_mode) else 'file', # TODO: What about links, which are of form: lrwxrwxrwx?
bytes=attrs.st_size,
owner=attrs.st_uid,
group=attrs.st_gid,
last_modified=attrs.st_mtime,
)
@override
def _mkdir(self, path, recursive, exist_ok):
if exist_ok and self.isdir(path):
return
assert self.execute('mkdir ' + ('-p ' if recursive else '') + '"{}"'.format(path)).returncode == 0, "Failed to create directory at: `{}`".format(path)
@override
def _remove(self, path, recursive):
assert self.execute('rm -f ' + ('-r ' if recursive else '') + '"{}"'.format(path)).returncode == 0, "Failed to remove file(s) at: `{}`".format(path)
# File handling
@override
def _open(self, path, mode):
"""
Paramiko offers a complete file-like abstraction for files opened over
sftp, so we use that abstraction rather than a `FileSystemFile`. Results
should be indistinguishable.
"""
return self.__client_sftp.open(path, mode=mode)
# Port Forwarding Utility Code
# Largely based on code from: https://github.com/paramiko/paramiko/blob/master/demos/forward.py
class ForwardServer (SocketServer.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = True
class Handler (SocketServer.BaseRequestHandler):
def handle(self):
try:
chan = self.ssh_transport.open_channel('direct-tcpip',
(self.chain_host, self.chain_port),
self.request.getpeername())
except Exception as e:
logger.info('Incoming request to %s:%d failed: %s' % (self.chain_host,
self.chain_port,
repr(e)))
return
if chan is None:
logger.info('Incoming request to %s:%d was rejected by the SSH server.' %
(self.chain_host, self.chain_port))
return
logger.info('Connected! Tunnel open %r -> %r -> %r' % (self.request.getpeername(),
chan.getpeername(), (self.chain_host, self.chain_port)))
while True:
r, w, x = select.select([self.request, chan], [], [])
if self.request in r:
data = self.request.recv(1024)
if len(data) == 0:
break
chan.send(data)
if chan in r:
data = chan.recv(1024)
if len(data) == 0:
break
self.request.send(data)
peername = self.request.getpeername()
chan.close()
self.request.close()
logger.info('Tunnel closed from %r' % (peername,))
def forward_tunnel(local_port, remote_host, remote_port, transport):
# this is a little convoluted, but lets me configure things for the Handler
# object. (SocketServer doesn't give Handlers any way to access the outer
# server normally.)
class SubHandler(Handler):
chain_host = remote_host
chain_port = remote_port
ssh_transport = transport
server = ForwardServer(('', local_port), SubHandler)
t = threading.Thread(target=server.serve_forever)
t.setDaemon(True) # don't hang on exit
t.start()
return server