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