summaryrefslogtreecommitdiff
path: root/fs/expose/sftp.py
blob: 8909195d824df653195bedb01897ccea18426437 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
"""
fs.expose.sftp
==============

Expose an FS object over SFTP (via paramiko).

This module provides the necessary interfaces to expose an FS object over
SFTP, plugging into the infrastructure provided by the 'paramiko' module.

For simple usage, the class 'BaseSFTPServer' provides an all-in-one server
class based on the standard SocketServer module.  Use it like so::

    server = BaseSFTPServer((hostname,port),fs)
    server.serve_forever()

Note that the base class allows UNAUTHENTICATED ACCESS by default.  For more
serious work you will probably want to subclass it and override methods such
as check_auth_password() and get_allowed_auths().

To integrate this module into an existing server framework based on paramiko,
the 'SFTPServerInterface' class provides a concrete implementation of the
paramiko.SFTPServerInterface protocol.  If you don't understand what this
is, you probably don't want to use it.

"""

from __future__ import with_statement

import os
import stat as statinfo
import time
import SocketServer
import threading

import paramiko

from fs.base import flags_to_mode
from fs.path import *
from fs.errors import *
from fs.local_functools import wraps
from fs.filelike import StringIO
from fs.utils import isdir


# Default host key used by BaseSFTPServer
#
DEFAULT_HOST_KEY = paramiko.RSAKey.from_private_key(StringIO(
    "-----BEGIN RSA PRIVATE KEY-----\n" \
    "MIICXgIBAAKCAIEAl7sAF0x2O/HwLhG68b1uG8KHSOTqe3Cdlj5i/1RhO7E2BJ4B\n" \
    "3jhKYDYtupRnMFbpu7fb21A24w3Y3W5gXzywBxR6dP2HgiSDVecoDg2uSYPjnlDk\n" \
    "HrRuviSBG3XpJ/awn1DObxRIvJP4/sCqcMY8Ro/3qfmid5WmMpdCZ3EBeC0CAwEA\n" \
    "AQKCAIBSGefUs5UOnr190C49/GiGMN6PPP78SFWdJKjgzEHI0P0PxofwPLlSEj7w\n" \
    "RLkJWR4kazpWE7N/bNC6EK2pGueMN9Ag2GxdIRC5r1y8pdYbAkuFFwq9Tqa6j5B0\n" \
    "GkkwEhrcFNBGx8UfzHESXe/uE16F+e8l6xBMcXLMJVo9Xjui6QJBAL9MsJEx93iO\n" \
    "zwjoRpSNzWyZFhiHbcGJ0NahWzc3wASRU6L9M3JZ1VkabRuWwKNuEzEHNK8cLbRl\n" \
    "TyH0mceWXcsCQQDLDEuWcOeoDteEpNhVJFkXJJfwZ4Rlxu42MDsQQ/paJCjt2ONU\n" \
    "WBn/P6iYDTvxrt/8+CtLfYc+QQkrTnKn3cLnAkEAk3ixXR0h46Rj4j/9uSOfyyow\n" \
    "qHQunlZ50hvNz8GAm4TU7v82m96449nFZtFObC69SLx/VsboTPsUh96idgRrBQJA\n" \
    "QBfGeFt1VGAy+YTLYLzTfnGnoFQcv7+2i9ZXnn/Gs9N8M+/lekdBFYgzoKN0y4pG\n" \
    "2+Q+Tlr2aNlAmrHtkT13+wJAJVgZATPI5X3UO0Wdf24f/w9+OY+QxKGl86tTQXzE\n" \
    "4bwvYtUGufMIHiNeWP66i6fYCucXCMYtx6Xgu2hpdZZpFw==\n" \
    "-----END RSA PRIVATE KEY-----\n"
))


def report_sftp_errors(func):
    """Decorator to catch and report FS errors as SFTP error codes.

    Any FSError exceptions are caught and translated into an appropriate
    return code, while other exceptions are passed through untouched.
    """
    @wraps(func)
    def wrapper(*args,**kwds):
        try:
            return func(*args, **kwds)
        except ResourceNotFoundError, e:
            return paramiko.SFTP_NO_SUCH_FILE
        except UnsupportedError, e:
            return paramiko.SFTP_OP_UNSUPPORTED
        except FSError, e:
            return paramiko.SFTP_FAILURE
    return wrapper


class SFTPServerInterface(paramiko.SFTPServerInterface):
    """SFTPServerInterface implementation that exposes an FS object.

    This SFTPServerInterface subclass expects a single additional argument,
    the fs object to be exposed.  Use it to set up a transport subsystem
    handler like so::

      t.set_subsystem_handler("sftp",SFTPServer,SFTPServerInterface,fs)

    If this all looks too complicated, you might consider the BaseSFTPServer
    class also provided by this module - it automatically creates the enclosing
    paramiko server infrastructure.
    """

    def __init__(self, server, fs, encoding=None, *args, **kwds):
        self.fs = fs
        if encoding is None:
            encoding = "utf8"
        self.encoding = encoding
        super(SFTPServerInterface,self).__init__(server, *args, **kwds)

    def close(self):
        # Close the pyfs file system and dereference it.
        self.fs.close()
        self.fs = None

    @report_sftp_errors
    def open(self, path, flags, attr):
        return SFTPHandle(self, path, flags)

    @report_sftp_errors
    def list_folder(self, path):
        if not isinstance(path, unicode):
            path = path.decode(self.encoding)
        stats = []
        for entry in self.fs.listdir(path, absolute=True):
            stat = self.stat(entry)
            if not isinstance(stat, int):
                stats.append(stat)
        return stats

    @report_sftp_errors
    def stat(self, path):
        if not isinstance(path, unicode):
            path = path.decode(self.encoding)

        info = self.fs.getinfo(path)

        stat = paramiko.SFTPAttributes()
        stat.filename = basename(path).encode(self.encoding)
        stat.st_size = info.get("size")

        if 'st_atime' in info:
            stat.st_atime = info.get('st_atime')
        elif 'accessed_time' in info:
            stat.st_atime = time.mktime(info.get("accessed_time").timetuple())

        if 'st_mtime' in info:
            stat.st_mtime = info.get('st_mtime')
        else:
            if 'modified_time' in info:
                stat.st_mtime = time.mktime(info.get("modified_time").timetuple())

        if isdir(self.fs, path, info):
            stat.st_mode = 0777 | statinfo.S_IFDIR
        else:
            stat.st_mode = 0777 | statinfo.S_IFREG
        return stat

    def lstat(self, path):
        return self.stat(path)

    @report_sftp_errors
    def remove(self, path):
        if not isinstance(path, unicode):
            path = path.decode(self.encoding)
        self.fs.remove(path)
        return paramiko.SFTP_OK

    @report_sftp_errors
    def rename(self, oldpath, newpath):
        if not isinstance(oldpath, unicode):
            oldpath = oldpath.decode(self.encoding)
        if not isinstance(newpath, unicode):
            newpath = newpath.decode(self.encoding)
        if self.fs.isfile(oldpath):
            self.fs.move(oldpath, newpath)
        else:
            self.fs.movedir(oldpath, newpath)
        return paramiko.SFTP_OK

    @report_sftp_errors
    def mkdir(self, path, attr):
        if not isinstance(path, unicode):
            path = path.decode(self.encoding)
        self.fs.makedir(path)
        return paramiko.SFTP_OK

    @report_sftp_errors
    def rmdir(self, path):
        if not isinstance(path, unicode):
            path = path.decode(self.encoding)
        self.fs.removedir(path)
        return paramiko.SFTP_OK

    def canonicalize(self, path):
        try:
            return abspath(normpath(path)).encode(self.encoding)
        except BackReferenceError:
            # If the client tries to use backrefs to escape root, gently
            # nudge them back to /.
            return '/'

    @report_sftp_errors
    def chattr(self, path, attr):
        #  f.truncate() is implemented by setting the size attr.
        #  Any other attr requests fail out.
        if attr._flags:
            if attr._flags != attr.FLAG_SIZE:
                raise UnsupportedError
            with self.fs.open(path,"r+") as f:
                f.truncate(attr.st_size)
        return paramiko.SFTP_OK

    def readlink(self, path):
        return paramiko.SFTP_OP_UNSUPPORTED

    def symlink(self, path):
        return paramiko.SFTP_OP_UNSUPPORTED


class SFTPHandle(paramiko.SFTPHandle):
    """SFTP file handler pointing to a file in an FS object.

    This is a simple file wrapper for SFTPServerInterface, passing read
    and write requests directly through the to underlying file from the FS.
    """

    def __init__(self, owner, path, flags):
        super(SFTPHandle, self).__init__(flags)
        mode = flags_to_mode(flags)
        self.owner = owner
        if not isinstance(path, unicode):
            path = path.decode(self.owner.encoding)
        self.path = path
        self._file = owner.fs.open(path, mode)

    @report_sftp_errors
    def close(self):
        self._file.close()
        return paramiko.SFTP_OK

    @report_sftp_errors
    def read(self, offset, length):
        self._file.seek(offset)
        return self._file.read(length)

    @report_sftp_errors
    def write(self, offset, data):
        self._file.seek(offset)
        self._file.write(data)
        return paramiko.SFTP_OK

    def stat(self):
        return self.owner.stat(self.path)

    def chattr(self,attr):
        return self.owner.chattr(self.path, attr)


class SFTPServer(paramiko.SFTPServer):
    """
    An SFTPServer class that closes the filesystem when done.
    """

    def finish_subsystem(self):
        # Close the SFTPServerInterface, it will close the pyfs file system.
        self.server.close()
        super(SFTPServer, self).finish_subsystem()


class SFTPRequestHandler(SocketServer.BaseRequestHandler):
    """SocketServer RequestHandler subclass for BaseSFTPServer.

    This RequestHandler subclass creates a paramiko Transport, sets up the
    sftp subsystem, and hands off to the transport's own request handling
    thread.
    """
    timeout = 60
    auth_timeout = 60

    def setup(self):
        """
        Creates the SSH transport. Sets security options.
        """
        self.transport = paramiko.Transport(self.request)
        self.transport.load_server_moduli()
        so = self.transport.get_security_options()
        so.digests = ('hmac-sha1', )
        so.compression = ('zlib@openssh.com', 'none')
        self.transport.add_server_key(self.server.host_key)
        self.transport.set_subsystem_handler("sftp", SFTPServer, SFTPServerInterface, self.server.fs, encoding=self.server.encoding)

    def handle(self):
        """
        Start the paramiko server, this will start a thread to handle the connection.
        """
        self.transport.start_server(server=BaseServerInterface())
        # TODO: I like the code below _in theory_ but it does not work as I expected.
        # Figure out how to actually time out a new client if they fail to auth in a
        # certain amount of time.
        #chan = self.transport.accept(self.auth_timeout)
        #if chan is None:
        #    self.transport.close()

    def handle_timeout(self):
        try:
            self.transport.close()
        finally:
            super(SFTPRequestHandler, self).handle_timeout()



class ThreadedTCPServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
    pass


class BaseSFTPServer(ThreadedTCPServer):
    """SocketServer.TCPServer subclass exposing an FS via SFTP.

    Operation is in the standard SocketServer style.  The target FS object
    can be passed into the constructor, or set as an attribute on the server::

        server = BaseSFTPServer((hostname,port),fs)
        server.serve_forever()

    It is also possible to specify the host key used by the sever by setting
    the 'host_key' attribute.  If this is not specified, it will default to
    the key found in the DEFAULT_HOST_KEY variable.
    """
    # If the server stops/starts quickly, don't fail because of
    # "port in use" error.
    allow_reuse_address = True

    def __init__(self, address, fs=None, encoding=None, host_key=None, RequestHandlerClass=None):
        self.fs = fs
        self.encoding = encoding
        if host_key is None:
            host_key = DEFAULT_HOST_KEY
        self.host_key = host_key
        if RequestHandlerClass is None:
            RequestHandlerClass = SFTPRequestHandler
        SocketServer.TCPServer.__init__(self, address, RequestHandlerClass)

    def shutdown_request(self, request):
        # Prevent TCPServer from closing the connection prematurely
        return

    def close_request(self, request):
        # Prevent TCPServer from closing the connection prematurely
        return


class BaseServerInterface(paramiko.ServerInterface):
    """
    Paramiko ServerInterface implementation that performs user authentication.

    Note that this base class allows UNAUTHENTICATED ACCESS to the exposed
    FS.  This is intentional, since we can't guess what your authentication
    needs are.  To protect the exposed FS, override the following methods:

        * get_allowed_auths Determine the allowed auth modes
        * check_auth_none Check auth with no credentials
        * check_auth_password Check auth with a password
        * check_auth_publickey Check auth with a public key
    """

    def check_channel_request(self, kind, chanid):
        if kind == 'session':
            return paramiko.OPEN_SUCCEEDED
        return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED

    def check_auth_none(self, username):
        """Check whether the user can proceed without authentication."""
        return paramiko.AUTH_SUCCESSFUL

    def check_auth_publickey(self, username,key):
        """Check whether the given public key is valid for authentication."""
        return paramiko.AUTH_FAILED

    def check_auth_password(self, username, password):
        """Check whether the given password is valid for authentication."""
        return paramiko.AUTH_FAILED

    def get_allowed_auths(self,username):
        """Return string containing a comma separated list of allowed auth modes.

        The available modes are  "node", "password" and "publickey".
        """
        return "none"


#  When called from the command-line, expose a TempFS for testing purposes
if __name__ == "__main__":
    from fs.tempfs import TempFS
    server = BaseSFTPServer(("localhost",8022),TempFS())
    try:
        #import rpdb2; rpdb2.start_embedded_debugger('password')
        server.serve_forever()
    except (SystemExit,KeyboardInterrupt):
        server.server_close()