← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~julian-edwards/launchpad/twisted-ftp-poppy into lp:launchpad

 

Julian Edwards has proposed merging lp:~julian-edwards/launchpad/twisted-ftp-poppy into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~julian-edwards/launchpad/twisted-ftp-poppy/+merge/50721

= Summary =
Extend poppy-sftp to also handle ftp.

== Proposed fix ==
Package uploads are currently handled either in Poppy (FTP) or Poppy-sftp 
(SFTP).  The former is written using Zope's FTP server, the latter using 
Twisted.

The Zope FTP server has a number of bugs, so this branch adds FTP support via 
the Twisted libraries.

== Pre-implementation notes ==
None, I suck.

== Implementation details ==
Some interesting points:

 * Poppy doesn't really care about authentication, it lets any credentials 
upload files, whether it be anonymous or otherwise.  Twisted has a special 
mode where if anyone logs in as "anonymous" it will prevent any file uploads.  
This behaviour is hard-coded in the base FTPShell class.  To get around this, 
the FTPFactory's "userAnonymous" is modified to a user called 
"userthatwillneverhappen" so that the read-only mode is not encountered for 
anonymous access.

 * The old port number was passed on the command line, it's now set in config.

 * The tests were easy to fix since they just needed the PoppyTac fixture 
swapped in in favour of the old PoppyTestSetup.

 * The old Poppy had a file mode bug where it set group execute on uploaded 
files, this is reflected in the test changes for the new ftp service.

 * The poppy server does post-processing of the upload.  There's no way to 
know when this is finished, so the test just sleeps for a few seconds.  See 
bug 586695.  The old poppy code had some weird hook to print output to stdout 
when it finished uploading, we might need to do that here too.

== Tests ==

bin/test -cvv test_poppy

== Demo and Q/A ==


= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/poppy/twistedftp.py
  lib/canonical/config/schema-lazr.conf
  lib/lp/poppy/tests/test_poppy.py
  daemons/poppy-sftp.tac

./lib/canonical/config/schema-lazr.conf
     522: Line exceeds 78 characters.
    1034: Line exceeds 78 characters.
-- 
https://code.launchpad.net/~julian-edwards/launchpad/twisted-ftp-poppy/+merge/50721
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~julian-edwards/launchpad/twisted-ftp-poppy into lp:launchpad.
=== modified file 'daemons/poppy-sftp.tac'
--- daemons/poppy-sftp.tac	2011-02-02 01:24:33 +0000
+++ daemons/poppy-sftp.tac	2011-02-22 10:21:46 +0000
@@ -7,10 +7,11 @@
 
 import os
 
-from twisted.application import service
+from twisted.application import service, strports
 from twisted.conch.interfaces import ISession
 from twisted.conch.ssh import filetransfer
 from twisted.cred.portal import IRealm, Portal
+from twisted.protocols import ftp
 from twisted.protocols.policies import TimeoutFactory
 from twisted.python import components
 from twisted.web.xmlrpc import Proxy
@@ -20,6 +21,10 @@
 from canonical.config import config
 from canonical.launchpad.daemons import readyservice
 
+from lp.poppy.twistedftp import (
+    FTPRealm,
+    PoppyAccessCheck,
+    )
 from lp.poppy.twistedsftp import SFTPServer
 from lp.services.sshserver.auth import (
     LaunchpadAvatar, PublicKeyFromLaunchpadChecker)
@@ -85,9 +90,41 @@
 components.registerAdapter(DoNothingSession, LaunchpadAvatar, ISession)
 
 
-# Construct an Application that has the Poppy SSH server.
+class FTPServiceFactory(service.Service):
+    def __init__(self, port):
+        factory = ftp.FTPFactory()
+        realm = FTPRealm(get_poppy_root())
+        portal = Portal(realm)
+        portal.registerChecker(PoppyAccessCheck())
+
+        factory.tld = get_poppy_root()
+        factory.portal = portal
+        factory.protocol = ftp.FTP
+        factory.welcomeMessage = "Launchpad upload server"
+        factory.timeOut = config.poppy.idle_timeout
+
+        # Setting this works around the fact that the twistd FTP server
+        # invokes a special restricted shell when someone logs in as
+        # "anonymous" which is the default for 'dput'.
+        factory.userAnonymous = "userthatwillneverhappen"
+        self.ftpfactory = factory
+        self.portno = port
+
+    @staticmethod
+    def makeFTPService(port=2121):
+        strport = "tcp:%s" % port
+        factory = FTPServiceFactory(port)
+        return strports.service(strport, factory.ftpfactory)
+
+# ftpport defaults to 2121 in schema-lazr.conf
+ftpservice = FTPServiceFactory.makeFTPService(port=config.poppy.ftpport)
+
+# Construct an Application that has the Poppy SSH server,
+# and the Poppy FTP server.
 application = service.Application('poppy-sftp')
 
+ftpservice.setServiceParent(application)
+
 def timeout_decorator(factory):
     """Add idle timeouts to a factory."""
     return TimeoutFactory(factory, timeoutPeriod=config.poppy.idle_timeout)

=== modified file 'lib/canonical/config/schema-lazr.conf'
--- lib/canonical/config/schema-lazr.conf	2011-02-21 18:54:53 +0000
+++ lib/canonical/config/schema-lazr.conf	2011-02-22 10:21:46 +0000
@@ -1654,6 +1654,9 @@
 # datatype: string
 port: tcp:5022
 
+# datatype: string
+ftpport: 2121
+
 # See [error_reports].
 copy_to_zlog: False
 

=== modified file 'lib/lp/poppy/tests/test_poppy.py'
--- lib/lp/poppy/tests/test_poppy.py	2010-11-07 12:10:38 +0000
+++ lib/lp/poppy/tests/test_poppy.py	2011-02-22 10:21:46 +0000
@@ -1,14 +1,12 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Functional tests for poppy FTP daemon."""
 
 __metaclass__ = type
 
-import ftplib
 import os
 import shutil
-import socket
 import StringIO
 import tempfile
 import time
@@ -33,7 +31,6 @@
     ZopelessAppServerLayer,
     ZopelessDatabaseLayer,
     )
-from lp.poppy.tests.helpers import PoppyTestSetup
 from lp.registry.interfaces.ssh import (
     ISSHKeySet,
     )
@@ -45,38 +42,21 @@
 
     def __init__(self, root_dir, factory):
         self.root_dir = root_dir
-        self.port = 3421
+        self.port = 2121
 
     def setUp(self):
         super(FTPServer, self).setUp()
-        self.poppy = PoppyTestSetup(
-            self.root_dir, port=self.port, cmd='echo CLOSED')
-        self.poppy.startPoppy()
-        self.addCleanup(self.poppy.killPoppy)
+        self.useFixture(PoppyTac(self.root_dir))
 
     def getTransport(self):
         return get_transport('ftp://ubuntu:@localhost:%s/' % (self.port,))
 
     def disconnect(self, transport):
-        transport._get_connection().quit()
-
-    def _getFTPConnection(self):
-        # poppy usually takes sometime to come up, we need to wait, or insist.
-        conn = ftplib.FTP()
-        while True:
-            try:
-                conn.connect("localhost", self.port)
-            except socket.error:
-                if not self.poppy.alive:
-                    raise
-            else:
-                break
-        return conn
+        transport._get_connection().close()
 
     def waitForStartUp(self):
         """Wait for the FTP server to start up."""
-        conn = self._getFTPConnection()
-        conn.quit()
+        pass
 
     def waitForClose(self):
         """Wait for an FTP connection to close.
@@ -86,7 +66,7 @@
         output as a way to tell that the server has finished with the
         connection.
         """
-        self.poppy.verify_output(['CLOSED'])
+        time.sleep(5)
 
 
 class SFTPServer(Fixture):
@@ -269,7 +249,11 @@
         wanted_path = self._uploadPath('foo/bar/baz')
         fs_content = open(os.path.join(wanted_path)).read()
         self.assertEqual(fs_content, "fake contents")
-        self.assertEqual(os.stat(wanted_path).st_mode, 0102674)
+        # This is a magic and very opaque number.  It corresponds to
+        # the following stat.S_* masks:
+        # S_IROTH, S_ISGID, S_IRGRP, S_IWGRP, S_IWUSR, S_IWUSR
+        # which in readable terms is: -rw-rwSr--
+        self.assertEqual(os.stat(wanted_path).st_mode, 0102664)
 
     def test_full_source_upload(self):
         """Check that the connection will deal with multiple files being
@@ -298,13 +282,17 @@
         if transport._user == 'joe':
             self.assertEqual(dir_name.startswith('upload-sftp-2'), True)
         elif transport._user == 'ubuntu':
-            self.assertEqual(dir_name.startswith('upload-2'), True)
+            self.assertEqual(dir_name.startswith('upload-ftp-2'), True)
         for upload in files:
             wanted_path = self._uploadPath(
                 "~ppa-user/ppa/ubuntu/%s" % upload)
             fs_content = open(os.path.join(wanted_path)).read()
             self.assertEqual(fs_content, upload)
-            self.assertEqual(os.stat(wanted_path).st_mode, 0102674)
+            # This is a magic and very opaque number.  It corresponds to
+            # the following stat.S_* masks:
+            # S_IROTH, S_ISGID, S_IRGRP, S_IWGRP, S_IWUSR, S_IWUSR
+            # which in readable terms is: -rw-rwSr--
+            self.assertEqual(os.stat(wanted_path).st_mode, 0102664)
 
     def test_upload_isolation(self):
         """Check if poppy isolates the uploads properly.

=== added file 'lib/lp/poppy/twistedftp.py'
--- lib/lp/poppy/twistedftp.py	1970-01-01 00:00:00 +0000
+++ lib/lp/poppy/twistedftp.py	2011-02-22 10:21:46 +0000
@@ -0,0 +1,124 @@
+# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Twisted FTP implementation of the Poppy upload server."""
+
+__metaclass__ = type
+__all__ = [
+    'FTPRealm',
+    'PoppyAnonymousShell',
+    ]
+
+import logging
+import os
+import tempfile
+
+from twisted.cred import checkers, credentials
+from twisted.cred.portal import IRealm
+from twisted.internet import defer
+from twisted.protocols import ftp
+from twisted.python import filepath
+
+from zope.interface import implements
+
+from lp.poppy.filesystem import UploadFileSystem
+from lp.poppy.hooks import Hooks
+
+
+class PoppyAccessCheck:
+    """An `ICredentialsChecker` for Poppy FTP sessions."""
+    implements(checkers.ICredentialsChecker)
+    credentialInterfaces = credentials.IUsernamePassword,
+
+    def requestAvatarId(self, credentials):
+        # Poppy allows any credentials.  People can use "anonymous" if
+        # they want but anything goes.  Returning "poppy" here is
+        # a totally arbitrary avatar.
+        return "poppy"
+
+
+class PoppyAnonymousShell(ftp.FTPShell):
+    """The 'command' interface for sessions.
+
+    Roughly equivalent to the SFTPServer in the sftp side of things.
+    """
+
+    def __init__(self, fsroot):
+        self._fs_root = fsroot
+        self.uploadfilesystem = UploadFileSystem(tempfile.mkdtemp())
+        self._current_upload = self.uploadfilesystem.rootpath
+        os.chmod(self._current_upload, 0770)
+        self._log = logging.getLogger("poppy-sftp")
+        self.hook = Hooks(
+            self._fs_root, self._log, "ubuntu", perms='g+rws',
+            prefix='-ftp')
+        self.hook.new_client_hook(self._current_upload, 0, 0)
+        self.hook.auth_verify_hook(self._current_upload, None, None)
+        super(PoppyAnonymousShell, self).__init__(
+            filepath.FilePath(self._current_upload))
+
+    def openForWriting(self, file_segments):
+        """Write the uploaded file to disk, safely.
+
+        :param file_segments: A list containing string items, one for each
+            path component of the file being uploaded.  The file referenced
+            is relative to the temporary root for this session.
+
+        If the file path contains directories, we create them.
+        """
+        filename = os.sep.join(file_segments)
+        self._create_missing_directories(filename)
+        return super(PoppyAnonymousShell, self).openForWriting(file_segments)
+
+    def makeDirectory(self, path):
+        """Make a directory using the secure `UploadFileSystem`."""
+        path = os.sep.join(path)
+        return defer.maybeDeferred(self.uploadfilesystem.mkdir, path)
+
+    def access(self, segments):
+        """Permissive CWD that auto-creates target directories."""
+        if segments:
+            path = self._path(segments)
+            path.makedirs()
+        return super(PoppyAnonymousShell, self).access(segments)
+
+    def logout(self):
+        """Called when the client disconnects.
+
+        We need to post-process the upload.
+        """
+        self.hook.client_done_hook(self._current_upload, 0, 0)
+
+    def _create_missing_directories(self, filename):
+        # Same as SFTPServer
+        new_dir, new_file = os.path.split(
+            self.uploadfilesystem._sanitize(filename))
+        if new_dir != '':
+            if not os.path.exists(
+                os.path.join(self._current_upload, new_dir)):
+                self.uploadfilesystem.mkdir(new_dir)
+
+    def list(self, path_segments, attrs):
+        return defer.fail(ftp.CmdNotImplementedError("LIST"))
+
+
+class FTPRealm:
+    """FTP Realm that lets anyone in."""
+    implements(IRealm)
+
+    def __init__(self, root):
+        self.root = root
+
+    def requestAvatar(self, avatarId, mind, *interfaces):
+        """Return a Poppy avatar - that is, an "authorisation".
+
+        Poppy FTP avatars are totally fake, we don't care about credentials.
+        See `PoppyAccessCheck` above.
+        """
+        for iface in interfaces:
+            if iface is ftp.IFTPShell:
+                avatar = PoppyAnonymousShell(self.root)
+                return ftp.IFTPShell, avatar, getattr(
+                    avatar, 'logout', lambda: None)
+        raise NotImplementedError(
+            "Only IFTPShell interface is supported by this realm")