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