chiark / gitweb /
nightly: prompt user to create a debug.keystore if its not there
[fdroidserver.git] / fdroidserver / nightly.py
1 #!/usr/bin/env python3
2 #
3 # nightly.py - part of the FDroid server tools
4 # Copyright (C) 2017 Hans-Christoph Steiner <hans@eds.org>
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 base64
20 import datetime
21 import git
22 import hashlib
23 import logging
24 import os
25 import paramiko
26 import platform
27 import shutil
28 import subprocess
29 import sys
30 import tempfile
31 import yaml
32 from urllib.parse import urlparse
33 from argparse import ArgumentParser
34
35 from . import _
36 from . import common
37
38
39 # hard coded defaults for Android ~/.android/debug.keystore files
40 # https://developers.google.com/android/guides/client-auth
41 KEYSTORE_FILE = os.path.join(os.getenv('HOME'), '.android', 'debug.keystore')
42 PASSWORD = 'android'
43 KEY_ALIAS = 'androiddebugkey'
44 DISTINGUISHED_NAME = 'CN=Android Debug,O=Android,C=US'
45
46 # standard suffix for naming fdroid git repos
47 NIGHTLY = '-nightly'
48
49
50 def _ssh_key_from_debug_keystore():
51     tmp_dir = tempfile.mkdtemp(prefix='.')
52     privkey = os.path.join(tmp_dir, '.privkey')
53     key_pem = os.path.join(tmp_dir, '.key.pem')
54     p12 = os.path.join(tmp_dir, '.keystore.p12')
55     _config = dict()
56     common.fill_config_defaults(_config)
57     subprocess.check_call([_config['keytool'], '-importkeystore',
58                            '-srckeystore', KEYSTORE_FILE, '-srcalias', KEY_ALIAS,
59                            '-srcstorepass', PASSWORD, '-srckeypass', PASSWORD,
60                            '-destkeystore', p12, '-destalias', KEY_ALIAS,
61                            '-deststorepass', PASSWORD, '-destkeypass', PASSWORD,
62                            '-deststoretype', 'PKCS12'])
63     subprocess.check_call(['openssl', 'pkcs12', '-in', p12, '-out', key_pem,
64                            '-passin', 'pass:' + PASSWORD, '-passout', 'pass:' + PASSWORD])
65     subprocess.check_call(['openssl', 'rsa', '-in', key_pem, '-out', privkey,
66                            '-passin', 'pass:' + PASSWORD])
67     os.remove(key_pem)
68     os.remove(p12)
69     os.chmod(privkey, 0o600)  # os.umask() should cover this, but just in case
70
71     rsakey = paramiko.RSAKey.from_private_key_file(privkey)
72     fingerprint = base64.b64encode(hashlib.sha256(rsakey.asbytes()).digest()).decode('ascii').rstrip('=')
73     ssh_private_key_file = os.path.join(tmp_dir, 'debug_keystore_' + fingerprint + '_id_rsa')
74     shutil.move(privkey, ssh_private_key_file)
75
76     pub = rsakey.get_name() + ' ' + rsakey.get_base64() + ' ' + ssh_private_key_file
77     with open(ssh_private_key_file + '.pub', 'w') as fp:
78         fp.write(pub)
79
80     logging.info(_('\nSSH Public Key to be used as Deploy Key:') + '\n' + pub)
81
82     return ssh_private_key_file
83
84
85 def main():
86
87     parser = ArgumentParser(usage="%(prog)s")
88     common.setup_global_opts(parser)
89     parser.add_argument("--show-secret-var", action="store_true", default=False,
90                         help=_("Print the secret variable to the terminal for easy copy/paste"))
91     parser.add_argument("--file", default='app/build/outputs/apk/*.apk',
92                         help=_('The the file to be included in the repo (path or glob)'))
93     parser.add_argument("--no-checksum", action="store_true", default=False,
94                         help=_("Don't use rsync checksums"))
95     # TODO add --with-btlog
96     options = parser.parse_args()
97
98     # force a tighter umask since this writes private key material
99     umask = os.umask(0o077)
100
101     if 'CI' in os.environ:
102         v = os.getenv('DEBUG_KEYSTORE')
103         debug_keystore = None
104         if v:
105             debug_keystore = base64.b64decode(v)
106         if not debug_keystore:
107             logging.error(_('DEBUG_KEYSTORE is not set or the value is incomplete'))
108             sys.exit(1)
109         os.makedirs(os.path.dirname(KEYSTORE_FILE), exist_ok=True)
110         if os.path.exists(KEYSTORE_FILE):
111             logging.warning(_('overwriting existing {path}').format(path=KEYSTORE_FILE))
112         with open(KEYSTORE_FILE, 'wb') as fp:
113             fp.write(debug_keystore)
114
115         repo_basedir = os.path.join(os.getcwd(), 'fdroid')
116         repodir = os.path.join(repo_basedir, 'repo')
117         cibase = os.getcwd()
118         os.makedirs(repodir, exist_ok=True)
119
120         if 'CI_PROJECT_PATH' in os.environ and 'CI_PROJECT_URL' in os.environ:
121             # we are in GitLab CI
122             repo_git_base = os.getenv('CI_PROJECT_PATH') + NIGHTLY
123             clone_url = os.getenv('CI_PROJECT_URL') + NIGHTLY
124             repo_base = clone_url + '/raw/master/fdroid'
125             servergitmirror = 'git@' + urlparse(clone_url).netloc + ':' + repo_git_base
126             deploy_key_url = clone_url + '/settings/repository'
127             git_user_name = os.getenv('GITLAB_USER_NAME')
128             git_user_email = os.getenv('GITLAB_USER_EMAIL')
129         elif 'TRAVIS_REPO_SLUG' in os.environ:
130             # we are in Travis CI
131             repo_git_base = os.getenv('TRAVIS_REPO_SLUG') + NIGHTLY
132             clone_url = 'https://github.com/' + repo_git_base
133             _branch = os.getenv('TRAVIS_BRANCH')
134             repo_base = 'https://raw.githubusercontent.com/' + repo_git_base + '/' + _branch + '/fdroid'
135             servergitmirror = 'git@github.com:' + repo_git_base
136             deploy_key_url = ('https://github.com/' + repo_git_base + '/settings/keys'
137                               + '\nhttps://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys')
138             git_user_name = repo_git_base
139             git_user_email = os.getenv('USER') + '@' + platform.node()
140         elif 'CIRCLE_REPOSITORY_URL' in os.environ \
141              and 'CIRCLE_PROJECT_USERNAME' in os.environ \
142              and 'CIRCLE_PROJECT_REPONAME' in os.environ:
143             # we are in Circle CI
144             repo_git_base = (os.getenv('CIRCLE_PROJECT_USERNAME')
145                              + '/' + os.getenv('CIRCLE_PROJECT_REPONAME') + NIGHTLY)
146             clone_url = os.getenv('CIRCLE_REPOSITORY_URL') + NIGHTLY
147             repo_base = clone_url + '/raw/master/fdroid'
148             servergitmirror = 'git@' + urlparse(clone_url).netloc + ':' + repo_git_base
149             deploy_key_url = ('https://github.com/' + repo_git_base + '/settings/keys'
150                               + '\nhttps://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys')
151             git_user_name = os.getenv('CIRCLE_USERNAME')
152             git_user_email = git_user_name + '@' + platform.node()
153         else:
154             print(_('ERROR: unsupported CI type, patches welcome!'))
155             sys.exit(1)
156
157         repo_url = repo_base + '/repo'
158         git_mirror_path = os.path.join(repo_basedir, 'git-mirror')
159         git_mirror_repodir = os.path.join(git_mirror_path, 'fdroid', 'repo')
160         git_mirror_metadatadir = os.path.join(git_mirror_path, 'fdroid', 'metadata')
161         if not os.path.isdir(git_mirror_repodir):
162             logging.debug(_('cloning {url}').format(url=clone_url))
163             try:
164                 git.Repo.clone_from(clone_url, git_mirror_path)
165             except Exception:
166                 pass
167         if not os.path.isdir(git_mirror_repodir):
168             os.makedirs(git_mirror_repodir, mode=0o755)
169
170         mirror_git_repo = git.Repo.init(git_mirror_path)
171         writer = mirror_git_repo.config_writer()
172         writer.set_value('user', 'name', git_user_name)
173         writer.set_value('user', 'email', git_user_email)
174         writer.release()
175         for remote in mirror_git_repo.remotes:
176             mirror_git_repo.delete_remote(remote)
177
178         readme_path = os.path.join(git_mirror_path, 'README.md')
179         readme = '''
180 # {repo_git_base}
181
182 [![{repo_url}](icon.png)]({repo_url})
183
184 Last updated: {date}'''.format(repo_git_base=repo_git_base,
185                                repo_url=repo_url,
186                                date=datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'))
187         with open(readme_path, 'w') as fp:
188             fp.write(readme)
189         mirror_git_repo.git.add(all=True)
190         mirror_git_repo.index.commit("update README")
191
192         icon_path = os.path.join(git_mirror_path, 'icon.png')
193         try:
194             import qrcode
195             qrcode.make(repo_url).save(icon_path)
196         except Exception:
197             exampleicon = os.path.join(common.get_examples_dir(), 'fdroid-icon.png')
198             shutil.copy(exampleicon, icon_path)
199         mirror_git_repo.git.add(all=True)
200         mirror_git_repo.index.commit("update repo/website icon")
201         shutil.copy(icon_path, repo_basedir)
202
203         os.chdir(repo_basedir)
204         if os.path.isdir(git_mirror_repodir):
205             common.local_rsync(options, git_mirror_repodir + '/', 'repo/')
206         if os.path.isdir(git_mirror_metadatadir):
207             common.local_rsync(options, git_mirror_metadatadir + '/', 'metadata/')
208
209         ssh_private_key_file = _ssh_key_from_debug_keystore()
210         # this is needed for GitPython to find the SSH key
211         ssh_dir = os.path.join(os.getenv('HOME'), '.ssh')
212         os.makedirs(ssh_dir, exist_ok=True)
213         ssh_config = os.path.join(ssh_dir, 'config')
214         logging.debug(_('adding IdentityFile to {path}').format(path=ssh_config))
215         with open(ssh_config, 'a') as fp:
216             fp.write('\n\nHost *\n\tIdentityFile %s\n' % ssh_private_key_file)
217
218         config = ''
219         config += "identity_file = '%s'\n" % ssh_private_key_file
220         config += "repo_name = '%s'\n" % repo_git_base
221         config += "repo_url = '%s'\n" % repo_url
222         config += "repo_icon = 'icon.png'\n"
223         config += "archive_name = '%s'\n" % (repo_git_base + ' archive')
224         config += "archive_url = '%s'\n" % (repo_base + '/archive')
225         config += "archive_icon = 'icon.png'\n"
226         config += "servergitmirrors = '%s'\n" % servergitmirror
227         config += "keystore = '%s'\n" % KEYSTORE_FILE
228         config += "repo_keyalias = '%s'\n" % KEY_ALIAS
229         config += "keystorepass = '%s'\n" % PASSWORD
230         config += "keypass = '%s'\n" % PASSWORD
231         config += "keydname = '%s'\n" % DISTINGUISHED_NAME
232         config += "make_current_version_link = False\n"
233         config += "accepted_formats = ('txt', 'yml')\n"
234         # TODO add update_stats = True
235         with open('config.py', 'w') as fp:
236             fp.write(config)
237         os.chmod('config.py', 0o600)
238         config = common.read_config(options)
239         common.assert_config_keystore(config)
240
241         for root, dirs, files in os.walk(cibase):
242             for d in ('fdroid', '.git', '.gradle'):
243                 if d in dirs:
244                     dirs.remove(d)
245             for f in files:
246                 if f.endswith('-debug.apk'):
247                     apkfilename = os.path.join(root, f)
248                     logging.debug(_('Striping mystery signature from {apkfilename}')
249                                   .format(apkfilename=apkfilename))
250                     destapk = os.path.join(repodir, os.path.basename(f))
251                     os.chmod(apkfilename, 0o644)
252                     logging.debug(_('Resigning {apkfilename} with provided debug.keystore')
253                                   .format(apkfilename=os.path.basename(apkfilename)))
254                     common.apk_strip_signatures(apkfilename, strip_manifest=True)
255                     common.sign_apk(apkfilename, destapk, KEY_ALIAS)
256
257         if options.verbose:
258             logging.debug(_('attempting bare ssh connection to test deploy key:'))
259             try:
260                 subprocess.check_call(['ssh', '-Tvi', ssh_private_key_file,
261                                        '-oIdentitiesOnly=yes', '-oStrictHostKeyChecking=no',
262                                        servergitmirror.split(':')[0]])
263             except subprocess.CalledProcessError:
264                 pass
265
266         app_url = clone_url[:-len(NIGHTLY)]
267         template = dict()
268         template['AuthorName'] = clone_url.split('/')[4]
269         template['AuthorWebSite'] = '/'.join(clone_url.split('/')[:4])
270         template['Categories'] = ['nightly']
271         template['SourceCode'] = app_url
272         template['IssueTracker'] = app_url + '/issues'
273         template['Summary'] = 'Nightly build of ' + urlparse(app_url).path[1:]
274         template['Description'] = template['Summary']
275         with open('template.yml', 'w') as fp:
276             yaml.dump(template, fp)
277
278         subprocess.check_call(['fdroid', 'update', '--rename-apks', '--create-metadata', '--verbose'],
279                               cwd=repo_basedir)
280         common.local_rsync(options, repo_basedir + '/metadata/', git_mirror_metadatadir + '/')
281         mirror_git_repo.git.add(all=True)
282         mirror_git_repo.index.commit("update app metadata")
283         try:
284             subprocess.check_call(['fdroid', 'server', 'update', '--verbose'], cwd=repo_basedir)
285         except subprocess.CalledProcessError:
286             logging.error(_('cannot publish update, did you set the deploy key?')
287                           + '\n' + deploy_key_url)
288             sys.exit(1)
289         if shutil.rmtree.avoids_symlink_attacks:
290             shutil.rmtree(os.path.dirname(ssh_private_key_file))
291
292     else:
293         if not os.path.isfile(KEYSTORE_FILE):
294             androiddir = os.path.dirname(KEYSTORE_FILE)
295             if not os.path.exists(androiddir):
296                 os.mkdir(androiddir)
297                 logging.info(_('created {path}').format(path=androiddir))
298             logging.error(_('{path} does not exist!  Create it by running:').format(path=KEYSTORE_FILE)
299                           + '\n    keytool -genkey -v -keystore ' + KEYSTORE_FILE + ' -storepass android \\'
300                           + '\n     -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 \\'
301                           + '\n     -dname "CN=Android Debug,O=Android,C=US"')
302             sys.exit(1)
303         ssh_dir = os.path.join(os.getenv('HOME'), '.ssh')
304         os.makedirs(os.path.dirname(ssh_dir), exist_ok=True)
305         privkey = _ssh_key_from_debug_keystore()
306         ssh_private_key_file = os.path.join(ssh_dir, os.path.basename(privkey))
307         shutil.move(privkey, ssh_private_key_file)
308         shutil.move(privkey + '.pub', ssh_private_key_file + '.pub')
309         if shutil.rmtree.avoids_symlink_attacks:
310             shutil.rmtree(os.path.dirname(privkey))
311
312         if options.show_secret_var:
313             with open(KEYSTORE_FILE, 'rb') as fp:
314                 debug_keystore = base64.standard_b64encode(fp.read()).decode('ascii')
315             print(_('\n{path} encoded for the DEBUG_KEYSTORE secret variable:')
316                   .format(path=KEYSTORE_FILE))
317             print(debug_keystore)
318
319     os.umask(umask)
320
321
322 if __name__ == "__main__":
323     main()