]> git.rkrishnan.org Git - tahoe-lafs/tahoe-lafs.git/blob - src/allmydata/frontends/ftpd.py
ftp/sftp: move to a new frontends/ directory in preparation for factoring out passwor...
[tahoe-lafs/tahoe-lafs.git] / src / allmydata / frontends / ftpd.py
1
2 import os
3 import tempfile
4 from zope.interface import implements
5 from twisted.application import service, strports
6 from twisted.internet import defer
7 from twisted.internet.interfaces import IConsumer
8 from twisted.protocols import ftp
9 from twisted.cred import error, portal, checkers, credentials
10 from twisted.web.client import getPage
11
12 from allmydata.interfaces import IDirectoryNode, ExistingChildError, \
13      NoSuchChildError
14 from allmydata.immutable.download import ConsumerAdapter
15 from allmydata.immutable.upload import FileHandle
16 from allmydata.util import base32
17
18 class ReadFile:
19     implements(ftp.IReadFile)
20     def __init__(self, node):
21         self.node = node
22     def send(self, consumer):
23         ad = ConsumerAdapter(consumer)
24         d = self.node.download(ad)
25         return d # when consumed
26
27 class FileWriter:
28     implements(IConsumer)
29
30     def registerProducer(self, producer, streaming):
31         if not streaming:
32             raise NotImplementedError("Non-streaming producer not supported.")
33         # we write the data to a temporary file, since Tahoe can't do
34         # streaming upload yet.
35         self.f = tempfile.TemporaryFile()
36         return None
37
38     def unregisterProducer(self):
39         # the upload actually happens in WriteFile.close()
40         pass
41
42     def write(self, data):
43         self.f.write(data)
44
45 class WriteFile:
46     implements(ftp.IWriteFile)
47
48     def __init__(self, parent, childname, convergence):
49         self.parent = parent
50         self.childname = childname
51         self.convergence = convergence
52
53     def receive(self):
54         self.c = FileWriter()
55         return defer.succeed(self.c)
56
57     def close(self):
58         u = FileHandle(self.c.f, self.convergence)
59         d = self.parent.add_file(self.childname, u)
60         return d
61
62
63 class NoParentError(Exception):
64     pass
65
66 class Handler:
67     implements(ftp.IFTPShell)
68     def __init__(self, client, rootnode, username, convergence):
69         self.client = client
70         self.root = rootnode
71         self.username = username
72         self.convergence = convergence
73
74     def makeDirectory(self, path):
75         d = self._get_root(path)
76         d.addCallback(lambda (root,path):
77                       self._get_or_create_directories(root, path))
78         return d
79
80     def _get_or_create_directories(self, node, path):
81         if not IDirectoryNode.providedBy(node):
82             # unfortunately it is too late to provide the name of the
83             # blocking directory in the error message.
84             raise ftp.FileExistsError("cannot create directory because there "
85                                       "is a file in the way")
86         if not path:
87             return defer.succeed(node)
88         d = node.get(path[0])
89         def _maybe_create(f):
90             f.trap(NoSuchChildError)
91             return node.create_empty_directory(path[0])
92         d.addErrback(_maybe_create)
93         d.addCallback(self._get_or_create_directories, path[1:])
94         return d
95
96     def _get_parent(self, path):
97         # fire with (parentnode, childname)
98         path = [unicode(p) for p in path]
99         if not path:
100             raise NoParentError
101         childname = path[-1]
102         d = self._get_root(path)
103         def _got_root((root, path)):
104             if not path:
105                 raise NoParentError
106             return root.get_child_at_path(path[:-1])
107         d.addCallback(_got_root)
108         def _got_parent(parent):
109             return (parent, childname)
110         d.addCallback(_got_parent)
111         return d
112
113     def _remove_thing(self, path, must_be_directory=False, must_be_file=False):
114         d = defer.maybeDeferred(self._get_parent, path)
115         def _convert_error(f):
116             f.trap(NoParentError)
117             raise ftp.PermissionDeniedError("cannot delete root directory")
118         d.addErrback(_convert_error)
119         def _got_parent( (parent, childname) ):
120             d = parent.get(childname)
121             def _got_child(child):
122                 if must_be_directory and not IDirectoryNode.providedBy(child):
123                     raise ftp.IsNotADirectoryError("rmdir called on a file")
124                 if must_be_file and IDirectoryNode.providedBy(child):
125                     raise ftp.IsADirectoryError("rmfile called on a directory")
126                 return parent.delete(childname)
127             d.addCallback(_got_child)
128             d.addErrback(self._convert_error)
129             return d
130         d.addCallback(_got_parent)
131         return d
132
133     def removeDirectory(self, path):
134         return self._remove_thing(path, must_be_directory=True)
135
136     def removeFile(self, path):
137         return self._remove_thing(path, must_be_file=True)
138
139     def rename(self, fromPath, toPath):
140         # the target directory must already exist
141         d = self._get_parent(fromPath)
142         def _got_from_parent( (fromparent, childname) ):
143             d = self._get_parent(toPath)
144             d.addCallback(lambda (toparent, tochildname):
145                           fromparent.move_child_to(childname,
146                                                    toparent, tochildname,
147                                                    overwrite=False))
148             return d
149         d.addCallback(_got_from_parent)
150         d.addErrback(self._convert_error)
151         return d
152
153     def access(self, path):
154         # we allow access to everything that exists. We are required to raise
155         # an error for paths that don't exist: FTP clients (at least ncftp)
156         # uses this to decide whether to mkdir or not.
157         d = self._get_node_and_metadata_for_path(path)
158         d.addErrback(self._convert_error)
159         d.addCallback(lambda res: None)
160         return d
161
162     def _convert_error(self, f):
163         if f.check(NoSuchChildError):
164             childname = f.value.args[0].encode("utf-8")
165             msg = "'%s' doesn't exist" % childname
166             raise ftp.FileNotFoundError(msg)
167         if f.check(ExistingChildError):
168             msg = f.value.args[0].encode("utf-8")
169             raise ftp.FileExistsError(msg)
170         return f
171
172     def _get_root(self, path):
173         # return (root, remaining_path)
174         path = [unicode(p) for p in path]
175         if path and path[0] == "uri":
176             d = defer.maybeDeferred(self.client.create_node_from_uri,
177                                     str(path[1]))
178             d.addCallback(lambda root: (root, path[2:]))
179         else:
180             d = defer.succeed((self.root,path))
181         return d
182
183     def _get_node_and_metadata_for_path(self, path):
184         d = self._get_root(path)
185         def _got_root((root,path)):
186             if path:
187                 return root.get_child_and_metadata_at_path(path)
188             else:
189                 return (root,{})
190         d.addCallback(_got_root)
191         return d
192
193     def _populate_row(self, keys, (childnode, metadata)):
194         values = []
195         isdir = bool(IDirectoryNode.providedBy(childnode))
196         for key in keys:
197             if key == "size":
198                 if isdir:
199                     value = 0
200                 else:
201                     value = childnode.get_size()
202             elif key == "directory":
203                 value = isdir
204             elif key == "permissions":
205                 value = 0600
206             elif key == "hardlinks":
207                 value = 1
208             elif key == "modified":
209                 value = metadata.get("mtime", 0)
210             elif key == "owner":
211                 value = self.username
212             elif key == "group":
213                 value = self.username
214             else:
215                 value = "??"
216             values.append(value)
217         return values
218
219     def stat(self, path, keys=()):
220         # for files only, I think
221         d = self._get_node_and_metadata_for_path(path)
222         def _render((node,metadata)):
223             assert not IDirectoryNode.providedBy(node)
224             return self._populate_row(keys, (node,metadata))
225         d.addCallback(_render)
226         d.addErrback(self._convert_error)
227         return d
228
229     def list(self, path, keys=()):
230         # the interface claims that path is a list of unicodes, but in
231         # practice it is not
232         d = self._get_node_and_metadata_for_path(path)
233         def _list((node, metadata)):
234             if IDirectoryNode.providedBy(node):
235                 return node.list()
236             return { path[-1]: (node, metadata) } # need last-edge metadata
237         d.addCallback(_list)
238         def _render(children):
239             results = []
240             for (name, childnode) in children.iteritems():
241                 # the interface claims that the result should have a unicode
242                 # object as the name, but it fails unless you give it a
243                 # bytestring
244                 results.append( (name.encode("utf-8"),
245                                  self._populate_row(keys, childnode) ) )
246             return results
247         d.addCallback(_render)
248         d.addErrback(self._convert_error)
249         return d
250
251     def openForReading(self, path):
252         d = self._get_node_and_metadata_for_path(path)
253         d.addCallback(lambda (node,metadata): ReadFile(node))
254         d.addErrback(self._convert_error)
255         return d
256
257     def openForWriting(self, path):
258         path = [unicode(p) for p in path]
259         if not path:
260             raise ftp.PermissionDeniedError("cannot STOR to root directory")
261         childname = path[-1]
262         d = self._get_root(path)
263         def _got_root((root, path)):
264             if not path:
265                 raise ftp.PermissionDeniedError("cannot STOR to root directory")
266             return root.get_child_at_path(path[:-1])
267         d.addCallback(_got_root)
268         def _got_parent(parent):
269             return WriteFile(parent, childname, self.convergence)
270         d.addCallback(_got_parent)
271         return d
272
273
274 class FTPAvatarID:
275     def __init__(self, username, rootcap):
276         self.username = username
277         self.rootcap = rootcap
278
279 class AccountFileChecker:
280     implements(checkers.ICredentialsChecker)
281     credentialInterfaces = (credentials.IUsernamePassword,
282                             credentials.IUsernameHashedPassword)
283     def __init__(self, client, accountfile):
284         self.client = client
285         self.passwords = {}
286         self.rootcaps = {}
287         for line in open(os.path.expanduser(accountfile), "r"):
288             line = line.strip()
289             if line.startswith("#") or not line:
290                 continue
291             name, passwd, rootcap = line.split()
292             self.passwords[name] = passwd
293             self.rootcaps[name] = rootcap
294
295     def _cbPasswordMatch(self, matched, username):
296         if matched:
297             return FTPAvatarID(username, self.rootcaps[username])
298         raise error.UnauthorizedLogin
299
300     def requestAvatarId(self, credentials):
301         if credentials.username in self.passwords:
302             d = defer.maybeDeferred(credentials.checkPassword,
303                                     self.passwords[credentials.username])
304             d.addCallback(self._cbPasswordMatch, str(credentials.username))
305             return d
306         return defer.fail(error.UnauthorizedLogin())
307
308 class AccountURLChecker:
309     implements(checkers.ICredentialsChecker)
310     credentialInterfaces = (credentials.IUsernamePassword,)
311
312     def __init__(self, client, auth_url):
313         self.client = client
314         self.auth_url = auth_url
315
316     def _cbPasswordMatch(self, rootcap, username):
317         return FTPAvatarID(username, rootcap)
318
319     def post_form(self, username, password):
320         sepbase = base32.b2a(os.urandom(4))
321         sep = "--" + sepbase
322         form = []
323         form.append(sep)
324         fields = {"action": "authenticate",
325                   "email": username,
326                   "passwd": password,
327                   }
328         for name, value in fields.iteritems():
329             form.append('Content-Disposition: form-data; name="%s"' % name)
330             form.append('')
331             assert isinstance(value, str)
332             form.append(value)
333             form.append(sep)
334         form[-1] += "--"
335         body = "\r\n".join(form) + "\r\n"
336         headers = {"content-type": "multipart/form-data; boundary=%s" % sepbase,
337                    }
338         return getPage(self.auth_url, method="POST",
339                        postdata=body, headers=headers,
340                        followRedirect=True, timeout=30)
341
342     def _parse_response(self, res):
343         rootcap = res.strip()
344         if rootcap == "0":
345             raise error.UnauthorizedLogin
346         return rootcap
347
348     def requestAvatarId(self, credentials):
349         # construct a POST to the login form. While this could theoretically
350         # be done with something like the stdlib 'email' package, I can't
351         # figure out how, so we just slam together a form manually.
352         d = self.post_form(credentials.username, credentials.password)
353         d.addCallback(self._parse_response)
354         d.addCallback(self._cbPasswordMatch, str(credentials.username))
355         return d
356
357
358 class Dispatcher:
359     implements(portal.IRealm)
360     def __init__(self, client):
361         self.client = client
362
363     def requestAvatar(self, avatarID, mind, interface):
364         assert interface == ftp.IFTPShell
365         rootnode = self.client.create_node_from_uri(avatarID.rootcap)
366         convergence = self.client.convergence
367         s = Handler(self.client, rootnode, avatarID.username, convergence)
368         def logout(): pass
369         return (interface, s, None)
370
371
372 class FTPServer(service.MultiService):
373     def __init__(self, client, accountfile, accounturl, ftp_portstr):
374         service.MultiService.__init__(self)
375
376         if accountfile:
377             c = AccountFileChecker(self, accountfile)
378         elif accounturl:
379             c = AccountURLChecker(self, accounturl)
380         else:
381             # we could leave this anonymous, with just the /uri/CAP form
382             raise RuntimeError("must provide some translation")
383
384         # make sure we're using a patched Twisted that uses IWriteFile.close:
385         # see docs/ftp.txt for details.
386         assert "close" in ftp.IWriteFile.names(), "your twisted is lacking"
387
388         r = Dispatcher(client)
389         p = portal.Portal(r)
390         p.registerChecker(c)
391         f = ftp.FTPFactory(p)
392
393         s = strports.service(ftp_portstr, f)
394         s.setServiceParent(self)