chiark / gitweb /
Make git server mirror upload honor config['identity_file'] option
[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 import time
28 from argparse import ArgumentParser
29 import logging
30 import shutil
31
32 from . import common
33
34 config = None
35 options = None
36
37 BINARY_TRANSPARENCY_DIR = 'binary_transparency'
38
39
40 def update_awsbucket(repo_section):
41     '''
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.
45
46     Requires AWS credentials set in config.py: awsaccesskeyid, awssecretkey
47     '''
48
49     logging.debug('Syncing "' + repo_section + '" to Amazon S3 bucket "'
50                   + config['awsbucket'] + '"')
51
52     if common.set_command_in_config('s3cmd'):
53         update_awsbucket_s3cmd(repo_section)
54     else:
55         update_awsbucket_libcloud(repo_section)
56
57
58 def update_awsbucket_s3cmd(repo_section):
59     '''upload using the CLI tool s3cmd, which provides rsync-like sync
60
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.
68     '''
69
70     logging.debug('using s3cmd to sync with ' + config['awsbucket'])
71
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'))
77     os.close(fd)
78
79     s3url = 's3://' + config['awsbucket'] + '/fdroid/'
80     s3cmdargs = [
81         's3cmd',
82         'sync',
83         '--config=' + configfilename,
84         '--acl-public',
85     ]
86     if options.verbose:
87         s3cmdargs += ['--verbose']
88     if options.quiet:
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:
100         sys.exit(1)
101     logging.debug('s3cmd sync all files in ' + repo_section + ' to ' + s3url)
102     if subprocess.call(s3cmdargs +
103                        ['--no-check-md5',
104                         '--exclude', indexxml,
105                         '--exclude', indexjar,
106                         '--exclude', indexv1jar,
107                         repo_section, s3url]) != 0:
108         sys.exit(1)
109
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')
115     else:
116         s3cmdargs.append('--check-md5')
117     if subprocess.call(s3cmdargs + [repo_section, s3url]) != 0:
118         sys.exit(1)
119
120
121 def update_awsbucket_libcloud(repo_section):
122     '''
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.
126
127     Requires AWS credentials set in config.py: awsaccesskeyid, awssecretkey
128     '''
129
130     logging.debug('using Apache libcloud to sync with ' + config['awsbucket'])
131
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
136
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!')
139         sys.exit(1)
140     awsbucket = config['awsbucket']
141
142     cls = get_driver(Provider.S3)
143     driver = cls(config['awsaccesskeyid'], config['awssecretkey'])
144     try:
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 + '"')
149
150     upload_dir = 'fdroid/' + repo_section
151     objs = dict()
152     for obj in container.list_objects():
153         if obj.name.startswith(upload_dir + '/'):
154             objs[obj.name] = obj
155
156     for root, _, files in os.walk(os.path.join(os.getcwd(), repo_section)):
157         for name in files:
158             upload = False
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:
162                 upload = True
163             else:
164                 obj = objs.pop(object_name)
165                 if obj.size != os.path.getsize(file_to_upload):
166                     upload = True
167                 else:
168                     # if the sizes match, then compare by MD5
169                     md5 = hashlib.md5()
170                     with open(file_to_upload, 'rb') as f:
171                         while True:
172                             data = f.read(8192)
173                             if not data:
174                                 break
175                             md5.update(data)
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)
181                         upload = True
182
183             if upload:
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,
194                                                           container=container,
195                                                           object_name=object_name,
196                                                           extra=extra)
197     # delete the remnants in the bucket, they do not exist locally
198     while objs:
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)
204         else:
205             logging.info(' skipping ' + s3url)
206
207
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')
214     if options.verbose:
215         rsyncargs += ['--verbose']
216     if options.quiet:
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:
237         sys.exit(1)
238     if subprocess.call(rsyncargs + [repo_section, serverwebroot]) != 0:
239         sys.exit(1)
240     # upload "current version" symlinks if requested
241     if config['make_current_version_link'] and repo_section == 'repo':
242         links_to_upload = []
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:
249                 sys.exit(1)
250
251
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')
259     if options.verbose:
260         rsyncargs += ['--verbose']
261     if options.quiet:
262         rsyncargs += ['--quiet']
263     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
264     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
265         sys.exit(1)
266
267
268 def sync_from_localcopy(repo_section, local_copy_dir):
269     '''Syncs the repo from "local copy dir" filesystem to this box
270
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.
275
276     '''
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('/') + '/')
282
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)
287
288
289 def update_localcopy(repo_section, local_copy_dir):
290     '''copy data from offline to the "local copy dir" filesystem
291
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
294     drive.
295
296     '''
297     # local_copy_dir is guaranteed to have a trailing slash in main() below
298     _local_sync(repo_section, local_copy_dir)
299
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)
304
305
306 def update_servergitmirrors(servergitmirrors, repo_section):
307     '''update repo mirrors stored in git repos
308
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
315     transparency log.
316
317     '''
318     import git
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`')
323         return
324
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)
333
334         fdroid_repo_path = os.path.join(git_mirror_path, "fdroid")
335         _local_sync(repo_section, fdroid_repo_path)
336
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']
343
344         repo = git.Repo.init(git_mirror_path)
345
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)
350
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")
356
357         if options.verbose:
358             bar = progress.Bar()
359
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()
365         else:
366             progress = None
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)
372         if progress:
373             bar.done()
374
375
376 def upload_to_android_observatory(repo_section):
377     # depend on requests and lxml only if users enable AO
378     import requests
379     from lxml.html import fromstring
380
381     if repo_section == 'repo':
382         for f in glob.glob(os.path.join(repo_section, '*.apk')):
383             fpath = f
384             fname = os.path.basename(f)
385             logging.info('Uploading ' + fname + ' to androidobservatory.org')
386
387             # upload the file with a post request
388             r = requests.post('https://androidobservatory.org/upload', files={'apk': (fname, open(fpath, 'rb'))})
389             response = r.text
390             page = r.url
391
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]
397
398             message = ""
399             appurl = page
400             for el in alert:
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
406                 else:
407                     message += el.tail
408             message = message.strip() + " " + appurl
409             logging.info(message)
410
411
412 def upload_to_virustotal(repo_section, vt_apikey):
413     import json
414     import requests
415
416     logging.getLogger("urllib3").setLevel(logging.WARNING)
417     logging.getLogger("requests").setLevel(logging.WARNING)
418
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)
431                     continue
432                 filename = package['apkName']
433                 repofilename = os.path.join(repo_section, filename)
434                 logging.info('Checking if ' + repofilename + ' is on virustotal')
435
436                 headers = {
437                     "User-Agent": "F-Droid"
438                 }
439                 params = {
440                     'apikey': vt_apikey,
441                     'resource': package['hash'],
442                 }
443                 needs_file_upload = False
444                 while True:
445                     r = requests.post('https://www.virustotal.com/vtapi/v2/file/report',
446                                       params=params, headers=headers)
447                     if r.status_code == 200:
448                         response = r.json()
449                         if response['response_code'] == 0:
450                             needs_file_upload = True
451                         else:
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)
458
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'])
463                         break
464                     elif r.status_code == 204:
465                         time.sleep(10)  # wait for public API rate limiting
466
467                 if needs_file_upload:
468                     logging.info('Uploading ' + repofilename + ' to virustotal')
469                     files = {
470                         'file': (filename, open(repofilename, 'rb'))
471                     }
472                     r = requests.post('https://www.virustotal.com/vtapi/v2/file/scan',
473                                       params=params, headers=headers, files=files)
474                     response = r.json()
475
476                     logging.info(response['verbose_msg'] + " " + response['permalink'])
477
478
479 def push_binary_transparency(git_repo_path, git_remote):
480     '''push the binary transparency git repo to the specifed remote.
481
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.
485
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
490     drive.
491
492     '''
493     import git
494
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')
501         config.release()
502
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)
510     else:
511         origin = gitrepo.create_remote('origin', git_remote)
512     origin.push('master')
513
514
515 def main():
516     global config, options
517
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()
529
530     config = common.read_config(options)
531
532     if options.command != 'init' and options.command != 'update':
533         logging.critical("The only commands currently supported are 'init' and 'update'")
534         sys.exit(1)
535
536     if config.get('nonstandardwebroot') is True:
537         standardwebroot = False
538     else:
539         standardwebroot = True
540
541     for serverwebroot in config.get('serverwebroot', []):
542         # this supports both an ssh host:path and just a path
543         s = serverwebroot.rstrip('/').split(':')
544         if len(s) == 1:
545             fdroiddir = s[0]
546         elif len(s) == 2:
547             host, fdroiddir = s
548         else:
549             logging.error('Malformed serverwebroot line: ' + serverwebroot)
550             sys.exit(1)
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')
557             sys.exit(1)
558
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']
563     else:
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!')
569             sys.exit(1)
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!')
574             sys.exit(1)
575         if not os.path.isabs(fdroiddir):
576             logging.error('local_copy_dir must be an absolute path!')
577             sys.exit(1)
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')
582             sys.exit(1)
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):
587             os.mkdir(fdroiddir)
588
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')
598         sys.exit(1)
599
600     repo_sections = ['repo']
601     if config['archive_older'] != 0:
602         repo_sections.append('archive')
603         if not os.path.exists('archive'):
604             os.mkdir('archive')
605     if config['per_app_repos']:
606         repo_sections += common.get_per_app_repos()
607
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('@')
615             else:
616                 username = pwd.getpwuid(os.getuid())[0]  # get effective uid
617                 hostname = sshstr
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)
628             sftp.close()
629             ssh.close()
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)
635                 else:
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'))
649
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)
654
655     sys.exit(0)
656
657
658 if __name__ == "__main__":
659     main()