diff --git a/README.rst b/README.rst index c4626a7..637c1a4 100644 --- a/README.rst +++ b/README.rst @@ -1,19 +1,16 @@ sftpserver ========== -``sftpserver`` is a simple single-threaded SFTP server based on -Paramiko's SFTPServer. +``sftpserver`` is a skeletal SFTP server written using `Paramiko`_ -I needed a simple server that could be used as a stub for testing -Python SFTP clients so I whipped out one. +This project exists to serve as a starting point / demonstration of how to build +an SFTP server or as something to be used in tests. As such the goal is *not* to +provide a full featured sftp server. - -Installation ------------- - -Using ``pip``:: - - $ [sudo] pip install sftpserver +This was initially a simple fork of `@rspivak`'s `sftpserver`_, which in turn +was an adaptation of the code from Paramiko's tests. However, I updated it +further to demonstrate the use of different `threaded` and `forked` modes of +operation. Examples @@ -21,33 +18,31 @@ Examples :: - $ sftpserver - Usage: sftpserver [options] - -k/--keyfile should be specified + # run sftpserver with defaults (serving current dir, at + # localhost:3373, in threaded mode) + $ python -m sftpserver - Options: - -h, --help show this help message and exit - --host=HOST listen on HOST [default: localhost] - -p PORT, --port=PORT listen on PORT [default: 3373] - -l LEVEL, --level=LEVEL - Debug level: WARNING, INFO, DEBUG [default: INFO] - -k FILE, --keyfile=FILE - Path to private key, for example /tmp/test_rsa.key + # run sftpserver with defaults (serving dir /tmp, at + # localhost:3373, in forked mode, using server key /tmp/test_rsa.key) - $ sftpserver -k /tmp/test_rsa.key -l DEBUG + $ sftpserver -r /tmp -k /tmp/test_rsa.key -l DEBUG -m forked Generating a test private key:: $ openssl req -out CSR.csr -new -newkey rsa:2048 -nodes -keyout /tmp/test_rsa.key -Connecting with a Python client to our server: +Connecting with a Python client to our server:: + + >>> import paramiko + >>> pkey = paramiko.RSAKey.from_private_key_file('/tmp/test_rsa.key') + >>> transport = paramiko.Transport(('localhost', 3373)) + >>> transport.connect(username='admin', password='admin', pkey=pkey) + >>> sftp = paramiko.SFTPClient.from_transport(transport) + >>> sftp.listdir('.') + ['loop.py', 'stub_sftp.py'] + ->>> import paramiko ->>> pkey = paramiko.RSAKey.from_private_key_file('/tmp/test_rsa.key') ->>> transport = paramiko.Transport(('localhost', 3373)) ->>> transport.connect(username='admin', password='admin', pkey=pkey) ->>> sftp = paramiko.SFTPClient.from_transport(transport) ->>> sftp.listdir('.') -['loop.py', 'stub_sftp.py'] +.. _Paramiko: https://www.paramiko.org/ +.. _sftpserver: https://github.com/rspivak/sftpserver diff --git a/setup.py b/setup.py index dcf564c..c46a798 100644 --- a/setup.py +++ b/setup.py @@ -11,25 +11,26 @@ Operating System :: Unix """ + def read(*rel_names): return open(os.path.join(os.path.dirname(__file__), *rel_names)).read() setup( name='sftpserver', - version='0.3', - url='http://github.com/rspivak/sftpserver', + version='0.5', + url='http://github.com/lonetwin/sftpserver', license='MIT', - description='sftpserver - a simple single-threaded sftp server', - author='Ruslan Spivak', - author_email='ruslan.spivak@gmail.com', + description='sftpserver - a skeletal SFTP server written using Paramiko', + author='Steven Fernandez', + author_email='steve@lonetwin.net', packages=find_packages('src'), package_dir={'': 'src'}, install_requires=['setuptools>=0.7', 'paramiko'], zip_safe=False, entry_points="""\ [console_scripts] - sftpserver = sftpserver:main + sftpserver = sftpserver.__main__:main """, classifiers=filter(None, classifiers.split('\n')), long_description=read('README.rst'), diff --git a/src/sftpserver/__init__.py b/src/sftpserver/__init__.py index 283ccf8..e69de29 100644 --- a/src/sftpserver/__init__.py +++ b/src/sftpserver/__init__.py @@ -1,100 +0,0 @@ -############################################################################### -# -# Copyright (c) 2011-2017 Ruslan Spivak -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -# -############################################################################### - -__author__ = 'Ruslan Spivak ' - -import time -import socket -import argparse -import sys -import textwrap - -import paramiko - -from sftpserver.stub_sftp import StubServer, StubSFTPServer - -HOST, PORT = 'localhost', 3373 -BACKLOG = 10 - - -def start_server(host, port, keyfile, level): - paramiko_level = getattr(paramiko.common, level) - paramiko.common.logging.basicConfig(level=paramiko_level) - - server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True) - server_socket.bind((host, port)) - server_socket.listen(BACKLOG) - - while True: - conn, addr = server_socket.accept() - - host_key = paramiko.RSAKey.from_private_key_file(keyfile) - transport = paramiko.Transport(conn) - transport.add_server_key(host_key) - transport.set_subsystem_handler( - 'sftp', paramiko.SFTPServer, StubSFTPServer) - - server = StubServer() - transport.start_server(server=server) - - channel = transport.accept() - while transport.is_active(): - time.sleep(1) - - -def main(): - usage = """\ - usage: sftpserver [options] - -k/--keyfile should be specified - """ - parser = argparse.ArgumentParser(usage=textwrap.dedent(usage)) - parser.add_argument( - '--host', dest='host', default=HOST, - help='listen on HOST [default: %(default)s]' - ) - parser.add_argument( - '-p', '--port', dest='port', type=int, default=PORT, - help='listen on PORT [default: %(default)d]' - ) - parser.add_argument( - '-l', '--level', dest='level', default='INFO', - help='Debug level: WARNING, INFO, DEBUG [default: %(default)s]' - ) - parser.add_argument( - '-k', '--keyfile', dest='keyfile', metavar='FILE', - help='Path to private key, for example /tmp/test_rsa.key' - ) - - args = parser.parse_args() - - if args.keyfile is None: - parser.print_help() - sys.exit(-1) - - start_server(args.host, args.port, args.keyfile, args.level) - - -if __name__ == '__main__': - main() diff --git a/src/sftpserver/__main__.py b/src/sftpserver/__main__.py new file mode 100644 index 0000000..95979ec --- /dev/null +++ b/src/sftpserver/__main__.py @@ -0,0 +1,149 @@ +############################################################################### +# +# Copyright (c) 2011-2017 Ruslan Spivak +# Copyright (c) 2020 Steven Fernandez +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +############################################################################### + +__author__ = 'Steven Fernandez ' + +import argparse +import logging +import os +import pwd +import socket +import sys + +import paramiko + +from sftpserver.stub_sftp import StubSFTPServer, ssh_server + +# - Defaults +HOST, PORT = 'localhost', 3373 +ROOT = StubSFTPServer.ROOT +LOG_LEVEL = logging.getLevelName(logging.INFO) +MODE = 'threaded' + +BACKLOG = 10 + + +def setup_logging(level, mode): + if mode == 'threaded': + log_format = logging.BASIC_FORMAT + else: + log_format = '%(process)d:' + logging.BASIC_FORMAT + + logging.basicConfig(format=log_format) + + # - setup paramiko logging + paramiko_logger = logging.getLogger('paramiko') + paramiko_logger.setLevel(logging.INFO) + + logger = logging.getLogger(__name__) + logger.setLevel(level) + return logger + + +def start_server(host=HOST, port=PORT, root=ROOT, keyfile=None, level=LOG_LEVEL, mode=MODE): + logger = setup_logging(level, mode) + + if keyfile is None: + server_key = paramiko.RSAKey.generate(bits=1024) + else: + server_key = paramiko.RSAKey.from_private_key_file(keyfile) + + StubSFTPServer.ROOT = root + + logger.debug('Serving %s over sftp at %s:%s', root, host, port) + + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True) + server_socket.bind((host, port)) + server_socket.listen(BACKLOG) + + sessions = [] + while True: + connection, _ = server_socket.accept() + transport = paramiko.Transport(connection) + transport.add_server_key(server_key) + transport.set_subsystem_handler('sftp', paramiko.SFTPServer, StubSFTPServer) + + if mode == 'threaded': + logger.debug('Starting a new thread') + transport.start_server(server=ssh_server) + sessions.append(transport.accept()) + elif mode == 'forked': + pid = os.fork() + if pid == 0: + logger.debug('Starting a new process') + transport.start_server(server=ssh_server) + sessions.append(transport.accept()) + if os.geteuid() == 0: + user = pwd.getpwnam(transport.get_username()) + logger.debug('Dropping privileges, will run as %s', user) + os.setuid(user.pw_uid) + os.setgid(user.pw_gid) + else: + transport.atfork() + os.waitpid(-1, 0) + + logger.debug('%s active sessions', len(sessions)) + + +def main(): + usage = """usage: sftpserver [options]""" + parser = argparse.ArgumentParser(usage=usage) + parser.add_argument( + '--host', dest='host', default=HOST, + help='listen on HOST [default: %(default)s]' + ) + parser.add_argument( + '-p', '--port', dest='port', type=int, default=PORT, + help='listen on PORT [default: %(default)d]' + ) + parser.add_argument( + '-l', '--level', dest='level', default=LOG_LEVEL, + help='Debug level: WARNING, INFO, DEBUG [default: %(default)s]' + ) + parser.add_argument( + '-k', '--keyfile', dest='keyfile', metavar='FILE', + help='Path to private key, for example /tmp/test_rsa.key' + ) + parser.add_argument( + '-r', '--root', dest='root', default=ROOT, + help='Directory to serve as root for the server' + ) + parser.add_argument( + '-m', '--mode', default=MODE, const=MODE, nargs='?', choices=('threaded', 'forked'), + help='Mode to run server in [default: %(default)s]' + ) + + args = parser.parse_args() + + if not os.path.isdir(args.root): + parser.print_help() + sys.exit(-1) + + start_server(args.host, args.port, args.root, args.keyfile, args.level, args.mode) + + +if __name__ == '__main__': + main() diff --git a/src/sftpserver/stub_sftp.py b/src/sftpserver/stub_sftp.py index 2bb180a..6f9139f 100644 --- a/src/sftpserver/stub_sftp.py +++ b/src/sftpserver/stub_sftp.py @@ -29,11 +29,11 @@ class StubServer (ServerInterface): def check_auth_password(self, username, password): # all are allowed return AUTH_SUCCESSFUL - + def check_auth_publickey(self, username, key): # all are allowed return AUTH_SUCCESSFUL - + def check_channel_request(self, kind, chanid): return OPEN_SUCCEEDED @@ -63,14 +63,14 @@ class StubSFTPServer (SFTPServerInterface): # assume current folder is a fine root # (the tests always create and eventualy delete a subfolder, so there shouldn't be any mess) ROOT = os.getcwd() - + def _realpath(self, path): return self.ROOT + self.canonicalize(path) def list_folder(self, path): path = self._realpath(path) try: - out = [ ] + out = [] flist = os.listdir(path) for fname in flist: attr = SFTPAttributes.from_stat(os.stat(os.path.join(path, fname))) @@ -212,3 +212,6 @@ def readlink(self, path): else: symlink = '' return symlink + + +ssh_server = StubServer()