#pysftpserver An OpenSSH SFTP wrapper written in Python.
##Features
- Possibility to automatically jail users in a virtual chroot environment as soon as they login.
- Possibility to automatically forward SFTP requests to another server.
- Compatible with both Python 2 and Python 3.
- Fully extensible and customizable (examples below).
- Totally conforms to the SFTP RFC.
##Installation Simply install pysftpserver with pip:
$ pip install pysftpserver # add the --user flag to install it just for you
Note: if you'd like to use the automatic forwarding storage you have to explicitly specify the paramiko dependency:
$ pip install pysftpserver[pysftpproxy]
Otherwise, you could always clone this repository and manually launch setup.py
:
$ git clone https://github.com/unbit/pysftpserver.git
$ cd pysftpserver
$ python setup.py install
##Usage We provide a couple of fully working examples:
- pysftpjail: an SFTP storage that jails users in a virtual chroot environment.
- pysftpproxy: an SFTP storage that acts as a proxy, forwarding each request to another SFTP server.
You'll find both our storages in your $PATH
after the installation, so you can simply launch them by using the appropriate command line executable / arguments:
$ pysftpjail -h
usage: pysftpjail [-h] [--logfile LOGFILE] [--umask UMASK] chroot
An OpenSSH SFTP server wrapper that jails the user in a chroot directory.
positional arguments:
chroot the path of the chroot jail
optional arguments:
-h, --help show this help message and exit
--logfile LOGFILE, -l LOGFILE
path to the logfile
--umask UMASK, -u UMASK
set the umask of the SFTP server
$ pysftpproxy -h
usage: pysftpproxy [-h] [-l LOGFILE] [-k private-key-path] [-p PORT] [-a]
[-c ssh config path] [-n known_hosts path] [-d]
user[:password]@hostname
An OpenSSH SFTP server proxy that forwards each request to a remote server.
positional arguments:
user[:password]@hostname
the ssh-url ([user[:password]@]hostname) of the remote
server. The hostname can be specified as a
ssh_config's hostname too. Every missing information
will be gathered from there
optional arguments:
-h, --help show this help message and exit
-l LOGFILE, --logfile LOGFILE
path to the logfile
-k private-key-path, --key private-key-path
private key identity path (defaults to ~/.ssh/id_rsa)
-p PORT, --port PORT SSH remote port (defaults to 22)
-a, --ssh-agent enable ssh-agent support
-c ssh config path, --ssh-config ssh config path
path to the ssh-configuration file (default to
~/.ssh/config)
-n known_hosts path, --known-hosts known_hosts path
path to the openSSH known_hosts file
-d, --disable-known-hosts
disable known_hosts fingerprint checking (security
warning!)
If you want a user to be attached to one of these servers when they connect, you need to arrange for the appropriate command to be started by SSHD:
###authorized_keys magic
With pysftpjail
you can jail any user in the virtual chroot as soon as she connects to the SFTP server.
You can do it by simply prepending the pysftpjail
command to the user entry in your SSH authorized_keys
file, e.g.:
command="pysftpjail path_to_your_jail" ssh-rsa AAAAB3[... and so on]
Probably, you'll want to add the following options too:
no-port-forwarding,no-x11-forwarding,no-agent-forwarding
Achieving as final result:
command="pysftpjail path_to_your_jail",no-port-forwarding,no-x11-forwarding,no-agent-forwarding ssh-rsa AAAAB3[... and so on]
Obviusly, you could do the same with pysftpproxy
.
###Customizing sshd_config
If you need to do this at a system level, or if you need to support password authentication as well as public keys, you can use the ForceCommand option in /etc/ssh/sshd_config
.
For example, to ensure that the sftptest
user only has access to the /tmp
directory, put something like the following at the end of sshd_config
:
Match User sftptest
ForceCommand /usr/local/bin/pysftpjail /tmp
##Customization We provide two complete examples of SFTP storage: simple and jailed. Anyway, you can subclass our generic abstract storage and you can adapt it to your needs. Any contribution is welcomed, as always. 👍
###Real world customization: MongoDB / GridFS storage MongoDB is an open, NOSQL, document database. GridFS is a specification for storing and retrieving arbitrary files in a MongoDB database. The following example will show how to build a storage that handles files in a MongoDB / GridFS database.
####Preliminary requirements
I assume you already have a MongoDB database running somewhere and you are using a virtualenv
.
Let's install the MongoDB Python driver, pymongo
, with:
$ pip install pymongo
Now clone this project's repository and install the base package in development mode.
$ git clone https://github.com/unbit/pysftpserver.git
$ cd pysftpserver
$ python setup.py develop
Info for those who are asking: development mode will let us modify the source of the packages and use it globally without needing to reinstall it.
Now you're ready to create the storage.
####New storage class
Let's create a new storage (save it as pysftpserver/mongostorage.py
) that subclasses the abstract storage class.
"""MongoDB GridFS SFTP storage."""
from pysftpserver.abstractstorage import SFTPAbstractServerStorage
from pysftpserver.pysftpexceptions import SFTPNotFound
import pymongo
import gridfs
class SFTPServerMongoStorage(SFTPAbstractServerStorage):
"""MongoDB GridFS SFTP storage class."""
def __init__(self, home, remote, port, db_name):
"""Home sweet home.
NOTE: you should set your home to something reasonable.
Instruct the client to connect to your MongoDB.
"""
self.home = "/"
client = pymongo.MongoClient(remote, port)
db = client[db_name]
self.gridfs = gridfs.GridFS(db)
def open(self, filename, flags, mode):
"""Return the file handle."""
filename = filename.decode() # needed in Python 3
if self.gridfs.exists(filename=filename):
return self.gridfs.find({'filename': filename})[0]
raise SFTPNotFound
def read(self, handle, off, size):
"""Read size from the handle. Offset is ignored."""
return handle.read(size)
def close(self, handle):
"""Close the file handle."""
handle.close()
"""
Warning:
this implementation is incomplete, many required methods are missing.
"""
As you can see, it's all pretty straight-forward.
In the init
method, we initialize the MongoDB client, select the database to use and then we initialize GridFS.
Then, in the open
method, we check if the file exists and return it's handler; in the read
and close
methods we simply forward the calls to the GridFS.
####Testing the new storage
I strongly encourage you to test your newly created storage.
Here's an example (save it as pysftpserver/tests/test_server_mongo.py
):
import unittest
import os
from shutil import rmtree
import pymongo
import gridfs
from pysftpserver.server import *
from pysftpserver.mongostorage import SFTPServerMongoStorage
from pysftpserver.tests.utils import *
"""To run this tests you must have an instance of MongoDB running somewhere."""
REMOTE = "localhost"
PORT = 1727
DB_NAME = "mydb"
class Test(unittest.TestCase):
@classmethod
def setUpClass(cls):
client = pymongo.MongoClient(REMOTE, PORT)
db = client[DB_NAME]
cls.gridfs = gridfs.GridFS(db)
def setUp(self):
os.chdir(t_path())
self.home = 'home'
if not os.path.isdir(self.home):
os.mkdir(self.home)
self.server = SFTPServer(
SFTPServerMongoStorage(REMOTE, PORT, DB_NAME),
logfile=t_path('log'),
raise_on_error=True
)
def tearDown(self):
os.chdir(t_path())
rmtree(self.home)
def test_read(self):
s = b"This is a test file."
f_name = "test" # put expects a non byte string!
b_f_name = b"test"
f = self.gridfs.put(s, filename=f_name)
self.server.input_queue = sftpcmd(
SSH2_FXP_OPEN,
sftpstring(b_f_name),
sftpint(SSH2_FXF_CREAT),
sftpint(0)
)
self.server.process()
handle = get_sftphandle(self.server.output_queue)
self.server.output_queue = b'' # reset the output queue
self.server.input_queue = sftpcmd(
SSH2_FXP_READ,
sftpstring(handle),
sftpint64(0),
sftpint(len(s)),
)
self.server.process()
data = get_sftpdata(self.server.output_queue)
self.assertEqual(s, data)
self.server.output_queue = b'' # reset output queue
self.server.input_queue = sftpcmd(
SSH2_FXP_CLOSE,
sftpstring(handle)
)
self.server.process()
# Cleanup!
self.gridfs.delete(f)
@classmethod
def tearDownClass(cls):
os.unlink(t_path("log")) # comment me to see the log!
rmtree(t_path("home"), ignore_errors=True)
####Final results
Finally, you can create a binary to comfortably launch the server using the created storage.
Save it as bin/pysftpmongo
.
#!/usr/bin/env python
"""pysftpmongo executable."""
import argparse
from pysftpserver.server import SFTPServer
from pysftpserver.mongostorage import SFTPServerMongoStorage
def main():
parser = argparse.ArgumentParser(
description='An OpenSSH SFTP server wrapper that uses a MongoDB/GridFS storage.'
)
parser.add_argument('remote', type=str,
help='the remote address of the MongoDB instance')
parser.add_argument('port', type=int,
help='the remote port of the MongoDB instance')
parser.add_argument('db_name', type=str,
help='the name of the DB to use')
parser.add_argument('--logfile', '-l', dest='logfile',
help='path to the logfile')
args = parser.parse_args()
SFTPServer(
storage=SFTPServerMongoStorage(
args.remote,
args.port,
args.db_name
),
logfile=args.logfile
).run()
if __name__ == '__main__':
main()
Now, chmod
the binary and check that it starts without a hitch:
$ chmod +x bin/pysftpmongo
$ bin/pysftpmongo "localhost" 1727 "mydb"
Finally, you should edit the setup.py
scripts
field to include your new binary.
Now, running python setup.py install
will put it somewhere in your $PATH
, for later ease: e.g. when using it in the authorized_keys file.
A sneak peek of the final result (in the authorized_keys
file):
command="pysftpmongo REMOTE_TO_YOUR_DB REMOTE_PORT DB_NAME",no-port-forwarding,no-x11-forwarding,no-agent-forwarding ssh-rsa AAAAB3[... and so on]
That's it!
####Code used in this example
All the code used in this example can be found in the examples/mongodb_gridfs
directory of this repository.
##FileZilla compatibility
FileZilla requires the longname
returned with each SSH2_FXP_NAME
response (e.g. each time readdir
is called) to be a string of the same format of the output of ls -l
(-rw-r--r-- 1 aldur staff 9596 Dec 29 18:36 README.md
).
So, if you want to keep compatibility with FileZilla, be sure to include a proper longname
field to the stats dictionary you return from your storage, as we do here.
##Tests You can use nose for tests. From the project directory, simply run:
$ nosetests
$ python setup.py test # alternatively