3 # server.py - part of the FDroid server tools
4 # Copyright (C) 2010-15, Ciaran Gultnieks, ciaran@ciarang.com
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.
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.
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/>.
28 from argparse import ArgumentParser
37 BINARY_TRANSPARENCY_DIR = 'binary_transparency'
40 def update_awsbucket(repo_section):
42 Upload the contents of the directory `repo_section` (including
43 subdirectories) to the AWS S3 "bucket". The contents of that subdir of the
44 bucket will first be deleted.
46 Requires AWS credentials set in config.py: awsaccesskeyid, awssecretkey
49 logging.debug('Syncing "' + repo_section + '" to Amazon S3 bucket "'
50 + config['awsbucket'] + '"')
52 if common.set_command_in_config('s3cmd'):
53 update_awsbucket_s3cmd(repo_section)
55 update_awsbucket_libcloud(repo_section)
58 def update_awsbucket_s3cmd(repo_section):
59 '''upload using the CLI tool s3cmd, which provides rsync-like sync
61 The upload is done in multiple passes to reduce the chance of
62 interfering with an existing client-server interaction. In the
63 first pass, only new files are uploaded. In the second pass,
64 changed files are uploaded, overwriting what is on the server. On
65 the third/last pass, the indexes are uploaded, and any removed
66 files are deleted from the server. The last pass is the only pass
67 to use a full MD5 checksum of all files to detect changes.
70 logging.debug('using s3cmd to sync with ' + config['awsbucket'])
72 configfilename = '.s3cfg'
73 fd = os.open(configfilename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
74 os.write(fd, '[default]\n'.encode('utf-8'))
75 os.write(fd, ('access_key = ' + config['awsaccesskeyid'] + '\n').encode('utf-8'))
76 os.write(fd, ('secret_key = ' + config['awssecretkey'] + '\n').encode('utf-8'))
79 s3url = 's3://' + config['awsbucket'] + '/fdroid/'
83 '--config=' + configfilename,
87 s3cmdargs += ['--verbose']
89 s3cmdargs += ['--quiet']
90 indexxml = os.path.join(repo_section, 'index.xml')
91 indexjar = os.path.join(repo_section, 'index.jar')
92 indexv1jar = os.path.join(repo_section, 'index-v1.jar')
93 logging.debug('s3cmd sync new files in ' + repo_section + ' to ' + s3url)
94 if subprocess.call(s3cmdargs +
95 ['--no-check-md5', '--skip-existing',
96 '--exclude', indexxml,
97 '--exclude', indexjar,
98 '--exclude', indexv1jar,
99 repo_section, s3url]) != 0:
101 logging.debug('s3cmd sync all files in ' + repo_section + ' to ' + s3url)
102 if subprocess.call(s3cmdargs +
104 '--exclude', indexxml,
105 '--exclude', indexjar,
106 '--exclude', indexv1jar,
107 repo_section, s3url]) != 0:
110 logging.debug('s3cmd sync indexes ' + repo_section + ' to ' + s3url + ' and delete')
111 s3cmdargs.append('--delete-removed')
112 s3cmdargs.append('--delete-after')
113 if options.no_checksum:
114 s3cmdargs.append('--no-check-md5')
116 s3cmdargs.append('--check-md5')
117 if subprocess.call(s3cmdargs + [repo_section, s3url]) != 0:
121 def update_awsbucket_libcloud(repo_section):
123 Upload the contents of the directory `repo_section` (including
124 subdirectories) to the AWS S3 "bucket". The contents of that subdir of the
125 bucket will first be deleted.
127 Requires AWS credentials set in config.py: awsaccesskeyid, awssecretkey
130 logging.debug('using Apache libcloud to sync with ' + config['awsbucket'])
132 import libcloud.security
133 libcloud.security.VERIFY_SSL_CERT = True
134 from libcloud.storage.types import Provider, ContainerDoesNotExistError
135 from libcloud.storage.providers import get_driver
137 if not config.get('awsaccesskeyid') or not config.get('awssecretkey'):
138 logging.error('To use awsbucket, you must set awssecretkey and awsaccesskeyid in config.py!')
140 awsbucket = config['awsbucket']
142 cls = get_driver(Provider.S3)
143 driver = cls(config['awsaccesskeyid'], config['awssecretkey'])
145 container = driver.get_container(container_name=awsbucket)
146 except ContainerDoesNotExistError:
147 container = driver.create_container(container_name=awsbucket)
148 logging.info('Created new container "' + container.name + '"')
150 upload_dir = 'fdroid/' + repo_section
152 for obj in container.list_objects():
153 if obj.name.startswith(upload_dir + '/'):
156 for root, _, files in os.walk(os.path.join(os.getcwd(), repo_section)):
159 file_to_upload = os.path.join(root, name)
160 object_name = 'fdroid/' + os.path.relpath(file_to_upload, os.getcwd())
161 if object_name not in objs:
164 obj = objs.pop(object_name)
165 if obj.size != os.path.getsize(file_to_upload):
168 # if the sizes match, then compare by MD5
170 with open(file_to_upload, 'rb') as f:
176 if obj.hash != md5.hexdigest():
177 s3url = 's3://' + awsbucket + '/' + obj.name
178 logging.info(' deleting ' + s3url)
179 if not driver.delete_object(obj):
180 logging.warn('Could not delete ' + s3url)
184 logging.debug(' uploading "' + file_to_upload + '"...')
185 extra = {'acl': 'public-read'}
186 if file_to_upload.endswith('.sig'):
187 extra['content_type'] = 'application/pgp-signature'
188 elif file_to_upload.endswith('.asc'):
189 extra['content_type'] = 'application/pgp-signature'
190 logging.info(' uploading ' + os.path.relpath(file_to_upload)
191 + ' to s3://' + awsbucket + '/' + object_name)
192 with open(file_to_upload, 'rb') as iterator:
193 obj = driver.upload_object_via_stream(iterator=iterator,
195 object_name=object_name,
197 # delete the remnants in the bucket, they do not exist locally
199 object_name, obj = objs.popitem()
200 s3url = 's3://' + awsbucket + '/' + object_name
201 if object_name.startswith(upload_dir):
202 logging.warn(' deleting ' + s3url)
203 driver.delete_object(obj)
205 logging.info(' skipping ' + s3url)
208 def update_serverwebroot(serverwebroot, repo_section):
209 # use a checksum comparison for accurate comparisons on different
210 # filesystems, for example, FAT has a low resolution timestamp
211 rsyncargs = ['rsync', '--archive', '--delete-after', '--safe-links']
212 if not options.no_checksum:
213 rsyncargs.append('--checksum')
215 rsyncargs += ['--verbose']
217 rsyncargs += ['--quiet']
218 if options.identity_file is not None:
219 rsyncargs += ['-e', 'ssh -oBatchMode=yes -oIdentitiesOnly=yes -i ' + options.identity_file]
220 elif 'identity_file' in config:
221 rsyncargs += ['-e', 'ssh -oBatchMode=yes -oIdentitiesOnly=yes -i ' + config['identity_file']]
222 indexxml = os.path.join(repo_section, 'index.xml')
223 indexjar = os.path.join(repo_section, 'index.jar')
224 indexv1jar = os.path.join(repo_section, 'index-v1.jar')
225 # Upload the first time without the index files and delay the deletion as
226 # much as possible, that keeps the repo functional while this update is
227 # running. Then once it is complete, rerun the command again to upload
228 # the index files. Always using the same target with rsync allows for
229 # very strict settings on the receiving server, you can literally specify
230 # the one rsync command that is allowed to run in ~/.ssh/authorized_keys.
231 # (serverwebroot is guaranteed to have a trailing slash in common.py)
232 logging.info('rsyncing ' + repo_section + ' to ' + serverwebroot)
233 if subprocess.call(rsyncargs +
234 ['--exclude', indexxml, '--exclude', indexjar,
235 '--exclude', indexv1jar,
236 repo_section, serverwebroot]) != 0:
238 if subprocess.call(rsyncargs + [repo_section, serverwebroot]) != 0:
240 # upload "current version" symlinks if requested
241 if config['make_current_version_link'] and repo_section == 'repo':
243 for f in glob.glob('*.apk') \
244 + glob.glob('*.apk.asc') + glob.glob('*.apk.sig'):
245 if os.path.islink(f):
246 links_to_upload.append(f)
247 if len(links_to_upload) > 0:
248 if subprocess.call(rsyncargs + links_to_upload + [serverwebroot]) != 0:
252 def _local_sync(fromdir, todir):
253 rsyncargs = ['rsync', '--recursive', '--safe-links', '--times', '--perms',
254 '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
255 # use stricter rsync checking on all files since people using offline mode
256 # are already prioritizing security above ease and speed
257 if not options.no_checksum:
258 rsyncargs.append('--checksum')
260 rsyncargs += ['--verbose']
262 rsyncargs += ['--quiet']
263 logging.debug(' '.join(rsyncargs + [fromdir, todir]))
264 if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
268 def sync_from_localcopy(repo_section, local_copy_dir):
269 '''Syncs the repo from "local copy dir" filesystem to this box
271 In setups that use offline signing, this is the last step that
272 syncs the repo from the "local copy dir" e.g. a thumb drive to the
273 repo on the local filesystem. That local repo is then used to
274 push to all the servers that are configured.
277 logging.info('Syncing from local_copy_dir to this repo.')
278 # trailing slashes have a meaning in rsync which is not needed here, so
279 # make sure both paths have exactly one trailing slash
280 _local_sync(os.path.join(local_copy_dir, repo_section).rstrip('/') + '/',
281 repo_section.rstrip('/') + '/')
283 offline_copy = os.path.join(local_copy_dir, BINARY_TRANSPARENCY_DIR)
284 if os.path.exists(os.path.join(offline_copy, '.git')):
285 online_copy = os.path.join(os.getcwd(), BINARY_TRANSPARENCY_DIR)
286 push_binary_transparency(offline_copy, online_copy)
289 def update_localcopy(repo_section, local_copy_dir):
290 '''copy data from offline to the "local copy dir" filesystem
292 This updates the copy of this repo used to shuttle data from an
293 offline signing machine to the online machine, e.g. on a thumb
297 # local_copy_dir is guaranteed to have a trailing slash in main() below
298 _local_sync(repo_section, local_copy_dir)
300 offline_copy = os.path.join(os.getcwd(), BINARY_TRANSPARENCY_DIR)
301 if os.path.isdir(os.path.join(offline_copy, '.git')):
302 online_copy = os.path.join(local_copy_dir, BINARY_TRANSPARENCY_DIR)
303 push_binary_transparency(offline_copy, online_copy)
306 def update_servergitmirrors(servergitmirrors, repo_section):
307 '''update repo mirrors stored in git repos
309 This is a hack to use public git repos as F-Droid repos. It
310 recreates the git repo from scratch each time, so that there is no
311 history. That keeps the size of the git repo small. Services
312 like GitHub or GitLab have a size limit of something like 1 gig.
313 This git repo is only a git repo for the purpose of being hosted.
314 For history, there is the archive section, and there is the binary
319 from clint.textui import progress
320 if config.get('local_copy_dir') \
321 and not config.get('sync_from_local_copy_dir'):
322 logging.debug('Offline machine, skipping git mirror generation until `fdroid server update`')
325 # right now we support only 'repo' git-mirroring
326 if repo_section == 'repo':
327 git_mirror_path = 'git-mirror'
328 dotgit = os.path.join(git_mirror_path, '.git')
329 if not os.path.isdir(git_mirror_path):
330 os.mkdir(git_mirror_path)
331 elif os.path.isdir(dotgit):
332 shutil.rmtree(dotgit)
334 fdroid_repo_path = os.path.join(git_mirror_path, "fdroid")
335 _local_sync(repo_section, fdroid_repo_path)
337 # use custom SSH command if identity_file specified
338 ssh_cmd = 'ssh -oBatchMode=yes'
339 if options.identity_file is not None:
340 ssh_cmd += ' -oIdentitiesOnly=yes -i "%s"' % options.identity_file
341 elif 'identity_file' in config:
342 ssh_cmd += ' -oIdentitiesOnly=yes -i "%s"' % config['identity_file']
344 repo = git.Repo.init(git_mirror_path)
346 for mirror in servergitmirrors:
347 hostname = re.sub(r'\W*\w+\W+(\w+).*', r'\1', mirror)
348 repo.create_remote(hostname, mirror)
349 logging.info('Mirroring to: ' + mirror)
351 # sadly index.add don't allow the --all parameter
352 logging.debug('Adding all files to git mirror')
353 repo.git.add(all=True)
354 logging.debug('Committing all files into git mirror')
355 repo.index.commit("fdroidserver git-mirror")
360 class MyProgressPrinter(git.RemoteProgress):
361 def update(self, op_code, current, maximum=None, message=None):
362 if isinstance(maximum, float):
363 bar.show(current, maximum)
364 progress = MyProgressPrinter()
367 # push for every remote. This will overwrite the git history
368 for remote in repo.remotes:
369 logging.debug('Pushing to ' + remote.url)
370 with repo.git.custom_environment(GIT_SSH_COMMAND=ssh_cmd):
371 remote.push('master', force=True, set_upstream=True, progress=progress)
376 def upload_to_android_observatory(repo_section):
377 # depend on requests and lxml only if users enable AO
379 from lxml.html import fromstring
381 if repo_section == 'repo':
382 for f in glob.glob(os.path.join(repo_section, '*.apk')):
384 fname = os.path.basename(f)
385 logging.info('Uploading ' + fname + ' to androidobservatory.org')
387 # upload the file with a post request
388 r = requests.post('https://androidobservatory.org/upload', files={'apk': (fname, open(fpath, 'rb'))})
392 # from now on XPath will be used to retrieve the message in the HTML
393 # androidobservatory doesn't have a nice API to talk with
394 # so we must scrape the page content
395 tree = fromstring(response)
396 alert = tree.xpath("//html/body/div[@class='container content-container']/div[@class='alert alert-info']")[0]
401 # if the application was added successfully we retrive the url
402 # if the application was already uploaded we use the redirect page url
403 if el.attrib.get("href") is not None:
404 appurl = page + el.attrib["href"][1:]
405 message += el.text.replace(" here", "") + el.tail
408 message = message.strip() + " " + appurl
409 logging.info(message)
412 def upload_to_virustotal(repo_section, vt_apikey):
416 logging.getLogger("urllib3").setLevel(logging.WARNING)
417 logging.getLogger("requests").setLevel(logging.WARNING)
419 if repo_section == 'repo':
420 if not os.path.exists('virustotal'):
421 os.mkdir('virustotal')
422 with open(os.path.join(repo_section, 'index-v1.json')) as fp:
423 index = json.load(fp)
424 for packageName, packages in index['packages'].items():
425 for package in packages:
426 outputfilename = os.path.join('virustotal',
427 packageName + '_' + str(package.get('versionCode'))
428 + '_' + package['hash'] + '.json')
429 if os.path.exists(outputfilename):
430 logging.debug(package['apkName'] + ' results are in ' + outputfilename)
432 filename = package['apkName']
433 repofilename = os.path.join(repo_section, filename)
434 logging.info('Checking if ' + repofilename + ' is on virustotal')
437 "User-Agent": "F-Droid"
441 'resource': package['hash'],
443 needs_file_upload = False
445 r = requests.post('https://www.virustotal.com/vtapi/v2/file/report',
446 params=params, headers=headers)
447 if r.status_code == 200:
449 if response['response_code'] == 0:
450 needs_file_upload = True
452 response['filename'] = filename
453 response['packageName'] = packageName
454 response['versionCode'] = package.get('versionCode')
455 response['versionName'] = package.get('versionName')
456 with open(outputfilename, 'w') as fp:
457 json.dump(response, fp, indent=2, sort_keys=True)
459 if response.get('positives') > 0:
460 logging.warning(repofilename + ' has been flagged by virustotal '
461 + str(response['positives']) + ' times:'
462 + '\n\t' + response['permalink'])
464 elif r.status_code == 204:
465 time.sleep(10) # wait for public API rate limiting
467 if needs_file_upload:
468 logging.info('Uploading ' + repofilename + ' to virustotal')
470 'file': (filename, open(repofilename, 'rb'))
472 r = requests.post('https://www.virustotal.com/vtapi/v2/file/scan',
473 params=params, headers=headers, files=files)
476 logging.info(response['verbose_msg'] + " " + response['permalink'])
479 def push_binary_transparency(git_repo_path, git_remote):
480 '''push the binary transparency git repo to the specifed remote.
482 If the remote is a local directory, make sure it exists, and is a
483 git repo. This is used to move this git repo from an offline
484 machine onto a flash drive, then onto the online machine.
486 This is also used in offline signing setups, where it then also
487 creates a "local copy dir" git repo that serves to shuttle the git
488 data from the offline machine to the online machine. In that
489 case, git_remote is a dir on the local file system, e.g. a thumb
495 if os.path.isdir(os.path.dirname(git_remote)) \
496 and not os.path.isdir(os.path.join(git_remote, '.git')):
497 os.makedirs(git_remote, exist_ok=True)
498 repo = git.Repo.init(git_remote)
499 config = repo.config_writer()
500 config.set_value('receive', 'denyCurrentBranch', 'updateInstead')
503 logging.info('Pushing binary transparency log to ' + git_remote)
504 gitrepo = git.Repo(git_repo_path)
505 origin = git.remote.Remote(gitrepo, 'origin')
506 if origin in gitrepo.remotes:
507 origin = gitrepo.remote('origin')
508 if 'set_url' in dir(origin): # added in GitPython 2.x
509 origin.set_url(git_remote)
511 origin = gitrepo.create_remote('origin', git_remote)
512 origin.push('master')
516 global config, options
518 # Parse command line...
519 parser = ArgumentParser()
520 common.setup_global_opts(parser)
521 parser.add_argument("command", help="command to execute, either 'init' or 'update'")
522 parser.add_argument("-i", "--identity-file", default=None,
523 help="Specify an identity file to provide to SSH for rsyncing")
524 parser.add_argument("--local-copy-dir", default=None,
525 help="Specify a local folder to sync the repo to")
526 parser.add_argument("--no-checksum", action="store_true", default=False,
527 help="Don't use rsync checksums")
528 options = parser.parse_args()
530 config = common.read_config(options)
532 if options.command != 'init' and options.command != 'update':
533 logging.critical("The only commands currently supported are 'init' and 'update'")
536 if config.get('nonstandardwebroot') is True:
537 standardwebroot = False
539 standardwebroot = True
541 for serverwebroot in config.get('serverwebroot', []):
542 # this supports both an ssh host:path and just a path
543 s = serverwebroot.rstrip('/').split(':')
549 logging.error('Malformed serverwebroot line: ' + serverwebroot)
551 repobase = os.path.basename(fdroiddir)
552 if standardwebroot and repobase != 'fdroid':
553 logging.error('serverwebroot path does not end with "fdroid", '
554 + 'perhaps you meant one of these:\n\t'
555 + serverwebroot.rstrip('/') + '/fdroid\n\t'
556 + serverwebroot.rstrip('/').rstrip(repobase) + 'fdroid')
559 if options.local_copy_dir is not None:
560 local_copy_dir = options.local_copy_dir
561 elif config.get('local_copy_dir'):
562 local_copy_dir = config['local_copy_dir']
564 local_copy_dir = None
565 if local_copy_dir is not None:
566 fdroiddir = local_copy_dir.rstrip('/')
567 if os.path.exists(fdroiddir) and not os.path.isdir(fdroiddir):
568 logging.error('local_copy_dir must be directory, not a file!')
570 if not os.path.exists(os.path.dirname(fdroiddir)):
571 logging.error('The root dir for local_copy_dir "'
572 + os.path.dirname(fdroiddir)
573 + '" does not exist!')
575 if not os.path.isabs(fdroiddir):
576 logging.error('local_copy_dir must be an absolute path!')
578 repobase = os.path.basename(fdroiddir)
579 if standardwebroot and repobase != 'fdroid':
580 logging.error('local_copy_dir does not end with "fdroid", '
581 + 'perhaps you meant: ' + fdroiddir + '/fdroid')
583 if local_copy_dir[-1] != '/':
584 local_copy_dir += '/'
585 local_copy_dir = local_copy_dir.replace('//', '/')
586 if not os.path.exists(fdroiddir):
589 if not config.get('awsbucket') \
590 and not config.get('serverwebroot') \
591 and not config.get('servergitmirrors') \
592 and not config.get('androidobservatory') \
593 and not config.get('binary_transparency_remote') \
594 and not config.get('virustotal_apikey') \
595 and local_copy_dir is None:
596 logging.warn('No option set! Edit your config.py to set at least one among:\n'
597 + 'serverwebroot, servergitmirrors, local_copy_dir, awsbucket, virustotal_apikey, androidobservatory, or binary_transparency_remote')
600 repo_sections = ['repo']
601 if config['archive_older'] != 0:
602 repo_sections.append('archive')
603 if not os.path.exists('archive'):
605 if config['per_app_repos']:
606 repo_sections += common.get_per_app_repos()
608 if options.command == 'init':
609 ssh = paramiko.SSHClient()
610 ssh.load_system_host_keys()
611 for serverwebroot in config.get('serverwebroot', []):
612 sshstr, remotepath = serverwebroot.rstrip('/').split(':')
613 if sshstr.find('@') >= 0:
614 username, hostname = sshstr.split('@')
616 username = pwd.getpwuid(os.getuid())[0] # get effective uid
618 ssh.connect(hostname, username=username)
619 sftp = ssh.open_sftp()
620 if os.path.basename(remotepath) \
621 not in sftp.listdir(os.path.dirname(remotepath)):
622 sftp.mkdir(remotepath, mode=0o755)
623 for repo_section in repo_sections:
624 repo_path = os.path.join(remotepath, repo_section)
625 if os.path.basename(repo_path) \
626 not in sftp.listdir(remotepath):
627 sftp.mkdir(repo_path, mode=0o755)
630 elif options.command == 'update':
631 for repo_section in repo_sections:
632 if local_copy_dir is not None:
633 if config['sync_from_local_copy_dir']:
634 sync_from_localcopy(repo_section, local_copy_dir)
636 update_localcopy(repo_section, local_copy_dir)
637 for serverwebroot in config.get('serverwebroot', []):
638 update_serverwebroot(serverwebroot, repo_section)
639 if config.get('servergitmirrors', []):
640 # update_servergitmirrors will take care of multiple mirrors so don't need a foreach
641 servergitmirrors = config.get('servergitmirrors', [])
642 update_servergitmirrors(servergitmirrors, repo_section)
643 if config.get('awsbucket'):
644 update_awsbucket(repo_section)
645 if config.get('androidobservatory'):
646 upload_to_android_observatory(repo_section)
647 if config.get('virustotal_apikey'):
648 upload_to_virustotal(repo_section, config.get('virustotal_apikey'))
650 binary_transparency_remote = config.get('binary_transparency_remote')
651 if binary_transparency_remote:
652 push_binary_transparency(BINARY_TRANSPARENCY_DIR,
653 binary_transparency_remote)
658 if __name__ == "__main__":