chiark / gitweb /
support git@gitlab.com: style URLs in servergitmirrors
[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 re
26 import subprocess
27 from argparse import ArgumentParser
28 import logging
29 import shutil
30
31 from . import common
32
33 config = None
34 options = None
35
36 BINARY_TRANSPARENCY_DIR = 'binary_transparency'
37
38
39 def update_awsbucket(repo_section):
40     '''
41     Upload the contents of the directory `repo_section` (including
42     subdirectories) to the AWS S3 "bucket". The contents of that subdir of the
43     bucket will first be deleted.
44
45     Requires AWS credentials set in config.py: awsaccesskeyid, awssecretkey
46     '''
47
48     logging.debug('Syncing "' + repo_section + '" to Amazon S3 bucket "'
49                   + config['awsbucket'] + '"')
50
51     import libcloud.security
52     libcloud.security.VERIFY_SSL_CERT = True
53     from libcloud.storage.types import Provider, ContainerDoesNotExistError
54     from libcloud.storage.providers import get_driver
55
56     if not config.get('awsaccesskeyid') or not config.get('awssecretkey'):
57         logging.error('To use awsbucket, you must set awssecretkey and awsaccesskeyid in config.py!')
58         sys.exit(1)
59     awsbucket = config['awsbucket']
60
61     cls = get_driver(Provider.S3)
62     driver = cls(config['awsaccesskeyid'], config['awssecretkey'])
63     try:
64         container = driver.get_container(container_name=awsbucket)
65     except ContainerDoesNotExistError:
66         container = driver.create_container(container_name=awsbucket)
67         logging.info('Created new container "' + container.name + '"')
68
69     upload_dir = 'fdroid/' + repo_section
70     objs = dict()
71     for obj in container.list_objects():
72         if obj.name.startswith(upload_dir + '/'):
73             objs[obj.name] = obj
74
75     for root, _, files in os.walk(os.path.join(os.getcwd(), repo_section)):
76         for name in files:
77             upload = False
78             file_to_upload = os.path.join(root, name)
79             object_name = 'fdroid/' + os.path.relpath(file_to_upload, os.getcwd())
80             if object_name not in objs:
81                 upload = True
82             else:
83                 obj = objs.pop(object_name)
84                 if obj.size != os.path.getsize(file_to_upload):
85                     upload = True
86                 else:
87                     # if the sizes match, then compare by MD5
88                     md5 = hashlib.md5()
89                     with open(file_to_upload, 'rb') as f:
90                         while True:
91                             data = f.read(8192)
92                             if not data:
93                                 break
94                             md5.update(data)
95                     if obj.hash != md5.hexdigest():
96                         s3url = 's3://' + awsbucket + '/' + obj.name
97                         logging.info(' deleting ' + s3url)
98                         if not driver.delete_object(obj):
99                             logging.warn('Could not delete ' + s3url)
100                         upload = True
101
102             if upload:
103                 logging.debug(' uploading "' + file_to_upload + '"...')
104                 extra = {'acl': 'public-read'}
105                 if file_to_upload.endswith('.sig'):
106                     extra['content_type'] = 'application/pgp-signature'
107                 elif file_to_upload.endswith('.asc'):
108                     extra['content_type'] = 'application/pgp-signature'
109                 logging.info(' uploading ' + os.path.relpath(file_to_upload)
110                              + ' to s3://' + awsbucket + '/' + object_name)
111                 with open(file_to_upload, 'rb') as iterator:
112                     obj = driver.upload_object_via_stream(iterator=iterator,
113                                                           container=container,
114                                                           object_name=object_name,
115                                                           extra=extra)
116     # delete the remnants in the bucket, they do not exist locally
117     while objs:
118         object_name, obj = objs.popitem()
119         s3url = 's3://' + awsbucket + '/' + object_name
120         if object_name.startswith(upload_dir):
121             logging.warn(' deleting ' + s3url)
122             driver.delete_object(obj)
123         else:
124             logging.info(' skipping ' + s3url)
125
126
127 def update_serverwebroot(serverwebroot, repo_section):
128     # use a checksum comparison for accurate comparisons on different
129     # filesystems, for example, FAT has a low resolution timestamp
130     rsyncargs = ['rsync', '--archive', '--delete-after', '--safe-links']
131     if not options.no_checksum:
132         rsyncargs.append('--checksum')
133     if options.verbose:
134         rsyncargs += ['--verbose']
135     if options.quiet:
136         rsyncargs += ['--quiet']
137     if options.identity_file is not None:
138         rsyncargs += ['-e', 'ssh -i ' + options.identity_file]
139     if 'identity_file' in config:
140         rsyncargs += ['-e', 'ssh -i ' + config['identity_file']]
141     indexxml = os.path.join(repo_section, 'index.xml')
142     indexjar = os.path.join(repo_section, 'index.jar')
143     indexv1jar = os.path.join(repo_section, 'index-v1.jar')
144     # Upload the first time without the index files and delay the deletion as
145     # much as possible, that keeps the repo functional while this update is
146     # running.  Then once it is complete, rerun the command again to upload
147     # the index files.  Always using the same target with rsync allows for
148     # very strict settings on the receiving server, you can literally specify
149     # the one rsync command that is allowed to run in ~/.ssh/authorized_keys.
150     # (serverwebroot is guaranteed to have a trailing slash in common.py)
151     logging.info('rsyncing ' + repo_section + ' to ' + serverwebroot)
152     if subprocess.call(rsyncargs +
153                        ['--exclude', indexxml, '--exclude', indexjar,
154                         '--exclude', indexv1jar,
155                         repo_section, serverwebroot]) != 0:
156         sys.exit(1)
157     if subprocess.call(rsyncargs + [repo_section, serverwebroot]) != 0:
158         sys.exit(1)
159     # upload "current version" symlinks if requested
160     if config['make_current_version_link'] and repo_section == 'repo':
161         links_to_upload = []
162         for f in glob.glob('*.apk') \
163                 + glob.glob('*.apk.asc') + glob.glob('*.apk.sig'):
164             if os.path.islink(f):
165                 links_to_upload.append(f)
166         if len(links_to_upload) > 0:
167             if subprocess.call(rsyncargs + links_to_upload + [serverwebroot]) != 0:
168                 sys.exit(1)
169
170
171 def _local_sync(fromdir, todir):
172     rsyncargs = ['rsync', '--recursive', '--safe-links', '--times', '--perms',
173                  '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
174     # use stricter rsync checking on all files since people using offline mode
175     # are already prioritizing security above ease and speed
176     if not options.no_checksum:
177         rsyncargs.append('--checksum')
178     if options.verbose:
179         rsyncargs += ['--verbose']
180     if options.quiet:
181         rsyncargs += ['--quiet']
182     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
183     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
184         sys.exit(1)
185
186
187 def sync_from_localcopy(repo_section, local_copy_dir):
188     '''Syncs the repo from "local copy dir" filesystem to this box
189
190     In setups that use offline signing, this is the last step that
191     syncs the repo from the "local copy dir" e.g. a thumb drive to the
192     repo on the local filesystem.  That local repo is then used to
193     push to all the servers that are configured.
194
195     '''
196     logging.info('Syncing from local_copy_dir to this repo.')
197     # trailing slashes have a meaning in rsync which is not needed here, so
198     # make sure both paths have exactly one trailing slash
199     _local_sync(os.path.join(local_copy_dir, repo_section).rstrip('/') + '/',
200                 repo_section.rstrip('/') + '/')
201
202     offline_copy = os.path.join(local_copy_dir, BINARY_TRANSPARENCY_DIR)
203     if os.path.exists(os.path.join(offline_copy, '.git')):
204         online_copy = os.path.join(os.getcwd(), BINARY_TRANSPARENCY_DIR)
205         push_binary_transparency(offline_copy, online_copy)
206
207
208 def update_localcopy(repo_section, local_copy_dir):
209     '''copy data from offline to the "local copy dir" filesystem
210
211     This updates the copy of this repo used to shuttle data from an
212     offline signing machine to the online machine, e.g. on a thumb
213     drive.
214
215     '''
216     # local_copy_dir is guaranteed to have a trailing slash in main() below
217     _local_sync(repo_section, local_copy_dir)
218
219     offline_copy = os.path.join(os.getcwd(), BINARY_TRANSPARENCY_DIR)
220     if os.path.isdir(os.path.join(offline_copy, '.git')):
221         online_copy = os.path.join(local_copy_dir, BINARY_TRANSPARENCY_DIR)
222         push_binary_transparency(offline_copy, online_copy)
223
224
225 def update_servergitmirrors(servergitmirrors, repo_section):
226     # depend on GitPython only if users set a git mirror
227     import git
228     # right now we support only 'repo' git-mirroring
229     if repo_section == 'repo':
230         # create a new git-mirror folder
231         repo_dir = os.path.join('.', 'git-mirror/')
232
233         # remove if already present
234         if os.path.isdir(repo_dir):
235             shutil.rmtree(repo_dir)
236
237         repo = git.Repo.init(repo_dir)
238
239         for mirror in servergitmirrors:
240             hostname = re.sub(r'\W*\w+\W+(\w+).*', r'\1', mirror)
241             repo.create_remote(hostname, mirror)
242             logging.info('Mirroring to: ' + mirror)
243
244         # copy local 'repo' to 'git-mirror/fdroid/repo directory' with _local_sync
245         fdroid_repo_path = os.path.join(repo_dir, "fdroid")
246         _local_sync(repo_section, fdroid_repo_path)
247
248         # sadly index.add don't allow the --all parameter
249         repo.git.add(all=True)
250         repo.index.commit("fdroidserver git-mirror")
251
252         # push for every remote. This will overwrite the git history
253         for remote in repo.remotes:
254             remote.push('master', force=True, set_upstream=True)
255
256
257 def upload_to_android_observatory(repo_section):
258     # depend on requests and lxml only if users enable AO
259     import requests
260     from lxml.html import fromstring
261
262     if repo_section == 'repo':
263         for f in glob.glob(os.path.join(repo_section, '*.apk')):
264             fpath = f
265             fname = os.path.basename(f)
266             logging.info('Uploading ' + fname + ' to androidobservatory.org')
267
268             # upload the file with a post request
269             r = requests.post('https://androidobservatory.org/upload', files={'apk': (fname, open(fpath, 'rb'))})
270             response = r.text
271             page = r.url
272
273             # from now on XPath will be used to retrieve the message in the HTML
274             # androidobservatory doesn't have a nice API to talk with
275             # so we must scrape the page content
276             tree = fromstring(response)
277             alert = tree.xpath("//html/body/div[@class='container content-container']/div[@class='alert alert-info']")[0]
278
279             message = ""
280             appurl = page
281             for el in alert:
282                 # if the application was added successfully we retrive the url
283                 # if the application was already uploaded we use the redirect page url
284                 if el.attrib.get("href") is not None:
285                     appurl = page + el.attrib["href"][1:]
286                     message += el.text.replace(" here", "") + el.tail
287                 else:
288                     message += el.tail
289             message = message.strip() + " " + appurl
290             logging.info(message)
291
292
293 def upload_to_virustotal(repo_section, vt_apikey):
294     import requests
295
296     if repo_section == 'repo':
297         for f in glob.glob(os.path.join(repo_section, '*.apk')):
298             fpath = f
299             fname = os.path.basename(f)
300             logging.info('Uploading ' + fname + ' to virustotal.com')
301
302             # upload the file with a post request
303             params = {'apikey': vt_apikey}
304             files = {'file': (fname, open(fpath, 'rb'))}
305             r = requests.post('https://www.virustotal.com/vtapi/v2/file/scan', files=files, params=params)
306             response = r.json()
307
308             logging.info(response['verbose_msg'] + " " + response['permalink'])
309
310
311 def push_binary_transparency(git_repo_path, git_remote):
312     '''push the binary transparency git repo to the specifed remote.
313
314     If the remote is a local directory, make sure it exists, and is a
315     git repo.  This is used to move this git repo from an offline
316     machine onto a flash drive, then onto the online machine.
317
318     This is also used in offline signing setups, where it then also
319     creates a "local copy dir" git repo that serves to shuttle the git
320     data from the offline machine to the online machine.  In that
321     case, git_remote is a dir on the local file system, e.g. a thumb
322     drive.
323
324     '''
325     import git
326
327     if os.path.isdir(os.path.dirname(git_remote)) \
328        and not os.path.isdir(os.path.join(git_remote, '.git')):
329         os.makedirs(git_remote, exist_ok=True)
330         repo = git.Repo.init(git_remote)
331         config = repo.config_writer()
332         config.set_value('receive', 'denyCurrentBranch', 'updateInstead')
333         config.release()
334
335     logging.info('Pushing binary transparency log to ' + git_remote)
336     gitrepo = git.Repo(git_repo_path)
337     origin = git.remote.Remote(gitrepo, 'origin')
338     if origin in gitrepo.remotes:
339         origin = gitrepo.remote('origin')
340         if 'set_url' in dir(origin):  # added in GitPython 2.x
341             origin.set_url(git_remote)
342     else:
343         origin = gitrepo.create_remote('origin', git_remote)
344     origin.push('master')
345
346
347 def main():
348     global config, options
349
350     # Parse command line...
351     parser = ArgumentParser()
352     common.setup_global_opts(parser)
353     parser.add_argument("command", help="command to execute, either 'init' or 'update'")
354     parser.add_argument("-i", "--identity-file", default=None,
355                         help="Specify an identity file to provide to SSH for rsyncing")
356     parser.add_argument("--local-copy-dir", default=None,
357                         help="Specify a local folder to sync the repo to")
358     parser.add_argument("--no-checksum", action="store_true", default=False,
359                         help="Don't use rsync checksums")
360     options = parser.parse_args()
361
362     config = common.read_config(options)
363
364     if options.command != 'init' and options.command != 'update':
365         logging.critical("The only commands currently supported are 'init' and 'update'")
366         sys.exit(1)
367
368     if config.get('nonstandardwebroot') is True:
369         standardwebroot = False
370     else:
371         standardwebroot = True
372
373     for serverwebroot in config.get('serverwebroot', []):
374         # this supports both an ssh host:path and just a path
375         s = serverwebroot.rstrip('/').split(':')
376         if len(s) == 1:
377             fdroiddir = s[0]
378         elif len(s) == 2:
379             host, fdroiddir = s
380         else:
381             logging.error('Malformed serverwebroot line: ' + serverwebroot)
382             sys.exit(1)
383         repobase = os.path.basename(fdroiddir)
384         if standardwebroot and repobase != 'fdroid':
385             logging.error('serverwebroot path does not end with "fdroid", '
386                           + 'perhaps you meant one of these:\n\t'
387                           + serverwebroot.rstrip('/') + '/fdroid\n\t'
388                           + serverwebroot.rstrip('/').rstrip(repobase) + 'fdroid')
389             sys.exit(1)
390
391     if options.local_copy_dir is not None:
392         local_copy_dir = options.local_copy_dir
393     elif config.get('local_copy_dir'):
394         local_copy_dir = config['local_copy_dir']
395     else:
396         local_copy_dir = None
397     if local_copy_dir is not None:
398         fdroiddir = local_copy_dir.rstrip('/')
399         if os.path.exists(fdroiddir) and not os.path.isdir(fdroiddir):
400             logging.error('local_copy_dir must be directory, not a file!')
401             sys.exit(1)
402         if not os.path.exists(os.path.dirname(fdroiddir)):
403             logging.error('The root dir for local_copy_dir "'
404                           + os.path.dirname(fdroiddir)
405                           + '" does not exist!')
406             sys.exit(1)
407         if not os.path.isabs(fdroiddir):
408             logging.error('local_copy_dir must be an absolute path!')
409             sys.exit(1)
410         repobase = os.path.basename(fdroiddir)
411         if standardwebroot and repobase != 'fdroid':
412             logging.error('local_copy_dir does not end with "fdroid", '
413                           + 'perhaps you meant: ' + fdroiddir + '/fdroid')
414             sys.exit(1)
415         if local_copy_dir[-1] != '/':
416             local_copy_dir += '/'
417         local_copy_dir = local_copy_dir.replace('//', '/')
418         if not os.path.exists(fdroiddir):
419             os.mkdir(fdroiddir)
420
421     if not config.get('awsbucket') \
422             and not config.get('serverwebroot') \
423             and not config.get('servergitmirrors') \
424             and not config.get('androidobservatory') \
425             and not config.get('binary_transparency_remote') \
426             and not config.get('virustotal_apikey') \
427             and local_copy_dir is None:
428         logging.warn('No option set! Edit your config.py to set at least one among:\n'
429                      + 'serverwebroot, servergitmirrors, local_copy_dir, awsbucket, virustotal_apikey, androidobservatory, or binary_transparency_remote')
430         sys.exit(1)
431
432     repo_sections = ['repo']
433     if config['archive_older'] != 0:
434         repo_sections.append('archive')
435         if not os.path.exists('archive'):
436             os.mkdir('archive')
437     if config['per_app_repos']:
438         repo_sections += common.get_per_app_repos()
439
440     if options.command == 'init':
441         ssh = paramiko.SSHClient()
442         ssh.load_system_host_keys()
443         for serverwebroot in config.get('serverwebroot', []):
444             sshstr, remotepath = serverwebroot.rstrip('/').split(':')
445             if sshstr.find('@') >= 0:
446                 username, hostname = sshstr.split('@')
447             else:
448                 username = pwd.getpwuid(os.getuid())[0]  # get effective uid
449                 hostname = sshstr
450             ssh.connect(hostname, username=username)
451             sftp = ssh.open_sftp()
452             if os.path.basename(remotepath) \
453                     not in sftp.listdir(os.path.dirname(remotepath)):
454                 sftp.mkdir(remotepath, mode=0o755)
455             for repo_section in repo_sections:
456                 repo_path = os.path.join(remotepath, repo_section)
457                 if os.path.basename(repo_path) \
458                         not in sftp.listdir(remotepath):
459                     sftp.mkdir(repo_path, mode=0o755)
460             sftp.close()
461             ssh.close()
462     elif options.command == 'update':
463         for repo_section in repo_sections:
464             if local_copy_dir is not None:
465                 if config['sync_from_local_copy_dir']:
466                     sync_from_localcopy(repo_section, local_copy_dir)
467                 else:
468                     update_localcopy(repo_section, local_copy_dir)
469             for serverwebroot in config.get('serverwebroot', []):
470                 update_serverwebroot(serverwebroot, repo_section)
471             if config.get('servergitmirrors', []):
472                 # update_servergitmirrors will take care of multiple mirrors so don't need a foreach
473                 servergitmirrors = config.get('servergitmirrors', [])
474                 update_servergitmirrors(servergitmirrors, repo_section)
475             if config.get('awsbucket'):
476                 update_awsbucket(repo_section)
477             if config.get('androidobservatory'):
478                 upload_to_android_observatory(repo_section)
479             if config.get('virustotal_apikey'):
480                 upload_to_virustotal(repo_section, config.get('virustotal_apikey'))
481
482             binary_transparency_remote = config.get('binary_transparency_remote')
483             if binary_transparency_remote:
484                 push_binary_transparency(BINARY_TRANSPARENCY_DIR,
485                                          binary_transparency_remote)
486
487     sys.exit(0)
488
489
490 if __name__ == "__main__":
491     main()