# -*- coding: utf-8 -*-
# Copyright (C) 2010-2020 by Mike Gabriel <mike.gabriel@das-netzwerkteam.de>
# The Python X2Go sFTPServer code was originally written by Richard Murri,
# for further information see his website: http://www.richardmurri.com
# Python X2Go is free software; you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# Python X2Go is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program; if not, write to the
# Free Software Foundation, Inc.,
# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.
"""\
For sharing local folders via sFTP/sshfs Python X2Go implements its own sFTP
server (as end point of reverse forwarding tunnel requests). Thus, Python X2Go
does not need a locally installed SSH daemon on the client side machine.
The Python X2Go sFTP server code was originally written by Richard Murri,
for further information see his website: http://www.richardmurri.com
"""
__NAME__ = "x2gosftpserver-pylib"
__package__ = 'x2go'
__name__ = 'x2go.sftpserver'
import os
import shutil
import copy
import threading
import paramiko
import gevent
# Python X2Go modules
from . import rforward
from . import defaults
from . import log
class _SSHServer(paramiko.ServerInterface):
"""\
Implementation of a basic SSH server that is supposed
to run with its sFTP server implementation.
"""
def __init__(self, auth_key=None, session_instance=None, logger=None, loglevel=log.loglevel_DEFAULT, *args, **kwargs):
"""\
Initialize a new sFTP server interface.
:param auth_key: Server key that the client has to authenticate against
:type auth_key: ``paramiko.RSAKey`` instance
:param session_instance: the calling :class:`x2go.session.X2GoSession` instance
:type session_instance: :class:`x2go.session.X2GoSession` instance
:param logger: you can pass an :class:`x2go.log.X2GoLogger` object to the :class:`x2go.xserver.X2GoClientXConfig` constructor
:type logger: ``obj``
:param loglevel: if no :class:`x2go.log.X2GoLogger` object has been supplied a new one will be
constructed with the given loglevel
:type loglevel: ``int``
"""
if logger is None:
self.logger = log.X2GoLogger(loglevel=loglevel)
else:
self.logger = copy.deepcopy(logger)
self.logger.tag = __NAME__
self.current_local_user = defaults.CURRENT_LOCAL_USER
self.auth_key = auth_key
self.session_instance = session_instance
paramiko.ServerInterface.__init__(self, *args, **kwargs)
logger('initializing internal SSH server for handling incoming sFTP requests, allowing connections for user ,,%s\'\' only' % self.current_local_user, loglevel=log.loglevel_DEBUG)
def check_channel_request(self, kind, chanid):
"""\
Only allow session requests.
:param kind: request type
:type kind: ``str``
:param chanid: channel id (unused)
:type chanid: ``any``
:returns: returns a Paramiko/SSH return code
:rtype: ``int``
"""
self.logger('detected a channel request for sFTP', loglevel=log.loglevel_DEBUG_SFTPXFER)
if kind == 'session':
return paramiko.OPEN_SUCCEEDED
return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
def check_auth_publickey(self, username, key):
"""\
Ensure proper authentication.
:param username: username of incoming authentication request
:type username: ``str``
:param key: incoming SSH key to be used for authentication
:type key: ``paramiko.RSAKey`` instance
:returns: returns a Paramiko/SSH return code
:rtype: ``int``
"""
self.logger('sFTP server %s: username is %s' % (self, self.current_local_user), loglevel=log.loglevel_DEBUG)
if username == self.current_local_user:
if type(key) == paramiko.RSAKey and key == self.auth_key:
self.logger('sFTP server %s: publickey auth (type: %s) has been successful' % (self, key.get_name()), loglevel=log.loglevel_INFO)
return paramiko.AUTH_SUCCESSFUL
self.logger('sFTP server %s: publickey (type: %s) auth failed' % (self, key.get_name()), loglevel=log.loglevel_WARN)
return paramiko.AUTH_FAILED
def get_allowed_auths(self, username):
"""\
Only allow public key authentication.
:param username: username of incoming authentication request
:type username: ``str``
:returns: statically returns ``publickey`` as auth mechanism
:rtype: ``str``
"""
self.logger('sFTP client asked for support auth methods, answering: publickey', loglevel=log.loglevel_DEBUG_SFTPXFER)
return 'publickey'
class _SFTPHandle(paramiko.SFTPHandle):
"""\
Represents a handle to an open file.
"""
def stat(self):
"""\
Create an SFTPAttributes object from an existing stat object (an object returned by os.stat).
:returns: new ``SFTPAttributes`` object with the same attribute fields.
:rtype: ``obj``
"""
try:
return paramiko.SFTPAttributes.from_stat(os.fstat(self.readfile.fileno()))
except OSError as e:
return paramiko.SFTPServer.convert_errno(e.errno)
class _SFTPServerInterface(paramiko.SFTPServerInterface):
"""\
sFTP server implementation.
"""
def __init__(self, server, chroot=None, logger=None, loglevel=log.loglevel_DEFAULT, server_event=None, *args, **kwargs):
"""\
Make user information accessible as well as set chroot jail directory.
:param server: a ``paramiko.ServerInterface`` instance to use with this SFTP server interface
:type server: ``paramiko.ServerInterface`` instance
:param chroot: chroot environment for this SFTP interface
:type chroot: ``str``
:param logger: you can pass an :class:`x2go.log.X2GoLogger` object to the :class:`x2go.xserver.X2GoClientXConfig` constructor
:type logger: ``obj``
:param loglevel: if no :class:`x2go.log.X2GoLogger` object has been supplied a new one will be
constructed with the given loglevel
:type loglevel: ``int``
:param server_event: a ``threading.Event`` instance that can signal SFTP session termination
:type server_event: ``threading.Event`` instance
"""
if logger is None:
self.logger = log.X2GoLogger(loglevel=loglevel)
else:
self.logger = copy.deepcopy(logger)
self.logger.tag = __NAME__
self.server_event = server_event
self.logger('sFTP server: initializing new channel...', loglevel=log.loglevel_DEBUG)
self.CHROOT = chroot or '/tmp'
def _realpath(self, path):
"""\
Enforce the chroot jail. On Windows systems the drive letter is incorporated in the
chroot path name (/windrive/<drive_letter>/path/to/file/or/folder).
:param path: path name within chroot
:type path: ``str``
:returns: real path name (including drive letter on Windows systems)
:rtype: ``str``
"""
if defaults.X2GOCLIENT_OS == 'Windows' and path.startswith('/windrive'):
_path_components = path.split('/')
_drive = _path_components[2]
_tail_components = (len(_path_components) > 3) and _path_components[3:] or ''
_tail = os.path.normpath('/'.join(_tail_components))
path = os.path.join('%s:' % _drive, '/', _tail)
else:
path = self.CHROOT + self.canonicalize(path)
path = path.replace('//', '/')
return path
def list_folder(self, path):
"""\
List the contents of a folder.
:param path: path to folder
:type path: ``str``
:returns: returns the folder contents, on failure returns a Paramiko/SSH return code
:rtype: ``dict`` or ``int``
"""
path = self._realpath(path)
self.logger('sFTP server: listing files in folder: %s' % path, loglevel=log.loglevel_DEBUG_SFTPXFER)
try:
out = []
flist = os.listdir(path)
for fname in flist:
try:
attr = paramiko.SFTPAttributes.from_stat(os.lstat(os.path.join(path, fname)))
attr.filename = fname
self.logger('sFTP server %s: file attributes ok: %s' % (self, fname), loglevel=log.loglevel_DEBUG_SFTPXFER)
out.append(attr)
except OSError as e:
self.logger('sFTP server %s: encountered error processing attributes of file %s: %s' % (self, fname, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
self.logger('sFTP server: folder list is : %s' % str([ a.filename for a in out ]), loglevel=log.loglevel_DEBUG_SFTPXFER)
return out
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
def stat(self, path):
"""\
Stat on a file.
:param path: path to file/folder
:type path: ``str``
:returns: returns the file's stat output, on failure: returns a Paramiko/SSH return code
:rtype: ``class`` or ``int``
"""
path = self._realpath(path)
self.logger('sFTP server %s: calling stat on path: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
try:
return paramiko.SFTPAttributes.from_stat(os.stat(path))
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
def lstat(self, path):
"""\
LStat on a file.
:param path: path to folder
:type path: ``str``
:returns: returns the file's lstat output, on failure: returns a Paramiko/SSH return code
:rtype: ``class`` or ``int``
"""
path = self._realpath(path)
self.logger('sFTP server: calling lstat on path: %s' % path, loglevel=log.loglevel_DEBUG_SFTPXFER)
try:
return paramiko.SFTPAttributes.from_stat(os.lstat(path))
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
def open(self, path, flags, attr):
"""\
Open a file for reading, writing, appending etc.
:param path: path to file
:type path: ``str``
:param flags: file flags
:type flags: ``str``
:param attr: file attributes
:type attr: ``class``
:returns: file handle/object for remote file, on failure: returns a Paramiko/SSH return code
:rtype: :class:`x2go.sftpserver._SFTPHandle` instance or ``int``
"""
path = self._realpath(path)
self.logger('sFTP server %s: opening file: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
try:
binary_flag = getattr(os, 'O_BINARY', 0)
flags |= binary_flag
mode = getattr(attr, 'st_mode', None)
if mode is not None:
fd = os.open(path, flags, mode)
else:
# os.open() defaults to 0777 which is
# an odd default mode for files
fd = os.open(path, flags, 0o666)
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
if (flags & os.O_CREAT) and (attr is not None):
attr._flags &= ~attr.FLAG_PERMISSIONS
paramiko.SFTPServer.set_file_attr(path, attr)
if flags & os.O_WRONLY:
if flags & os.O_APPEND:
fstr = 'ab'
else:
fstr = 'wb'
elif flags & os.O_RDWR:
if flags & os.O_APPEND:
fstr = 'a+b'
else:
fstr = 'r+b'
else:
# O_RDONLY (== 0)
fstr = 'rb'
try:
f = os.fdopen(fd, fstr)
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
fobj = _SFTPHandle(flags)
fobj.filename = path
fobj.readfile = f
fobj.writefile = f
return fobj
def remove(self, path):
"""\
Remove a file.
:param path: path to file
:type path: ``str``
:returns: returns Paramiko/SSH return code
:rtype: ``int``
"""
path = self._realpath(path)
os.remove(path)
self.logger('sFTP server %s: removing file: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTP_OK
def rename(self, oldpath, newpath):
"""\
Rename/move a file.
:param oldpath: old path/location/file name
:type oldpath: ``str``
:param newpath: new path/location/file name
:type newpath: ``str``
:returns: returns Paramiko/SSH return code
:rtype: ``int``
"""
self.logger('sFTP server %s: renaming path from %s to %s' % (self, oldpath, newpath), loglevel=log.loglevel_DEBUG_SFTPXFER)
oldpath = self._realpath(oldpath)
newpath = self._realpath(newpath)
try:
shutil.move(oldpath, newpath)
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
return paramiko.SFTP_OK
def mkdir(self, path, attr):
"""\
Make a directory.
:param path: path to new folder
:type path: ``str``
:param attr: file attributes
:type attr: ``class``
:returns: returns Paramiko/SSH return code
:rtype: ``int``
"""
self.logger('sFTP server: creating new dir (perms: %s): %s' % (attr.st_mode, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
path = self._realpath(path)
try:
os.mkdir(path, attr.st_mode)
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
return paramiko.SFTP_OK
def rmdir(self, path):
"""\
Remove a directory (if needed recursively).
:param path: folder to be removed
:type path: ``str``
:returns: returns Paramiko/SSH return code
:rtype: ``int``
"""
self.logger('sFTP server %s: removing dir: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
path = self._realpath(path)
try:
shutil.rmtree(path)
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
return paramiko.SFTP_OK
def chattr(self, path, attr):
"""\
Change file attributes.
:param path: path of file/folder
:type path: ``str``
:param attr: new file attributes
:type attr: ``class``
:returns: returns Paramiko/SSH return code
:rtype: ``int``
"""
self.logger('sFTP server %s: modifying attributes of path: %s' % (self, path), loglevel=log.loglevel_DEBUG_SFTPXFER)
path = self._realpath(path)
try:
if attr.st_mode is not None:
os.chmod(path, attr.st_mode)
if attr.st_uid is not None:
os.chown(path, attr.st_uid, attr.st_gid)
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
return paramiko.SFTP_OK
def symlink(self, target_path, path):
"""\
Create a symbolic link.
:param target_path: link shall point to this path
:type target_path: ``str``
:param path: link location
:type path: ``str``
:returns: returns Paramiko/SSH return code
:rtype: ``int``
"""
self.logger('sFTP server %s: creating symlink from: %s to target: %s' % (self, path, target_path), loglevel=log.loglevel_DEBUG_SFTPXFER)
path = self._realpath(path)
if target_path.startswith('/'):
target_path = self._realpath(target_path)
try:
os.symlink(target_path, path)
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
return paramiko.SFTP_OK
def readlink(self, path):
"""\
Read the target of a symbolic link.
:param path: path of symbolic link
:type path: ``str``
:returns: target location of the symbolic link, on failure: returns a Paramiko/SSH return code
:rtype: ``str`` or ``int``
"""
path = self._realpath(path)
try:
return os.readlink(path)
except OSError as e:
self.logger('sFTP server %s: encountered error: %s' % (self, str(e)), loglevel=log.loglevel_DEBUG_SFTPXFER)
return paramiko.SFTPServer.convert_errno(e.errno)
def session_ended(self):
"""\
Tidy up when the sFTP session has ended.
"""
if self.server_event is not None:
self.logger('sFTP server %s: session has ended' % self, loglevel=log.loglevel_DEBUG_SFTPXFER)
self.server_event.set()
[docs]class X2GoRevFwTunnelToSFTP(rforward.X2GoRevFwTunnel):
"""\
A reverse fowarding tunnel with an sFTP server at its endpoint. This blend of a Paramiko/SSH
reverse forwarding tunnel is used to provide access to local X2Go client folders
from within the the remote X2Go server session.
"""
def __init__(self, server_port, ssh_transport, auth_key=None, session_instance=None, logger=None, loglevel=log.loglevel_DEFAULT):
"""\
Start a Paramiko/SSH reverse forwarding tunnel, that has an sFTP server listening at
the endpoint of the tunnel.
:param server_port: the TCP/IP port on the X2Go server (starting point of the tunnel),
normally some number above 30000
:type server_port: ``int``
:param ssh_transport: the :class:`x2go.session.X2GoSession`'s Paramiko/SSH transport instance
:type ssh_transport: ``paramiko.Transport`` instance
:param auth_key: Paramiko/SSH RSAkey object that has to be authenticated against by
the remote sFTP client
:type auth_key: ``paramiko.RSAKey`` instance
:param logger: you can pass an :class:`x2go.log.X2GoLogger` object to the
:class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` constructor
:type logger: :class:`x2go.log.X2GoLogger` instance
:param loglevel: if no :class:`x2go.log.X2GoLogger` object has been supplied a new one will be
constructed with the given loglevel
:type loglevel: ``int``
"""
self.ready = False
if logger is None:
self.logger = log.X2GoLogger(loglevel=loglevel)
else:
self.logger = copy.deepcopy(logger)
self.logger.tag = __NAME__
self.server_port = server_port
self.ssh_transport = ssh_transport
self.session_instance = session_instance
if type(auth_key) is not paramiko.RSAKey:
auth_key = None
self.auth_key = auth_key
self.open_channels = {}
self.incoming_channel = threading.Condition()
threading.Thread.__init__(self)
self.daemon = True
self._accept_channels = True
[docs] def run(self):
"""\
This method gets run once an :class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` has been started with its
:func:`start()` method. Use :func:`X2GoRevFwTunnelToSFTP.stop_thread() <x2go.sftpserver.X2GoRevFwTunnelToSFTP.stop_thread()>` to stop the
reverse forwarding tunnel again (refer also to its pause() and resume() method).
:func:`X2GoRevFwTunnelToSFTP.run() <x2go.rforward.X2GoRevFwTunnelToSFTP.run()>` waits for notifications of an appropriate incoming
Paramiko/SSH channel (issued by :func:`X2GoRevFwTunnelToSFTP.notify() <x2go.rforward.X2GoRevFwTunnelToSFTP.notify()>`). Appropriate in
this context means, that its starting point on the X2Go server matches the class's
property ``server_port``.
Once a new incoming channel gets announced by the :func:`notify()` method, a new
:class:`x2go.sftpserver.X2GoRevFwSFTPChannelThread` instance will be initialized. As a data stream handler,
the function :func:`x2go_rev_forward_sftpchannel_handler()` will be used.
The channel will last till the connection gets dropped on the X2Go server side or
until the tunnel gets paused by an :func:`X2GoRevFwTunnelToSFTP.pause() <x2go.rforward.X2GoRevFwTunnelToSFTP.pause()>` call or
stopped via the ``X2GoRevFwTunnelToSFTP.stop_thread()`` method.
"""
self._request_port_forwarding()
self._keepalive = True
self.ready = True
while self._keepalive:
self.incoming_channel.acquire()
self.logger('waiting for incoming sFTP channel on X2Go server port: [localhost]:%s' % self.server_port, loglevel=log.loglevel_DEBUG)
self.incoming_channel.wait()
if self._keepalive:
self.logger('Detected incoming sFTP channel on X2Go server port: [localhost]:%s' % self.server_port, loglevel=log.loglevel_DEBUG)
_chan = self.ssh_transport.accept()
self.logger('sFTP channel %s for server port [localhost]:%s is up' % (_chan, self.server_port), loglevel=log.loglevel_DEBUG)
else:
self.logger('closing down rev forwarding sFTP tunnel on remote end [localhost]:%s' % self.server_port, loglevel=log.loglevel_DEBUG)
self.incoming_channel.release()
if self._accept_channels and self._keepalive:
_new_chan_thread = X2GoRevFwSFTPChannelThread(_chan,
target=x2go_rev_forward_sftpchannel_handler,
kwargs={
'chan': _chan,
'auth_key': self.auth_key,
'logger': self.logger,
}
)
_new_chan_thread.start()
self.open_channels['[%s]:%s' % _chan.origin_addr] = _new_chan_thread
self.ready = False
[docs]def x2go_rev_forward_sftpchannel_handler(chan=None, auth_key=None, logger=None):
"""\
Handle incoming sFTP channels that got setup by an :class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` instance.
The channel (and the corresponding connections) close either ...
- ... if the connecting application closes the connection and thus, drops
the sFTP channel, or
- ... if the :class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` parent thread gets paused. The call
of :func:`X2GoRevFwTunnelToSFTP.pause() <x2go.rforward.X2GoRevFwTunnelToSFTP.pause()>` on the instance can be used to shut down all incoming
tunneled SSH connections associated to this :class:`x2go.sftpserver.X2GoRevFwTunnelToSFTP` instance
from within a Python X2Go application.
:param chan: an incoming sFTP channel (Default value = None)
:type chan: paramiko.Channel instance
:param auth_key: Paramiko/SSH RSAkey object that has to be authenticated against by
the remote sFTP client (Default value = None)
:type auth_key: ``paramiko.RSAKey`` instance
:param logger: you must pass an :class:`x2go.log.X2GoLogger` object to this handler method (Default value = None)
:type logger: ``X2GoLogger`` instance
"""
if logger is None:
def _dummy_logger(msg, l):
pass
logger = _dummy_logger
if auth_key is None:
logger('sFTP channel %s closed because of missing authentication key' % chan, loglevel=log.loglevel_DEBUG)
return
# set up server
t = paramiko.Transport(chan)
t.daemon = True
t.load_server_moduli()
t.add_server_key(defaults.RSAHostKey)
# set up sftp handler, server and event
event = threading.Event()
t.set_subsystem_handler('sftp', paramiko.SFTPServer, sftp_si=_SFTPServerInterface, chroot='/', logger=logger, server_event=event)
logger('registered sFTP subsystem handler', loglevel=log.loglevel_DEBUG_SFTPXFER)
server = _SSHServer(auth_key=auth_key, logger=logger)
# start ssh server session
t.start_server(server=server, event=event)
while t.is_active():
gevent.sleep(1)
t.stop_thread()
logger('sFTP channel %s closed down' % chan, loglevel=log.loglevel_DEBUG)
[docs]class X2GoRevFwSFTPChannelThread(rforward.X2GoRevFwChannelThread): pass
"""A clone of :class:`x2go.rforward.X2GoRevFwChannelThread`."""