chiark / gitweb /
make sure config exists before writing to it
[fdroidserver.git] / fdroidserver / btlog.py
1 #!/usr/bin/env python3
2 #
3 # btlog.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 # This is for creating a binary transparency log in a git repo for any
20 # F-Droid repo accessible via HTTP.  It is meant to run very often,
21 # even once a minute in a cronjob, so it uses HEAD requests and the
22 # HTTP ETag to check if the file has changed.  HEAD requests should
23 # not count against the download counts.  This pattern of a HEAD then
24 # a GET is what fdroidclient uses to avoid ETags being abused as
25 # cookies. This also uses the same HTTP User Agent as the F-Droid
26 # client app so its not easy for the server to distinguish this from
27 # the F-Droid client.
28
29
30 import collections
31 import git
32 import glob
33 import os
34 import json
35 import logging
36 import requests
37 import shutil
38 import tempfile
39 import xml.dom.minidom
40 import zipfile
41 from argparse import ArgumentParser
42
43 from .exception import FDroidException
44 from . import common
45 from . import server
46
47
48 options = None
49
50
51 def make_binary_transparency_log(repodirs, btrepo='binary_transparency',
52                                  url=None,
53                                  commit_title='fdroid update'):
54     '''Log the indexes in a standalone git repo to serve as a "binary
55     transparency" log.
56
57     see: https://www.eff.org/deeplinks/2014/02/open-letter-to-tech-companies
58
59     '''
60
61     logging.info('Committing indexes to ' + btrepo)
62     if os.path.exists(os.path.join(btrepo, '.git')):
63         gitrepo = git.Repo(btrepo)
64     else:
65         if not os.path.exists(btrepo):
66             os.mkdir(btrepo)
67         gitrepo = git.Repo.init(btrepo)
68
69         if not url:
70             url = common.config['repo_url'].rstrip('/')
71         with open(os.path.join(btrepo, 'README.md'), 'w') as fp:
72             fp.write("""
73 # Binary Transparency Log for %s
74
75 This is a log of the signed app index metadata.  This is stored in a
76 git repo, which serves as an imperfect append-only storage mechanism.
77 People can then check that any file that they received from that
78 F-Droid repository was a publicly released file.
79
80 For more info on this idea:
81 * https://wiki.mozilla.org/Security/Binary_Transparency
82 """ % url[:url.rindex('/')])  # strip '/repo'
83         gitrepo.index.add(['README.md', ])
84         gitrepo.index.commit('add README')
85
86     for repodir in repodirs:
87         cpdir = os.path.join(btrepo, repodir)
88         if not os.path.exists(cpdir):
89             os.mkdir(cpdir)
90         for f in ('index.xml', 'index-v1.json'):
91             repof = os.path.join(repodir, f)
92             if not os.path.exists(repof):
93                 continue
94             dest = os.path.join(cpdir, f)
95             if f.endswith('.xml'):
96                 doc = xml.dom.minidom.parse(repof)
97                 output = doc.toprettyxml(encoding='utf-8')
98                 with open(dest, 'wb') as f:
99                     f.write(output)
100             elif f.endswith('.json'):
101                 with open(repof) as fp:
102                     output = json.load(fp, object_pairs_hook=collections.OrderedDict)
103                 with open(dest, 'w') as fp:
104                     json.dump(output, fp, indent=2)
105             gitrepo.index.add([repof, ])
106         for f in ('index.jar', 'index-v1.jar'):
107             repof = os.path.join(repodir, f)
108             if not os.path.exists(repof):
109                 continue
110             dest = os.path.join(cpdir, f)
111             jarin = zipfile.ZipFile(repof, 'r')
112             jarout = zipfile.ZipFile(dest, 'w')
113             for info in jarin.infolist():
114                 if info.filename.startswith('META-INF/'):
115                     jarout.writestr(info, jarin.read(info.filename))
116             jarout.close()
117             jarin.close()
118             gitrepo.index.add([repof, ])
119
120         files = []
121         for root, dirs, filenames in os.walk(repodir):
122             for f in filenames:
123                 files.append(os.path.relpath(os.path.join(root, f), repodir))
124         output = collections.OrderedDict()
125         for f in sorted(files):
126             repofile = os.path.join(repodir, f)
127             stat = os.stat(repofile)
128             output[f] = (
129                 stat.st_size,
130                 stat.st_ctime_ns,
131                 stat.st_mtime_ns,
132                 stat.st_mode,
133                 stat.st_uid,
134                 stat.st_gid,
135             )
136         fslogfile = os.path.join(cpdir, 'filesystemlog.json')
137         with open(fslogfile, 'w') as fp:
138             json.dump(output, fp, indent=2)
139         gitrepo.index.add([os.path.join(repodir, 'filesystemlog.json'), ])
140
141         for f in glob.glob(os.path.join(cpdir, '*.HTTP-headers.json')):
142             gitrepo.index.add([os.path.join(repodir, os.path.basename(f)), ])
143
144     gitrepo.index.commit(commit_title)
145
146
147 def main():
148     global options
149
150     parser = ArgumentParser(usage="%(prog)s [options]")
151     common.setup_global_opts(parser)
152     parser.add_argument("--git-repo",
153                         default=os.path.join(os.getcwd(), 'binary_transparency'),
154                         help="Path to the git repo to use as the log")
155     parser.add_argument("-u", "--url", default='https://f-droid.org',
156                         help="The base URL for the repo to log (default: https://f-droid.org)")
157     parser.add_argument("--git-remote", default=None,
158                         help="Push the log to this git remote repository")
159     options = parser.parse_args()
160
161     if options.verbose:
162         logging.getLogger("requests").setLevel(logging.INFO)
163         logging.getLogger("urllib3").setLevel(logging.INFO)
164     else:
165         logging.getLogger("requests").setLevel(logging.WARNING)
166         logging.getLogger("urllib3").setLevel(logging.WARNING)
167
168     if not os.path.exists(options.git_repo):
169         raise FDroidException(
170             '"%s" does not exist! Create it, or use --git-repo' % options.git_repo)
171
172     session = requests.Session()
173
174     new_files = False
175     repodirs = ('repo', 'archive')
176     tempdirbase = tempfile.mkdtemp(prefix='.fdroid-btlog-')
177     for repodir in repodirs:
178         # TODO read HTTP headers for etag from git repo
179         tempdir = os.path.join(tempdirbase, repodir)
180         os.makedirs(tempdir, exist_ok=True)
181         gitrepodir = os.path.join(options.git_repo, repodir)
182         os.makedirs(gitrepodir, exist_ok=True)
183         for f in ('index.jar', 'index.xml', 'index-v1.jar', 'index-v1.json'):
184             dlfile = os.path.join(tempdir, f)
185             dlurl = options.url + '/' + repodir + '/' + f
186             http_headers_file = os.path.join(gitrepodir, f + '.HTTP-headers.json')
187
188             headers = {
189                 'User-Agent': 'F-Droid 0.102.3'
190             }
191             etag = None
192             if os.path.exists(http_headers_file):
193                 with open(http_headers_file) as fp:
194                     etag = json.load(fp)['ETag']
195
196             r = session.head(dlurl, headers=headers, allow_redirects=False)
197             if r.status_code != 200:
198                 logging.debug('HTTP Response (' + str(r.status_code) + '), did not download ' + dlurl)
199                 continue
200             if etag and etag == r.headers.get('ETag'):
201                 logging.debug('ETag matches, did not download ' + dlurl)
202                 continue
203
204             r = session.get(dlurl, headers=headers, allow_redirects=False)
205             if r.status_code == 200:
206                 with open(dlfile, 'wb') as f:
207                     for chunk in r:
208                         f.write(chunk)
209
210                 dump = dict()
211                 for k, v in r.headers.items():
212                     dump[k] = v
213                 with open(http_headers_file, 'w') as fp:
214                     json.dump(dump, fp, indent=2, sort_keys=True)
215                 new_files = True
216
217     if new_files:
218         os.chdir(tempdirbase)
219         make_binary_transparency_log(repodirs, options.git_repo, options.url, 'fdroid btlog')
220     if options.git_remote:
221         server.push_binary_transparency(options.git_repo, options.git_remote)
222     shutil.rmtree(tempdirbase, ignore_errors=True)
223
224
225 if __name__ == "__main__":
226     main()