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