]> git.ipfire.org Git - location/libloc.git/blame - src/python/location-downloader.in
test-database: Check opening files with no or random data
[location/libloc.git] / src / python / location-downloader.in
CommitLineData
244a3b61
MT
1#!/usr/bin/python3
2###############################################################################
3# #
4# libloc - A library to determine the location of someone on the Internet #
5# #
6# Copyright (C) 2019 IPFire Development Team <info@ipfire.org> #
7# #
8# This library is free software; you can redistribute it and/or #
9# modify it under the terms of the GNU Lesser General Public #
10# License as published by the Free Software Foundation; either #
11# version 2.1 of the License, or (at your option) any later version. #
12# #
13# This library is distributed in the hope that it will be useful, #
14# but WITHOUT ANY WARRANTY; without even the implied warranty of #
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
16# Lesser General Public License for more details. #
17# #
18###############################################################################
19
20import argparse
f4fef543 21import datetime
5a9b4c77 22import logging
244a3b61
MT
23import lzma
24import os
25import random
26import shutil
27import sys
28import tempfile
29import time
30import urllib.error
31import urllib.parse
32import urllib.request
33
34# Load our location module
35import location
7dccb767 36from location.i18n import _
244a3b61 37
8881d5f4 38DATABASE_FILENAME = "location.db.xz"
244a3b61
MT
39MIRRORS = (
40 "https://location.ipfire.org/databases/",
244a3b61
MT
41)
42
5a9b4c77 43# Initialise logging
e44b30f4
MT
44log = logging.getLogger("location.downloader")
45log.propagate = 1
5a9b4c77 46
244a3b61
MT
47class Downloader(object):
48 def __init__(self, mirrors):
49 self.mirrors = list(mirrors)
50
51 # Randomize mirrors
52 random.shuffle(self.mirrors)
53
54 # Get proxies from environment
55 self.proxies = self._get_proxies()
56
57 def _get_proxies(self):
58 proxies = {}
59
60 for protocol in ("https", "http"):
61 proxy = os.environ.get("%s_proxy" % protocol, None)
62
63 if proxy:
64 proxies[protocol] = proxy
65
66 return proxies
67
68 def _make_request(self, url, baseurl=None, headers={}):
69 if baseurl:
70 url = urllib.parse.urljoin(baseurl, url)
71
72 req = urllib.request.Request(url, method="GET")
73
74 # Update headers
75 headers.update({
d2714e4a 76 "User-Agent" : "location-downloader/@VERSION@",
244a3b61
MT
77 })
78
79 # Set headers
80 for header in headers:
81 req.add_header(header, headers[header])
82
83 # Set proxies
84 for protocol in self.proxies:
85 req.set_proxy(self.proxies[protocol], protocol)
86
87 return req
88
89 def _send_request(self, req, **kwargs):
90 # Log request headers
5a9b4c77
MT
91 log.debug("HTTP %s Request to %s" % (req.method, req.host))
92 log.debug(" URL: %s" % req.full_url)
93 log.debug(" Headers:")
244a3b61 94 for k, v in req.header_items():
5a9b4c77 95 log.debug(" %s: %s" % (k, v))
244a3b61
MT
96
97 try:
98 res = urllib.request.urlopen(req, **kwargs)
99
100 except urllib.error.HTTPError as e:
101 # Log response headers
5a9b4c77
MT
102 log.debug("HTTP Response: %s" % e.code)
103 log.debug(" Headers:")
244a3b61 104 for header in e.headers:
5a9b4c77 105 log.debug(" %s: %s" % (header, e.headers[header]))
244a3b61 106
244a3b61
MT
107 # Raise all other errors
108 raise e
109
110 # Log response headers
5a9b4c77
MT
111 log.debug("HTTP Response: %s" % res.code)
112 log.debug(" Headers:")
244a3b61 113 for k, v in res.getheaders():
5a9b4c77 114 log.debug(" %s: %s" % (k, v))
244a3b61
MT
115
116 return res
117
116b1352 118 def download(self, url, public_key, timestamp=None, **kwargs):
244a3b61
MT
119 headers = {}
120
f4fef543
MT
121 if timestamp:
122 headers["If-Modified-Since"] = timestamp.strftime(
123 "%a, %d %b %Y %H:%M:%S GMT",
244a3b61
MT
124 )
125
126 t = tempfile.NamedTemporaryFile(delete=False)
127 with t:
128 # Try all mirrors
129 for mirror in self.mirrors:
130 # Prepare HTTP request
131 req = self._make_request(url, baseurl=mirror, headers=headers)
132
133 try:
134 with self._send_request(req) as res:
135 decompressor = lzma.LZMADecompressor()
136
137 # Read all data
138 while True:
139 buf = res.read(1024)
140 if not buf:
141 break
142
143 # Decompress data
144 buf = decompressor.decompress(buf)
145 if buf:
146 t.write(buf)
147
f4fef543
MT
148 # Write all data to disk
149 t.flush()
244a3b61
MT
150
151 # Catch decompression errors
152 except lzma.LZMAError as e:
5a9b4c77 153 log.warning("Could not decompress downloaded file: %s" % e)
244a3b61
MT
154 continue
155
244a3b61 156 except urllib.error.HTTPError as e:
f4fef543
MT
157 # The file on the server was too old
158 if e.code == 304:
159 log.warning("%s is serving an outdated database. Trying next mirror..." % mirror)
244a3b61 160
f4fef543
MT
161 # Log any other HTTP errors
162 else:
163 log.warning("%s reported: %s" % (mirror, e))
164
165 # Throw away any downloaded content and try again
166 t.truncate()
244a3b61 167
f4fef543
MT
168 else:
169 # Check if the downloaded database is recent
116b1352 170 if not self._check_database(t, public_key, timestamp):
f4fef543 171 log.warning("Downloaded database is outdated. Trying next mirror...")
244a3b61 172
f4fef543
MT
173 # Throw away the data and try again
174 t.truncate()
175 continue
176
177 # Return temporary file
178 return t
244a3b61
MT
179
180 raise FileNotFoundError(url)
181
116b1352 182 def _check_database(self, f, public_key, timestamp=None):
f4fef543
MT
183 """
184 Checks the downloaded database if it can be opened,
185 verified and if it is recent enough
186 """
187 log.debug("Opening downloaded database at %s" % f.name)
188
189 db = location.Database(f.name)
190
191 # Database is not recent
192 if timestamp and db.created_at < timestamp.timestamp():
193 return False
194
195 log.info("Downloaded new database from %s" % (time.strftime(
196 "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(db.created_at),
197 )))
198
116b1352
MT
199 # Verify the database
200 with open(public_key, "r") as f:
201 if not db.verify(f):
202 log.error("Could not verify database")
203 return False
204
f4fef543
MT
205 return True
206
244a3b61
MT
207
208class CLI(object):
209 def __init__(self):
210 self.downloader = Downloader(mirrors=MIRRORS)
211
212 def parse_cli(self):
213 parser = argparse.ArgumentParser(
214 description=_("Location Downloader Command Line Interface"),
215 )
216 subparsers = parser.add_subparsers()
217
218 # Global configuration flags
219 parser.add_argument("--debug", action="store_true",
220 help=_("Enable debug output"))
bc1f5f53
MT
221 parser.add_argument("--quiet", action="store_true",
222 help=_("Enable quiet mode"))
244a3b61
MT
223
224 # version
225 parser.add_argument("--version", action="version",
d2714e4a 226 version="%(prog)s @VERSION@")
244a3b61
MT
227
228 # database
229 parser.add_argument("--database", "-d",
230 default="@databasedir@/database.db", help=_("Path to database"),
231 )
232
116b1352
MT
233 # public key
234 parser.add_argument("--public-key", "-k",
235 default="@databasedir@/signing-key.pem", help=_("Public Signing Key"),
236 )
237
244a3b61
MT
238 # Update
239 update = subparsers.add_parser("update", help=_("Update database"))
240 update.set_defaults(func=self.handle_update)
241
8e753500
MT
242 # Verify
243 verify = subparsers.add_parser("verify",
244 help=_("Verify the downloaded database"))
245 verify.set_defaults(func=self.handle_verify)
246
244a3b61
MT
247 args = parser.parse_args()
248
bc1f5f53 249 # Configure logging
244a3b61 250 if args.debug:
f9de5e61 251 location.logger.set_level(logging.DEBUG)
bc1f5f53
MT
252 elif args.quiet:
253 location.logger.set_level(logging.WARNING)
244a3b61
MT
254
255 # Print usage if no action was given
256 if not "func" in args:
257 parser.print_usage()
258 sys.exit(2)
259
260 return args
261
262 def run(self):
263 # Parse command line arguments
264 args = self.parse_cli()
265
266 # Call function
267 ret = args.func(args)
268
269 # Return with exit code
270 if ret:
271 sys.exit(ret)
272
273 # Otherwise just exit
274 sys.exit(0)
275
276 def handle_update(self, ns):
f4fef543
MT
277 # Fetch the version we need from DNS
278 t = location.discover_latest_version()
279
280 # Parse timestamp into datetime format
281 try:
282 timestamp = datetime.datetime.fromtimestamp(t)
283 except:
284 raise
244a3b61
MT
285
286 # Open database
287 try:
288 db = location.Database(ns.database)
289
f4fef543
MT
290 # Check if we are already on the latest version
291 if db.created_at >= timestamp.timestamp():
292 log.info("Already on the latest version")
293 return
294
244a3b61
MT
295 except FileNotFoundError as e:
296 db = None
297
298 # Try downloading a new database
299 try:
116b1352
MT
300 t = self.downloader.download(DATABASE_FILENAME,
301 public_key=ns.public_key, timestamp=timestamp)
244a3b61
MT
302
303 # If no file could be downloaded, log a message
304 except FileNotFoundError as e:
5a9b4c77 305 log.error("Could not download a new database")
244a3b61
MT
306 return 1
307
308 # If we have not received a new file, there is nothing to do
309 if not t:
f47a500f 310 return 3
244a3b61 311
244a3b61
MT
312 # Write temporary file to destination
313 shutil.copyfile(t.name, ns.database)
314
315 # Remove temporary file
316 os.unlink(t.name)
317
8e753500
MT
318 return 0
319
320 def handle_verify(self, ns):
321 try:
322 db = location.Database(ns.database)
323 except FileNotFoundError as e:
324 log.error("%s: %s" % (ns.database, e))
325 return 127
326
327 # Verify the database
328 with open(ns.public_key, "r") as f:
329 if not db.verify(f):
330 log.error("Could not verify database")
331 return 1
332
333 # Success
334 log.debug("Database successfully verified")
335 return 0
f47a500f 336
244a3b61
MT
337
338def main():
339 # Run the command line interface
340 c = CLI()
341 c.run()
342
343main()