3 # btlog.py - part of the FDroid server tools
4 # Copyright (C) 2017, Hans-Christoph Steiner <hans@eds.org>
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.
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.
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/>.
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
39 import xml.dom.minidom
41 from argparse import ArgumentParser
43 from .exception import FDroidException
51 def make_binary_transparency_log(repodirs, btrepo='binary_transparency',
53 commit_title='fdroid update'):
54 '''Log the indexes in a standalone git repo to serve as a "binary
57 see: https://www.eff.org/deeplinks/2014/02/open-letter-to-tech-companies
61 logging.info('Committing indexes to ' + btrepo)
62 if os.path.exists(os.path.join(btrepo, '.git')):
63 gitrepo = git.Repo(btrepo)
65 if not os.path.exists(btrepo):
67 gitrepo = git.Repo.init(btrepo)
70 url = common.config['repo_url'].rstrip('/')
71 with open(os.path.join(btrepo, 'README.md'), 'w') as fp:
73 # Binary Transparency Log for %s
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.
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')
86 for repodir in repodirs:
87 cpdir = os.path.join(btrepo, repodir)
88 if not os.path.exists(cpdir):
90 for f in ('index.xml', 'index-v1.json'):
91 repof = os.path.join(repodir, f)
92 if not os.path.exists(repof):
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:
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):
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))
118 gitrepo.index.add([repof, ])
121 for root, dirs, filenames in os.walk(repodir):
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)
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'), ])
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)), ])
144 gitrepo.index.commit(commit_title)
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()
162 logging.getLogger("requests").setLevel(logging.INFO)
163 logging.getLogger("urllib3").setLevel(logging.INFO)
165 logging.getLogger("requests").setLevel(logging.WARNING)
166 logging.getLogger("urllib3").setLevel(logging.WARNING)
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)
172 session = requests.Session()
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')
189 'User-Agent': 'F-Droid 0.102.3'
192 if os.path.exists(http_headers_file):
193 with open(http_headers_file) as fp:
194 etag = json.load(fp)['ETag']
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)
200 if etag and etag == r.headers.get('ETag'):
201 logging.debug('ETag matches, did not download ' + dlurl)
204 r = session.get(dlurl, headers=headers, allow_redirects=False)
205 if r.status_code == 200:
206 with open(dlfile, 'wb') as f:
211 for k, v in r.headers.items():
213 with open(http_headers_file, 'w') as fp:
214 json.dump(dump, fp, indent=2, sort_keys=True)
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)
225 if __name__ == "__main__":