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