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) |