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/>.
27 from argparse import ArgumentParser
36 BINARY_TRANSPARENCY_DIR = 'binary_transparency'
39 def update_awsbucket(repo_section):
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.
45 Requires AWS credentials set in config.py: awsaccesskeyid, awssecretkey
48 logging.debug('Syncing "' + repo_section + '" to Amazon S3 bucket "'
49 + config['awsbucket'] + '"')
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
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!')
59 awsbucket = config['awsbucket']
61 cls = get_driver(Provider.S3)
62 driver = cls(config['awsaccesskeyid'], config['awssecretkey'])
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 + '"')
69 upload_dir = 'fdroid/' + repo_section
71 for obj in container.list_objects():
72 if obj.name.startswith(upload_dir + '/'):
75 for root, _, files in os.walk(os.path.join(os.getcwd(), repo_section)):
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:
83 obj = objs.pop(object_name)
84 if obj.size != os.path.getsize(file_to_upload):
87 # if the sizes match, then compare by MD5
89 with open(file_to_upload, 'rb') as f:
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)
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,
114 object_name=object_name,
116 # delete the remnants in the bucket, they do not exist locally
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)
124 logging.info(' skipping ' + s3url)
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')
134 rsyncargs += ['--verbose']
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:
157 if subprocess.call(rsyncargs + [repo_section, serverwebroot]) != 0:
159 # upload "current version" symlinks if requested
160 if config['make_current_version_link'] and repo_section == 'repo':
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:
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')
179 rsyncargs += ['--verbose']
181 rsyncargs += ['--quiet']
182 logging.debug(' '.join(rsyncargs + [fromdir, todir]))
183 if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
187 def sync_from_localcopy(repo_section, local_copy_dir):
188 '''Syncs the repo from "local copy dir" filesystem to this box
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.
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('/') + '/')
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)
208 def update_localcopy(repo_section, local_copy_dir):
209 '''copy data from offline to the "local copy dir" filesystem
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
216 # local_copy_dir is guaranteed to have a trailing slash in main() below
217 _local_sync(repo_section, local_copy_dir)
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)
225 def update_servergitmirrors(servergitmirrors, repo_section):
226 # depend on GitPython only if users set a git mirror
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/')
233 # remove if already present
234 if os.path.isdir(repo_dir):
235 shutil.rmtree(repo_dir)
237 repo = git.Repo.init(repo_dir)
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)
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)
248 # sadly index.add don't allow the --all parameter
249 repo.git.add(all=True)
250 repo.index.commit("fdroidserver git-mirror")
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)
257 def upload_to_android_observatory(repo_section):
258 # depend on requests and lxml only if users enable AO
260 from lxml.html import fromstring
262 if repo_section == 'repo':
263 for f in glob.glob(os.path.join(repo_section, '*.apk')):
265 fname = os.path.basename(f)
266 logging.info('Uploading ' + fname + ' to androidobservatory.org')
268 # upload the file with a post request
269 r = requests.post('https://androidobservatory.org/upload', files={'apk': (fname, open(fpath, 'rb'))})
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]
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
289 message = message.strip() + " " + appurl
290 logging.info(message)
293 def upload_to_virustotal(repo_section, vt_apikey):
296 if repo_section == 'repo':
297 for f in glob.glob(os.path.join(repo_section, '*.apk')):
299 fname = os.path.basename(f)
300 logging.info('Uploading ' + fname + ' to virustotal.com')
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)
308 logging.info(response['verbose_msg'] + " " + response['permalink'])
311 def push_binary_transparency(git_repo_path, git_remote):
312 '''push the binary transparency git repo to the specifed remote.
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.
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
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')
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)
343 origin = gitrepo.create_remote('origin', git_remote)
344 origin.push('master')
348 global config, options
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()
362 config = common.read_config(options)
364 if options.command != 'init' and options.command != 'update':
365 logging.critical("The only commands currently supported are 'init' and 'update'")
368 if config.get('nonstandardwebroot') is True:
369 standardwebroot = False
371 standardwebroot = True
373 for serverwebroot in config.get('serverwebroot', []):
374 # this supports both an ssh host:path and just a path
375 s = serverwebroot.rstrip('/').split(':')
381 logging.error('Malformed serverwebroot line: ' + serverwebroot)
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')
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']
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!')
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!')
407 if not os.path.isabs(fdroiddir):
408 logging.error('local_copy_dir must be an absolute path!')
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')
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):
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')
432 repo_sections = ['repo']
433 if config['archive_older'] != 0:
434 repo_sections.append('archive')
435 if not os.path.exists('archive'):
437 if config['per_app_repos']:
438 repo_sections += common.get_per_app_repos()
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('@')
448 username = pwd.getpwuid(os.getuid())[0] # get effective uid
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)
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)
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'))
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)
490 if __name__ == "__main__":