~launchpad-pqm/launchpad/devel

12392.7.13 by Julian Edwards
Move the new ftp code to its own file
1
# Copyright 2011 Canonical Ltd.  This software is licensed under the
2
# GNU Affero General Public License version 3 (see the file LICENSE).
3
4
"""Twisted FTP implementation of the Poppy upload server."""
5
6
__metaclass__ = type
7
__all__ = [
12392.7.14 by Julian Edwards
fix __all__
8
    'FTPRealm',
9
    'PoppyAnonymousShell',
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
10
    ]
11
12
import logging
13
import os
14
import tempfile
15
12392.7.21 by Julian Edwards
move code out of the tac file and into twistedftp.py
16
from twisted.application import service, strports
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
17
from twisted.cred import checkers, credentials
12392.7.21 by Julian Edwards
move code out of the tac file and into twistedftp.py
18
from twisted.cred.portal import IRealm, Portal
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
19
from twisted.internet import defer
20
from twisted.protocols import ftp
21
from twisted.python import filepath
22
23
from zope.interface import implements
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
24
from zope.component import getUtility
25
26
from canonical.launchpad.interfaces.gpghandler import (
27
    GPGVerificationError,
12919.6.6 by Ian Booth
Use single gpghandler implementation
28
    IGPGHandler,
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
29
    )
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
30
12392.7.21 by Julian Edwards
move code out of the tac file and into twistedftp.py
31
from canonical.config import config
32
from lp.poppy import get_poppy_root
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
33
from lp.poppy.filesystem import UploadFileSystem
34
from lp.poppy.hooks import Hooks
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
35
from lp.registry.interfaces.gpg import IGPGKeySet
12579.1.2 by William Grant
Fix poppy's validateGPG to abort the transaction once it's done.
36
from lp.services.database import read_transaction
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
37
38
39
class PoppyAccessCheck:
40
    """An `ICredentialsChecker` for Poppy FTP sessions."""
41
    implements(checkers.ICredentialsChecker)
12392.7.25 by Julian Edwards
make anonymous access work without frigging the user name for it
42
    credentialInterfaces = (
43
        credentials.IUsernamePassword, credentials.IAnonymous)
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
44
45
    def requestAvatarId(self, credentials):
46
        # Poppy allows any credentials.  People can use "anonymous" if
12392.7.25 by Julian Edwards
make anonymous access work without frigging the user name for it
47
        # they want but anything goes.  Thus, we don't actually *check* the
48
        # credentials, and we return the standard avatarId for 'anonymous'.
49
        return checkers.ANONYMOUS
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
50
51
52
class PoppyAnonymousShell(ftp.FTPShell):
53
    """The 'command' interface for sessions.
54
55
    Roughly equivalent to the SFTPServer in the sftp side of things.
56
    """
12392.7.20 by Julian Edwards
fix stupid lint
57
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
58
    def __init__(self, fsroot):
59
        self._fs_root = fsroot
60
        self.uploadfilesystem = UploadFileSystem(tempfile.mkdtemp())
61
        self._current_upload = self.uploadfilesystem.rootpath
62
        os.chmod(self._current_upload, 0770)
63
        self._log = logging.getLogger("poppy-sftp")
64
        self.hook = Hooks(
65
            self._fs_root, self._log, "ubuntu", perms='g+rws',
12392.7.18 by Julian Edwards
fix tests
66
            prefix='-ftp')
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
67
        self.hook.new_client_hook(self._current_upload, 0, 0)
68
        self.hook.auth_verify_hook(self._current_upload, None, None)
69
        super(PoppyAnonymousShell, self).__init__(
70
            filepath.FilePath(self._current_upload))
71
72
    def openForWriting(self, file_segments):
73
        """Write the uploaded file to disk, safely.
74
75
        :param file_segments: A list containing string items, one for each
76
            path component of the file being uploaded.  The file referenced
77
            is relative to the temporary root for this session.
78
79
        If the file path contains directories, we create them.
80
        """
81
        filename = os.sep.join(file_segments)
82
        self._create_missing_directories(filename)
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
83
        path = self._path(file_segments)
84
        try:
85
            fObj = path.open("w")
86
        except (IOError, OSError), e:
87
            return ftp.errnoToFailure(e.errno, path)
88
        except:
12392.8.11 by Julian Edwards
Add an explanatory comment
89
            # Push any other error up to Twisted to deal with.
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
90
            return defer.fail()
91
        return defer.succeed(PoppyFileWriter(fObj))
12392.7.13 by Julian Edwards
Move the new ftp code to its own file
92
93
    def makeDirectory(self, path):
94
        """Make a directory using the secure `UploadFileSystem`."""
95
        path = os.sep.join(path)
96
        return defer.maybeDeferred(self.uploadfilesystem.mkdir, path)
97
98
    def access(self, segments):
99
        """Permissive CWD that auto-creates target directories."""
100
        if segments:
101
            path = self._path(segments)
102
            path.makedirs()
103
        return super(PoppyAnonymousShell, self).access(segments)
104
105
    def logout(self):
106
        """Called when the client disconnects.
107
108
        We need to post-process the upload.
109
        """
110
        self.hook.client_done_hook(self._current_upload, 0, 0)
111
112
    def _create_missing_directories(self, filename):
113
        # Same as SFTPServer
114
        new_dir, new_file = os.path.split(
115
            self.uploadfilesystem._sanitize(filename))
116
        if new_dir != '':
117
            if not os.path.exists(
118
                os.path.join(self._current_upload, new_dir)):
119
                self.uploadfilesystem.mkdir(new_dir)
120
121
    def list(self, path_segments, attrs):
122
        return defer.fail(ftp.CmdNotImplementedError("LIST"))
123
124
125
class FTPRealm:
126
    """FTP Realm that lets anyone in."""
127
    implements(IRealm)
128
129
    def __init__(self, root):
130
        self.root = root
131
132
    def requestAvatar(self, avatarId, mind, *interfaces):
133
        """Return a Poppy avatar - that is, an "authorisation".
134
135
        Poppy FTP avatars are totally fake, we don't care about credentials.
136
        See `PoppyAccessCheck` above.
137
        """
138
        for iface in interfaces:
139
            if iface is ftp.IFTPShell:
140
                avatar = PoppyAnonymousShell(self.root)
141
                return ftp.IFTPShell, avatar, getattr(
142
                    avatar, 'logout', lambda: None)
143
        raise NotImplementedError(
144
            "Only IFTPShell interface is supported by this realm")
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
145
146
147
class PoppyFileWriter(ftp._FileWriter):
148
    """An `IWriteFile` that checks for signed changes files."""
149
150
    def close(self):
151
        """Called after the file has been completely downloaded."""
152
        if self.fObj.name.endswith(".changes"):
12392.8.8 by Julian Edwards
fix lint
153
            error = self.validateGPG(self.fObj.name)
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
154
            if error is not None:
155
                # PermissionDeniedError is one of the few ftp exceptions
156
                # that lets us pass an error string back to the client.
157
                return defer.fail(ftp.PermissionDeniedError(error))
158
        return defer.succeed(None)
159
12579.1.2 by William Grant
Fix poppy's validateGPG to abort the transaction once it's done.
160
    @read_transaction
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
161
    def validateGPG(self, signed_file):
162
        """Check the GPG signature in the file referenced by signed_file.
12392.8.8 by Julian Edwards
fix lint
163
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
164
        Return an error string if there's a problem, or None.
165
        """
166
        try:
12919.6.6 by Ian Booth
Use single gpghandler implementation
167
            sig = getUtility(IGPGHandler).getVerifiedSignatureResilient(
12392.8.1 by Julian Edwards
first stab at rejecting unsigned changes files - requires a patch to Twisted to return the error code properly
168
                file(signed_file, "rb").read())
169
        except GPGVerificationError, error:
170
            return ("Changes file must be signed with a valid GPG "
171
                    "signature: %s" % error)
172
173
        key = getUtility(IGPGKeySet).getByFingerprint(sig.fingerprint)
174
        if key is None:
175
            return (
176
                "Signing key %s not registered in launchpad."
177
                % sig.fingerprint)
178
179
        if key.active == False:
180
            return "Changes file is signed with a deactivated key"
181
182
        return None
183
184
12392.7.21 by Julian Edwards
move code out of the tac file and into twistedftp.py
185
class FTPServiceFactory(service.Service):
12392.7.31 by Julian Edwards
fix some lint
186
    """A factory that makes an `FTPService`"""
187
12392.7.21 by Julian Edwards
move code out of the tac file and into twistedftp.py
188
    def __init__(self, port):
189
        realm = FTPRealm(get_poppy_root())
190
        portal = Portal(realm)
191
        portal.registerChecker(PoppyAccessCheck())
12392.7.24 by Julian Edwards
make the tests also use anonymous as the FTP user ID
192
        factory = ftp.FTPFactory(portal)
12392.7.21 by Julian Edwards
move code out of the tac file and into twistedftp.py
193
194
        factory.tld = get_poppy_root()
195
        factory.protocol = ftp.FTP
196
        factory.welcomeMessage = "Launchpad upload server"
197
        factory.timeOut = config.poppy.idle_timeout
198
12392.7.24 by Julian Edwards
make the tests also use anonymous as the FTP user ID
199
        self.ftpfactory = factory
200
        self.portno = port
201
12392.7.21 by Julian Edwards
move code out of the tac file and into twistedftp.py
202
    @staticmethod
203
    def makeFTPService(port=2121):
204
        strport = "tcp:%s" % port
205
        factory = FTPServiceFactory(port)
206
        return strports.service(strport, factory.ftpfactory)