chiark / gitweb /
nightly: resign APKs with provided debug.keystore
[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 from urllib.parse import urlparse
32 from argparse import ArgumentParser
33
34 from . import _
35 from . import common
36
37
38 # hard coded defaults for Android ~/.android/debug.keystore files
39 # https://developers.google.com/android/guides/client-auth
40 KEYSTORE_FILE = os.path.join(os.getenv('HOME'), '.android', 'debug.keystore')
41 PASSWORD = 'android'
42 KEY_ALIAS = 'androiddebugkey'
43 DISTINGUISHED_NAME = 'CN=Android Debug,O=Android,C=US'
44
45 # standard suffix for naming fdroid git repos
46 NIGHTLY = '-nightly'
47
48
49 def _ssh_key_from_debug_keystore():
50     tmp_dir = tempfile.mkdtemp(prefix='.')
51     privkey = os.path.join(tmp_dir, '.privkey')
52     key_pem = os.path.join(tmp_dir, '.key.pem')
53     p12 = os.path.join(tmp_dir, '.keystore.p12')
54     subprocess.check_call([common.config['keytool'], '-importkeystore',
55                            '-srckeystore', KEYSTORE_FILE, '-srcalias', KEY_ALIAS,
56                            '-srcstorepass', PASSWORD, '-srckeypass', PASSWORD,
57                            '-destkeystore', p12, '-destalias', KEY_ALIAS,
58                            '-deststorepass', PASSWORD, '-destkeypass', PASSWORD,
59                            '-deststoretype', 'PKCS12'])
60     subprocess.check_call(['openssl', 'pkcs12', '-in', p12, '-out', key_pem,
61                            '-passin', 'pass:' + PASSWORD, '-passout', 'pass:' + PASSWORD])
62     subprocess.check_call(['openssl', 'rsa', '-in', key_pem, '-out', privkey,
63                            '-passin', 'pass:' + PASSWORD])
64     os.remove(key_pem)
65     os.remove(p12)
66     os.chmod(privkey, 0o600)  # os.umask() should cover this, but just in case
67
68     rsakey = paramiko.RSAKey.from_private_key_file(privkey)
69     fingerprint = base64.b64encode(hashlib.sha256(rsakey.asbytes()).digest()).decode('ascii').rstrip('=')
70     ssh_private_key_file = os.path.join(tmp_dir, 'debug_keystore_' + fingerprint + '_id_rsa')
71     os.rename(privkey, ssh_private_key_file)
72
73     pub = rsakey.get_name() + ' ' + rsakey.get_base64() + ' ' + ssh_private_key_file
74     with open(ssh_private_key_file + '.pub', 'w') as fp:
75         fp.write(pub)
76
77     logging.info(_('\nSSH Public Key to be used as Deploy Key:') + '\n' + pub)
78
79     return ssh_private_key_file
80
81
82 def main():
83
84     parser = ArgumentParser(usage="%(prog)s")
85     common.setup_global_opts(parser)
86     parser.add_argument("--show-secret-var", action="store_true", default=False,
87                         help=_("Print the secret variable to the terminal for easy copy/paste"))
88     parser.add_argument("--file", default='app/build/outputs/apk/*.apk',
89                         help=_('The the file to be included in the repo (path or glob)'))
90     parser.add_argument("--no-checksum", action="store_true", default=False,
91                         help=_("Don't use rsync checksums"))
92     # TODO add --with-btlog
93     options = parser.parse_args()
94     common.read_config(None)
95
96     # force a tighter umask since this writes private key material
97     umask = os.umask(0o077)
98
99     if 'CI' in os.environ:
100         v = os.getenv('DEBUG_KEYSTORE')
101         debug_keystore = None
102         if v:
103             debug_keystore = base64.b64decode(v)
104         if not debug_keystore:
105             logging.error(_('DEBUG_KEYSTORE is not set or the value is incomplete'))
106             sys.exit(1)
107         os.makedirs(os.path.dirname(KEYSTORE_FILE), exist_ok=True)
108         if os.path.exists(KEYSTORE_FILE):
109             logging.warning(_('overwriting existing {path}').format(path=KEYSTORE_FILE))
110         with open(KEYSTORE_FILE, 'wb') as fp:
111             fp.write(debug_keystore)
112
113         repo_basedir = os.path.join(os.getcwd(), 'fdroid')
114         repodir = os.path.join(repo_basedir, 'repo')
115         cibase = os.getcwd()
116         os.makedirs(repodir, exist_ok=True)
117
118         if 'CI_PROJECT_PATH' in os.environ and 'CI_PROJECT_URL' in os.environ:
119             # we are in GitLab CI
120             repo_git_base = os.getenv('CI_PROJECT_PATH') + NIGHTLY
121             clone_url = os.getenv('CI_PROJECT_URL') + NIGHTLY
122             repo_base = clone_url + '/raw/master/fdroid'
123             servergitmirror = 'git@' + urlparse(clone_url).netloc + ':' + repo_git_base
124             deploy_key_url = clone_url + '/settings/repository'
125             git_user_name = os.getenv('GITLAB_USER_NAME')
126             git_user_email = os.getenv('GITLAB_USER_EMAIL')
127         elif 'TRAVIS_REPO_SLUG' in os.environ:
128             # we are in Travis CI
129             repo_git_base = os.getenv('TRAVIS_REPO_SLUG') + NIGHTLY
130             clone_url = 'https://github.com/' + repo_git_base
131             _branch = os.getenv('TRAVIS_BRANCH')
132             repo_base = 'https://raw.githubusercontent.com/' + repo_git_base + '/' + _branch + '/fdroid'
133             servergitmirror = 'git@github.com:' + repo_git_base
134             deploy_key_url = ('https://github.com/' + repo_git_base + '/settings/keys'
135                               + '\nhttps://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys')
136             git_user_name = repo_git_base
137             git_user_email = os.getenv('USER') + '@' + platform.node()
138         elif 'CIRCLE_REPOSITORY_URL' in os.environ \
139              and 'CIRCLE_PROJECT_USERNAME' in os.environ \
140              and 'CIRCLE_PROJECT_REPONAME' in os.environ:
141             # we are in Circle CI
142             repo_git_base = (os.getenv('CIRCLE_PROJECT_USERNAME')
143                              + '/' + os.getenv('CIRCLE_PROJECT_REPONAME') + NIGHTLY)
144             clone_url = os.getenv('CIRCLE_REPOSITORY_URL') + NIGHTLY
145             repo_base = clone_url + '/raw/master/fdroid'
146             servergitmirror = 'git@' + urlparse(clone_url).netloc + ':' + repo_git_base
147             deploy_key_url = ('https://github.com/' + repo_git_base + '/settings/keys'
148                               + '\nhttps://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys')
149             git_user_name = os.getenv('CIRCLE_USERNAME')
150             git_user_email = git_user_name + '@' + platform.node()
151         else:
152             print(_('ERROR: unsupported CI type, patches welcome!'))
153             sys.exit(1)
154
155         repo_url = repo_base + '/repo'
156         git_mirror_path = os.path.join(repo_basedir, 'git-mirror')
157         git_mirror_repodir = os.path.join(git_mirror_path, 'fdroid', 'repo')
158         git_mirror_metadatadir = os.path.join(git_mirror_path, 'fdroid', 'metadata')
159         if not os.path.isdir(git_mirror_repodir):
160             logging.debug(_('cloning {url}').format(url=clone_url))
161             try:
162                 git.Repo.clone_from(clone_url, git_mirror_path)
163             except Exception:
164                 pass
165         if not os.path.isdir(git_mirror_repodir):
166             os.makedirs(git_mirror_repodir, mode=0o755)
167
168         mirror_git_repo = git.Repo.init(git_mirror_path)
169         writer = mirror_git_repo.config_writer()
170         writer.set_value('user', 'name', git_user_name)
171         writer.set_value('user', 'email', git_user_email)
172         writer.release()
173         for remote in mirror_git_repo.remotes:
174             mirror_git_repo.delete_remote(remote)
175
176         readme_path = os.path.join(git_mirror_path, 'README.md')
177         readme = '''
178 # {repo_git_base}
179
180 [![{repo_url}](icon.png)]({repo_url})
181
182 Last updated: {date}'''.format(repo_git_base=repo_git_base,
183                                repo_url=repo_url,
184                                date=datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'))
185         with open(readme_path, 'w') as fp:
186             fp.write(readme)
187         mirror_git_repo.git.add(all=True)
188         mirror_git_repo.index.commit("update README")
189
190         icon_path = os.path.join(git_mirror_path, 'icon.png')
191         try:
192             import qrcode
193             img = qrcode.make(repo_url)
194             with open(icon_path, 'wb') as fp:
195                 fp.write(img)
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         subprocess.check_call(['fdroid', 'update', '--rename-apks', '--create-metadata', '--verbose'],
267                               cwd=repo_basedir)
268         common.local_rsync(options, repo_basedir + '/metadata/', git_mirror_metadatadir + '/')
269         mirror_git_repo.git.add(all=True)
270         mirror_git_repo.index.commit("update app metadata")
271         try:
272             subprocess.check_call(['fdroid', 'server', 'update', '--verbose'], cwd=repo_basedir)
273         except subprocess.CalledProcessError:
274             logging.error(_('cannot publish update, did you set the deploy key?')
275                           + '\n' + deploy_key_url)
276             sys.exit(1)
277         if shutil.rmtree.avoids_symlink_attacks:
278             shutil.rmtree(os.path.dirname(ssh_private_key_file))
279
280     else:
281         ssh_dir = os.path.join(os.getenv('HOME'), '.ssh')
282         os.makedirs(os.path.dirname(ssh_dir), exist_ok=True)
283         privkey = _ssh_key_from_debug_keystore()
284         ssh_private_key_file = os.path.join(ssh_dir, os.path.basename(privkey))
285         os.rename(privkey, ssh_private_key_file)
286         os.rename(privkey + '.pub', ssh_private_key_file + '.pub')
287         if shutil.rmtree.avoids_symlink_attacks:
288             shutil.rmtree(os.path.dirname(privkey))
289
290         if options.show_secret_var:
291             with open(KEYSTORE_FILE, 'rb') as fp:
292                 debug_keystore = base64.standard_b64encode(fp.read()).decode('ascii')
293             print(_('\n{path} encoded for the DEBUG_KEYSTORE secret variable:')
294                   .format(path=KEYSTORE_FILE))
295             print(debug_keystore)
296
297     os.umask(umask)
298
299
300 if __name__ == "__main__":
301     main()