chiark / gitweb /
Correction to no-checksum
[fdroidserver.git] / fdroidserver / server.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # server.py - part of the FDroid server tools
5 # Copyright (C) 2010-15, Ciaran Gultnieks, ciaran@ciarang.com
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import sys
21 import glob
22 import hashlib
23 import os
24 import paramiko
25 import pwd
26 import subprocess
27 from optparse import OptionParser
28 import logging
29 import common
30
31 config = None
32 options = None
33
34
35 def update_awsbucket(repo_section):
36     '''
37     Upload the contents of the directory `repo_section` (including
38     subdirectories) to the AWS S3 "bucket". The contents of that subdir of the
39     bucket will first be deleted.
40
41     Requires AWS credentials set in config.py: awsaccesskeyid, awssecretkey
42     '''
43
44     logging.debug('Syncing "' + repo_section + '" to Amazon S3 bucket "'
45                   + config['awsbucket'] + '"')
46
47     import libcloud.security
48     libcloud.security.VERIFY_SSL_CERT = True
49     from libcloud.storage.types import Provider, ContainerDoesNotExistError
50     from libcloud.storage.providers import get_driver
51
52     if not config.get('awsaccesskeyid') or not config.get('awssecretkey'):
53         logging.error('To use awsbucket, you must set awssecretkey and awsaccesskeyid in config.py!')
54         sys.exit(1)
55     awsbucket = config['awsbucket']
56
57     cls = get_driver(Provider.S3)
58     driver = cls(config['awsaccesskeyid'], config['awssecretkey'])
59     try:
60         container = driver.get_container(container_name=awsbucket)
61     except ContainerDoesNotExistError:
62         container = driver.create_container(container_name=awsbucket)
63         logging.info('Created new container "' + container.name + '"')
64
65     upload_dir = 'fdroid/' + repo_section
66     objs = dict()
67     for obj in container.list_objects():
68         if obj.name.startswith(upload_dir + '/'):
69             objs[obj.name] = obj
70
71     for root, _, files in os.walk(os.path.join(os.getcwd(), repo_section)):
72         for name in files:
73             upload = False
74             file_to_upload = os.path.join(root, name)
75             object_name = 'fdroid/' + os.path.relpath(file_to_upload, os.getcwd())
76             if object_name not in objs:
77                 upload = True
78             else:
79                 obj = objs.pop(object_name)
80                 if obj.size != os.path.getsize(file_to_upload):
81                     upload = True
82                 else:
83                     # if the sizes match, then compare by MD5
84                     md5 = hashlib.md5()
85                     with open(file_to_upload, 'rb') as f:
86                         while True:
87                             data = f.read(8192)
88                             if not data:
89                                 break
90                             md5.update(data)
91                     if obj.hash != md5.hexdigest():
92                         s3url = 's3://' + awsbucket + '/' + obj.name
93                         logging.info(' deleting ' + s3url)
94                         if not driver.delete_object(obj):
95                             logging.warn('Could not delete ' + s3url)
96                         upload = True
97
98             if upload:
99                 logging.debug(' uploading "' + file_to_upload + '"...')
100                 extra = {'acl': 'public-read'}
101                 if file_to_upload.endswith('.sig'):
102                     extra['content_type'] = 'application/pgp-signature'
103                 elif file_to_upload.endswith('.asc'):
104                     extra['content_type'] = 'application/pgp-signature'
105                 logging.info(' uploading ' + os.path.relpath(file_to_upload)
106                              + ' to s3://' + awsbucket + '/' + object_name)
107                 obj = driver.upload_object(file_path=file_to_upload,
108                                            container=container,
109                                            object_name=object_name,
110                                            verify_hash=False,
111                                            extra=extra)
112     # delete the remnants in the bucket, they do not exist locally
113     while objs:
114         object_name, obj = objs.popitem()
115         s3url = 's3://' + awsbucket + '/' + object_name
116         if object_name.startswith(upload_dir):
117             logging.warn(' deleting ' + s3url)
118             driver.delete_object(obj)
119         else:
120             logging.info(' skipping ' + s3url)
121
122
123 def update_serverwebroot(serverwebroot, repo_section):
124     # use a checksum comparison for accurate comparisons on different
125     # filesystems, for example, FAT has a low resolution timestamp
126     rsyncargs = ['rsync', '--archive', '--delete']
127     if not options.no_checksum:
128         rsyncargs.append('--checksum')
129     if options.verbose:
130         rsyncargs += ['--verbose']
131     if options.quiet:
132         rsyncargs += ['--quiet']
133     if options.identity_file is not None:
134         rsyncargs += ['-e', 'ssh -i ' + options.identity_file]
135     if 'identity_file' in config:
136         rsyncargs += ['-e', 'ssh -i ' + config['identity_file']]
137     indexxml = os.path.join(repo_section, 'index.xml')
138     indexjar = os.path.join(repo_section, 'index.jar')
139     # upload the first time without the index so that the repo stays working
140     # while this update is running.  Then once it is complete, rerun the
141     # command again to upload the index.  Always using the same target with
142     # rsync allows for very strict settings on the receiving server, you can
143     # literally specify the one rsync command that is allowed to run in
144     # ~/.ssh/authorized_keys.  (serverwebroot is guaranteed to have a trailing
145     # slash in common.py)
146     if subprocess.call(rsyncargs +
147                        ['--exclude', indexxml, '--exclude', indexjar,
148                         repo_section, serverwebroot]) != 0:
149         sys.exit(1)
150     if subprocess.call(rsyncargs + [repo_section, serverwebroot]) != 0:
151         sys.exit(1)
152     # upload "current version" symlinks if requested
153     if config['make_current_version_link'] and repo_section == 'repo':
154         links_to_upload = []
155         for f in glob.glob('*.apk') \
156                 + glob.glob('*.apk.asc') + glob.glob('*.apk.sig'):
157             if os.path.islink(f):
158                 links_to_upload.append(f)
159         if len(links_to_upload) > 0:
160             if subprocess.call(rsyncargs + links_to_upload + [serverwebroot]) != 0:
161                 sys.exit(1)
162
163
164 def _local_sync(fromdir, todir):
165     rsyncargs = ['rsync', '--recursive', '--links', '--times', '--perms',
166                  '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
167     # use stricter rsync checking on all files since people using offline mode
168     # are already prioritizing security above ease and speed
169     if not options.nochecksum:
170         rsyncargs.append('--checksum')
171     if options.verbose:
172         rsyncargs += ['--verbose']
173     if options.quiet:
174         rsyncargs += ['--quiet']
175     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
176     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
177         sys.exit(1)
178
179
180 def sync_from_localcopy(repo_section, local_copy_dir):
181     logging.info('Syncing from local_copy_dir to this repo.')
182     # trailing slashes have a meaning in rsync which is not needed here, so
183     # make sure both paths have exactly one trailing slash
184     _local_sync(os.path.join(local_copy_dir, repo_section).rstrip('/') + '/',
185                 repo_section.rstrip('/') + '/')
186
187
188 def update_localcopy(repo_section, local_copy_dir):
189     # local_copy_dir is guaranteed to have a trailing slash in main() below
190     _local_sync(repo_section, local_copy_dir)
191
192
193 def main():
194     global config, options
195
196     # Parse command line...
197     parser = OptionParser()
198     parser.add_option("-i", "--identity-file", default=None,
199                       help="Specify an identity file to provide to SSH for rsyncing")
200     parser.add_option("--local-copy-dir", default=None,
201                       help="Specify a local folder to sync the repo to")
202     parser.add_option("--sync-from-local-copy-dir", action="store_true", default=False,
203                       help="Before uploading to servers, sync from local copy dir")
204     parser.add_option("-v", "--verbose", action="store_true", default=False,
205                       help="Spew out even more information than normal")
206     parser.add_option("-q", "--quiet", action="store_true", default=False,
207                       help="Restrict output to warnings and errors")
208     parser.add_option("--no-checksum", action="store_true", default=False,
209                       help="Don't use rsync checksums")
210     (options, args) = parser.parse_args()
211
212     config = common.read_config(options)
213
214     if len(args) != 1:
215         logging.critical("Specify a single command")
216         sys.exit(1)
217
218     if args[0] != 'init' and args[0] != 'update':
219         logging.critical("The only commands currently supported are 'init' and 'update'")
220         sys.exit(1)
221
222     if config.get('nonstandardwebroot') is True:
223         standardwebroot = False
224     else:
225         standardwebroot = True
226
227     for serverwebroot in config.get('serverwebroot', []):
228         host, fdroiddir = serverwebroot.rstrip('/').split(':')
229         repobase = os.path.basename(fdroiddir)
230         if standardwebroot and repobase != 'fdroid':
231             logging.error('serverwebroot path does not end with "fdroid", '
232                           + 'perhaps you meant one of these:\n\t'
233                           + serverwebroot.rstrip('/') + '/fdroid\n\t'
234                           + serverwebroot.rstrip('/').rstrip(repobase) + 'fdroid')
235             sys.exit(1)
236
237     if options.local_copy_dir is not None:
238         local_copy_dir = options.local_copy_dir
239     elif config.get('local_copy_dir'):
240         local_copy_dir = config['local_copy_dir']
241     else:
242         local_copy_dir = None
243     if local_copy_dir is not None:
244         fdroiddir = local_copy_dir.rstrip('/')
245         if os.path.exists(fdroiddir) and not os.path.isdir(fdroiddir):
246             logging.error('local_copy_dir must be directory, not a file!')
247             sys.exit(1)
248         if not os.path.exists(os.path.dirname(fdroiddir)):
249             logging.error('The root dir for local_copy_dir "'
250                           + os.path.dirname(fdroiddir)
251                           + '" does not exist!')
252             sys.exit(1)
253         if not os.path.isabs(fdroiddir):
254             logging.error('local_copy_dir must be an absolute path!')
255             sys.exit(1)
256         repobase = os.path.basename(fdroiddir)
257         if standardwebroot and repobase != 'fdroid':
258             logging.error('local_copy_dir does not end with "fdroid", '
259                           + 'perhaps you meant: ' + fdroiddir + '/fdroid')
260             sys.exit(1)
261         if local_copy_dir[-1] != '/':
262             local_copy_dir += '/'
263         local_copy_dir = local_copy_dir.replace('//', '/')
264         if not os.path.exists(fdroiddir):
265             os.mkdir(fdroiddir)
266
267     if not config.get('awsbucket') \
268             and not config.get('serverwebroot') \
269             and local_copy_dir is None:
270         logging.warn('No serverwebroot, local_copy_dir, or awsbucket set!'
271                      + 'Edit your config.py to set at least one.')
272         sys.exit(1)
273
274     repo_sections = ['repo']
275     if config['archive_older'] != 0:
276         repo_sections.append('archive')
277         if not os.path.exists('archive'):
278             os.mkdir('archive')
279
280     if args[0] == 'init':
281         ssh = paramiko.SSHClient()
282         ssh.load_system_host_keys()
283         for serverwebroot in config.get('serverwebroot', []):
284             sshstr, remotepath = serverwebroot.rstrip('/').split(':')
285             if sshstr.find('@') >= 0:
286                 username, hostname = sshstr.split('@')
287             else:
288                 username = pwd.getpwuid(os.getuid())[0]  # get effective uid
289                 hostname = sshstr
290             ssh.connect(hostname, username=username)
291             sftp = ssh.open_sftp()
292             if os.path.basename(remotepath) \
293                     not in sftp.listdir(os.path.dirname(remotepath)):
294                 sftp.mkdir(remotepath, mode=0755)
295             for repo_section in repo_sections:
296                 repo_path = os.path.join(remotepath, repo_section)
297                 if os.path.basename(repo_path) \
298                         not in sftp.listdir(remotepath):
299                     sftp.mkdir(repo_path, mode=0755)
300             sftp.close()
301             ssh.close()
302     elif args[0] == 'update':
303         for repo_section in repo_sections:
304             if local_copy_dir is not None:
305                 if config['sync_from_local_copy_dir'] and os.path.exists(repo_section):
306                     sync_from_localcopy(repo_section, local_copy_dir)
307                 else:
308                     update_localcopy(repo_section, local_copy_dir)
309             for serverwebroot in config.get('serverwebroot', []):
310                 update_serverwebroot(serverwebroot, repo_section)
311             if config.get('awsbucket'):
312                 update_awsbucket(repo_section)
313
314     sys.exit(0)
315
316 if __name__ == "__main__":
317     main()