--- /dev/null
+From: Michael Tremer <michael.tremer@ipfire.org>
+Date: Thu, 14 Apr 2022 18:31:56 +0000
+Subject: Make sources around that we can run tests without location installed
+
+In order to run the test suite, we need to make the Python module
+loadable from the build directory so that we first of all test the right
+code and that it just works without running "make install" first.
+
+Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
+---
+ .gitignore | 5 +-
+ Makefile.am | 37 +-
+ po/POTFILES.in | 18 +-
+ src/python/__init__.py.in | 26 -
+ src/python/database.py | 213 -----
+ src/python/downloader.py | 211 -----
+ src/python/export.py | 291 -------
+ src/python/i18n.py | 26 -
+ src/python/importer.py | 250 ------
+ src/python/location-importer.in | 1535 -------------------------------------
+ src/python/location.in | 644 ----------------
+ src/python/location/__init__.py | 24 +
+ src/python/location/database.py | 213 +++++
+ src/python/location/downloader.py | 211 +++++
+ src/python/location/export.py | 291 +++++++
+ src/python/location/i18n.py | 26 +
+ src/python/location/importer.py | 250 ++++++
+ src/python/location/logger.py | 46 ++
+ src/python/logger.py | 46 --
+ src/scripts/location-importer.in | 1535 +++++++++++++++++++++++++++++++++++++
+ src/scripts/location.in | 644 ++++++++++++++++
+ 21 files changed, 3266 insertions(+), 3276 deletions(-)
+ delete mode 100644 src/python/__init__.py.in
+ delete mode 100644 src/python/database.py
+ delete mode 100644 src/python/downloader.py
+ delete mode 100644 src/python/export.py
+ delete mode 100644 src/python/i18n.py
+ delete mode 100644 src/python/importer.py
+ delete mode 100644 src/python/location-importer.in
+ delete mode 100644 src/python/location.in
+ create mode 100644 src/python/location/__init__.py
+ create mode 100644 src/python/location/database.py
+ create mode 100644 src/python/location/downloader.py
+ create mode 100644 src/python/location/export.py
+ create mode 100644 src/python/location/i18n.py
+ create mode 100644 src/python/location/importer.py
+ create mode 100644 src/python/location/logger.py
+ delete mode 100644 src/python/logger.py
+ create mode 100644 src/scripts/location-importer.in
+ create mode 100644 src/scripts/location.in
+
+diff --git a/.gitignore b/.gitignore
+index f04b70e..20bc895 100644
+--- a/.gitignore
++++ b/.gitignore
+@@ -15,9 +15,8 @@ Makefile.in
+ /*.db.xz
+ /libtool
+ /stamp-h1
+-/src/python/location
+-/src/python/location-importer
+-/src/python/__init__.py
++/src/scripts/location
++/src/scripts/location-importer
+ /src/systemd/location-update.service
+ /src/systemd/location-update.timer
+ /test.db
+diff --git a/Makefile.am b/Makefile.am
+index 983cb4a..38ce961 100644
+--- a/Makefile.am
++++ b/Makefile.am
+@@ -175,21 +175,13 @@ CLEANFILES += \
+ src/libloc.pc
+
+ dist_pkgpython_PYTHON = \
+- src/python/database.py \
+- src/python/downloader.py \
+- src/python/export.py \
+- src/python/i18n.py \
+- src/python/importer.py \
+- src/python/logger.py
+-
+-pkgpython_PYTHON = \
+- src/python/__init__.py
+-
+-EXTRA_DIST += \
+- src/python/__init__.py.in
+-
+-CLEANFILES += \
+- src/python/__init__.py
++ src/python/location/__init__.py \
++ src/python/location/database.py \
++ src/python/location/downloader.py \
++ src/python/location/export.py \
++ src/python/location/i18n.py \
++ src/python/location/importer.py \
++ src/python/location/logger.py
+
+ pyexec_LTLIBRARIES = \
+ src/python/_location.la
+@@ -275,16 +267,16 @@ uninstall-perl:
+ $(DESTDIR)/$(prefix)/man/man3/Location.3pm
+
+ bin_SCRIPTS = \
+- src/python/location \
+- src/python/location-importer
++ src/scripts/location \
++ src/scripts/location-importer
+
+ EXTRA_DIST += \
+- src/python/location.in \
+- src/python/location-importer.in
++ src/scripts/location.in \
++ src/scripts/location-importer.in
+
+ CLEANFILES += \
+- src/python/location \
+- src/python/location-importer
++ src/scripts/location \
++ src/scripts/location-importer
+
+ # ------------------------------------------------------------------------------
+
+@@ -321,6 +313,7 @@ TESTS_LDADD = \
+ src/libloc-internal.la
+
+ TESTS_ENVIRONMENT = \
++ PYTHONPATH=$(abs_srcdir)/src/python:$(abs_builddir)/src/python/.libs \
+ TEST_DATA_DIR="$(abs_top_srcdir)/tests/data"
+
+ TESTS = \
+@@ -334,7 +327,7 @@ CLEANFILES += \
+ testdata.db
+
+ testdata.db: examples/python/create-database.py
+- PYTHONPATH=$(abs_builddir)/src/python/.libs \
++ PYTHONPATH=$(abs_srcdir)/src/python:$(abs_builddir)/src/python/.libs \
+ ABS_SRCDIR="$(abs_srcdir)" \
+ $(PYTHON) $< $@
+
+diff --git a/po/POTFILES.in b/po/POTFILES.in
+index 5d2cc46..5f5afa8 100644
+--- a/po/POTFILES.in
++++ b/po/POTFILES.in
+@@ -1,12 +1,12 @@
+ src/libloc.pc.in
+-src/python/__init__.py.in
+-src/python/database.py
+-src/python/downloader.py
+-src/python/export.py
+-src/python/i18n.py
+-src/python/importer.py
+-src/python/location-importer.in
+-src/python/location.in
+-src/python/logger.py
++src/python/location/__init__.py
++src/python/location/database.py
++src/python/location/downloader.py
++src/python/location/export.py
++src/python/location/i18n.py
++src/python/location/importer.py
++src/python/location/logger.py
++src/scripts/location-importer.in
++src/scripts/location.in
+ src/systemd/location-update.service.in
+ src/systemd/location-update.timer.in
+diff --git a/src/python/__init__.py.in b/src/python/__init__.py.in
+deleted file mode 100644
+index bd94d35..0000000
+--- a/src/python/__init__.py.in
++++ /dev/null
+@@ -1,26 +0,0 @@
+-#!/usr/bin/python3
+-###############################################################################
+-# #
+-# libloc - A library to determine the location of someone on the Internet #
+-# #
+-# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
+-# #
+-# This library is free software; you can redistribute it and/or #
+-# modify it under the terms of the GNU Lesser General Public #
+-# License as published by the Free Software Foundation; either #
+-# version 2.1 of the License, or (at your option) any later version. #
+-# #
+-# This library is distributed in the hope that it will be useful, #
+-# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
+-# Lesser General Public License for more details. #
+-# #
+-###############################################################################
+-
+-__version__ = "@VERSION@"
+-
+-# Import everything from the C module
+-from _location import *
+-
+-# Initialise logging
+-from . import logger
+diff --git a/src/python/database.py b/src/python/database.py
+deleted file mode 100644
+index 5d79941..0000000
+--- a/src/python/database.py
++++ /dev/null
+@@ -1,213 +0,0 @@
+-#!/usr/bin/env python
+-
+-"""
+- A lightweight wrapper around psycopg2.
+-
+- Originally part of the Tornado framework. The tornado.database module
+- is slated for removal in Tornado 3.0, and it is now available separately
+- as torndb.
+-"""
+-
+-import logging
+-import psycopg2
+-
+-log = logging.getLogger("location.database")
+-log.propagate = 1
+-
+-class Connection(object):
+- """
+- A lightweight wrapper around MySQLdb DB-API connections.
+-
+- The main value we provide is wrapping rows in a dict/object so that
+- columns can be accessed by name. Typical usage::
+-
+- db = torndb.Connection("localhost", "mydatabase")
+- for article in db.query("SELECT * FROM articles"):
+- print article.title
+-
+- Cursors are hidden by the implementation, but other than that, the methods
+- are very similar to the DB-API.
+-
+- We explicitly set the timezone to UTC and the character encoding to
+- UTF-8 on all connections to avoid time zone and encoding errors.
+- """
+- def __init__(self, host, database, user=None, password=None):
+- self.host = host
+- self.database = database
+-
+- self._db = None
+- self._db_args = {
+- "host" : host,
+- "database" : database,
+- "user" : user,
+- "password" : password,
+- "sslmode" : "require",
+- }
+-
+- try:
+- self.reconnect()
+- except Exception:
+- log.error("Cannot connect to database on %s", self.host, exc_info=True)
+-
+- def __del__(self):
+- self.close()
+-
+- def close(self):
+- """
+- Closes this database connection.
+- """
+- if getattr(self, "_db", None) is not None:
+- self._db.close()
+- self._db = None
+-
+- def reconnect(self):
+- """
+- Closes the existing database connection and re-opens it.
+- """
+- self.close()
+-
+- self._db = psycopg2.connect(**self._db_args)
+- self._db.autocommit = True
+-
+- # Initialize the timezone setting.
+- self.execute("SET TIMEZONE TO 'UTC'")
+-
+- def query(self, query, *parameters, **kwparameters):
+- """
+- Returns a row list for the given query and parameters.
+- """
+- cursor = self._cursor()
+- try:
+- self._execute(cursor, query, parameters, kwparameters)
+- column_names = [d[0] for d in cursor.description]
+- return [Row(zip(column_names, row)) for row in cursor]
+- finally:
+- cursor.close()
+-
+- def get(self, query, *parameters, **kwparameters):
+- """
+- Returns the first row returned for the given query.
+- """
+- rows = self.query(query, *parameters, **kwparameters)
+- if not rows:
+- return None
+- elif len(rows) > 1:
+- raise Exception("Multiple rows returned for Database.get() query")
+- else:
+- return rows[0]
+-
+- def execute(self, query, *parameters, **kwparameters):
+- """
+- Executes the given query, returning the lastrowid from the query.
+- """
+- return self.execute_lastrowid(query, *parameters, **kwparameters)
+-
+- def execute_lastrowid(self, query, *parameters, **kwparameters):
+- """
+- Executes the given query, returning the lastrowid from the query.
+- """
+- cursor = self._cursor()
+- try:
+- self._execute(cursor, query, parameters, kwparameters)
+- return cursor.lastrowid
+- finally:
+- cursor.close()
+-
+- def execute_rowcount(self, query, *parameters, **kwparameters):
+- """
+- Executes the given query, returning the rowcount from the query.
+- """
+- cursor = self._cursor()
+- try:
+- self._execute(cursor, query, parameters, kwparameters)
+- return cursor.rowcount
+- finally:
+- cursor.close()
+-
+- def executemany(self, query, parameters):
+- """
+- Executes the given query against all the given param sequences.
+-
+- We return the lastrowid from the query.
+- """
+- return self.executemany_lastrowid(query, parameters)
+-
+- def executemany_lastrowid(self, query, parameters):
+- """
+- Executes the given query against all the given param sequences.
+-
+- We return the lastrowid from the query.
+- """
+- cursor = self._cursor()
+- try:
+- cursor.executemany(query, parameters)
+- return cursor.lastrowid
+- finally:
+- cursor.close()
+-
+- def executemany_rowcount(self, query, parameters):
+- """
+- Executes the given query against all the given param sequences.
+-
+- We return the rowcount from the query.
+- """
+- cursor = self._cursor()
+-
+- try:
+- cursor.executemany(query, parameters)
+- return cursor.rowcount
+- finally:
+- cursor.close()
+-
+- def _ensure_connected(self):
+- if self._db is None:
+- log.warning("Database connection was lost...")
+-
+- self.reconnect()
+-
+- def _cursor(self):
+- self._ensure_connected()
+- return self._db.cursor()
+-
+- def _execute(self, cursor, query, parameters, kwparameters):
+- log.debug("SQL Query: %s" % (query % (kwparameters or parameters)))
+-
+- try:
+- return cursor.execute(query, kwparameters or parameters)
+- except (OperationalError, psycopg2.ProgrammingError):
+- log.error("Error connecting to database on %s", self.host)
+- self.close()
+- raise
+-
+- def transaction(self):
+- return Transaction(self)
+-
+-
+-class Row(dict):
+- """A dict that allows for object-like property access syntax."""
+- def __getattr__(self, name):
+- try:
+- return self[name]
+- except KeyError:
+- raise AttributeError(name)
+-
+-
+-class Transaction(object):
+- def __init__(self, db):
+- self.db = db
+-
+- self.db.execute("START TRANSACTION")
+-
+- def __enter__(self):
+- return self
+-
+- def __exit__(self, exctype, excvalue, traceback):
+- if exctype is not None:
+- self.db.execute("ROLLBACK")
+- else:
+- self.db.execute("COMMIT")
+-
+-
+-# Alias some common exceptions
+-IntegrityError = psycopg2.IntegrityError
+-OperationalError = psycopg2.OperationalError
+diff --git a/src/python/downloader.py b/src/python/downloader.py
+deleted file mode 100644
+index 05f7872..0000000
+--- a/src/python/downloader.py
++++ /dev/null
+@@ -1,211 +0,0 @@
+-#!/usr/bin/python3
+-###############################################################################
+-# #
+-# libloc - A library to determine the location of someone on the Internet #
+-# #
+-# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
+-# #
+-# This library is free software; you can redistribute it and/or #
+-# modify it under the terms of the GNU Lesser General Public #
+-# License as published by the Free Software Foundation; either #
+-# version 2.1 of the License, or (at your option) any later version. #
+-# #
+-# This library is distributed in the hope that it will be useful, #
+-# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
+-# Lesser General Public License for more details. #
+-# #
+-###############################################################################
+-
+-import logging
+-import lzma
+-import os
+-import random
+-import stat
+-import tempfile
+-import time
+-import urllib.error
+-import urllib.parse
+-import urllib.request
+-
+-from . import __version__
+-from _location import Database, DATABASE_VERSION_LATEST
+-
+-DATABASE_FILENAME = "location.db.xz"
+-MIRRORS = (
+- "https://location.ipfire.org/databases/",
+-)
+-
+-# Initialise logging
+-log = logging.getLogger("location.downloader")
+-log.propagate = 1
+-
+-class Downloader(object):
+- def __init__(self, version=DATABASE_VERSION_LATEST, mirrors=None):
+- self.version = version
+-
+- # Set mirrors or use defaults
+- self.mirrors = list(mirrors or MIRRORS)
+-
+- # Randomize mirrors
+- random.shuffle(self.mirrors)
+-
+- # Get proxies from environment
+- self.proxies = self._get_proxies()
+-
+- def _get_proxies(self):
+- proxies = {}
+-
+- for protocol in ("https", "http"):
+- proxy = os.environ.get("%s_proxy" % protocol, None)
+-
+- if proxy:
+- proxies[protocol] = proxy
+-
+- return proxies
+-
+- def _make_request(self, url, baseurl=None, headers={}):
+- if baseurl:
+- url = urllib.parse.urljoin(baseurl, url)
+-
+- req = urllib.request.Request(url, method="GET")
+-
+- # Update headers
+- headers.update({
+- "User-Agent" : "location/%s" % __version__,
+- })
+-
+- # Set headers
+- for header in headers:
+- req.add_header(header, headers[header])
+-
+- # Set proxies
+- for protocol in self.proxies:
+- req.set_proxy(self.proxies[protocol], protocol)
+-
+- return req
+-
+- def _send_request(self, req, **kwargs):
+- # Log request headers
+- log.debug("HTTP %s Request to %s" % (req.method, req.host))
+- log.debug(" URL: %s" % req.full_url)
+- log.debug(" Headers:")
+- for k, v in req.header_items():
+- log.debug(" %s: %s" % (k, v))
+-
+- try:
+- res = urllib.request.urlopen(req, **kwargs)
+-
+- except urllib.error.HTTPError as e:
+- # Log response headers
+- log.debug("HTTP Response: %s" % e.code)
+- log.debug(" Headers:")
+- for header in e.headers:
+- log.debug(" %s: %s" % (header, e.headers[header]))
+-
+- # Raise all other errors
+- raise e
+-
+- # Log response headers
+- log.debug("HTTP Response: %s" % res.code)
+- log.debug(" Headers:")
+- for k, v in res.getheaders():
+- log.debug(" %s: %s" % (k, v))
+-
+- return res
+-
+- def download(self, public_key, timestamp=None, tmpdir=None, **kwargs):
+- url = "%s/%s" % (self.version, DATABASE_FILENAME)
+-
+- headers = {}
+- if timestamp:
+- headers["If-Modified-Since"] = time.strftime(
+- "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(timestamp),
+- )
+-
+- t = tempfile.NamedTemporaryFile(dir=tmpdir, delete=False)
+- with t:
+- # Try all mirrors
+- for mirror in self.mirrors:
+- # Prepare HTTP request
+- req = self._make_request(url, baseurl=mirror, headers=headers)
+-
+- try:
+- with self._send_request(req) as res:
+- decompressor = lzma.LZMADecompressor()
+-
+- # Read all data
+- while True:
+- buf = res.read(1024)
+- if not buf:
+- break
+-
+- # Decompress data
+- buf = decompressor.decompress(buf)
+- if buf:
+- t.write(buf)
+-
+- # Write all data to disk
+- t.flush()
+-
+- # Catch decompression errors
+- except lzma.LZMAError as e:
+- log.warning("Could not decompress downloaded file: %s" % e)
+- continue
+-
+- except urllib.error.HTTPError as e:
+- # The file on the server was too old
+- if e.code == 304:
+- log.warning("%s is serving an outdated database. Trying next mirror..." % mirror)
+-
+- # Log any other HTTP errors
+- else:
+- log.warning("%s reported: %s" % (mirror, e))
+-
+- # Throw away any downloaded content and try again
+- t.truncate()
+-
+- else:
+- # Check if the downloaded database is recent
+- if not self._check_database(t, public_key, timestamp):
+- log.warning("Downloaded database is outdated. Trying next mirror...")
+-
+- # Throw away the data and try again
+- t.truncate()
+- continue
+-
+- # Make the file readable for everyone
+- os.chmod(t.name, stat.S_IRUSR|stat.S_IRGRP|stat.S_IROTH)
+-
+- # Return temporary file
+- return t
+-
+- # Delete the temporary file after unsuccessful downloads
+- os.unlink(t.name)
+-
+- raise FileNotFoundError(url)
+-
+- def _check_database(self, f, public_key, timestamp=None):
+- """
+- Checks the downloaded database if it can be opened,
+- verified and if it is recent enough
+- """
+- log.debug("Opening downloaded database at %s" % f.name)
+-
+- db = Database(f.name)
+-
+- # Database is not recent
+- if timestamp and db.created_at < timestamp:
+- return False
+-
+- log.info("Downloaded new database from %s" % (time.strftime(
+- "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(db.created_at),
+- )))
+-
+- # Verify the database
+- with open(public_key, "r") as f:
+- if not db.verify(f):
+- log.error("Could not verify database")
+- return False
+-
+- return True
+diff --git a/src/python/export.py b/src/python/export.py
+deleted file mode 100644
+index 3cdece4..0000000
+--- a/src/python/export.py
++++ /dev/null
+@@ -1,291 +0,0 @@
+-#!/usr/bin/python3
+-###############################################################################
+-# #
+-# libloc - A library to determine the location of someone on the Internet #
+-# #
+-# Copyright (C) 2020-2021 IPFire Development Team <info@ipfire.org> #
+-# #
+-# This library is free software; you can redistribute it and/or #
+-# modify it under the terms of the GNU Lesser General Public #
+-# License as published by the Free Software Foundation; either #
+-# version 2.1 of the License, or (at your option) any later version. #
+-# #
+-# This library is distributed in the hope that it will be useful, #
+-# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
+-# Lesser General Public License for more details. #
+-# #
+-###############################################################################
+-
+-import functools
+-import io
+-import ipaddress
+-import logging
+-import math
+-import os
+-import socket
+-import sys
+-
+-from .i18n import _
+-import _location
+-
+-# Initialise logging
+-log = logging.getLogger("location.export")
+-log.propagate = 1
+-
+-FLAGS = {
+- _location.NETWORK_FLAG_ANONYMOUS_PROXY : "A1",
+- _location.NETWORK_FLAG_SATELLITE_PROVIDER : "A2",
+- _location.NETWORK_FLAG_ANYCAST : "A3",
+- _location.NETWORK_FLAG_DROP : "XD",
+-}
+-
+-class OutputWriter(object):
+- suffix = "networks"
+- mode = "w"
+-
+- def __init__(self, name, family=None, directory=None, f=None):
+- self.name = name
+- self.family = family
+- self.directory = directory
+-
+- # Open output file
+- if f:
+- self.f = f
+- elif self.directory:
+- self.f = open(self.filename, self.mode)
+- elif "b" in self.mode:
+- self.f = io.BytesIO()
+- else:
+- self.f = io.StringIO()
+-
+- # Call any custom initialization
+- self.init()
+-
+- # Immediately write the header
+- self._write_header()
+-
+- def init(self):
+- """
+- To be overwritten by anything that inherits from this
+- """
+- pass
+-
+- def __repr__(self):
+- return "<%s %s f=%s>" % (self.__class__.__name__, self, self.f)
+-
+- @functools.cached_property
+- def tag(self):
+- families = {
+- socket.AF_INET6 : "6",
+- socket.AF_INET : "4",
+- }
+-
+- return "%sv%s" % (self.name, families.get(self.family, "?"))
+-
+- @functools.cached_property
+- def filename(self):
+- if self.directory:
+- return os.path.join(self.directory, "%s.%s" % (self.tag, self.suffix))
+-
+- def _write_header(self):
+- """
+- The header of the file
+- """
+- pass
+-
+- def _write_footer(self):
+- """
+- The footer of the file
+- """
+- pass
+-
+- def write(self, network):
+- self.f.write("%s\n" % network)
+-
+- def finish(self):
+- """
+- Called when all data has been written
+- """
+- self._write_footer()
+-
+- # Flush all output
+- self.f.flush()
+-
+- def print(self):
+- """
+- Prints the entire output line by line
+- """
+- if isinstance(self.f, io.BytesIO):
+- raise TypeError(_("Won't write binary output to stdout"))
+-
+- # Go back to the beginning
+- self.f.seek(0)
+-
+- # Iterate over everything line by line
+- for line in self.f:
+- sys.stdout.write(line)
+-
+-
+-class IpsetOutputWriter(OutputWriter):
+- """
+- For ipset
+- """
+- suffix = "ipset"
+-
+- # The value is being used if we don't know any better
+- DEFAULT_HASHSIZE = 64
+-
+- # We aim for this many networks in a bucket on average. This allows us to choose
+- # how much memory we want to sacrifice to gain better performance. The lower the
+- # factor, the faster a lookup will be, but it will use more memory.
+- # We will aim for only using three quarters of all buckets to avoid any searches
+- # through the linked lists.
+- HASHSIZE_FACTOR = 0.75
+-
+- def init(self):
+- # Count all networks
+- self.networks = 0
+-
+- @property
+- def hashsize(self):
+- """
+- Calculates an optimized hashsize
+- """
+- # Return the default value if we don't know the size of the set
+- if not self.networks:
+- return self.DEFAULT_HASHSIZE
+-
+- # Find the nearest power of two that is larger than the number of networks
+- # divided by the hashsize factor.
+- exponent = math.log(self.networks / self.HASHSIZE_FACTOR, 2)
+-
+- # Return the size of the hash (the minimum is 64)
+- return max(2 ** math.ceil(exponent), 64)
+-
+- def _write_header(self):
+- # This must have a fixed size, because we will write the header again in the end
+- self.f.write("create %s hash:net family inet%s" % (
+- self.tag,
+- "6" if self.family == socket.AF_INET6 else ""
+- ))
+- self.f.write(" hashsize %8d maxelem 1048576 -exist\n" % self.hashsize)
+- self.f.write("flush %s\n" % self.tag)
+-
+- def write(self, network):
+- self.f.write("add %s %s\n" % (self.tag, network))
+-
+- # Increment network counter
+- self.networks += 1
+-
+- def _write_footer(self):
+- # Jump back to the beginning of the file
+- self.f.seek(0)
+-
+- # Rewrite the header with better configuration
+- self._write_header()
+-
+-
+-class NftablesOutputWriter(OutputWriter):
+- """
+- For nftables
+- """
+- suffix = "set"
+-
+- def _write_header(self):
+- self.f.write("define %s = {\n" % self.tag)
+-
+- def _write_footer(self):
+- self.f.write("}\n")
+-
+- def write(self, network):
+- self.f.write(" %s,\n" % network)
+-
+-
+-class XTGeoIPOutputWriter(OutputWriter):
+- """
+- Formats the output in that way, that it can be loaded by
+- the xt_geoip kernel module from xtables-addons.
+- """
+- mode = "wb"
+-
+- @property
+- def tag(self):
+- return self.name
+-
+- @property
+- def suffix(self):
+- return "iv%s" % ("6" if self.family == socket.AF_INET6 else "4")
+-
+- def write(self, network):
+- self.f.write(network._first_address)
+- self.f.write(network._last_address)
+-
+-
+-formats = {
+- "ipset" : IpsetOutputWriter,
+- "list" : OutputWriter,
+- "nftables" : NftablesOutputWriter,
+- "xt_geoip" : XTGeoIPOutputWriter,
+-}
+-
+-class Exporter(object):
+- def __init__(self, db, writer):
+- self.db, self.writer = db, writer
+-
+- def export(self, directory, families, countries, asns):
+- for family in families:
+- log.debug("Exporting family %s" % family)
+-
+- writers = {}
+-
+- # Create writers for countries
+- for country_code in countries:
+- writers[country_code] = self.writer(country_code, family=family, directory=directory)
+-
+- # Create writers for ASNs
+- for asn in asns:
+- writers[asn] = self.writer("AS%s" % asn, family=family, directory=directory)
+-
+- # Filter countries from special country codes
+- country_codes = [
+- country_code for country_code in countries if not country_code in FLAGS.values()
+- ]
+-
+- # Get all networks that match the family
+- networks = self.db.search_networks(family=family,
+- country_codes=country_codes, asns=asns, flatten=True)
+-
+- # Walk through all networks
+- for network in networks:
+- # Write matching countries
+- try:
+- writers[network.country_code].write(network)
+- except KeyError:
+- pass
+-
+- # Write matching ASNs
+- try:
+- writers[network.asn].write(network)
+- except KeyError:
+- pass
+-
+- # Handle flags
+- for flag in FLAGS:
+- if network.has_flag(flag):
+- # Fetch the "fake" country code
+- country = FLAGS[flag]
+-
+- try:
+- writers[country].write(network)
+- except KeyError:
+- pass
+-
+- # Write everything to the filesystem
+- for writer in writers.values():
+- writer.finish()
+-
+- # Print to stdout
+- if not directory:
+- for writer in writers.values():
+- writer.print()
+diff --git a/src/python/i18n.py b/src/python/i18n.py
+deleted file mode 100644
+index 2161aa6..0000000
+--- a/src/python/i18n.py
++++ /dev/null
+@@ -1,26 +0,0 @@
+-#!/usr/bin/python3
+-###############################################################################
+-# #
+-# libloc - A library to determine the location of someone on the Internet #
+-# #
+-# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
+-# #
+-# This library is free software; you can redistribute it and/or #
+-# modify it under the terms of the GNU Lesser General Public #
+-# License as published by the Free Software Foundation; either #
+-# version 2.1 of the License, or (at your option) any later version. #
+-# #
+-# This library is distributed in the hope that it will be useful, #
+-# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
+-# Lesser General Public License for more details. #
+-# #
+-###############################################################################
+-
+-import gettext
+-
+-def _(singular, plural=None, n=None):
+- if plural:
+- return gettext.dngettext("libloc", singular, plural, n)
+-
+- return gettext.dgettext("libloc", singular)
+diff --git a/src/python/importer.py b/src/python/importer.py
+deleted file mode 100644
+index dee36ed..0000000
+--- a/src/python/importer.py
++++ /dev/null
+@@ -1,250 +0,0 @@
+-#!/usr/bin/python3
+-###############################################################################
+-# #
+-# libloc - A library to determine the location of someone on the Internet #
+-# #
+-# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
+-# #
+-# This library is free software; you can redistribute it and/or #
+-# modify it under the terms of the GNU Lesser General Public #
+-# License as published by the Free Software Foundation; either #
+-# version 2.1 of the License, or (at your option) any later version. #
+-# #
+-# This library is distributed in the hope that it will be useful, #
+-# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
+-# Lesser General Public License for more details. #
+-# #
+-###############################################################################
+-
+-import gzip
+-import logging
+-import urllib.request
+-
+-# Initialise logging
+-log = logging.getLogger("location.importer")
+-log.propagate = 1
+-
+-WHOIS_SOURCES = {
+- # African Network Information Centre
+- "AFRINIC": [
+- "https://ftp.afrinic.net/pub/pub/dbase/afrinic.db.gz"
+- ],
+-
+- # Asia Pacific Network Information Centre
+- "APNIC": [
+- "https://ftp.apnic.net/apnic/whois/apnic.db.inet6num.gz",
+- "https://ftp.apnic.net/apnic/whois/apnic.db.inetnum.gz",
+- #"https://ftp.apnic.net/apnic/whois/apnic.db.route6.gz",
+- #"https://ftp.apnic.net/apnic/whois/apnic.db.route.gz",
+- "https://ftp.apnic.net/apnic/whois/apnic.db.aut-num.gz",
+- "https://ftp.apnic.net/apnic/whois/apnic.db.organisation.gz"
+- ],
+-
+- # American Registry for Internet Numbers
+- # XXX there is nothing useful for us in here
+- # ARIN: [
+- # "https://ftp.arin.net/pub/rr/arin.db"
+- # ],
+-
+- # Japan Network Information Center
+- "JPNIC": [
+- "https://ftp.nic.ad.jp/jpirr/jpirr.db.gz"
+- ],
+-
+- # Latin America and Caribbean Network Information Centre
+- "LACNIC": [
+- "https://ftp.lacnic.net/lacnic/dbase/lacnic.db.gz"
+- ],
+-
+- # Réseaux IP Européens
+- "RIPE": [
+- "https://ftp.ripe.net/ripe/dbase/split/ripe.db.inet6num.gz",
+- "https://ftp.ripe.net/ripe/dbase/split/ripe.db.inetnum.gz",
+- #"https://ftp.ripe.net/ripe/dbase/split/ripe.db.route6.gz",
+- #"https://ftp.ripe.net/ripe/dbase/split/ripe.db.route.gz",
+- "https://ftp.ripe.net/ripe/dbase/split/ripe.db.aut-num.gz",
+- "https://ftp.ripe.net/ripe/dbase/split/ripe.db.organisation.gz"
+- ],
+-}
+-
+-EXTENDED_SOURCES = {
+- # African Network Information Centre
+- # "ARIN": [
+- # "https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest"
+- # ],
+-
+- # Asia Pacific Network Information Centre
+- # "APNIC": [
+- # "https://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-extended-latest"
+- # ],
+-
+- # American Registry for Internet Numbers
+- "ARIN": [
+- "https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest"
+- ],
+-
+- # Latin America and Caribbean Network Information Centre
+- "LACNIC": [
+- "https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest"
+- ],
+-
+- # Réseaux IP Européens
+- # "RIPE": [
+- # "https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest"
+- # ],
+-}
+-
+-class Downloader(object):
+- def __init__(self):
+- self.proxy = None
+-
+- def set_proxy(self, url):
+- """
+- Sets a HTTP proxy that is used to perform all requests
+- """
+- log.info("Using proxy %s" % url)
+- self.proxy = url
+-
+- def request(self, url, data=None, return_blocks=False):
+- req = urllib.request.Request(url, data=data)
+-
+- # Configure proxy
+- if self.proxy:
+- req.set_proxy(self.proxy, "http")
+-
+- return DownloaderContext(self, req, return_blocks=return_blocks)
+-
+-
+-class DownloaderContext(object):
+- def __init__(self, downloader, request, return_blocks=False):
+- self.downloader = downloader
+- self.request = request
+-
+- # Should we return one block or a single line?
+- self.return_blocks = return_blocks
+-
+- # Save the response object
+- self.response = None
+-
+- def __enter__(self):
+- log.info("Retrieving %s..." % self.request.full_url)
+-
+- # Send request
+- self.response = urllib.request.urlopen(self.request)
+-
+- # Log the response headers
+- log.debug("Response Headers:")
+- for header in self.headers:
+- log.debug(" %s: %s" % (header, self.get_header(header)))
+-
+- return self
+-
+- def __exit__(self, type, value, traceback):
+- pass
+-
+- def __iter__(self):
+- """
+- Makes the object iterable by going through each block
+- """
+- if self.return_blocks:
+- return iterate_over_blocks(self.body)
+-
+- return iterate_over_lines(self.body)
+-
+- @property
+- def headers(self):
+- if self.response:
+- return self.response.headers
+-
+- def get_header(self, name):
+- if self.headers:
+- return self.headers.get(name)
+-
+- @property
+- def body(self):
+- """
+- Returns a file-like object with the decoded content
+- of the response.
+- """
+- content_type = self.get_header("Content-Type")
+-
+- # Decompress any gzipped response on the fly
+- if content_type in ("application/x-gzip", "application/gzip"):
+- return gzip.GzipFile(fileobj=self.response, mode="rb")
+-
+- # Return the response by default
+- return self.response
+-
+-
+-def read_blocks(f):
+- for block in iterate_over_blocks(f):
+- type = None
+- data = {}
+-
+- for i, line in enumerate(block):
+- key, value = line.split(":", 1)
+-
+- # The key of the first line defines the type
+- if i == 0:
+- type = key
+-
+- # Store value
+- data[key] = value.strip()
+-
+- yield type, data
+-
+-def iterate_over_blocks(f, charsets=("utf-8", "latin1")):
+- block = []
+-
+- for line in f:
+- # Convert to string
+- for charset in charsets:
+- try:
+- line = line.decode(charset)
+- except UnicodeDecodeError:
+- continue
+- else:
+- break
+-
+- # Skip commented lines
+- if line.startswith("#") or line.startswith("%"):
+- continue
+-
+- # Strip line-endings
+- line = line.rstrip()
+-
+- # Remove any comments at the end of line
+- line, hash, comment = line.partition("#")
+-
+- if comment:
+- # Strip any whitespace before the comment
+- line = line.rstrip()
+-
+- # If the line is now empty, we move on
+- if not line:
+- continue
+-
+- if line:
+- block.append(line)
+- continue
+-
+- # End the block on an empty line
+- if block:
+- yield block
+-
+- # Reset the block
+- block = []
+-
+- # Return the last block
+- if block:
+- yield block
+-
+-
+-def iterate_over_lines(f):
+- for line in f:
+- # Decode the line
+- line = line.decode()
+-
+- # Strip the ending
+- yield line.rstrip()
+diff --git a/src/python/location-importer.in b/src/python/location-importer.in
+deleted file mode 100644
+index bee9186..0000000
+--- a/src/python/location-importer.in
++++ /dev/null
+@@ -1,1535 +0,0 @@
+-#!/usr/bin/python3
+-###############################################################################
+-# #
+-# libloc - A library to determine the location of someone on the Internet #
+-# #
+-# Copyright (C) 2020-2022 IPFire Development Team <info@ipfire.org> #
+-# #
+-# This library is free software; you can redistribute it and/or #
+-# modify it under the terms of the GNU Lesser General Public #
+-# License as published by the Free Software Foundation; either #
+-# version 2.1 of the License, or (at your option) any later version. #
+-# #
+-# This library is distributed in the hope that it will be useful, #
+-# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
+-# Lesser General Public License for more details. #
+-# #
+-###############################################################################
+-
+-import argparse
+-import ipaddress
+-import json
+-import logging
+-import math
+-import re
+-import socket
+-import sys
+-import telnetlib
+-
+-# Load our location module
+-import location
+-import location.database
+-import location.importer
+-from location.i18n import _
+-
+-# Initialise logging
+-log = logging.getLogger("location.importer")
+-log.propagate = 1
+-
+-# Define constants
+-VALID_ASN_RANGES = (
+- (1, 23455),
+- (23457, 64495),
+- (131072, 4199999999),
+-)
+-
+-
+-class CLI(object):
+- def parse_cli(self):
+- parser = argparse.ArgumentParser(
+- description=_("Location Importer Command Line Interface"),
+- )
+- subparsers = parser.add_subparsers()
+-
+- # Global configuration flags
+- parser.add_argument("--debug", action="store_true",
+- help=_("Enable debug output"))
+- parser.add_argument("--quiet", action="store_true",
+- help=_("Enable quiet mode"))
+-
+- # version
+- parser.add_argument("--version", action="version",
+- version="%(prog)s @VERSION@")
+-
+- # Database
+- parser.add_argument("--database-host", required=True,
+- help=_("Database Hostname"), metavar=_("HOST"))
+- parser.add_argument("--database-name", required=True,
+- help=_("Database Name"), metavar=_("NAME"))
+- parser.add_argument("--database-username", required=True,
+- help=_("Database Username"), metavar=_("USERNAME"))
+- parser.add_argument("--database-password", required=True,
+- help=_("Database Password"), metavar=_("PASSWORD"))
+-
+- # Write Database
+- write = subparsers.add_parser("write", help=_("Write database to file"))
+- write.set_defaults(func=self.handle_write)
+- write.add_argument("file", nargs=1, help=_("Database File"))
+- write.add_argument("--signing-key", nargs="?", type=open, help=_("Signing Key"))
+- write.add_argument("--backup-signing-key", nargs="?", type=open, help=_("Backup Signing Key"))
+- write.add_argument("--vendor", nargs="?", help=_("Sets the vendor"))
+- write.add_argument("--description", nargs="?", help=_("Sets a description"))
+- write.add_argument("--license", nargs="?", help=_("Sets the license"))
+- write.add_argument("--version", type=int, help=_("Database Format Version"))
+-
+- # Update WHOIS
+- update_whois = subparsers.add_parser("update-whois", help=_("Update WHOIS Information"))
+- update_whois.set_defaults(func=self.handle_update_whois)
+-
+- # Update announcements
+- update_announcements = subparsers.add_parser("update-announcements",
+- help=_("Update BGP Annoucements"))
+- update_announcements.set_defaults(func=self.handle_update_announcements)
+- update_announcements.add_argument("server", nargs=1,
+- help=_("Route Server to connect to"), metavar=_("SERVER"))
+-
+- # Update overrides
+- update_overrides = subparsers.add_parser("update-overrides",
+- help=_("Update overrides"),
+- )
+- update_overrides.add_argument(
+- "files", nargs="+", help=_("Files to import"),
+- )
+- update_overrides.set_defaults(func=self.handle_update_overrides)
+-
+- # Import countries
+- import_countries = subparsers.add_parser("import-countries",
+- help=_("Import countries"),
+- )
+- import_countries.add_argument("file", nargs=1, type=argparse.FileType("r"),
+- help=_("File to import"))
+- import_countries.set_defaults(func=self.handle_import_countries)
+-
+- args = parser.parse_args()
+-
+- # Configure logging
+- if args.debug:
+- location.logger.set_level(logging.DEBUG)
+- elif args.quiet:
+- location.logger.set_level(logging.WARNING)
+-
+- # Print usage if no action was given
+- if not "func" in args:
+- parser.print_usage()
+- sys.exit(2)
+-
+- return args
+-
+- def run(self):
+- # Parse command line arguments
+- args = self.parse_cli()
+-
+- # Initialise database
+- self.db = self._setup_database(args)
+-
+- # Call function
+- ret = args.func(args)
+-
+- # Return with exit code
+- if ret:
+- sys.exit(ret)
+-
+- # Otherwise just exit
+- sys.exit(0)
+-
+- def _setup_database(self, ns):
+- """
+- Initialise the database
+- """
+- # Connect to database
+- db = location.database.Connection(
+- host=ns.database_host, database=ns.database_name,
+- user=ns.database_username, password=ns.database_password,
+- )
+-
+- with db.transaction():
+- db.execute("""
+- -- announcements
+- CREATE TABLE IF NOT EXISTS announcements(network inet, autnum bigint,
+- first_seen_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
+- last_seen_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP);
+- CREATE UNIQUE INDEX IF NOT EXISTS announcements_networks ON announcements(network);
+- CREATE INDEX IF NOT EXISTS announcements_family ON announcements(family(network));
+- CREATE INDEX IF NOT EXISTS announcements_search ON announcements USING GIST(network inet_ops);
+-
+- -- autnums
+- CREATE TABLE IF NOT EXISTS autnums(number bigint, name text NOT NULL);
+- ALTER TABLE autnums ADD COLUMN IF NOT EXISTS source text;
+- CREATE UNIQUE INDEX IF NOT EXISTS autnums_number ON autnums(number);
+-
+- -- countries
+- CREATE TABLE IF NOT EXISTS countries(
+- country_code text NOT NULL, name text NOT NULL, continent_code text NOT NULL);
+- CREATE UNIQUE INDEX IF NOT EXISTS countries_country_code ON countries(country_code);
+-
+- -- networks
+- CREATE TABLE IF NOT EXISTS networks(network inet, country text);
+- ALTER TABLE networks ADD COLUMN IF NOT EXISTS original_countries text[];
+- ALTER TABLE networks ADD COLUMN IF NOT EXISTS source text;
+- CREATE UNIQUE INDEX IF NOT EXISTS networks_network ON networks(network);
+- CREATE INDEX IF NOT EXISTS networks_family ON networks USING BTREE(family(network));
+- CREATE INDEX IF NOT EXISTS networks_search ON networks USING GIST(network inet_ops);
+-
+- -- overrides
+- CREATE TABLE IF NOT EXISTS autnum_overrides(
+- number bigint NOT NULL,
+- name text,
+- country text,
+- is_anonymous_proxy boolean,
+- is_satellite_provider boolean,
+- is_anycast boolean
+- );
+- CREATE UNIQUE INDEX IF NOT EXISTS autnum_overrides_number
+- ON autnum_overrides(number);
+- ALTER TABLE autnum_overrides ADD COLUMN IF NOT EXISTS source text;
+- ALTER TABLE autnum_overrides ADD COLUMN IF NOT EXISTS is_drop boolean;
+-
+- CREATE TABLE IF NOT EXISTS network_overrides(
+- network inet NOT NULL,
+- country text,
+- is_anonymous_proxy boolean,
+- is_satellite_provider boolean,
+- is_anycast boolean
+- );
+- CREATE UNIQUE INDEX IF NOT EXISTS network_overrides_network
+- ON network_overrides(network);
+- CREATE INDEX IF NOT EXISTS network_overrides_search
+- ON network_overrides USING GIST(network inet_ops);
+- ALTER TABLE network_overrides ADD COLUMN IF NOT EXISTS source text;
+- ALTER TABLE network_overrides ADD COLUMN IF NOT EXISTS is_drop boolean;
+- """)
+-
+- return db
+-
+- def handle_write(self, ns):
+- """
+- Compiles a database in libloc format out of what is in the database
+- """
+- # Allocate a writer
+- writer = location.Writer(ns.signing_key, ns.backup_signing_key)
+-
+- # Set all metadata
+- if ns.vendor:
+- writer.vendor = ns.vendor
+-
+- if ns.description:
+- writer.description = ns.description
+-
+- if ns.license:
+- writer.license = ns.license
+-
+- # Add all Autonomous Systems
+- log.info("Writing Autonomous Systems...")
+-
+- # Select all ASes with a name
+- rows = self.db.query("""
+- SELECT
+- autnums.number AS number,
+- COALESCE(
+- (SELECT overrides.name FROM autnum_overrides overrides
+- WHERE overrides.number = autnums.number),
+- autnums.name
+- ) AS name
+- FROM autnums
+- WHERE name <> %s ORDER BY number
+- """, "")
+-
+- for row in rows:
+- a = writer.add_as(row.number)
+- a.name = row.name
+-
+- # Add all networks
+- log.info("Writing networks...")
+-
+- # Select all known networks
+- rows = self.db.query("""
+- WITH known_networks AS (
+- SELECT network FROM announcements
+- UNION
+- SELECT network FROM networks
+- UNION
+- SELECT network FROM network_overrides
+- ),
+-
+- ordered_networks AS (
+- SELECT
+- known_networks.network AS network,
+- announcements.autnum AS autnum,
+- networks.country AS country,
+-
+- -- Must be part of returned values for ORDER BY clause
+- masklen(announcements.network) AS sort_a,
+- masklen(networks.network) AS sort_b
+- FROM
+- known_networks
+- LEFT JOIN
+- announcements ON known_networks.network <<= announcements.network
+- LEFT JOIN
+- networks ON known_networks.network <<= networks.network
+- ORDER BY
+- known_networks.network,
+- sort_a DESC,
+- sort_b DESC
+- )
+-
+- -- Return a list of those networks enriched with all
+- -- other information that we store in the database
+- SELECT
+- DISTINCT ON (network)
+- network,
+- autnum,
+-
+- -- Country
+- COALESCE(
+- (
+- SELECT country FROM network_overrides overrides
+- WHERE networks.network <<= overrides.network
+- ORDER BY masklen(overrides.network) DESC
+- LIMIT 1
+- ),
+- (
+- SELECT country FROM autnum_overrides overrides
+- WHERE networks.autnum = overrides.number
+- ),
+- networks.country
+- ) AS country,
+-
+- -- Flags
+- COALESCE(
+- (
+- SELECT is_anonymous_proxy FROM network_overrides overrides
+- WHERE networks.network <<= overrides.network
+- ORDER BY masklen(overrides.network) DESC
+- LIMIT 1
+- ),
+- (
+- SELECT is_anonymous_proxy FROM autnum_overrides overrides
+- WHERE networks.autnum = overrides.number
+- ),
+- FALSE
+- ) AS is_anonymous_proxy,
+- COALESCE(
+- (
+- SELECT is_satellite_provider FROM network_overrides overrides
+- WHERE networks.network <<= overrides.network
+- ORDER BY masklen(overrides.network) DESC
+- LIMIT 1
+- ),
+- (
+- SELECT is_satellite_provider FROM autnum_overrides overrides
+- WHERE networks.autnum = overrides.number
+- ),
+- FALSE
+- ) AS is_satellite_provider,
+- COALESCE(
+- (
+- SELECT is_anycast FROM network_overrides overrides
+- WHERE networks.network <<= overrides.network
+- ORDER BY masklen(overrides.network) DESC
+- LIMIT 1
+- ),
+- (
+- SELECT is_anycast FROM autnum_overrides overrides
+- WHERE networks.autnum = overrides.number
+- ),
+- FALSE
+- ) AS is_anycast,
+- COALESCE(
+- (
+- SELECT is_drop FROM network_overrides overrides
+- WHERE networks.network <<= overrides.network
+- ORDER BY masklen(overrides.network) DESC
+- LIMIT 1
+- ),
+- (
+- SELECT is_drop FROM autnum_overrides overrides
+- WHERE networks.autnum = overrides.number
+- ),
+- FALSE
+- ) AS is_drop
+- FROM
+- ordered_networks networks
+- """)
+-
+- for row in rows:
+- network = writer.add_network(row.network)
+-
+- # Save country
+- if row.country:
+- network.country_code = row.country
+-
+- # Save ASN
+- if row.autnum:
+- network.asn = row.autnum
+-
+- # Set flags
+- if row.is_anonymous_proxy:
+- network.set_flag(location.NETWORK_FLAG_ANONYMOUS_PROXY)
+-
+- if row.is_satellite_provider:
+- network.set_flag(location.NETWORK_FLAG_SATELLITE_PROVIDER)
+-
+- if row.is_anycast:
+- network.set_flag(location.NETWORK_FLAG_ANYCAST)
+-
+- if row.is_drop:
+- network.set_flag(location.NETWORK_FLAG_DROP)
+-
+- # Add all countries
+- log.info("Writing countries...")
+- rows = self.db.query("SELECT * FROM countries ORDER BY country_code")
+-
+- for row in rows:
+- c = writer.add_country(row.country_code)
+- c.continent_code = row.continent_code
+- c.name = row.name
+-
+- # Write everything to file
+- log.info("Writing database to file...")
+- for file in ns.file:
+- writer.write(file)
+-
+- def handle_update_whois(self, ns):
+- downloader = location.importer.Downloader()
+-
+- # Download all sources
+- with self.db.transaction():
+- # Create some temporary tables to store parsed data
+- self.db.execute("""
+- CREATE TEMPORARY TABLE _autnums(number integer NOT NULL, organization text NOT NULL, source text NOT NULL)
+- ON COMMIT DROP;
+- CREATE UNIQUE INDEX _autnums_number ON _autnums(number);
+-
+- CREATE TEMPORARY TABLE _organizations(handle text NOT NULL, name text NOT NULL, source text NOT NULL)
+- ON COMMIT DROP;
+- CREATE UNIQUE INDEX _organizations_handle ON _organizations(handle);
+-
+- CREATE TEMPORARY TABLE _rirdata(network inet NOT NULL, country text NOT NULL, original_countries text[] NOT NULL, source text NOT NULL)
+- ON COMMIT DROP;
+- CREATE INDEX _rirdata_search ON _rirdata USING BTREE(family(network), masklen(network));
+- CREATE UNIQUE INDEX _rirdata_network ON _rirdata(network);
+- """)
+-
+- # Remove all previously imported content
+- self.db.execute("""
+- TRUNCATE TABLE networks;
+- """)
+-
+- # Fetch all valid country codes to check parsed networks aganist...
+- rows = self.db.query("SELECT * FROM countries ORDER BY country_code")
+- validcountries = []
+-
+- for row in rows:
+- validcountries.append(row.country_code)
+-
+- for source_key in location.importer.WHOIS_SOURCES:
+- for single_url in location.importer.WHOIS_SOURCES[source_key]:
+- with downloader.request(single_url, return_blocks=True) as f:
+- for block in f:
+- self._parse_block(block, source_key, validcountries)
+-
+- # Process all parsed networks from every RIR we happen to have access to,
+- # insert the largest network chunks into the networks table immediately...
+- families = self.db.query("SELECT DISTINCT family(network) AS family FROM _rirdata ORDER BY family(network)")
+-
+- for family in (row.family for row in families):
+- smallest = self.db.get("SELECT MIN(masklen(network)) AS prefix FROM _rirdata WHERE family(network) = %s", family)
+-
+- self.db.execute("INSERT INTO networks(network, country, original_countries, source) \
+- SELECT network, country, original_countries, source FROM _rirdata WHERE masklen(network) = %s AND family(network) = %s", smallest.prefix, family)
+-
+- # ... determine any other prefixes for this network family, ...
+- prefixes = self.db.query("SELECT DISTINCT masklen(network) AS prefix FROM _rirdata \
+- WHERE family(network) = %s ORDER BY masklen(network) ASC OFFSET 1", family)
+-
+- # ... and insert networks with this prefix in case they provide additional
+- # information (i. e. subnet of a larger chunk with a different country)
+- for prefix in (row.prefix for row in prefixes):
+- self.db.execute("""
+- WITH candidates AS (
+- SELECT
+- _rirdata.network,
+- _rirdata.country,
+- _rirdata.original_countries,
+- _rirdata.source
+- FROM
+- _rirdata
+- WHERE
+- family(_rirdata.network) = %s
+- AND
+- masklen(_rirdata.network) = %s
+- ),
+- filtered AS (
+- SELECT
+- DISTINCT ON (c.network)
+- c.network,
+- c.country,
+- c.original_countries,
+- c.source,
+- masklen(networks.network),
+- networks.country AS parent_country
+- FROM
+- candidates c
+- LEFT JOIN
+- networks
+- ON
+- c.network << networks.network
+- ORDER BY
+- c.network,
+- masklen(networks.network) DESC NULLS LAST
+- )
+- INSERT INTO
+- networks(network, country, original_countries, source)
+- SELECT
+- network,
+- country,
+- original_countries,
+- source
+- FROM
+- filtered
+- WHERE
+- parent_country IS NULL
+- OR
+- country <> parent_country
+- ON CONFLICT DO NOTHING""",
+- family, prefix,
+- )
+-
+- self.db.execute("""
+- INSERT INTO autnums(number, name, source)
+- SELECT _autnums.number, _organizations.name, _organizations.source FROM _autnums
+- JOIN _organizations ON _autnums.organization = _organizations.handle
+- ON CONFLICT (number) DO UPDATE SET name = excluded.name;
+- """)
+-
+- # Download all extended sources
+- for source_key in location.importer.EXTENDED_SOURCES:
+- for single_url in location.importer.EXTENDED_SOURCES[source_key]:
+- with self.db.transaction():
+- # Download data
+- with downloader.request(single_url) as f:
+- for line in f:
+- self._parse_line(line, source_key, validcountries)
+-
+- # Download and import (technical) AS names from ARIN
+- self._import_as_names_from_arin()
+-
+- def _check_parsed_network(self, network):
+- """
+- Assistive function to detect and subsequently sort out parsed
+- networks from RIR data (both Whois and so-called "extended sources"),
+- which are or have...
+-
+- (a) not globally routable (RFC 1918 space, et al.)
+- (b) covering a too large chunk of the IP address space (prefix length
+- is < 7 for IPv4 networks, and < 10 for IPv6)
+- (c) "0.0.0.0" or "::" as a network address
+- (d) are too small for being publicly announced (we have decided not to
+- process them at the moment, as they significantly enlarge our
+- database without providing very helpful additional information)
+-
+- This unfortunately is necessary due to brain-dead clutter across
+- various RIR databases, causing mismatches and eventually disruptions.
+-
+- We will return False in case a network is not suitable for adding
+- it to our database, and True otherwise.
+- """
+-
+- if not network or not (isinstance(network, ipaddress.IPv4Network) or isinstance(network, ipaddress.IPv6Network)):
+- return False
+-
+- if not network.is_global:
+- log.debug("Skipping non-globally routable network: %s" % network)
+- return False
+-
+- if network.version == 4:
+- if network.prefixlen < 7:
+- log.debug("Skipping too big IP chunk: %s" % network)
+- return False
+-
+- if network.prefixlen > 24:
+- log.debug("Skipping network too small to be publicly announced: %s" % network)
+- return False
+-
+- if str(network.network_address) == "0.0.0.0":
+- log.debug("Skipping network based on 0.0.0.0: %s" % network)
+- return False
+-
+- elif network.version == 6:
+- if network.prefixlen < 10:
+- log.debug("Skipping too big IP chunk: %s" % network)
+- return False
+-
+- if network.prefixlen > 48:
+- log.debug("Skipping network too small to be publicly announced: %s" % network)
+- return False
+-
+- if str(network.network_address) == "::":
+- log.debug("Skipping network based on '::': %s" % network)
+- return False
+-
+- else:
+- # This should not happen...
+- log.warning("Skipping network of unknown family, this should not happen: %s" % network)
+- return False
+-
+- # In case we have made it here, the network is considered to
+- # be suitable for libloc consumption...
+- return True
+-
+- def _check_parsed_asn(self, asn):
+- """
+- Assistive function to filter Autonomous System Numbers not being suitable
+- for adding to our database. Returns False in such cases, and True otherwise.
+- """
+-
+- for start, end in VALID_ASN_RANGES:
+- if start <= asn and end >= asn:
+- return True
+-
+- log.info("Supplied ASN %s out of publicly routable ASN ranges" % asn)
+- return False
+-
+- def _parse_block(self, block, source_key, validcountries = None):
+- # Get first line to find out what type of block this is
+- line = block[0]
+-
+- # aut-num
+- if line.startswith("aut-num:"):
+- return self._parse_autnum_block(block, source_key)
+-
+- # inetnum
+- if line.startswith("inet6num:") or line.startswith("inetnum:"):
+- return self._parse_inetnum_block(block, source_key, validcountries)
+-
+- # organisation
+- elif line.startswith("organisation:"):
+- return self._parse_org_block(block, source_key)
+-
+- def _parse_autnum_block(self, block, source_key):
+- autnum = {}
+- for line in block:
+- # Split line
+- key, val = split_line(line)
+-
+- if key == "aut-num":
+- m = re.match(r"^(AS|as)(\d+)", val)
+- if m:
+- autnum["asn"] = m.group(2)
+-
+- elif key == "org":
+- autnum[key] = val.upper()
+-
+- elif key == "descr":
+- # Save the first description line as well...
+- if not key in autnum:
+- autnum[key] = val
+-
+- # Skip empty objects
+- if not autnum or not "asn" in autnum:
+- return
+-
+- # Insert a dummy organisation handle into our temporary organisations
+- # table in case the AS does not have an organisation handle set, but
+- # has a description (a quirk often observed in APNIC area), so we can
+- # later display at least some string for this AS.
+- if not "org" in autnum:
+- if "descr" in autnum:
+- autnum["org"] = "LIBLOC-%s-ORGHANDLE" % autnum.get("asn")
+-
+- self.db.execute("INSERT INTO _organizations(handle, name, source) \
+- VALUES(%s, %s, %s) ON CONFLICT (handle) DO NOTHING",
+- autnum.get("org"), autnum.get("descr"), source_key,
+- )
+- else:
+- log.warning("ASN %s neither has an organisation handle nor a description line set, omitting" % \
+- autnum.get("asn"))
+- return
+-
+- # Insert into database
+- self.db.execute("INSERT INTO _autnums(number, organization, source) \
+- VALUES(%s, %s, %s) ON CONFLICT (number) DO UPDATE SET \
+- organization = excluded.organization",
+- autnum.get("asn"), autnum.get("org"), source_key,
+- )
+-
+- def _parse_inetnum_block(self, block, source_key, validcountries = None):
+- log.debug("Parsing inetnum block:")
+-
+- inetnum = {}
+- for line in block:
+- log.debug(line)
+-
+- # Split line
+- key, val = split_line(line)
+-
+- # Filter any inetnum records which are only referring to IP space
+- # not managed by that specific RIR...
+- if key == "netname":
+- if re.match(r"^(ERX-NETBLOCK|(AFRINIC|ARIN|LACNIC|RIPE)-CIDR-BLOCK|IANA-NETBLOCK-\d{1,3}|NON-RIPE-NCC-MANAGED-ADDRESS-BLOCK|STUB-[\d-]{3,}SLASH\d{1,2})", val.strip()):
+- log.debug("Skipping record indicating historic/orphaned data: %s" % val.strip())
+- return
+-
+- if key == "inetnum":
+- start_address, delim, end_address = val.partition("-")
+-
+- # Strip any excess space
+- start_address, end_address = start_address.rstrip(), end_address.strip()
+-
+- # Handle "inetnum" formatting in LACNIC DB (e.g. "24.152.8/22" instead of "24.152.8.0/22")
+- if start_address and not (delim or end_address):
+- try:
+- start_address = ipaddress.ip_network(start_address, strict=False)
+- except ValueError:
+- start_address = start_address.split("/")
+- ldigits = start_address[0].count(".")
+-
+- # How many octets do we need to add?
+- # (LACNIC does not seem to have a /8 or greater assigned, so the following should suffice.)
+- if ldigits == 1:
+- start_address = start_address[0] + ".0.0/" + start_address[1]
+- elif ldigits == 2:
+- start_address = start_address[0] + ".0/" + start_address[1]
+- else:
+- log.warning("Could not recover IPv4 address from line in LACNIC DB format: %s" % line)
+- return
+-
+- try:
+- start_address = ipaddress.ip_network(start_address, strict=False)
+- except ValueError:
+- log.warning("Could not parse line in LACNIC DB format: %s" % line)
+- return
+-
+- # Enumerate first and last IP address of this network
+- end_address = start_address[-1]
+- start_address = start_address[0]
+-
+- else:
+- # Convert to IP address
+- try:
+- start_address = ipaddress.ip_address(start_address)
+- end_address = ipaddress.ip_address(end_address)
+- except ValueError:
+- log.warning("Could not parse line: %s" % line)
+- return
+-
+- inetnum["inetnum"] = list(ipaddress.summarize_address_range(start_address, end_address))
+-
+- elif key == "inet6num":
+- inetnum[key] = [ipaddress.ip_network(val, strict=False)]
+-
+- elif key == "country":
+- val = val.upper()
+-
+- # Catch RIR data objects with more than one country code...
+- if not key in inetnum:
+- inetnum[key] = []
+- else:
+- if val in inetnum.get("country"):
+- # ... but keep this list distinct...
+- continue
+-
+- # When people set country codes to "UK", they actually mean "GB"
+- if val == "UK":
+- val = "GB"
+-
+- inetnum[key].append(val)
+-
+- # Skip empty objects
+- if not inetnum or not "country" in inetnum:
+- return
+-
+- # Prepare skipping objects with unknown country codes...
+- invalidcountries = [singlecountry for singlecountry in inetnum.get("country") if singlecountry not in validcountries]
+-
+- # Iterate through all networks enumerated from above, check them for plausibility and insert
+- # them into the database, if _check_parsed_network() succeeded
+- for single_network in inetnum.get("inet6num") or inetnum.get("inetnum"):
+- if self._check_parsed_network(single_network):
+-
+- # Skip objects with unknown country codes if they are valid to avoid log spam...
+- if validcountries and invalidcountries:
+- log.warning("Skipping network with bogus countr(y|ies) %s (original countries: %s): %s" % \
+- (invalidcountries, inetnum.get("country"), inetnum.get("inet6num") or inetnum.get("inetnum")))
+- break
+-
+- # Everything is fine here, run INSERT statement...
+- self.db.execute("INSERT INTO _rirdata(network, country, original_countries, source) \
+- VALUES(%s, %s, %s, %s) ON CONFLICT (network) DO UPDATE SET country = excluded.country",
+- "%s" % single_network, inetnum.get("country")[0], inetnum.get("country"), source_key,
+- )
+-
+- def _parse_org_block(self, block, source_key):
+- org = {}
+- for line in block:
+- # Split line
+- key, val = split_line(line)
+-
+- if key == "organisation":
+- org[key] = val.upper()
+- elif key == "org-name":
+- org[key] = val
+-
+- # Skip empty objects
+- if not org:
+- return
+-
+- self.db.execute("INSERT INTO _organizations(handle, name, source) \
+- VALUES(%s, %s, %s) ON CONFLICT (handle) DO \
+- UPDATE SET name = excluded.name",
+- org.get("organisation"), org.get("org-name"), source_key,
+- )
+-
+- def _parse_line(self, line, source_key, validcountries = None):
+- # Skip version line
+- if line.startswith("2"):
+- return
+-
+- # Skip comments
+- if line.startswith("#"):
+- return
+-
+- try:
+- registry, country_code, type, line = line.split("|", 3)
+- except:
+- log.warning("Could not parse line: %s" % line)
+- return
+-
+- # Skip any lines that are for stats only or do not have a country
+- # code at all (avoids log spam below)
+- if not country_code or country_code == '*':
+- return
+-
+- # Skip objects with unknown country codes
+- if validcountries and country_code not in validcountries:
+- log.warning("Skipping line with bogus country '%s': %s" % \
+- (country_code, line))
+- return
+-
+- if type in ("ipv6", "ipv4"):
+- return self._parse_ip_line(country_code, type, line, source_key)
+-
+- def _parse_ip_line(self, country, type, line, source_key):
+- try:
+- address, prefix, date, status, organization = line.split("|")
+- except ValueError:
+- organization = None
+-
+- # Try parsing the line without organization
+- try:
+- address, prefix, date, status = line.split("|")
+- except ValueError:
+- log.warning("Unhandled line format: %s" % line)
+- return
+-
+- # Skip anything that isn't properly assigned
+- if not status in ("assigned", "allocated"):
+- return
+-
+- # Cast prefix into an integer
+- try:
+- prefix = int(prefix)
+- except:
+- log.warning("Invalid prefix: %s" % prefix)
+- return
+-
+- # Fix prefix length for IPv4
+- if type == "ipv4":
+- prefix = 32 - int(math.log(prefix, 2))
+-
+- # Try to parse the address
+- try:
+- network = ipaddress.ip_network("%s/%s" % (address, prefix), strict=False)
+- except ValueError:
+- log.warning("Invalid IP address: %s" % address)
+- return
+-
+- if not self._check_parsed_network(network):
+- return
+-
+- self.db.execute("INSERT INTO networks(network, country, original_countries, source) \
+- VALUES(%s, %s, %s, %s) ON CONFLICT (network) DO \
+- UPDATE SET country = excluded.country",
+- "%s" % network, country, [country], source_key,
+- )
+-
+- def _import_as_names_from_arin(self):
+- downloader = location.importer.Downloader()
+-
+- # XXX: Download AS names file from ARIN (note that these names appear to be quite
+- # technical, not intended for human consumption, as description fields in
+- # organisation handles for other RIRs are - however, this is what we have got,
+- # and in some cases, it might be still better than nothing)
+- with downloader.request("https://ftp.arin.net/info/asn.txt", return_blocks=False) as f:
+- for line in f:
+- # Convert binary line to string...
+- line = str(line)
+-
+- # ... valid lines start with a space, followed by the number of the Autonomous System ...
+- if not line.startswith(" "):
+- continue
+-
+- # Split line and check if there is a valid ASN in it...
+- asn, name = line.split()[0:2]
+-
+- try:
+- asn = int(asn)
+- except ValueError:
+- log.debug("Skipping ARIN AS names line not containing an integer for ASN")
+- continue
+-
+- # Filter invalid ASNs...
+- if not self._check_parsed_asn(asn):
+- continue
+-
+- # Skip any AS name that appears to be a placeholder for a different RIR or entity...
+- if re.match(r"^(ASN-BLK|)(AFCONC|AFRINIC|APNIC|ASNBLK|DNIC|LACNIC|RIPE|IANA)(?:\d?$|\-)", name):
+- continue
+-
+- # Bail out in case the AS name contains anything we do not expect here...
+- if re.search(r"[^a-zA-Z0-9-_]", name):
+- log.debug("Skipping ARIN AS name for %s containing invalid characters: %s" % \
+- (asn, name))
+-
+- # Things look good here, run INSERT statement and skip this one if we already have
+- # a (better?) name for this Autonomous System...
+- self.db.execute("""
+- INSERT INTO autnums(
+- number,
+- name,
+- source
+- ) VALUES (%s, %s, %s)
+- ON CONFLICT (number) DO NOTHING""",
+- asn,
+- name,
+- "ARIN",
+- )
+-
+- def handle_update_announcements(self, ns):
+- server = ns.server[0]
+-
+- with self.db.transaction():
+- if server.startswith("/"):
+- self._handle_update_announcements_from_bird(server)
+- else:
+- self._handle_update_announcements_from_telnet(server)
+-
+- # Purge anything we never want here
+- self.db.execute("""
+- -- Delete default routes
+- DELETE FROM announcements WHERE network = '::/0' OR network = '0.0.0.0/0';
+-
+- -- Delete anything that is not global unicast address space
+- DELETE FROM announcements WHERE family(network) = 6 AND NOT network <<= '2000::/3';
+-
+- -- DELETE "current network" address space
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '0.0.0.0/8';
+-
+- -- DELETE local loopback address space
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '127.0.0.0/8';
+-
+- -- DELETE RFC 1918 address space
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '10.0.0.0/8';
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '172.16.0.0/12';
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.168.0.0/16';
+-
+- -- DELETE test, benchmark and documentation address space
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.0.0.0/24';
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.0.2.0/24';
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '198.18.0.0/15';
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '198.51.100.0/24';
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '203.0.113.0/24';
+-
+- -- DELETE CGNAT address space (RFC 6598)
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '100.64.0.0/10';
+-
+- -- DELETE link local address space
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '169.254.0.0/16';
+-
+- -- DELETE IPv6 to IPv4 (6to4) address space (RFC 3068)
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.88.99.0/24';
+- DELETE FROM announcements WHERE family(network) = 6 AND network <<= '2002::/16';
+-
+- -- DELETE multicast and reserved address space
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '224.0.0.0/4';
+- DELETE FROM announcements WHERE family(network) = 4 AND network <<= '240.0.0.0/4';
+-
+- -- Delete networks that are too small to be in the global routing table
+- DELETE FROM announcements WHERE family(network) = 6 AND masklen(network) > 48;
+- DELETE FROM announcements WHERE family(network) = 4 AND masklen(network) > 24;
+-
+- -- Delete any non-public or reserved ASNs
+- DELETE FROM announcements WHERE NOT (
+- (autnum >= 1 AND autnum <= 23455)
+- OR
+- (autnum >= 23457 AND autnum <= 64495)
+- OR
+- (autnum >= 131072 AND autnum <= 4199999999)
+- );
+-
+- -- Delete everything that we have not seen for 14 days
+- DELETE FROM announcements WHERE last_seen_at <= CURRENT_TIMESTAMP - INTERVAL '14 days';
+- """)
+-
+- def _handle_update_announcements_from_bird(self, server):
+- # Pre-compile the regular expression for faster searching
+- route = re.compile(b"^\s(.+?)\s+.+?\[(?:AS(.*?))?.\]$")
+-
+- log.info("Requesting routing table from Bird (%s)" % server)
+-
+- aggregated_networks = []
+-
+- # Send command to list all routes
+- for line in self._bird_cmd(server, "show route"):
+- m = route.match(line)
+- if not m:
+- # Skip empty lines
+- if not line:
+- pass
+-
+- # Ignore any header lines with the name of the routing table
+- elif line.startswith(b"Table"):
+- pass
+-
+- # Log anything else
+- else:
+- log.debug("Could not parse line: %s" % line.decode())
+-
+- continue
+-
+- # Fetch the extracted network and ASN
+- network, autnum = m.groups()
+-
+- # Decode into strings
+- if network:
+- network = network.decode()
+- if autnum:
+- autnum = autnum.decode()
+-
+- # Collect all aggregated networks
+- if not autnum:
+- log.debug("%s is an aggregated network" % network)
+- aggregated_networks.append(network)
+- continue
+-
+- # Insert it into the database
+- self.db.execute("INSERT INTO announcements(network, autnum) \
+- VALUES(%s, %s) ON CONFLICT (network) DO \
+- UPDATE SET autnum = excluded.autnum, last_seen_at = CURRENT_TIMESTAMP",
+- network, autnum,
+- )
+-
+- # Process any aggregated networks
+- for network in aggregated_networks:
+- log.debug("Processing aggregated network %s" % network)
+-
+- # Run "show route all" for each network
+- for line in self._bird_cmd(server, "show route %s all" % network):
+- # Try finding the path
+- m = re.match(b"\s+BGP\.as_path:.* (\d+) {\d+}$", line)
+- if m:
+- # Select the last AS number in the path
+- autnum = m.group(1).decode()
+-
+- # Insert it into the database
+- self.db.execute("INSERT INTO announcements(network, autnum) \
+- VALUES(%s, %s) ON CONFLICT (network) DO \
+- UPDATE SET autnum = excluded.autnum, last_seen_at = CURRENT_TIMESTAMP",
+- network, autnum,
+- )
+-
+- # We don't need to process any more
+- break
+-
+- def _handle_update_announcements_from_telnet(self, server):
+- # Pre-compile regular expression for routes
+- route = re.compile(b"^\*[\s\>]i([^\s]+).+?(\d+)\si\r\n", re.MULTILINE|re.DOTALL)
+-
+- with telnetlib.Telnet(server) as t:
+- # Enable debug mode
+- #if ns.debug:
+- # t.set_debuglevel(10)
+-
+- # Wait for console greeting
+- greeting = t.read_until(b"> ", timeout=30)
+- if not greeting:
+- log.error("Could not get a console prompt")
+- return 1
+-
+- # Disable pagination
+- t.write(b"terminal length 0\n")
+-
+- # Wait for the prompt to return
+- t.read_until(b"> ")
+-
+- # Fetch the routing tables
+- for protocol in ("ipv6", "ipv4"):
+- log.info("Requesting %s routing table" % protocol)
+-
+- # Request the full unicast routing table
+- t.write(b"show bgp %s unicast\n" % protocol.encode())
+-
+- # Read entire header which ends with "Path"
+- t.read_until(b"Path\r\n")
+-
+- while True:
+- # Try reading a full entry
+- # Those might be broken across multiple lines but ends with i
+- line = t.read_until(b"i\r\n", timeout=5)
+- if not line:
+- break
+-
+- # Show line for debugging
+- #log.debug(repr(line))
+-
+- # Try finding a route in here
+- m = route.match(line)
+- if m:
+- network, autnum = m.groups()
+-
+- # Convert network to string
+- network = network.decode()
+-
+- # Append /24 for IPv4 addresses
+- if not "/" in network and not ":" in network:
+- network = "%s/24" % network
+-
+- # Convert AS number to integer
+- autnum = int(autnum)
+-
+- log.info("Found announcement for %s by %s" % (network, autnum))
+-
+- self.db.execute("INSERT INTO announcements(network, autnum) \
+- VALUES(%s, %s) ON CONFLICT (network) DO \
+- UPDATE SET autnum = excluded.autnum, last_seen_at = CURRENT_TIMESTAMP",
+- network, autnum,
+- )
+-
+- log.info("Finished reading the %s routing table" % protocol)
+-
+- def _bird_cmd(self, socket_path, command):
+- # Connect to the socket
+- s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+- s.connect(socket_path)
+-
+- # Allocate some buffer
+- buffer = b""
+-
+- log.debug("Sending Bird command: %s" % command)
+-
+- # Send the command
+- s.send(b"%s\n" % command.encode())
+-
+- while True:
+- # Fill up the buffer
+- buffer += s.recv(4096)
+-
+- while True:
+- # Search for the next newline
+- pos = buffer.find(b"\n")
+-
+- # If we cannot find one, we go back and read more data
+- if pos <= 0:
+- break
+-
+- # Cut after the newline character
+- pos += 1
+-
+- # Split the line we want and keep the rest in buffer
+- line, buffer = buffer[:pos], buffer[pos:]
+-
+- # Try parsing any status lines
+- if len(line) > 4 and line[:4].isdigit() and line[4] in (32, 45):
+- code, delim, line = int(line[:4]), line[4], line[5:]
+-
+- log.debug("Received response code %s from bird" % code)
+-
+- # End of output
+- if code == 0:
+- return
+-
+- # Ignore hello line
+- elif code == 1:
+- continue
+-
+- # Otherwise return the line
+- yield line
+-
+- def handle_update_overrides(self, ns):
+- with self.db.transaction():
+- # Drop all data that we have
+- self.db.execute("""
+- TRUNCATE TABLE autnum_overrides;
+- TRUNCATE TABLE network_overrides;
+- """)
+-
+- # Update overrides for various cloud providers big enough to publish their own IP
+- # network allocation lists in a machine-readable format...
+- self._update_overrides_for_aws()
+-
+- # Update overrides for Spamhaus DROP feeds...
+- self._update_overrides_for_spamhaus_drop()
+-
+- for file in ns.files:
+- log.info("Reading %s..." % file)
+-
+- with open(file, "rb") as f:
+- for type, block in location.importer.read_blocks(f):
+- if type == "net":
+- network = block.get("net")
+- # Try to parse and normalise the network
+- try:
+- network = ipaddress.ip_network(network, strict=False)
+- except ValueError as e:
+- log.warning("Invalid IP network: %s: %s" % (network, e))
+- continue
+-
+- # Prevent that we overwrite all networks
+- if network.prefixlen == 0:
+- log.warning("Skipping %s: You cannot overwrite default" % network)
+- continue
+-
+- self.db.execute("""
+- INSERT INTO network_overrides(
+- network,
+- country,
+- source,
+- is_anonymous_proxy,
+- is_satellite_provider,
+- is_anycast,
+- is_drop
+- ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+- ON CONFLICT (network) DO NOTHING""",
+- "%s" % network,
+- block.get("country"),
+- "manual",
+- self._parse_bool(block, "is-anonymous-proxy"),
+- self._parse_bool(block, "is-satellite-provider"),
+- self._parse_bool(block, "is-anycast"),
+- self._parse_bool(block, "drop"),
+- )
+-
+- elif type == "aut-num":
+- autnum = block.get("aut-num")
+-
+- # Check if AS number begins with "AS"
+- if not autnum.startswith("AS"):
+- log.warning("Invalid AS number: %s" % autnum)
+- continue
+-
+- # Strip "AS"
+- autnum = autnum[2:]
+-
+- self.db.execute("""
+- INSERT INTO autnum_overrides(
+- number,
+- name,
+- country,
+- source,
+- is_anonymous_proxy,
+- is_satellite_provider,
+- is_anycast,
+- is_drop
+- ) VALUES(%s, %s, %s, %s, %s, %s, %s, %s)
+- ON CONFLICT DO NOTHING""",
+- autnum,
+- block.get("name"),
+- block.get("country"),
+- "manual",
+- self._parse_bool(block, "is-anonymous-proxy"),
+- self._parse_bool(block, "is-satellite-provider"),
+- self._parse_bool(block, "is-anycast"),
+- self._parse_bool(block, "drop"),
+- )
+-
+- else:
+- log.warning("Unsupported type: %s" % type)
+-
+- def _update_overrides_for_aws(self):
+- # Download Amazon AWS IP allocation file to create overrides...
+- downloader = location.importer.Downloader()
+-
+- try:
+- with downloader.request("https://ip-ranges.amazonaws.com/ip-ranges.json", return_blocks=False) as f:
+- aws_ip_dump = json.load(f.body)
+- except Exception as e:
+- log.error("unable to preprocess Amazon AWS IP ranges: %s" % e)
+- return
+-
+- # XXX: Set up a dictionary for mapping a region name to a country. Unfortunately,
+- # there seems to be no machine-readable version available of this other than
+- # https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html
+- # (worse, it seems to be incomplete :-/ ); https://www.cloudping.cloud/endpoints
+- # was helpful here as well.
+- aws_region_country_map = {
+- "af-south-1": "ZA",
+- "ap-east-1": "HK",
+- "ap-south-1": "IN",
+- "ap-south-2": "IN",
+- "ap-northeast-3": "JP",
+- "ap-northeast-2": "KR",
+- "ap-southeast-1": "SG",
+- "ap-southeast-2": "AU",
+- "ap-southeast-3": "MY",
+- "ap-southeast-4": "AU",
+- "ap-northeast-1": "JP",
+- "ca-central-1": "CA",
+- "eu-central-1": "DE",
+- "eu-central-2": "CH",
+- "eu-west-1": "IE",
+- "eu-west-2": "GB",
+- "eu-south-1": "IT",
+- "eu-south-2": "ES",
+- "eu-west-3": "FR",
+- "eu-north-1": "SE",
+- "il-central-1": "IL", # XXX: This one is not documented anywhere except for ip-ranges.json itself
+- "me-central-1": "AE",
+- "me-south-1": "BH",
+- "sa-east-1": "BR"
+- }
+-
+- # Fetch all valid country codes to check parsed networks aganist...
+- rows = self.db.query("SELECT * FROM countries ORDER BY country_code")
+- validcountries = []
+-
+- for row in rows:
+- validcountries.append(row.country_code)
+-
+- with self.db.transaction():
+- for snetwork in aws_ip_dump["prefixes"] + aws_ip_dump["ipv6_prefixes"]:
+- try:
+- network = ipaddress.ip_network(snetwork.get("ip_prefix") or snetwork.get("ipv6_prefix"), strict=False)
+- except ValueError:
+- log.warning("Unable to parse line: %s" % snetwork)
+- continue
+-
+- # Sanitize parsed networks...
+- if not self._check_parsed_network(network):
+- continue
+-
+- # Determine region of this network...
+- region = snetwork["region"]
+- cc = None
+- is_anycast = False
+-
+- # Any region name starting with "us-" will get "US" country code assigned straight away...
+- if region.startswith("us-"):
+- cc = "US"
+- elif region.startswith("cn-"):
+- # ... same goes for China ...
+- cc = "CN"
+- elif region == "GLOBAL":
+- # ... funny region name for anycast-like networks ...
+- is_anycast = True
+- elif region in aws_region_country_map:
+- # ... assign looked up country code otherwise ...
+- cc = aws_region_country_map[region]
+- else:
+- # ... and bail out if we are missing something here
+- log.warning("Unable to determine country code for line: %s" % snetwork)
+- continue
+-
+- # Skip networks with unknown country codes
+- if not is_anycast and validcountries and cc not in validcountries:
+- log.warning("Skipping Amazon AWS network with bogus country '%s': %s" % \
+- (cc, network))
+- return
+-
+- # Conduct SQL statement...
+- self.db.execute("""
+- INSERT INTO network_overrides(
+- network,
+- country,
+- source,
+- is_anonymous_proxy,
+- is_satellite_provider,
+- is_anycast
+- ) VALUES (%s, %s, %s, %s, %s, %s)
+- ON CONFLICT (network) DO NOTHING""",
+- "%s" % network,
+- cc,
+- "Amazon AWS IP feed",
+- None,
+- None,
+- is_anycast,
+- )
+-
+-
+- def _update_overrides_for_spamhaus_drop(self):
+- downloader = location.importer.Downloader()
+-
+- ip_urls = [
+- "https://www.spamhaus.org/drop/drop.txt",
+- "https://www.spamhaus.org/drop/edrop.txt",
+- "https://www.spamhaus.org/drop/dropv6.txt"
+- ]
+-
+- asn_urls = [
+- "https://www.spamhaus.org/drop/asndrop.txt"
+- ]
+-
+- for url in ip_urls:
+- try:
+- with downloader.request(url, return_blocks=False) as f:
+- fcontent = f.body.readlines()
+- except Exception as e:
+- log.error("Unable to download Spamhaus DROP URL %s: %s" % (url, e))
+- return
+-
+- # Iterate through every line, filter comments and add remaining networks to
+- # the override table in case they are valid...
+- with self.db.transaction():
+- for sline in fcontent:
+-
+- # The response is assumed to be encoded in UTF-8...
+- sline = sline.decode("utf-8")
+-
+- # Comments start with a semicolon...
+- if sline.startswith(";"):
+- continue
+-
+- # Extract network and ignore anything afterwards...
+- try:
+- network = ipaddress.ip_network(sline.split()[0], strict=False)
+- except ValueError:
+- log.error("Unable to parse line: %s" % sline)
+- continue
+-
+- # Sanitize parsed networks...
+- if not self._check_parsed_network(network):
+- log.warning("Skipping bogus network found in Spamhaus DROP URL %s: %s" % \
+- (url, network))
+- continue
+-
+- # Conduct SQL statement...
+- self.db.execute("""
+- INSERT INTO network_overrides(
+- network,
+- source,
+- is_drop
+- ) VALUES (%s, %s, %s)
+- ON CONFLICT (network) DO UPDATE SET is_drop = True""",
+- "%s" % network,
+- "Spamhaus DROP lists",
+- True
+- )
+-
+- for url in asn_urls:
+- try:
+- with downloader.request(url, return_blocks=False) as f:
+- fcontent = f.body.readlines()
+- except Exception as e:
+- log.error("Unable to download Spamhaus DROP URL %s: %s" % (url, e))
+- return
+-
+- # Iterate through every line, filter comments and add remaining ASNs to
+- # the override table in case they are valid...
+- with self.db.transaction():
+- for sline in fcontent:
+-
+- # The response is assumed to be encoded in UTF-8...
+- sline = sline.decode("utf-8")
+-
+- # Comments start with a semicolon...
+- if sline.startswith(";"):
+- continue
+-
+- # Throw away anything after the first space...
+- sline = sline.split()[0]
+-
+- # ... strip the "AS" prefix from it ...
+- sline = sline.strip("AS")
+-
+- # ... and convert it into an integer. Voila.
+- asn = int(sline)
+-
+- # Filter invalid ASNs...
+- if not self._check_parsed_asn(asn):
+- log.warning("Skipping bogus ASN found in Spamhaus DROP URL %s: %s" % \
+- (url, asn))
+- continue
+-
+- # Conduct SQL statement...
+- self.db.execute("""
+- INSERT INTO autnum_overrides(
+- number,
+- source,
+- is_drop
+- ) VALUES (%s, %s, %s)
+- ON CONFLICT (number) DO UPDATE SET is_drop = True""",
+- "%s" % asn,
+- "Spamhaus ASN-DROP list",
+- True
+- )
+-
+- @staticmethod
+- def _parse_bool(block, key):
+- val = block.get(key)
+-
+- # There is no point to proceed when we got None
+- if val is None:
+- return
+-
+- # Convert to lowercase
+- val = val.lower()
+-
+- # True
+- if val in ("yes", "1"):
+- return True
+-
+- # False
+- if val in ("no", "0"):
+- return False
+-
+- # Default to None
+- return None
+-
+- def handle_import_countries(self, ns):
+- with self.db.transaction():
+- # Drop all data that we have
+- self.db.execute("TRUNCATE TABLE countries")
+-
+- for file in ns.file:
+- for line in file:
+- line = line.rstrip()
+-
+- # Ignore any comments
+- if line.startswith("#"):
+- continue
+-
+- try:
+- country_code, continent_code, name = line.split(maxsplit=2)
+- except:
+- log.warning("Could not parse line: %s" % line)
+- continue
+-
+- self.db.execute("INSERT INTO countries(country_code, name, continent_code) \
+- VALUES(%s, %s, %s) ON CONFLICT DO NOTHING", country_code, name, continent_code)
+-
+-
+-def split_line(line):
+- key, colon, val = line.partition(":")
+-
+- # Strip any excess space
+- key = key.strip()
+- val = val.strip()
+-
+- return key, val
+-
+-def main():
+- # Run the command line interface
+- c = CLI()
+- c.run()
+-
+-main()
+diff --git a/src/python/location.in b/src/python/location.in
+deleted file mode 100644
+index 233cea0..0000000
+--- a/src/python/location.in
++++ /dev/null
+@@ -1,644 +0,0 @@
+-#!/usr/bin/python3
+-###############################################################################
+-# #
+-# libloc - A library to determine the location of someone on the Internet #
+-# #
+-# Copyright (C) 2017-2021 IPFire Development Team <info@ipfire.org> #
+-# #
+-# This library is free software; you can redistribute it and/or #
+-# modify it under the terms of the GNU Lesser General Public #
+-# License as published by the Free Software Foundation; either #
+-# version 2.1 of the License, or (at your option) any later version. #
+-# #
+-# This library is distributed in the hope that it will be useful, #
+-# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
+-# Lesser General Public License for more details. #
+-# #
+-###############################################################################
+-
+-import argparse
+-import datetime
+-import ipaddress
+-import logging
+-import os
+-import re
+-import shutil
+-import socket
+-import sys
+-import time
+-
+-# Load our location module
+-import location
+-import location.downloader
+-import location.export
+-
+-from location.i18n import _
+-
+-# Setup logging
+-log = logging.getLogger("location")
+-
+-# Output formatters
+-
+-class CLI(object):
+- def parse_cli(self):
+- parser = argparse.ArgumentParser(
+- description=_("Location Database Command Line Interface"),
+- )
+- subparsers = parser.add_subparsers()
+-
+- # Global configuration flags
+- parser.add_argument("--debug", action="store_true",
+- help=_("Enable debug output"))
+- parser.add_argument("--quiet", action="store_true",
+- help=_("Enable quiet mode"))
+-
+- # version
+- parser.add_argument("--version", action="version",
+- version="%(prog)s @VERSION@")
+-
+- # database
+- parser.add_argument("--database", "-d",
+- default="@databasedir@/database.db", help=_("Path to database"),
+- )
+-
+- # public key
+- parser.add_argument("--public-key", "-k",
+- default="@databasedir@/signing-key.pem", help=_("Public Signing Key"),
+- )
+-
+- # Show the database version
+- version = subparsers.add_parser("version",
+- help=_("Show database version"))
+- version.set_defaults(func=self.handle_version)
+-
+- # lookup an IP address
+- lookup = subparsers.add_parser("lookup",
+- help=_("Lookup one or multiple IP addresses"),
+- )
+- lookup.add_argument("address", nargs="+")
+- lookup.set_defaults(func=self.handle_lookup)
+-
+- # Dump the whole database
+- dump = subparsers.add_parser("dump",
+- help=_("Dump the entire database"),
+- )
+- dump.add_argument("output", nargs="?", type=argparse.FileType("w"))
+- dump.set_defaults(func=self.handle_dump)
+-
+- # Update
+- update = subparsers.add_parser("update", help=_("Update database"))
+- update.add_argument("--cron",
+- help=_("Update the library only once per interval"),
+- choices=("daily", "weekly", "monthly"),
+- )
+- update.set_defaults(func=self.handle_update)
+-
+- # Verify
+- verify = subparsers.add_parser("verify",
+- help=_("Verify the downloaded database"))
+- verify.set_defaults(func=self.handle_verify)
+-
+- # Get AS
+- get_as = subparsers.add_parser("get-as",
+- help=_("Get information about one or multiple Autonomous Systems"),
+- )
+- get_as.add_argument("asn", nargs="+")
+- get_as.set_defaults(func=self.handle_get_as)
+-
+- # Search for AS
+- search_as = subparsers.add_parser("search-as",
+- help=_("Search for Autonomous Systems that match the string"),
+- )
+- search_as.add_argument("query", nargs=1)
+- search_as.set_defaults(func=self.handle_search_as)
+-
+- # List all networks in an AS
+- list_networks_by_as = subparsers.add_parser("list-networks-by-as",
+- help=_("Lists all networks in an AS"),
+- )
+- list_networks_by_as.add_argument("asn", nargs=1, type=int)
+- list_networks_by_as.add_argument("--family", choices=("ipv6", "ipv4"))
+- list_networks_by_as.add_argument("--format",
+- choices=location.export.formats.keys(), default="list")
+- list_networks_by_as.set_defaults(func=self.handle_list_networks_by_as)
+-
+- # List all networks in a country
+- list_networks_by_cc = subparsers.add_parser("list-networks-by-cc",
+- help=_("Lists all networks in a country"),
+- )
+- list_networks_by_cc.add_argument("country_code", nargs=1)
+- list_networks_by_cc.add_argument("--family", choices=("ipv6", "ipv4"))
+- list_networks_by_cc.add_argument("--format",
+- choices=location.export.formats.keys(), default="list")
+- list_networks_by_cc.set_defaults(func=self.handle_list_networks_by_cc)
+-
+- # List all networks with flags
+- list_networks_by_flags = subparsers.add_parser("list-networks-by-flags",
+- help=_("Lists all networks with flags"),
+- )
+- list_networks_by_flags.add_argument("--anonymous-proxy",
+- action="store_true", help=_("Anonymous Proxies"),
+- )
+- list_networks_by_flags.add_argument("--satellite-provider",
+- action="store_true", help=_("Satellite Providers"),
+- )
+- list_networks_by_flags.add_argument("--anycast",
+- action="store_true", help=_("Anycasts"),
+- )
+- list_networks_by_flags.add_argument("--drop",
+- action="store_true", help=_("Hostile Networks safe to drop"),
+- )
+- list_networks_by_flags.add_argument("--family", choices=("ipv6", "ipv4"))
+- list_networks_by_flags.add_argument("--format",
+- choices=location.export.formats.keys(), default="list")
+- list_networks_by_flags.set_defaults(func=self.handle_list_networks_by_flags)
+-
+- # List bogons
+- list_bogons = subparsers.add_parser("list-bogons",
+- help=_("Lists all bogons"),
+- )
+- list_bogons.add_argument("--family", choices=("ipv6", "ipv4"))
+- list_bogons.add_argument("--format",
+- choices=location.export.formats.keys(), default="list")
+- list_bogons.set_defaults(func=self.handle_list_bogons)
+-
+- # List countries
+- list_countries = subparsers.add_parser("list-countries",
+- help=_("Lists all countries"),
+- )
+- list_countries.add_argument("--show-name",
+- action="store_true", help=_("Show the name of the country"),
+- )
+- list_countries.add_argument("--show-continent",
+- action="store_true", help=_("Show the continent"),
+- )
+- list_countries.set_defaults(func=self.handle_list_countries)
+-
+- # Export
+- export = subparsers.add_parser("export",
+- help=_("Exports data in many formats to load it into packet filters"),
+- )
+- export.add_argument("--format", help=_("Output format"),
+- choices=location.export.formats.keys(), default="list")
+- export.add_argument("--directory", help=_("Output directory"))
+- export.add_argument("--family",
+- help=_("Specify address family"), choices=("ipv6", "ipv4"),
+- )
+- export.add_argument("objects", nargs="*", help=_("List country codes or ASNs to export"))
+- export.set_defaults(func=self.handle_export)
+-
+- args = parser.parse_args()
+-
+- # Configure logging
+- if args.debug:
+- location.logger.set_level(logging.DEBUG)
+- elif args.quiet:
+- location.logger.set_level(logging.WARNING)
+-
+- # Print usage if no action was given
+- if not "func" in args:
+- parser.print_usage()
+- sys.exit(2)
+-
+- return args
+-
+- def run(self):
+- # Parse command line arguments
+- args = self.parse_cli()
+-
+- # Open database
+- try:
+- db = location.Database(args.database)
+- except FileNotFoundError as e:
+- # Allow continuing without a database
+- if args.func == self.handle_update:
+- db = None
+-
+- else:
+- sys.stderr.write("location: Could not open database %s: %s\n" \
+- % (args.database, e))
+- sys.exit(1)
+-
+- # Translate family (if present)
+- if "family" in args:
+- if args.family == "ipv6":
+- args.family = socket.AF_INET6
+- elif args.family == "ipv4":
+- args.family = socket.AF_INET
+- else:
+- args.family = 0
+-
+- # Call function
+- try:
+- ret = args.func(db, args)
+-
+- # Catch invalid inputs
+- except ValueError as e:
+- sys.stderr.write("%s\n" % e)
+- ret = 2
+-
+- # Catch any other exceptions
+- except Exception as e:
+- sys.stderr.write("%s\n" % e)
+- ret = 1
+-
+- # Return with exit code
+- if ret:
+- sys.exit(ret)
+-
+- # Otherwise just exit
+- sys.exit(0)
+-
+- def handle_version(self, db, ns):
+- """
+- Print the version of the database
+- """
+- t = time.strftime(
+- "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(db.created_at),
+- )
+-
+- print(t)
+-
+- def handle_lookup(self, db, ns):
+- ret = 0
+-
+- format = " %-24s: %s"
+-
+- for address in ns.address:
+- try:
+- network = db.lookup(address)
+- except ValueError:
+- print(_("Invalid IP address: %s") % address, file=sys.stderr)
+- return 2
+-
+- args = {
+- "address" : address,
+- "network" : network,
+- }
+-
+- # Nothing found?
+- if not network:
+- print(_("Nothing found for %(address)s") % args, file=sys.stderr)
+- ret = 1
+- continue
+-
+- print("%s:" % address)
+- print(format % (_("Network"), network))
+-
+- # Print country
+- if network.country_code:
+- country = db.get_country(network.country_code)
+-
+- print(format % (
+- _("Country"),
+- country.name if country else network.country_code),
+- )
+-
+- # Print AS information
+- if network.asn:
+- autonomous_system = db.get_as(network.asn)
+-
+- print(format % (
+- _("Autonomous System"),
+- autonomous_system or "AS%s" % network.asn),
+- )
+-
+- # Anonymous Proxy
+- if network.has_flag(location.NETWORK_FLAG_ANONYMOUS_PROXY):
+- print(format % (
+- _("Anonymous Proxy"), _("yes"),
+- ))
+-
+- # Satellite Provider
+- if network.has_flag(location.NETWORK_FLAG_SATELLITE_PROVIDER):
+- print(format % (
+- _("Satellite Provider"), _("yes"),
+- ))
+-
+- # Anycast
+- if network.has_flag(location.NETWORK_FLAG_ANYCAST):
+- print(format % (
+- _("Anycast"), _("yes"),
+- ))
+-
+- # Hostile Network
+- if network.has_flag(location.NETWORK_FLAG_DROP):
+- print(format % (
+- _("Hostile Network safe to drop"), _("yes"),
+- ))
+-
+- return ret
+-
+- def handle_dump(self, db, ns):
+- # Use output file or write to stdout
+- f = ns.output or sys.stdout
+-
+- # Format everything like this
+- format = "%-24s %s\n"
+-
+- # Write metadata
+- f.write("#\n# Location Database Export\n#\n")
+-
+- f.write("# Generated: %s\n" % time.strftime(
+- "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(db.created_at),
+- ))
+-
+- if db.vendor:
+- f.write("# Vendor: %s\n" % db.vendor)
+-
+- if db.license:
+- f.write("# License: %s\n" % db.license)
+-
+- f.write("#\n")
+-
+- if db.description:
+- for line in db.description.splitlines():
+- line = "# %s" % line
+- f.write("%s\n" % line.rstrip())
+-
+- f.write("#\n")
+-
+- # Iterate over all ASes
+- for a in db.ases:
+- f.write("\n")
+- f.write(format % ("aut-num:", "AS%s" % a.number))
+- f.write(format % ("name:", a.name))
+-
+- flags = {
+- location.NETWORK_FLAG_ANONYMOUS_PROXY : "is-anonymous-proxy:",
+- location.NETWORK_FLAG_SATELLITE_PROVIDER : "is-satellite-provider:",
+- location.NETWORK_FLAG_ANYCAST : "is-anycast:",
+- location.NETWORK_FLAG_DROP : "drop:",
+- }
+-
+- # Iterate over all networks
+- for n in db.networks:
+- f.write("\n")
+- f.write(format % ("net:", n))
+-
+- if n.country_code:
+- f.write(format % ("country:", n.country_code))
+-
+- if n.asn:
+- f.write(format % ("aut-num:", n.asn))
+-
+- # Print all flags
+- for flag in flags:
+- if n.has_flag(flag):
+- f.write(format % (flags[flag], "yes"))
+-
+- def handle_get_as(self, db, ns):
+- """
+- Gets information about Autonomous Systems
+- """
+- ret = 0
+-
+- for asn in ns.asn:
+- try:
+- asn = int(asn)
+- except ValueError:
+- print(_("Invalid ASN: %s") % asn, file=sys.stderr)
+- ret = 1
+- continue
+-
+- # Fetch AS from database
+- a = db.get_as(asn)
+-
+- # Nothing found
+- if not a:
+- print(_("Could not find AS%s") % asn, file=sys.stderr)
+- ret = 1
+- continue
+-
+- print(_("AS%(asn)s belongs to %(name)s") % { "asn" : a.number, "name" : a.name })
+-
+- return ret
+-
+- def handle_search_as(self, db, ns):
+- for query in ns.query:
+- # Print all matches ASes
+- for a in db.search_as(query):
+- print(a)
+-
+- def handle_update(self, db, ns):
+- if ns.cron and db:
+- now = time.time()
+-
+- if ns.cron == "daily":
+- delta = datetime.timedelta(days=1)
+- elif ns.cron == "weekly":
+- delta = datetime.timedelta(days=7)
+- elif ns.cron == "monthly":
+- delta = datetime.timedelta(days=30)
+-
+- delta = delta.total_seconds()
+-
+- # Check if the database has recently been updated
+- if db.created_at >= (now - delta):
+- log.info(
+- _("The database has been updated recently"),
+- )
+- return 3
+-
+- # Fetch the timestamp we need from DNS
+- t = location.discover_latest_version()
+-
+- # Check the version of the local database
+- if db and t and db.created_at >= t:
+- log.info("Already on the latest version")
+- return
+-
+- # Download the database into the correct directory
+- tmpdir = os.path.dirname(ns.database)
+-
+- # Create a downloader
+- d = location.downloader.Downloader()
+-
+- # Try downloading a new database
+- try:
+- t = d.download(public_key=ns.public_key, timestamp=t, tmpdir=tmpdir)
+-
+- # If no file could be downloaded, log a message
+- except FileNotFoundError as e:
+- log.error("Could not download a new database")
+- return 1
+-
+- # If we have not received a new file, there is nothing to do
+- if not t:
+- return 3
+-
+- # Move temporary file to destination
+- shutil.move(t.name, ns.database)
+-
+- return 0
+-
+- def handle_verify(self, db, ns):
+- # Verify the database
+- with open(ns.public_key, "r") as f:
+- if not db.verify(f):
+- log.error("Could not verify database")
+- return 1
+-
+- # Success
+- log.debug("Database successfully verified")
+- return 0
+-
+- def __get_output_formatter(self, ns):
+- try:
+- cls = location.export.formats[ns.format]
+- except KeyError:
+- cls = location.export.OutputFormatter
+-
+- return cls
+-
+- def handle_list_countries(self, db, ns):
+- for country in db.countries:
+- line = [
+- country.code,
+- ]
+-
+- if ns.show_continent:
+- line.append(country.continent_code)
+-
+- if ns.show_name:
+- line.append(country.name)
+-
+- # Format the output
+- line = " ".join(line)
+-
+- # Print the output
+- print(line)
+-
+- def handle_list_networks_by_as(self, db, ns):
+- writer = self.__get_output_formatter(ns)
+-
+- for asn in ns.asn:
+- f = writer("AS%s" % asn, f=sys.stdout)
+-
+- # Print all matching networks
+- for n in db.search_networks(asns=[asn], family=ns.family):
+- f.write(n)
+-
+- f.finish()
+-
+- def handle_list_networks_by_cc(self, db, ns):
+- writer = self.__get_output_formatter(ns)
+-
+- for country_code in ns.country_code:
+- # Open standard output
+- f = writer(country_code, f=sys.stdout)
+-
+- # Print all matching networks
+- for n in db.search_networks(country_codes=[country_code], family=ns.family):
+- f.write(n)
+-
+- f.finish()
+-
+- def handle_list_networks_by_flags(self, db, ns):
+- flags = 0
+-
+- if ns.anonymous_proxy:
+- flags |= location.NETWORK_FLAG_ANONYMOUS_PROXY
+-
+- if ns.satellite_provider:
+- flags |= location.NETWORK_FLAG_SATELLITE_PROVIDER
+-
+- if ns.anycast:
+- flags |= location.NETWORK_FLAG_ANYCAST
+-
+- if ns.drop:
+- flags |= location.NETWORK_FLAG_DROP
+-
+- if not flags:
+- raise ValueError(_("You must at least pass one flag"))
+-
+- writer = self.__get_output_formatter(ns)
+- f = writer("custom", f=sys.stdout)
+-
+- for n in db.search_networks(flags=flags, family=ns.family):
+- f.write(n)
+-
+- f.finish()
+-
+- def handle_list_bogons(self, db, ns):
+- writer = self.__get_output_formatter(ns)
+- f = writer("bogons", f=sys.stdout)
+-
+- for n in db.list_bogons(family=ns.family):
+- f.write(n)
+-
+- f.finish()
+-
+- def handle_export(self, db, ns):
+- countries, asns = [], []
+-
+- # Translate family
+- if ns.family:
+- families = [ ns.family ]
+- else:
+- families = [ socket.AF_INET6, socket.AF_INET ]
+-
+- for object in ns.objects:
+- m = re.match("^AS(\d+)$", object)
+- if m:
+- object = int(m.group(1))
+-
+- asns.append(object)
+-
+- elif location.country_code_is_valid(object) \
+- or object in ("A1", "A2", "A3", "XD"):
+- countries.append(object)
+-
+- else:
+- log.warning("Invalid argument: %s" % object)
+- continue
+-
+- # Default to exporting all countries
+- if not countries and not asns:
+- countries = ["A1", "A2", "A3", "XD"] + [country.code for country in db.countries]
+-
+- # Select the output format
+- writer = self.__get_output_formatter(ns)
+-
+- e = location.export.Exporter(db, writer)
+- e.export(ns.directory, countries=countries, asns=asns, families=families)
+-
+-
+-def format_timedelta(t):
+- s = []
+-
+- if t.days:
+- s.append(
+- _("One Day", "%(days)s Days", t.days) % { "days" : t.days, }
+- )
+-
+- hours = t.seconds // 3600
+- if hours:
+- s.append(
+- _("One Hour", "%(hours)s Hours", hours) % { "hours" : hours, }
+- )
+-
+- minutes = (t.seconds % 3600) // 60
+- if minutes:
+- s.append(
+- _("One Minute", "%(minutes)s Minutes", minutes) % { "minutes" : minutes, }
+- )
+-
+- seconds = t.seconds % 60
+- if t.seconds:
+- s.append(
+- _("One Second", "%(seconds)s Seconds", seconds) % { "seconds" : seconds, }
+- )
+-
+- if not s:
+- return _("Now")
+-
+- return _("%s ago") % ", ".join(s)
+-
+-def main():
+- # Run the command line interface
+- c = CLI()
+- c.run()
+-
+-main()
+diff --git a/src/python/location/__init__.py b/src/python/location/__init__.py
+new file mode 100644
+index 0000000..9b570c7
+--- /dev/null
++++ b/src/python/location/__init__.py
+@@ -0,0 +1,24 @@
++#!/usr/bin/python3
++###############################################################################
++# #
++# libloc - A library to determine the location of someone on the Internet #
++# #
++# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
++# #
++# This library is free software; you can redistribute it and/or #
++# modify it under the terms of the GNU Lesser General Public #
++# License as published by the Free Software Foundation; either #
++# version 2.1 of the License, or (at your option) any later version. #
++# #
++# This library is distributed in the hope that it will be useful, #
++# but WITHOUT ANY WARRANTY; without even the implied warranty of #
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
++# Lesser General Public License for more details. #
++# #
++###############################################################################
++
++# Import everything from the C module
++from _location import *
++
++# Initialise logging
++from . import logger
+diff --git a/src/python/location/database.py b/src/python/location/database.py
+new file mode 100644
+index 0000000..5d79941
+--- /dev/null
++++ b/src/python/location/database.py
+@@ -0,0 +1,213 @@
++#!/usr/bin/env python
++
++"""
++ A lightweight wrapper around psycopg2.
++
++ Originally part of the Tornado framework. The tornado.database module
++ is slated for removal in Tornado 3.0, and it is now available separately
++ as torndb.
++"""
++
++import logging
++import psycopg2
++
++log = logging.getLogger("location.database")
++log.propagate = 1
++
++class Connection(object):
++ """
++ A lightweight wrapper around MySQLdb DB-API connections.
++
++ The main value we provide is wrapping rows in a dict/object so that
++ columns can be accessed by name. Typical usage::
++
++ db = torndb.Connection("localhost", "mydatabase")
++ for article in db.query("SELECT * FROM articles"):
++ print article.title
++
++ Cursors are hidden by the implementation, but other than that, the methods
++ are very similar to the DB-API.
++
++ We explicitly set the timezone to UTC and the character encoding to
++ UTF-8 on all connections to avoid time zone and encoding errors.
++ """
++ def __init__(self, host, database, user=None, password=None):
++ self.host = host
++ self.database = database
++
++ self._db = None
++ self._db_args = {
++ "host" : host,
++ "database" : database,
++ "user" : user,
++ "password" : password,
++ "sslmode" : "require",
++ }
++
++ try:
++ self.reconnect()
++ except Exception:
++ log.error("Cannot connect to database on %s", self.host, exc_info=True)
++
++ def __del__(self):
++ self.close()
++
++ def close(self):
++ """
++ Closes this database connection.
++ """
++ if getattr(self, "_db", None) is not None:
++ self._db.close()
++ self._db = None
++
++ def reconnect(self):
++ """
++ Closes the existing database connection and re-opens it.
++ """
++ self.close()
++
++ self._db = psycopg2.connect(**self._db_args)
++ self._db.autocommit = True
++
++ # Initialize the timezone setting.
++ self.execute("SET TIMEZONE TO 'UTC'")
++
++ def query(self, query, *parameters, **kwparameters):
++ """
++ Returns a row list for the given query and parameters.
++ """
++ cursor = self._cursor()
++ try:
++ self._execute(cursor, query, parameters, kwparameters)
++ column_names = [d[0] for d in cursor.description]
++ return [Row(zip(column_names, row)) for row in cursor]
++ finally:
++ cursor.close()
++
++ def get(self, query, *parameters, **kwparameters):
++ """
++ Returns the first row returned for the given query.
++ """
++ rows = self.query(query, *parameters, **kwparameters)
++ if not rows:
++ return None
++ elif len(rows) > 1:
++ raise Exception("Multiple rows returned for Database.get() query")
++ else:
++ return rows[0]
++
++ def execute(self, query, *parameters, **kwparameters):
++ """
++ Executes the given query, returning the lastrowid from the query.
++ """
++ return self.execute_lastrowid(query, *parameters, **kwparameters)
++
++ def execute_lastrowid(self, query, *parameters, **kwparameters):
++ """
++ Executes the given query, returning the lastrowid from the query.
++ """
++ cursor = self._cursor()
++ try:
++ self._execute(cursor, query, parameters, kwparameters)
++ return cursor.lastrowid
++ finally:
++ cursor.close()
++
++ def execute_rowcount(self, query, *parameters, **kwparameters):
++ """
++ Executes the given query, returning the rowcount from the query.
++ """
++ cursor = self._cursor()
++ try:
++ self._execute(cursor, query, parameters, kwparameters)
++ return cursor.rowcount
++ finally:
++ cursor.close()
++
++ def executemany(self, query, parameters):
++ """
++ Executes the given query against all the given param sequences.
++
++ We return the lastrowid from the query.
++ """
++ return self.executemany_lastrowid(query, parameters)
++
++ def executemany_lastrowid(self, query, parameters):
++ """
++ Executes the given query against all the given param sequences.
++
++ We return the lastrowid from the query.
++ """
++ cursor = self._cursor()
++ try:
++ cursor.executemany(query, parameters)
++ return cursor.lastrowid
++ finally:
++ cursor.close()
++
++ def executemany_rowcount(self, query, parameters):
++ """
++ Executes the given query against all the given param sequences.
++
++ We return the rowcount from the query.
++ """
++ cursor = self._cursor()
++
++ try:
++ cursor.executemany(query, parameters)
++ return cursor.rowcount
++ finally:
++ cursor.close()
++
++ def _ensure_connected(self):
++ if self._db is None:
++ log.warning("Database connection was lost...")
++
++ self.reconnect()
++
++ def _cursor(self):
++ self._ensure_connected()
++ return self._db.cursor()
++
++ def _execute(self, cursor, query, parameters, kwparameters):
++ log.debug("SQL Query: %s" % (query % (kwparameters or parameters)))
++
++ try:
++ return cursor.execute(query, kwparameters or parameters)
++ except (OperationalError, psycopg2.ProgrammingError):
++ log.error("Error connecting to database on %s", self.host)
++ self.close()
++ raise
++
++ def transaction(self):
++ return Transaction(self)
++
++
++class Row(dict):
++ """A dict that allows for object-like property access syntax."""
++ def __getattr__(self, name):
++ try:
++ return self[name]
++ except KeyError:
++ raise AttributeError(name)
++
++
++class Transaction(object):
++ def __init__(self, db):
++ self.db = db
++
++ self.db.execute("START TRANSACTION")
++
++ def __enter__(self):
++ return self
++
++ def __exit__(self, exctype, excvalue, traceback):
++ if exctype is not None:
++ self.db.execute("ROLLBACK")
++ else:
++ self.db.execute("COMMIT")
++
++
++# Alias some common exceptions
++IntegrityError = psycopg2.IntegrityError
++OperationalError = psycopg2.OperationalError
+diff --git a/src/python/location/downloader.py b/src/python/location/downloader.py
+new file mode 100644
+index 0000000..05f7872
+--- /dev/null
++++ b/src/python/location/downloader.py
+@@ -0,0 +1,211 @@
++#!/usr/bin/python3
++###############################################################################
++# #
++# libloc - A library to determine the location of someone on the Internet #
++# #
++# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
++# #
++# This library is free software; you can redistribute it and/or #
++# modify it under the terms of the GNU Lesser General Public #
++# License as published by the Free Software Foundation; either #
++# version 2.1 of the License, or (at your option) any later version. #
++# #
++# This library is distributed in the hope that it will be useful, #
++# but WITHOUT ANY WARRANTY; without even the implied warranty of #
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
++# Lesser General Public License for more details. #
++# #
++###############################################################################
++
++import logging
++import lzma
++import os
++import random
++import stat
++import tempfile
++import time
++import urllib.error
++import urllib.parse
++import urllib.request
++
++from . import __version__
++from _location import Database, DATABASE_VERSION_LATEST
++
++DATABASE_FILENAME = "location.db.xz"
++MIRRORS = (
++ "https://location.ipfire.org/databases/",
++)
++
++# Initialise logging
++log = logging.getLogger("location.downloader")
++log.propagate = 1
++
++class Downloader(object):
++ def __init__(self, version=DATABASE_VERSION_LATEST, mirrors=None):
++ self.version = version
++
++ # Set mirrors or use defaults
++ self.mirrors = list(mirrors or MIRRORS)
++
++ # Randomize mirrors
++ random.shuffle(self.mirrors)
++
++ # Get proxies from environment
++ self.proxies = self._get_proxies()
++
++ def _get_proxies(self):
++ proxies = {}
++
++ for protocol in ("https", "http"):
++ proxy = os.environ.get("%s_proxy" % protocol, None)
++
++ if proxy:
++ proxies[protocol] = proxy
++
++ return proxies
++
++ def _make_request(self, url, baseurl=None, headers={}):
++ if baseurl:
++ url = urllib.parse.urljoin(baseurl, url)
++
++ req = urllib.request.Request(url, method="GET")
++
++ # Update headers
++ headers.update({
++ "User-Agent" : "location/%s" % __version__,
++ })
++
++ # Set headers
++ for header in headers:
++ req.add_header(header, headers[header])
++
++ # Set proxies
++ for protocol in self.proxies:
++ req.set_proxy(self.proxies[protocol], protocol)
++
++ return req
++
++ def _send_request(self, req, **kwargs):
++ # Log request headers
++ log.debug("HTTP %s Request to %s" % (req.method, req.host))
++ log.debug(" URL: %s" % req.full_url)
++ log.debug(" Headers:")
++ for k, v in req.header_items():
++ log.debug(" %s: %s" % (k, v))
++
++ try:
++ res = urllib.request.urlopen(req, **kwargs)
++
++ except urllib.error.HTTPError as e:
++ # Log response headers
++ log.debug("HTTP Response: %s" % e.code)
++ log.debug(" Headers:")
++ for header in e.headers:
++ log.debug(" %s: %s" % (header, e.headers[header]))
++
++ # Raise all other errors
++ raise e
++
++ # Log response headers
++ log.debug("HTTP Response: %s" % res.code)
++ log.debug(" Headers:")
++ for k, v in res.getheaders():
++ log.debug(" %s: %s" % (k, v))
++
++ return res
++
++ def download(self, public_key, timestamp=None, tmpdir=None, **kwargs):
++ url = "%s/%s" % (self.version, DATABASE_FILENAME)
++
++ headers = {}
++ if timestamp:
++ headers["If-Modified-Since"] = time.strftime(
++ "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(timestamp),
++ )
++
++ t = tempfile.NamedTemporaryFile(dir=tmpdir, delete=False)
++ with t:
++ # Try all mirrors
++ for mirror in self.mirrors:
++ # Prepare HTTP request
++ req = self._make_request(url, baseurl=mirror, headers=headers)
++
++ try:
++ with self._send_request(req) as res:
++ decompressor = lzma.LZMADecompressor()
++
++ # Read all data
++ while True:
++ buf = res.read(1024)
++ if not buf:
++ break
++
++ # Decompress data
++ buf = decompressor.decompress(buf)
++ if buf:
++ t.write(buf)
++
++ # Write all data to disk
++ t.flush()
++
++ # Catch decompression errors
++ except lzma.LZMAError as e:
++ log.warning("Could not decompress downloaded file: %s" % e)
++ continue
++
++ except urllib.error.HTTPError as e:
++ # The file on the server was too old
++ if e.code == 304:
++ log.warning("%s is serving an outdated database. Trying next mirror..." % mirror)
++
++ # Log any other HTTP errors
++ else:
++ log.warning("%s reported: %s" % (mirror, e))
++
++ # Throw away any downloaded content and try again
++ t.truncate()
++
++ else:
++ # Check if the downloaded database is recent
++ if not self._check_database(t, public_key, timestamp):
++ log.warning("Downloaded database is outdated. Trying next mirror...")
++
++ # Throw away the data and try again
++ t.truncate()
++ continue
++
++ # Make the file readable for everyone
++ os.chmod(t.name, stat.S_IRUSR|stat.S_IRGRP|stat.S_IROTH)
++
++ # Return temporary file
++ return t
++
++ # Delete the temporary file after unsuccessful downloads
++ os.unlink(t.name)
++
++ raise FileNotFoundError(url)
++
++ def _check_database(self, f, public_key, timestamp=None):
++ """
++ Checks the downloaded database if it can be opened,
++ verified and if it is recent enough
++ """
++ log.debug("Opening downloaded database at %s" % f.name)
++
++ db = Database(f.name)
++
++ # Database is not recent
++ if timestamp and db.created_at < timestamp:
++ return False
++
++ log.info("Downloaded new database from %s" % (time.strftime(
++ "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(db.created_at),
++ )))
++
++ # Verify the database
++ with open(public_key, "r") as f:
++ if not db.verify(f):
++ log.error("Could not verify database")
++ return False
++
++ return True
+diff --git a/src/python/location/export.py b/src/python/location/export.py
+new file mode 100644
+index 0000000..3cdece4
+--- /dev/null
++++ b/src/python/location/export.py
+@@ -0,0 +1,291 @@
++#!/usr/bin/python3
++###############################################################################
++# #
++# libloc - A library to determine the location of someone on the Internet #
++# #
++# Copyright (C) 2020-2021 IPFire Development Team <info@ipfire.org> #
++# #
++# This library is free software; you can redistribute it and/or #
++# modify it under the terms of the GNU Lesser General Public #
++# License as published by the Free Software Foundation; either #
++# version 2.1 of the License, or (at your option) any later version. #
++# #
++# This library is distributed in the hope that it will be useful, #
++# but WITHOUT ANY WARRANTY; without even the implied warranty of #
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
++# Lesser General Public License for more details. #
++# #
++###############################################################################
++
++import functools
++import io
++import ipaddress
++import logging
++import math
++import os
++import socket
++import sys
++
++from .i18n import _
++import _location
++
++# Initialise logging
++log = logging.getLogger("location.export")
++log.propagate = 1
++
++FLAGS = {
++ _location.NETWORK_FLAG_ANONYMOUS_PROXY : "A1",
++ _location.NETWORK_FLAG_SATELLITE_PROVIDER : "A2",
++ _location.NETWORK_FLAG_ANYCAST : "A3",
++ _location.NETWORK_FLAG_DROP : "XD",
++}
++
++class OutputWriter(object):
++ suffix = "networks"
++ mode = "w"
++
++ def __init__(self, name, family=None, directory=None, f=None):
++ self.name = name
++ self.family = family
++ self.directory = directory
++
++ # Open output file
++ if f:
++ self.f = f
++ elif self.directory:
++ self.f = open(self.filename, self.mode)
++ elif "b" in self.mode:
++ self.f = io.BytesIO()
++ else:
++ self.f = io.StringIO()
++
++ # Call any custom initialization
++ self.init()
++
++ # Immediately write the header
++ self._write_header()
++
++ def init(self):
++ """
++ To be overwritten by anything that inherits from this
++ """
++ pass
++
++ def __repr__(self):
++ return "<%s %s f=%s>" % (self.__class__.__name__, self, self.f)
++
++ @functools.cached_property
++ def tag(self):
++ families = {
++ socket.AF_INET6 : "6",
++ socket.AF_INET : "4",
++ }
++
++ return "%sv%s" % (self.name, families.get(self.family, "?"))
++
++ @functools.cached_property
++ def filename(self):
++ if self.directory:
++ return os.path.join(self.directory, "%s.%s" % (self.tag, self.suffix))
++
++ def _write_header(self):
++ """
++ The header of the file
++ """
++ pass
++
++ def _write_footer(self):
++ """
++ The footer of the file
++ """
++ pass
++
++ def write(self, network):
++ self.f.write("%s\n" % network)
++
++ def finish(self):
++ """
++ Called when all data has been written
++ """
++ self._write_footer()
++
++ # Flush all output
++ self.f.flush()
++
++ def print(self):
++ """
++ Prints the entire output line by line
++ """
++ if isinstance(self.f, io.BytesIO):
++ raise TypeError(_("Won't write binary output to stdout"))
++
++ # Go back to the beginning
++ self.f.seek(0)
++
++ # Iterate over everything line by line
++ for line in self.f:
++ sys.stdout.write(line)
++
++
++class IpsetOutputWriter(OutputWriter):
++ """
++ For ipset
++ """
++ suffix = "ipset"
++
++ # The value is being used if we don't know any better
++ DEFAULT_HASHSIZE = 64
++
++ # We aim for this many networks in a bucket on average. This allows us to choose
++ # how much memory we want to sacrifice to gain better performance. The lower the
++ # factor, the faster a lookup will be, but it will use more memory.
++ # We will aim for only using three quarters of all buckets to avoid any searches
++ # through the linked lists.
++ HASHSIZE_FACTOR = 0.75
++
++ def init(self):
++ # Count all networks
++ self.networks = 0
++
++ @property
++ def hashsize(self):
++ """
++ Calculates an optimized hashsize
++ """
++ # Return the default value if we don't know the size of the set
++ if not self.networks:
++ return self.DEFAULT_HASHSIZE
++
++ # Find the nearest power of two that is larger than the number of networks
++ # divided by the hashsize factor.
++ exponent = math.log(self.networks / self.HASHSIZE_FACTOR, 2)
++
++ # Return the size of the hash (the minimum is 64)
++ return max(2 ** math.ceil(exponent), 64)
++
++ def _write_header(self):
++ # This must have a fixed size, because we will write the header again in the end
++ self.f.write("create %s hash:net family inet%s" % (
++ self.tag,
++ "6" if self.family == socket.AF_INET6 else ""
++ ))
++ self.f.write(" hashsize %8d maxelem 1048576 -exist\n" % self.hashsize)
++ self.f.write("flush %s\n" % self.tag)
++
++ def write(self, network):
++ self.f.write("add %s %s\n" % (self.tag, network))
++
++ # Increment network counter
++ self.networks += 1
++
++ def _write_footer(self):
++ # Jump back to the beginning of the file
++ self.f.seek(0)
++
++ # Rewrite the header with better configuration
++ self._write_header()
++
++
++class NftablesOutputWriter(OutputWriter):
++ """
++ For nftables
++ """
++ suffix = "set"
++
++ def _write_header(self):
++ self.f.write("define %s = {\n" % self.tag)
++
++ def _write_footer(self):
++ self.f.write("}\n")
++
++ def write(self, network):
++ self.f.write(" %s,\n" % network)
++
++
++class XTGeoIPOutputWriter(OutputWriter):
++ """
++ Formats the output in that way, that it can be loaded by
++ the xt_geoip kernel module from xtables-addons.
++ """
++ mode = "wb"
++
++ @property
++ def tag(self):
++ return self.name
++
++ @property
++ def suffix(self):
++ return "iv%s" % ("6" if self.family == socket.AF_INET6 else "4")
++
++ def write(self, network):
++ self.f.write(network._first_address)
++ self.f.write(network._last_address)
++
++
++formats = {
++ "ipset" : IpsetOutputWriter,
++ "list" : OutputWriter,
++ "nftables" : NftablesOutputWriter,
++ "xt_geoip" : XTGeoIPOutputWriter,
++}
++
++class Exporter(object):
++ def __init__(self, db, writer):
++ self.db, self.writer = db, writer
++
++ def export(self, directory, families, countries, asns):
++ for family in families:
++ log.debug("Exporting family %s" % family)
++
++ writers = {}
++
++ # Create writers for countries
++ for country_code in countries:
++ writers[country_code] = self.writer(country_code, family=family, directory=directory)
++
++ # Create writers for ASNs
++ for asn in asns:
++ writers[asn] = self.writer("AS%s" % asn, family=family, directory=directory)
++
++ # Filter countries from special country codes
++ country_codes = [
++ country_code for country_code in countries if not country_code in FLAGS.values()
++ ]
++
++ # Get all networks that match the family
++ networks = self.db.search_networks(family=family,
++ country_codes=country_codes, asns=asns, flatten=True)
++
++ # Walk through all networks
++ for network in networks:
++ # Write matching countries
++ try:
++ writers[network.country_code].write(network)
++ except KeyError:
++ pass
++
++ # Write matching ASNs
++ try:
++ writers[network.asn].write(network)
++ except KeyError:
++ pass
++
++ # Handle flags
++ for flag in FLAGS:
++ if network.has_flag(flag):
++ # Fetch the "fake" country code
++ country = FLAGS[flag]
++
++ try:
++ writers[country].write(network)
++ except KeyError:
++ pass
++
++ # Write everything to the filesystem
++ for writer in writers.values():
++ writer.finish()
++
++ # Print to stdout
++ if not directory:
++ for writer in writers.values():
++ writer.print()
+diff --git a/src/python/location/i18n.py b/src/python/location/i18n.py
+new file mode 100644
+index 0000000..2161aa6
+--- /dev/null
++++ b/src/python/location/i18n.py
+@@ -0,0 +1,26 @@
++#!/usr/bin/python3
++###############################################################################
++# #
++# libloc - A library to determine the location of someone on the Internet #
++# #
++# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
++# #
++# This library is free software; you can redistribute it and/or #
++# modify it under the terms of the GNU Lesser General Public #
++# License as published by the Free Software Foundation; either #
++# version 2.1 of the License, or (at your option) any later version. #
++# #
++# This library is distributed in the hope that it will be useful, #
++# but WITHOUT ANY WARRANTY; without even the implied warranty of #
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
++# Lesser General Public License for more details. #
++# #
++###############################################################################
++
++import gettext
++
++def _(singular, plural=None, n=None):
++ if plural:
++ return gettext.dngettext("libloc", singular, plural, n)
++
++ return gettext.dgettext("libloc", singular)
+diff --git a/src/python/location/importer.py b/src/python/location/importer.py
+new file mode 100644
+index 0000000..dee36ed
+--- /dev/null
++++ b/src/python/location/importer.py
+@@ -0,0 +1,250 @@
++#!/usr/bin/python3
++###############################################################################
++# #
++# libloc - A library to determine the location of someone on the Internet #
++# #
++# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
++# #
++# This library is free software; you can redistribute it and/or #
++# modify it under the terms of the GNU Lesser General Public #
++# License as published by the Free Software Foundation; either #
++# version 2.1 of the License, or (at your option) any later version. #
++# #
++# This library is distributed in the hope that it will be useful, #
++# but WITHOUT ANY WARRANTY; without even the implied warranty of #
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
++# Lesser General Public License for more details. #
++# #
++###############################################################################
++
++import gzip
++import logging
++import urllib.request
++
++# Initialise logging
++log = logging.getLogger("location.importer")
++log.propagate = 1
++
++WHOIS_SOURCES = {
++ # African Network Information Centre
++ "AFRINIC": [
++ "https://ftp.afrinic.net/pub/pub/dbase/afrinic.db.gz"
++ ],
++
++ # Asia Pacific Network Information Centre
++ "APNIC": [
++ "https://ftp.apnic.net/apnic/whois/apnic.db.inet6num.gz",
++ "https://ftp.apnic.net/apnic/whois/apnic.db.inetnum.gz",
++ #"https://ftp.apnic.net/apnic/whois/apnic.db.route6.gz",
++ #"https://ftp.apnic.net/apnic/whois/apnic.db.route.gz",
++ "https://ftp.apnic.net/apnic/whois/apnic.db.aut-num.gz",
++ "https://ftp.apnic.net/apnic/whois/apnic.db.organisation.gz"
++ ],
++
++ # American Registry for Internet Numbers
++ # XXX there is nothing useful for us in here
++ # ARIN: [
++ # "https://ftp.arin.net/pub/rr/arin.db"
++ # ],
++
++ # Japan Network Information Center
++ "JPNIC": [
++ "https://ftp.nic.ad.jp/jpirr/jpirr.db.gz"
++ ],
++
++ # Latin America and Caribbean Network Information Centre
++ "LACNIC": [
++ "https://ftp.lacnic.net/lacnic/dbase/lacnic.db.gz"
++ ],
++
++ # Réseaux IP Européens
++ "RIPE": [
++ "https://ftp.ripe.net/ripe/dbase/split/ripe.db.inet6num.gz",
++ "https://ftp.ripe.net/ripe/dbase/split/ripe.db.inetnum.gz",
++ #"https://ftp.ripe.net/ripe/dbase/split/ripe.db.route6.gz",
++ #"https://ftp.ripe.net/ripe/dbase/split/ripe.db.route.gz",
++ "https://ftp.ripe.net/ripe/dbase/split/ripe.db.aut-num.gz",
++ "https://ftp.ripe.net/ripe/dbase/split/ripe.db.organisation.gz"
++ ],
++}
++
++EXTENDED_SOURCES = {
++ # African Network Information Centre
++ # "ARIN": [
++ # "https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest"
++ # ],
++
++ # Asia Pacific Network Information Centre
++ # "APNIC": [
++ # "https://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-extended-latest"
++ # ],
++
++ # American Registry for Internet Numbers
++ "ARIN": [
++ "https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest"
++ ],
++
++ # Latin America and Caribbean Network Information Centre
++ "LACNIC": [
++ "https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest"
++ ],
++
++ # Réseaux IP Européens
++ # "RIPE": [
++ # "https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest"
++ # ],
++}
++
++class Downloader(object):
++ def __init__(self):
++ self.proxy = None
++
++ def set_proxy(self, url):
++ """
++ Sets a HTTP proxy that is used to perform all requests
++ """
++ log.info("Using proxy %s" % url)
++ self.proxy = url
++
++ def request(self, url, data=None, return_blocks=False):
++ req = urllib.request.Request(url, data=data)
++
++ # Configure proxy
++ if self.proxy:
++ req.set_proxy(self.proxy, "http")
++
++ return DownloaderContext(self, req, return_blocks=return_blocks)
++
++
++class DownloaderContext(object):
++ def __init__(self, downloader, request, return_blocks=False):
++ self.downloader = downloader
++ self.request = request
++
++ # Should we return one block or a single line?
++ self.return_blocks = return_blocks
++
++ # Save the response object
++ self.response = None
++
++ def __enter__(self):
++ log.info("Retrieving %s..." % self.request.full_url)
++
++ # Send request
++ self.response = urllib.request.urlopen(self.request)
++
++ # Log the response headers
++ log.debug("Response Headers:")
++ for header in self.headers:
++ log.debug(" %s: %s" % (header, self.get_header(header)))
++
++ return self
++
++ def __exit__(self, type, value, traceback):
++ pass
++
++ def __iter__(self):
++ """
++ Makes the object iterable by going through each block
++ """
++ if self.return_blocks:
++ return iterate_over_blocks(self.body)
++
++ return iterate_over_lines(self.body)
++
++ @property
++ def headers(self):
++ if self.response:
++ return self.response.headers
++
++ def get_header(self, name):
++ if self.headers:
++ return self.headers.get(name)
++
++ @property
++ def body(self):
++ """
++ Returns a file-like object with the decoded content
++ of the response.
++ """
++ content_type = self.get_header("Content-Type")
++
++ # Decompress any gzipped response on the fly
++ if content_type in ("application/x-gzip", "application/gzip"):
++ return gzip.GzipFile(fileobj=self.response, mode="rb")
++
++ # Return the response by default
++ return self.response
++
++
++def read_blocks(f):
++ for block in iterate_over_blocks(f):
++ type = None
++ data = {}
++
++ for i, line in enumerate(block):
++ key, value = line.split(":", 1)
++
++ # The key of the first line defines the type
++ if i == 0:
++ type = key
++
++ # Store value
++ data[key] = value.strip()
++
++ yield type, data
++
++def iterate_over_blocks(f, charsets=("utf-8", "latin1")):
++ block = []
++
++ for line in f:
++ # Convert to string
++ for charset in charsets:
++ try:
++ line = line.decode(charset)
++ except UnicodeDecodeError:
++ continue
++ else:
++ break
++
++ # Skip commented lines
++ if line.startswith("#") or line.startswith("%"):
++ continue
++
++ # Strip line-endings
++ line = line.rstrip()
++
++ # Remove any comments at the end of line
++ line, hash, comment = line.partition("#")
++
++ if comment:
++ # Strip any whitespace before the comment
++ line = line.rstrip()
++
++ # If the line is now empty, we move on
++ if not line:
++ continue
++
++ if line:
++ block.append(line)
++ continue
++
++ # End the block on an empty line
++ if block:
++ yield block
++
++ # Reset the block
++ block = []
++
++ # Return the last block
++ if block:
++ yield block
++
++
++def iterate_over_lines(f):
++ for line in f:
++ # Decode the line
++ line = line.decode()
++
++ # Strip the ending
++ yield line.rstrip()
+diff --git a/src/python/location/logger.py b/src/python/location/logger.py
+new file mode 100644
+index 0000000..0bdf9ec
+--- /dev/null
++++ b/src/python/location/logger.py
+@@ -0,0 +1,46 @@
++#!/usr/bin/python3
++###############################################################################
++# #
++# libloc - A library to determine the location of someone on the Internet #
++# #
++# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
++# #
++# This library is free software; you can redistribute it and/or #
++# modify it under the terms of the GNU Lesser General Public #
++# License as published by the Free Software Foundation; either #
++# version 2.1 of the License, or (at your option) any later version. #
++# #
++# This library is distributed in the hope that it will be useful, #
++# but WITHOUT ANY WARRANTY; without even the implied warranty of #
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
++# Lesser General Public License for more details. #
++# #
++###############################################################################
++
++import logging
++import logging.handlers
++
++# Initialise root logger
++log = logging.getLogger("location")
++log.setLevel(logging.INFO)
++
++# Log to console
++handler = logging.StreamHandler()
++handler.setLevel(logging.DEBUG)
++log.addHandler(handler)
++
++# Log to syslog
++handler = logging.handlers.SysLogHandler(address="/dev/log",
++ facility=logging.handlers.SysLogHandler.LOG_DAEMON)
++handler.setLevel(logging.INFO)
++log.addHandler(handler)
++
++# Format syslog messages
++formatter = logging.Formatter("%(message)s")
++handler.setFormatter(formatter)
++
++def set_level(level):
++ """
++ Sets the log level for the root logger
++ """
++ log.setLevel(level)
+diff --git a/src/python/logger.py b/src/python/logger.py
+deleted file mode 100644
+index 0bdf9ec..0000000
+--- a/src/python/logger.py
++++ /dev/null
+@@ -1,46 +0,0 @@
+-#!/usr/bin/python3
+-###############################################################################
+-# #
+-# libloc - A library to determine the location of someone on the Internet #
+-# #
+-# Copyright (C) 2020 IPFire Development Team <info@ipfire.org> #
+-# #
+-# This library is free software; you can redistribute it and/or #
+-# modify it under the terms of the GNU Lesser General Public #
+-# License as published by the Free Software Foundation; either #
+-# version 2.1 of the License, or (at your option) any later version. #
+-# #
+-# This library is distributed in the hope that it will be useful, #
+-# but WITHOUT ANY WARRANTY; without even the implied warranty of #
+-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
+-# Lesser General Public License for more details. #
+-# #
+-###############################################################################
+-
+-import logging
+-import logging.handlers
+-
+-# Initialise root logger
+-log = logging.getLogger("location")
+-log.setLevel(logging.INFO)
+-
+-# Log to console
+-handler = logging.StreamHandler()
+-handler.setLevel(logging.DEBUG)
+-log.addHandler(handler)
+-
+-# Log to syslog
+-handler = logging.handlers.SysLogHandler(address="/dev/log",
+- facility=logging.handlers.SysLogHandler.LOG_DAEMON)
+-handler.setLevel(logging.INFO)
+-log.addHandler(handler)
+-
+-# Format syslog messages
+-formatter = logging.Formatter("%(message)s")
+-handler.setFormatter(formatter)
+-
+-def set_level(level):
+- """
+- Sets the log level for the root logger
+- """
+- log.setLevel(level)
+diff --git a/src/scripts/location-importer.in b/src/scripts/location-importer.in
+new file mode 100644
+index 0000000..bee9186
+--- /dev/null
++++ b/src/scripts/location-importer.in
+@@ -0,0 +1,1535 @@
++#!/usr/bin/python3
++###############################################################################
++# #
++# libloc - A library to determine the location of someone on the Internet #
++# #
++# Copyright (C) 2020-2022 IPFire Development Team <info@ipfire.org> #
++# #
++# This library is free software; you can redistribute it and/or #
++# modify it under the terms of the GNU Lesser General Public #
++# License as published by the Free Software Foundation; either #
++# version 2.1 of the License, or (at your option) any later version. #
++# #
++# This library is distributed in the hope that it will be useful, #
++# but WITHOUT ANY WARRANTY; without even the implied warranty of #
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
++# Lesser General Public License for more details. #
++# #
++###############################################################################
++
++import argparse
++import ipaddress
++import json
++import logging
++import math
++import re
++import socket
++import sys
++import telnetlib
++
++# Load our location module
++import location
++import location.database
++import location.importer
++from location.i18n import _
++
++# Initialise logging
++log = logging.getLogger("location.importer")
++log.propagate = 1
++
++# Define constants
++VALID_ASN_RANGES = (
++ (1, 23455),
++ (23457, 64495),
++ (131072, 4199999999),
++)
++
++
++class CLI(object):
++ def parse_cli(self):
++ parser = argparse.ArgumentParser(
++ description=_("Location Importer Command Line Interface"),
++ )
++ subparsers = parser.add_subparsers()
++
++ # Global configuration flags
++ parser.add_argument("--debug", action="store_true",
++ help=_("Enable debug output"))
++ parser.add_argument("--quiet", action="store_true",
++ help=_("Enable quiet mode"))
++
++ # version
++ parser.add_argument("--version", action="version",
++ version="%(prog)s @VERSION@")
++
++ # Database
++ parser.add_argument("--database-host", required=True,
++ help=_("Database Hostname"), metavar=_("HOST"))
++ parser.add_argument("--database-name", required=True,
++ help=_("Database Name"), metavar=_("NAME"))
++ parser.add_argument("--database-username", required=True,
++ help=_("Database Username"), metavar=_("USERNAME"))
++ parser.add_argument("--database-password", required=True,
++ help=_("Database Password"), metavar=_("PASSWORD"))
++
++ # Write Database
++ write = subparsers.add_parser("write", help=_("Write database to file"))
++ write.set_defaults(func=self.handle_write)
++ write.add_argument("file", nargs=1, help=_("Database File"))
++ write.add_argument("--signing-key", nargs="?", type=open, help=_("Signing Key"))
++ write.add_argument("--backup-signing-key", nargs="?", type=open, help=_("Backup Signing Key"))
++ write.add_argument("--vendor", nargs="?", help=_("Sets the vendor"))
++ write.add_argument("--description", nargs="?", help=_("Sets a description"))
++ write.add_argument("--license", nargs="?", help=_("Sets the license"))
++ write.add_argument("--version", type=int, help=_("Database Format Version"))
++
++ # Update WHOIS
++ update_whois = subparsers.add_parser("update-whois", help=_("Update WHOIS Information"))
++ update_whois.set_defaults(func=self.handle_update_whois)
++
++ # Update announcements
++ update_announcements = subparsers.add_parser("update-announcements",
++ help=_("Update BGP Annoucements"))
++ update_announcements.set_defaults(func=self.handle_update_announcements)
++ update_announcements.add_argument("server", nargs=1,
++ help=_("Route Server to connect to"), metavar=_("SERVER"))
++
++ # Update overrides
++ update_overrides = subparsers.add_parser("update-overrides",
++ help=_("Update overrides"),
++ )
++ update_overrides.add_argument(
++ "files", nargs="+", help=_("Files to import"),
++ )
++ update_overrides.set_defaults(func=self.handle_update_overrides)
++
++ # Import countries
++ import_countries = subparsers.add_parser("import-countries",
++ help=_("Import countries"),
++ )
++ import_countries.add_argument("file", nargs=1, type=argparse.FileType("r"),
++ help=_("File to import"))
++ import_countries.set_defaults(func=self.handle_import_countries)
++
++ args = parser.parse_args()
++
++ # Configure logging
++ if args.debug:
++ location.logger.set_level(logging.DEBUG)
++ elif args.quiet:
++ location.logger.set_level(logging.WARNING)
++
++ # Print usage if no action was given
++ if not "func" in args:
++ parser.print_usage()
++ sys.exit(2)
++
++ return args
++
++ def run(self):
++ # Parse command line arguments
++ args = self.parse_cli()
++
++ # Initialise database
++ self.db = self._setup_database(args)
++
++ # Call function
++ ret = args.func(args)
++
++ # Return with exit code
++ if ret:
++ sys.exit(ret)
++
++ # Otherwise just exit
++ sys.exit(0)
++
++ def _setup_database(self, ns):
++ """
++ Initialise the database
++ """
++ # Connect to database
++ db = location.database.Connection(
++ host=ns.database_host, database=ns.database_name,
++ user=ns.database_username, password=ns.database_password,
++ )
++
++ with db.transaction():
++ db.execute("""
++ -- announcements
++ CREATE TABLE IF NOT EXISTS announcements(network inet, autnum bigint,
++ first_seen_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
++ last_seen_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP);
++ CREATE UNIQUE INDEX IF NOT EXISTS announcements_networks ON announcements(network);
++ CREATE INDEX IF NOT EXISTS announcements_family ON announcements(family(network));
++ CREATE INDEX IF NOT EXISTS announcements_search ON announcements USING GIST(network inet_ops);
++
++ -- autnums
++ CREATE TABLE IF NOT EXISTS autnums(number bigint, name text NOT NULL);
++ ALTER TABLE autnums ADD COLUMN IF NOT EXISTS source text;
++ CREATE UNIQUE INDEX IF NOT EXISTS autnums_number ON autnums(number);
++
++ -- countries
++ CREATE TABLE IF NOT EXISTS countries(
++ country_code text NOT NULL, name text NOT NULL, continent_code text NOT NULL);
++ CREATE UNIQUE INDEX IF NOT EXISTS countries_country_code ON countries(country_code);
++
++ -- networks
++ CREATE TABLE IF NOT EXISTS networks(network inet, country text);
++ ALTER TABLE networks ADD COLUMN IF NOT EXISTS original_countries text[];
++ ALTER TABLE networks ADD COLUMN IF NOT EXISTS source text;
++ CREATE UNIQUE INDEX IF NOT EXISTS networks_network ON networks(network);
++ CREATE INDEX IF NOT EXISTS networks_family ON networks USING BTREE(family(network));
++ CREATE INDEX IF NOT EXISTS networks_search ON networks USING GIST(network inet_ops);
++
++ -- overrides
++ CREATE TABLE IF NOT EXISTS autnum_overrides(
++ number bigint NOT NULL,
++ name text,
++ country text,
++ is_anonymous_proxy boolean,
++ is_satellite_provider boolean,
++ is_anycast boolean
++ );
++ CREATE UNIQUE INDEX IF NOT EXISTS autnum_overrides_number
++ ON autnum_overrides(number);
++ ALTER TABLE autnum_overrides ADD COLUMN IF NOT EXISTS source text;
++ ALTER TABLE autnum_overrides ADD COLUMN IF NOT EXISTS is_drop boolean;
++
++ CREATE TABLE IF NOT EXISTS network_overrides(
++ network inet NOT NULL,
++ country text,
++ is_anonymous_proxy boolean,
++ is_satellite_provider boolean,
++ is_anycast boolean
++ );
++ CREATE UNIQUE INDEX IF NOT EXISTS network_overrides_network
++ ON network_overrides(network);
++ CREATE INDEX IF NOT EXISTS network_overrides_search
++ ON network_overrides USING GIST(network inet_ops);
++ ALTER TABLE network_overrides ADD COLUMN IF NOT EXISTS source text;
++ ALTER TABLE network_overrides ADD COLUMN IF NOT EXISTS is_drop boolean;
++ """)
++
++ return db
++
++ def handle_write(self, ns):
++ """
++ Compiles a database in libloc format out of what is in the database
++ """
++ # Allocate a writer
++ writer = location.Writer(ns.signing_key, ns.backup_signing_key)
++
++ # Set all metadata
++ if ns.vendor:
++ writer.vendor = ns.vendor
++
++ if ns.description:
++ writer.description = ns.description
++
++ if ns.license:
++ writer.license = ns.license
++
++ # Add all Autonomous Systems
++ log.info("Writing Autonomous Systems...")
++
++ # Select all ASes with a name
++ rows = self.db.query("""
++ SELECT
++ autnums.number AS number,
++ COALESCE(
++ (SELECT overrides.name FROM autnum_overrides overrides
++ WHERE overrides.number = autnums.number),
++ autnums.name
++ ) AS name
++ FROM autnums
++ WHERE name <> %s ORDER BY number
++ """, "")
++
++ for row in rows:
++ a = writer.add_as(row.number)
++ a.name = row.name
++
++ # Add all networks
++ log.info("Writing networks...")
++
++ # Select all known networks
++ rows = self.db.query("""
++ WITH known_networks AS (
++ SELECT network FROM announcements
++ UNION
++ SELECT network FROM networks
++ UNION
++ SELECT network FROM network_overrides
++ ),
++
++ ordered_networks AS (
++ SELECT
++ known_networks.network AS network,
++ announcements.autnum AS autnum,
++ networks.country AS country,
++
++ -- Must be part of returned values for ORDER BY clause
++ masklen(announcements.network) AS sort_a,
++ masklen(networks.network) AS sort_b
++ FROM
++ known_networks
++ LEFT JOIN
++ announcements ON known_networks.network <<= announcements.network
++ LEFT JOIN
++ networks ON known_networks.network <<= networks.network
++ ORDER BY
++ known_networks.network,
++ sort_a DESC,
++ sort_b DESC
++ )
++
++ -- Return a list of those networks enriched with all
++ -- other information that we store in the database
++ SELECT
++ DISTINCT ON (network)
++ network,
++ autnum,
++
++ -- Country
++ COALESCE(
++ (
++ SELECT country FROM network_overrides overrides
++ WHERE networks.network <<= overrides.network
++ ORDER BY masklen(overrides.network) DESC
++ LIMIT 1
++ ),
++ (
++ SELECT country FROM autnum_overrides overrides
++ WHERE networks.autnum = overrides.number
++ ),
++ networks.country
++ ) AS country,
++
++ -- Flags
++ COALESCE(
++ (
++ SELECT is_anonymous_proxy FROM network_overrides overrides
++ WHERE networks.network <<= overrides.network
++ ORDER BY masklen(overrides.network) DESC
++ LIMIT 1
++ ),
++ (
++ SELECT is_anonymous_proxy FROM autnum_overrides overrides
++ WHERE networks.autnum = overrides.number
++ ),
++ FALSE
++ ) AS is_anonymous_proxy,
++ COALESCE(
++ (
++ SELECT is_satellite_provider FROM network_overrides overrides
++ WHERE networks.network <<= overrides.network
++ ORDER BY masklen(overrides.network) DESC
++ LIMIT 1
++ ),
++ (
++ SELECT is_satellite_provider FROM autnum_overrides overrides
++ WHERE networks.autnum = overrides.number
++ ),
++ FALSE
++ ) AS is_satellite_provider,
++ COALESCE(
++ (
++ SELECT is_anycast FROM network_overrides overrides
++ WHERE networks.network <<= overrides.network
++ ORDER BY masklen(overrides.network) DESC
++ LIMIT 1
++ ),
++ (
++ SELECT is_anycast FROM autnum_overrides overrides
++ WHERE networks.autnum = overrides.number
++ ),
++ FALSE
++ ) AS is_anycast,
++ COALESCE(
++ (
++ SELECT is_drop FROM network_overrides overrides
++ WHERE networks.network <<= overrides.network
++ ORDER BY masklen(overrides.network) DESC
++ LIMIT 1
++ ),
++ (
++ SELECT is_drop FROM autnum_overrides overrides
++ WHERE networks.autnum = overrides.number
++ ),
++ FALSE
++ ) AS is_drop
++ FROM
++ ordered_networks networks
++ """)
++
++ for row in rows:
++ network = writer.add_network(row.network)
++
++ # Save country
++ if row.country:
++ network.country_code = row.country
++
++ # Save ASN
++ if row.autnum:
++ network.asn = row.autnum
++
++ # Set flags
++ if row.is_anonymous_proxy:
++ network.set_flag(location.NETWORK_FLAG_ANONYMOUS_PROXY)
++
++ if row.is_satellite_provider:
++ network.set_flag(location.NETWORK_FLAG_SATELLITE_PROVIDER)
++
++ if row.is_anycast:
++ network.set_flag(location.NETWORK_FLAG_ANYCAST)
++
++ if row.is_drop:
++ network.set_flag(location.NETWORK_FLAG_DROP)
++
++ # Add all countries
++ log.info("Writing countries...")
++ rows = self.db.query("SELECT * FROM countries ORDER BY country_code")
++
++ for row in rows:
++ c = writer.add_country(row.country_code)
++ c.continent_code = row.continent_code
++ c.name = row.name
++
++ # Write everything to file
++ log.info("Writing database to file...")
++ for file in ns.file:
++ writer.write(file)
++
++ def handle_update_whois(self, ns):
++ downloader = location.importer.Downloader()
++
++ # Download all sources
++ with self.db.transaction():
++ # Create some temporary tables to store parsed data
++ self.db.execute("""
++ CREATE TEMPORARY TABLE _autnums(number integer NOT NULL, organization text NOT NULL, source text NOT NULL)
++ ON COMMIT DROP;
++ CREATE UNIQUE INDEX _autnums_number ON _autnums(number);
++
++ CREATE TEMPORARY TABLE _organizations(handle text NOT NULL, name text NOT NULL, source text NOT NULL)
++ ON COMMIT DROP;
++ CREATE UNIQUE INDEX _organizations_handle ON _organizations(handle);
++
++ CREATE TEMPORARY TABLE _rirdata(network inet NOT NULL, country text NOT NULL, original_countries text[] NOT NULL, source text NOT NULL)
++ ON COMMIT DROP;
++ CREATE INDEX _rirdata_search ON _rirdata USING BTREE(family(network), masklen(network));
++ CREATE UNIQUE INDEX _rirdata_network ON _rirdata(network);
++ """)
++
++ # Remove all previously imported content
++ self.db.execute("""
++ TRUNCATE TABLE networks;
++ """)
++
++ # Fetch all valid country codes to check parsed networks aganist...
++ rows = self.db.query("SELECT * FROM countries ORDER BY country_code")
++ validcountries = []
++
++ for row in rows:
++ validcountries.append(row.country_code)
++
++ for source_key in location.importer.WHOIS_SOURCES:
++ for single_url in location.importer.WHOIS_SOURCES[source_key]:
++ with downloader.request(single_url, return_blocks=True) as f:
++ for block in f:
++ self._parse_block(block, source_key, validcountries)
++
++ # Process all parsed networks from every RIR we happen to have access to,
++ # insert the largest network chunks into the networks table immediately...
++ families = self.db.query("SELECT DISTINCT family(network) AS family FROM _rirdata ORDER BY family(network)")
++
++ for family in (row.family for row in families):
++ smallest = self.db.get("SELECT MIN(masklen(network)) AS prefix FROM _rirdata WHERE family(network) = %s", family)
++
++ self.db.execute("INSERT INTO networks(network, country, original_countries, source) \
++ SELECT network, country, original_countries, source FROM _rirdata WHERE masklen(network) = %s AND family(network) = %s", smallest.prefix, family)
++
++ # ... determine any other prefixes for this network family, ...
++ prefixes = self.db.query("SELECT DISTINCT masklen(network) AS prefix FROM _rirdata \
++ WHERE family(network) = %s ORDER BY masklen(network) ASC OFFSET 1", family)
++
++ # ... and insert networks with this prefix in case they provide additional
++ # information (i. e. subnet of a larger chunk with a different country)
++ for prefix in (row.prefix for row in prefixes):
++ self.db.execute("""
++ WITH candidates AS (
++ SELECT
++ _rirdata.network,
++ _rirdata.country,
++ _rirdata.original_countries,
++ _rirdata.source
++ FROM
++ _rirdata
++ WHERE
++ family(_rirdata.network) = %s
++ AND
++ masklen(_rirdata.network) = %s
++ ),
++ filtered AS (
++ SELECT
++ DISTINCT ON (c.network)
++ c.network,
++ c.country,
++ c.original_countries,
++ c.source,
++ masklen(networks.network),
++ networks.country AS parent_country
++ FROM
++ candidates c
++ LEFT JOIN
++ networks
++ ON
++ c.network << networks.network
++ ORDER BY
++ c.network,
++ masklen(networks.network) DESC NULLS LAST
++ )
++ INSERT INTO
++ networks(network, country, original_countries, source)
++ SELECT
++ network,
++ country,
++ original_countries,
++ source
++ FROM
++ filtered
++ WHERE
++ parent_country IS NULL
++ OR
++ country <> parent_country
++ ON CONFLICT DO NOTHING""",
++ family, prefix,
++ )
++
++ self.db.execute("""
++ INSERT INTO autnums(number, name, source)
++ SELECT _autnums.number, _organizations.name, _organizations.source FROM _autnums
++ JOIN _organizations ON _autnums.organization = _organizations.handle
++ ON CONFLICT (number) DO UPDATE SET name = excluded.name;
++ """)
++
++ # Download all extended sources
++ for source_key in location.importer.EXTENDED_SOURCES:
++ for single_url in location.importer.EXTENDED_SOURCES[source_key]:
++ with self.db.transaction():
++ # Download data
++ with downloader.request(single_url) as f:
++ for line in f:
++ self._parse_line(line, source_key, validcountries)
++
++ # Download and import (technical) AS names from ARIN
++ self._import_as_names_from_arin()
++
++ def _check_parsed_network(self, network):
++ """
++ Assistive function to detect and subsequently sort out parsed
++ networks from RIR data (both Whois and so-called "extended sources"),
++ which are or have...
++
++ (a) not globally routable (RFC 1918 space, et al.)
++ (b) covering a too large chunk of the IP address space (prefix length
++ is < 7 for IPv4 networks, and < 10 for IPv6)
++ (c) "0.0.0.0" or "::" as a network address
++ (d) are too small for being publicly announced (we have decided not to
++ process them at the moment, as they significantly enlarge our
++ database without providing very helpful additional information)
++
++ This unfortunately is necessary due to brain-dead clutter across
++ various RIR databases, causing mismatches and eventually disruptions.
++
++ We will return False in case a network is not suitable for adding
++ it to our database, and True otherwise.
++ """
++
++ if not network or not (isinstance(network, ipaddress.IPv4Network) or isinstance(network, ipaddress.IPv6Network)):
++ return False
++
++ if not network.is_global:
++ log.debug("Skipping non-globally routable network: %s" % network)
++ return False
++
++ if network.version == 4:
++ if network.prefixlen < 7:
++ log.debug("Skipping too big IP chunk: %s" % network)
++ return False
++
++ if network.prefixlen > 24:
++ log.debug("Skipping network too small to be publicly announced: %s" % network)
++ return False
++
++ if str(network.network_address) == "0.0.0.0":
++ log.debug("Skipping network based on 0.0.0.0: %s" % network)
++ return False
++
++ elif network.version == 6:
++ if network.prefixlen < 10:
++ log.debug("Skipping too big IP chunk: %s" % network)
++ return False
++
++ if network.prefixlen > 48:
++ log.debug("Skipping network too small to be publicly announced: %s" % network)
++ return False
++
++ if str(network.network_address) == "::":
++ log.debug("Skipping network based on '::': %s" % network)
++ return False
++
++ else:
++ # This should not happen...
++ log.warning("Skipping network of unknown family, this should not happen: %s" % network)
++ return False
++
++ # In case we have made it here, the network is considered to
++ # be suitable for libloc consumption...
++ return True
++
++ def _check_parsed_asn(self, asn):
++ """
++ Assistive function to filter Autonomous System Numbers not being suitable
++ for adding to our database. Returns False in such cases, and True otherwise.
++ """
++
++ for start, end in VALID_ASN_RANGES:
++ if start <= asn and end >= asn:
++ return True
++
++ log.info("Supplied ASN %s out of publicly routable ASN ranges" % asn)
++ return False
++
++ def _parse_block(self, block, source_key, validcountries = None):
++ # Get first line to find out what type of block this is
++ line = block[0]
++
++ # aut-num
++ if line.startswith("aut-num:"):
++ return self._parse_autnum_block(block, source_key)
++
++ # inetnum
++ if line.startswith("inet6num:") or line.startswith("inetnum:"):
++ return self._parse_inetnum_block(block, source_key, validcountries)
++
++ # organisation
++ elif line.startswith("organisation:"):
++ return self._parse_org_block(block, source_key)
++
++ def _parse_autnum_block(self, block, source_key):
++ autnum = {}
++ for line in block:
++ # Split line
++ key, val = split_line(line)
++
++ if key == "aut-num":
++ m = re.match(r"^(AS|as)(\d+)", val)
++ if m:
++ autnum["asn"] = m.group(2)
++
++ elif key == "org":
++ autnum[key] = val.upper()
++
++ elif key == "descr":
++ # Save the first description line as well...
++ if not key in autnum:
++ autnum[key] = val
++
++ # Skip empty objects
++ if not autnum or not "asn" in autnum:
++ return
++
++ # Insert a dummy organisation handle into our temporary organisations
++ # table in case the AS does not have an organisation handle set, but
++ # has a description (a quirk often observed in APNIC area), so we can
++ # later display at least some string for this AS.
++ if not "org" in autnum:
++ if "descr" in autnum:
++ autnum["org"] = "LIBLOC-%s-ORGHANDLE" % autnum.get("asn")
++
++ self.db.execute("INSERT INTO _organizations(handle, name, source) \
++ VALUES(%s, %s, %s) ON CONFLICT (handle) DO NOTHING",
++ autnum.get("org"), autnum.get("descr"), source_key,
++ )
++ else:
++ log.warning("ASN %s neither has an organisation handle nor a description line set, omitting" % \
++ autnum.get("asn"))
++ return
++
++ # Insert into database
++ self.db.execute("INSERT INTO _autnums(number, organization, source) \
++ VALUES(%s, %s, %s) ON CONFLICT (number) DO UPDATE SET \
++ organization = excluded.organization",
++ autnum.get("asn"), autnum.get("org"), source_key,
++ )
++
++ def _parse_inetnum_block(self, block, source_key, validcountries = None):
++ log.debug("Parsing inetnum block:")
++
++ inetnum = {}
++ for line in block:
++ log.debug(line)
++
++ # Split line
++ key, val = split_line(line)
++
++ # Filter any inetnum records which are only referring to IP space
++ # not managed by that specific RIR...
++ if key == "netname":
++ if re.match(r"^(ERX-NETBLOCK|(AFRINIC|ARIN|LACNIC|RIPE)-CIDR-BLOCK|IANA-NETBLOCK-\d{1,3}|NON-RIPE-NCC-MANAGED-ADDRESS-BLOCK|STUB-[\d-]{3,}SLASH\d{1,2})", val.strip()):
++ log.debug("Skipping record indicating historic/orphaned data: %s" % val.strip())
++ return
++
++ if key == "inetnum":
++ start_address, delim, end_address = val.partition("-")
++
++ # Strip any excess space
++ start_address, end_address = start_address.rstrip(), end_address.strip()
++
++ # Handle "inetnum" formatting in LACNIC DB (e.g. "24.152.8/22" instead of "24.152.8.0/22")
++ if start_address and not (delim or end_address):
++ try:
++ start_address = ipaddress.ip_network(start_address, strict=False)
++ except ValueError:
++ start_address = start_address.split("/")
++ ldigits = start_address[0].count(".")
++
++ # How many octets do we need to add?
++ # (LACNIC does not seem to have a /8 or greater assigned, so the following should suffice.)
++ if ldigits == 1:
++ start_address = start_address[0] + ".0.0/" + start_address[1]
++ elif ldigits == 2:
++ start_address = start_address[0] + ".0/" + start_address[1]
++ else:
++ log.warning("Could not recover IPv4 address from line in LACNIC DB format: %s" % line)
++ return
++
++ try:
++ start_address = ipaddress.ip_network(start_address, strict=False)
++ except ValueError:
++ log.warning("Could not parse line in LACNIC DB format: %s" % line)
++ return
++
++ # Enumerate first and last IP address of this network
++ end_address = start_address[-1]
++ start_address = start_address[0]
++
++ else:
++ # Convert to IP address
++ try:
++ start_address = ipaddress.ip_address(start_address)
++ end_address = ipaddress.ip_address(end_address)
++ except ValueError:
++ log.warning("Could not parse line: %s" % line)
++ return
++
++ inetnum["inetnum"] = list(ipaddress.summarize_address_range(start_address, end_address))
++
++ elif key == "inet6num":
++ inetnum[key] = [ipaddress.ip_network(val, strict=False)]
++
++ elif key == "country":
++ val = val.upper()
++
++ # Catch RIR data objects with more than one country code...
++ if not key in inetnum:
++ inetnum[key] = []
++ else:
++ if val in inetnum.get("country"):
++ # ... but keep this list distinct...
++ continue
++
++ # When people set country codes to "UK", they actually mean "GB"
++ if val == "UK":
++ val = "GB"
++
++ inetnum[key].append(val)
++
++ # Skip empty objects
++ if not inetnum or not "country" in inetnum:
++ return
++
++ # Prepare skipping objects with unknown country codes...
++ invalidcountries = [singlecountry for singlecountry in inetnum.get("country") if singlecountry not in validcountries]
++
++ # Iterate through all networks enumerated from above, check them for plausibility and insert
++ # them into the database, if _check_parsed_network() succeeded
++ for single_network in inetnum.get("inet6num") or inetnum.get("inetnum"):
++ if self._check_parsed_network(single_network):
++
++ # Skip objects with unknown country codes if they are valid to avoid log spam...
++ if validcountries and invalidcountries:
++ log.warning("Skipping network with bogus countr(y|ies) %s (original countries: %s): %s" % \
++ (invalidcountries, inetnum.get("country"), inetnum.get("inet6num") or inetnum.get("inetnum")))
++ break
++
++ # Everything is fine here, run INSERT statement...
++ self.db.execute("INSERT INTO _rirdata(network, country, original_countries, source) \
++ VALUES(%s, %s, %s, %s) ON CONFLICT (network) DO UPDATE SET country = excluded.country",
++ "%s" % single_network, inetnum.get("country")[0], inetnum.get("country"), source_key,
++ )
++
++ def _parse_org_block(self, block, source_key):
++ org = {}
++ for line in block:
++ # Split line
++ key, val = split_line(line)
++
++ if key == "organisation":
++ org[key] = val.upper()
++ elif key == "org-name":
++ org[key] = val
++
++ # Skip empty objects
++ if not org:
++ return
++
++ self.db.execute("INSERT INTO _organizations(handle, name, source) \
++ VALUES(%s, %s, %s) ON CONFLICT (handle) DO \
++ UPDATE SET name = excluded.name",
++ org.get("organisation"), org.get("org-name"), source_key,
++ )
++
++ def _parse_line(self, line, source_key, validcountries = None):
++ # Skip version line
++ if line.startswith("2"):
++ return
++
++ # Skip comments
++ if line.startswith("#"):
++ return
++
++ try:
++ registry, country_code, type, line = line.split("|", 3)
++ except:
++ log.warning("Could not parse line: %s" % line)
++ return
++
++ # Skip any lines that are for stats only or do not have a country
++ # code at all (avoids log spam below)
++ if not country_code or country_code == '*':
++ return
++
++ # Skip objects with unknown country codes
++ if validcountries and country_code not in validcountries:
++ log.warning("Skipping line with bogus country '%s': %s" % \
++ (country_code, line))
++ return
++
++ if type in ("ipv6", "ipv4"):
++ return self._parse_ip_line(country_code, type, line, source_key)
++
++ def _parse_ip_line(self, country, type, line, source_key):
++ try:
++ address, prefix, date, status, organization = line.split("|")
++ except ValueError:
++ organization = None
++
++ # Try parsing the line without organization
++ try:
++ address, prefix, date, status = line.split("|")
++ except ValueError:
++ log.warning("Unhandled line format: %s" % line)
++ return
++
++ # Skip anything that isn't properly assigned
++ if not status in ("assigned", "allocated"):
++ return
++
++ # Cast prefix into an integer
++ try:
++ prefix = int(prefix)
++ except:
++ log.warning("Invalid prefix: %s" % prefix)
++ return
++
++ # Fix prefix length for IPv4
++ if type == "ipv4":
++ prefix = 32 - int(math.log(prefix, 2))
++
++ # Try to parse the address
++ try:
++ network = ipaddress.ip_network("%s/%s" % (address, prefix), strict=False)
++ except ValueError:
++ log.warning("Invalid IP address: %s" % address)
++ return
++
++ if not self._check_parsed_network(network):
++ return
++
++ self.db.execute("INSERT INTO networks(network, country, original_countries, source) \
++ VALUES(%s, %s, %s, %s) ON CONFLICT (network) DO \
++ UPDATE SET country = excluded.country",
++ "%s" % network, country, [country], source_key,
++ )
++
++ def _import_as_names_from_arin(self):
++ downloader = location.importer.Downloader()
++
++ # XXX: Download AS names file from ARIN (note that these names appear to be quite
++ # technical, not intended for human consumption, as description fields in
++ # organisation handles for other RIRs are - however, this is what we have got,
++ # and in some cases, it might be still better than nothing)
++ with downloader.request("https://ftp.arin.net/info/asn.txt", return_blocks=False) as f:
++ for line in f:
++ # Convert binary line to string...
++ line = str(line)
++
++ # ... valid lines start with a space, followed by the number of the Autonomous System ...
++ if not line.startswith(" "):
++ continue
++
++ # Split line and check if there is a valid ASN in it...
++ asn, name = line.split()[0:2]
++
++ try:
++ asn = int(asn)
++ except ValueError:
++ log.debug("Skipping ARIN AS names line not containing an integer for ASN")
++ continue
++
++ # Filter invalid ASNs...
++ if not self._check_parsed_asn(asn):
++ continue
++
++ # Skip any AS name that appears to be a placeholder for a different RIR or entity...
++ if re.match(r"^(ASN-BLK|)(AFCONC|AFRINIC|APNIC|ASNBLK|DNIC|LACNIC|RIPE|IANA)(?:\d?$|\-)", name):
++ continue
++
++ # Bail out in case the AS name contains anything we do not expect here...
++ if re.search(r"[^a-zA-Z0-9-_]", name):
++ log.debug("Skipping ARIN AS name for %s containing invalid characters: %s" % \
++ (asn, name))
++
++ # Things look good here, run INSERT statement and skip this one if we already have
++ # a (better?) name for this Autonomous System...
++ self.db.execute("""
++ INSERT INTO autnums(
++ number,
++ name,
++ source
++ ) VALUES (%s, %s, %s)
++ ON CONFLICT (number) DO NOTHING""",
++ asn,
++ name,
++ "ARIN",
++ )
++
++ def handle_update_announcements(self, ns):
++ server = ns.server[0]
++
++ with self.db.transaction():
++ if server.startswith("/"):
++ self._handle_update_announcements_from_bird(server)
++ else:
++ self._handle_update_announcements_from_telnet(server)
++
++ # Purge anything we never want here
++ self.db.execute("""
++ -- Delete default routes
++ DELETE FROM announcements WHERE network = '::/0' OR network = '0.0.0.0/0';
++
++ -- Delete anything that is not global unicast address space
++ DELETE FROM announcements WHERE family(network) = 6 AND NOT network <<= '2000::/3';
++
++ -- DELETE "current network" address space
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '0.0.0.0/8';
++
++ -- DELETE local loopback address space
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '127.0.0.0/8';
++
++ -- DELETE RFC 1918 address space
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '10.0.0.0/8';
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '172.16.0.0/12';
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.168.0.0/16';
++
++ -- DELETE test, benchmark and documentation address space
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.0.0.0/24';
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.0.2.0/24';
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '198.18.0.0/15';
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '198.51.100.0/24';
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '203.0.113.0/24';
++
++ -- DELETE CGNAT address space (RFC 6598)
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '100.64.0.0/10';
++
++ -- DELETE link local address space
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '169.254.0.0/16';
++
++ -- DELETE IPv6 to IPv4 (6to4) address space (RFC 3068)
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '192.88.99.0/24';
++ DELETE FROM announcements WHERE family(network) = 6 AND network <<= '2002::/16';
++
++ -- DELETE multicast and reserved address space
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '224.0.0.0/4';
++ DELETE FROM announcements WHERE family(network) = 4 AND network <<= '240.0.0.0/4';
++
++ -- Delete networks that are too small to be in the global routing table
++ DELETE FROM announcements WHERE family(network) = 6 AND masklen(network) > 48;
++ DELETE FROM announcements WHERE family(network) = 4 AND masklen(network) > 24;
++
++ -- Delete any non-public or reserved ASNs
++ DELETE FROM announcements WHERE NOT (
++ (autnum >= 1 AND autnum <= 23455)
++ OR
++ (autnum >= 23457 AND autnum <= 64495)
++ OR
++ (autnum >= 131072 AND autnum <= 4199999999)
++ );
++
++ -- Delete everything that we have not seen for 14 days
++ DELETE FROM announcements WHERE last_seen_at <= CURRENT_TIMESTAMP - INTERVAL '14 days';
++ """)
++
++ def _handle_update_announcements_from_bird(self, server):
++ # Pre-compile the regular expression for faster searching
++ route = re.compile(b"^\s(.+?)\s+.+?\[(?:AS(.*?))?.\]$")
++
++ log.info("Requesting routing table from Bird (%s)" % server)
++
++ aggregated_networks = []
++
++ # Send command to list all routes
++ for line in self._bird_cmd(server, "show route"):
++ m = route.match(line)
++ if not m:
++ # Skip empty lines
++ if not line:
++ pass
++
++ # Ignore any header lines with the name of the routing table
++ elif line.startswith(b"Table"):
++ pass
++
++ # Log anything else
++ else:
++ log.debug("Could not parse line: %s" % line.decode())
++
++ continue
++
++ # Fetch the extracted network and ASN
++ network, autnum = m.groups()
++
++ # Decode into strings
++ if network:
++ network = network.decode()
++ if autnum:
++ autnum = autnum.decode()
++
++ # Collect all aggregated networks
++ if not autnum:
++ log.debug("%s is an aggregated network" % network)
++ aggregated_networks.append(network)
++ continue
++
++ # Insert it into the database
++ self.db.execute("INSERT INTO announcements(network, autnum) \
++ VALUES(%s, %s) ON CONFLICT (network) DO \
++ UPDATE SET autnum = excluded.autnum, last_seen_at = CURRENT_TIMESTAMP",
++ network, autnum,
++ )
++
++ # Process any aggregated networks
++ for network in aggregated_networks:
++ log.debug("Processing aggregated network %s" % network)
++
++ # Run "show route all" for each network
++ for line in self._bird_cmd(server, "show route %s all" % network):
++ # Try finding the path
++ m = re.match(b"\s+BGP\.as_path:.* (\d+) {\d+}$", line)
++ if m:
++ # Select the last AS number in the path
++ autnum = m.group(1).decode()
++
++ # Insert it into the database
++ self.db.execute("INSERT INTO announcements(network, autnum) \
++ VALUES(%s, %s) ON CONFLICT (network) DO \
++ UPDATE SET autnum = excluded.autnum, last_seen_at = CURRENT_TIMESTAMP",
++ network, autnum,
++ )
++
++ # We don't need to process any more
++ break
++
++ def _handle_update_announcements_from_telnet(self, server):
++ # Pre-compile regular expression for routes
++ route = re.compile(b"^\*[\s\>]i([^\s]+).+?(\d+)\si\r\n", re.MULTILINE|re.DOTALL)
++
++ with telnetlib.Telnet(server) as t:
++ # Enable debug mode
++ #if ns.debug:
++ # t.set_debuglevel(10)
++
++ # Wait for console greeting
++ greeting = t.read_until(b"> ", timeout=30)
++ if not greeting:
++ log.error("Could not get a console prompt")
++ return 1
++
++ # Disable pagination
++ t.write(b"terminal length 0\n")
++
++ # Wait for the prompt to return
++ t.read_until(b"> ")
++
++ # Fetch the routing tables
++ for protocol in ("ipv6", "ipv4"):
++ log.info("Requesting %s routing table" % protocol)
++
++ # Request the full unicast routing table
++ t.write(b"show bgp %s unicast\n" % protocol.encode())
++
++ # Read entire header which ends with "Path"
++ t.read_until(b"Path\r\n")
++
++ while True:
++ # Try reading a full entry
++ # Those might be broken across multiple lines but ends with i
++ line = t.read_until(b"i\r\n", timeout=5)
++ if not line:
++ break
++
++ # Show line for debugging
++ #log.debug(repr(line))
++
++ # Try finding a route in here
++ m = route.match(line)
++ if m:
++ network, autnum = m.groups()
++
++ # Convert network to string
++ network = network.decode()
++
++ # Append /24 for IPv4 addresses
++ if not "/" in network and not ":" in network:
++ network = "%s/24" % network
++
++ # Convert AS number to integer
++ autnum = int(autnum)
++
++ log.info("Found announcement for %s by %s" % (network, autnum))
++
++ self.db.execute("INSERT INTO announcements(network, autnum) \
++ VALUES(%s, %s) ON CONFLICT (network) DO \
++ UPDATE SET autnum = excluded.autnum, last_seen_at = CURRENT_TIMESTAMP",
++ network, autnum,
++ )
++
++ log.info("Finished reading the %s routing table" % protocol)
++
++ def _bird_cmd(self, socket_path, command):
++ # Connect to the socket
++ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
++ s.connect(socket_path)
++
++ # Allocate some buffer
++ buffer = b""
++
++ log.debug("Sending Bird command: %s" % command)
++
++ # Send the command
++ s.send(b"%s\n" % command.encode())
++
++ while True:
++ # Fill up the buffer
++ buffer += s.recv(4096)
++
++ while True:
++ # Search for the next newline
++ pos = buffer.find(b"\n")
++
++ # If we cannot find one, we go back and read more data
++ if pos <= 0:
++ break
++
++ # Cut after the newline character
++ pos += 1
++
++ # Split the line we want and keep the rest in buffer
++ line, buffer = buffer[:pos], buffer[pos:]
++
++ # Try parsing any status lines
++ if len(line) > 4 and line[:4].isdigit() and line[4] in (32, 45):
++ code, delim, line = int(line[:4]), line[4], line[5:]
++
++ log.debug("Received response code %s from bird" % code)
++
++ # End of output
++ if code == 0:
++ return
++
++ # Ignore hello line
++ elif code == 1:
++ continue
++
++ # Otherwise return the line
++ yield line
++
++ def handle_update_overrides(self, ns):
++ with self.db.transaction():
++ # Drop all data that we have
++ self.db.execute("""
++ TRUNCATE TABLE autnum_overrides;
++ TRUNCATE TABLE network_overrides;
++ """)
++
++ # Update overrides for various cloud providers big enough to publish their own IP
++ # network allocation lists in a machine-readable format...
++ self._update_overrides_for_aws()
++
++ # Update overrides for Spamhaus DROP feeds...
++ self._update_overrides_for_spamhaus_drop()
++
++ for file in ns.files:
++ log.info("Reading %s..." % file)
++
++ with open(file, "rb") as f:
++ for type, block in location.importer.read_blocks(f):
++ if type == "net":
++ network = block.get("net")
++ # Try to parse and normalise the network
++ try:
++ network = ipaddress.ip_network(network, strict=False)
++ except ValueError as e:
++ log.warning("Invalid IP network: %s: %s" % (network, e))
++ continue
++
++ # Prevent that we overwrite all networks
++ if network.prefixlen == 0:
++ log.warning("Skipping %s: You cannot overwrite default" % network)
++ continue
++
++ self.db.execute("""
++ INSERT INTO network_overrides(
++ network,
++ country,
++ source,
++ is_anonymous_proxy,
++ is_satellite_provider,
++ is_anycast,
++ is_drop
++ ) VALUES (%s, %s, %s, %s, %s, %s, %s)
++ ON CONFLICT (network) DO NOTHING""",
++ "%s" % network,
++ block.get("country"),
++ "manual",
++ self._parse_bool(block, "is-anonymous-proxy"),
++ self._parse_bool(block, "is-satellite-provider"),
++ self._parse_bool(block, "is-anycast"),
++ self._parse_bool(block, "drop"),
++ )
++
++ elif type == "aut-num":
++ autnum = block.get("aut-num")
++
++ # Check if AS number begins with "AS"
++ if not autnum.startswith("AS"):
++ log.warning("Invalid AS number: %s" % autnum)
++ continue
++
++ # Strip "AS"
++ autnum = autnum[2:]
++
++ self.db.execute("""
++ INSERT INTO autnum_overrides(
++ number,
++ name,
++ country,
++ source,
++ is_anonymous_proxy,
++ is_satellite_provider,
++ is_anycast,
++ is_drop
++ ) VALUES(%s, %s, %s, %s, %s, %s, %s, %s)
++ ON CONFLICT DO NOTHING""",
++ autnum,
++ block.get("name"),
++ block.get("country"),
++ "manual",
++ self._parse_bool(block, "is-anonymous-proxy"),
++ self._parse_bool(block, "is-satellite-provider"),
++ self._parse_bool(block, "is-anycast"),
++ self._parse_bool(block, "drop"),
++ )
++
++ else:
++ log.warning("Unsupported type: %s" % type)
++
++ def _update_overrides_for_aws(self):
++ # Download Amazon AWS IP allocation file to create overrides...
++ downloader = location.importer.Downloader()
++
++ try:
++ with downloader.request("https://ip-ranges.amazonaws.com/ip-ranges.json", return_blocks=False) as f:
++ aws_ip_dump = json.load(f.body)
++ except Exception as e:
++ log.error("unable to preprocess Amazon AWS IP ranges: %s" % e)
++ return
++
++ # XXX: Set up a dictionary for mapping a region name to a country. Unfortunately,
++ # there seems to be no machine-readable version available of this other than
++ # https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html
++ # (worse, it seems to be incomplete :-/ ); https://www.cloudping.cloud/endpoints
++ # was helpful here as well.
++ aws_region_country_map = {
++ "af-south-1": "ZA",
++ "ap-east-1": "HK",
++ "ap-south-1": "IN",
++ "ap-south-2": "IN",
++ "ap-northeast-3": "JP",
++ "ap-northeast-2": "KR",
++ "ap-southeast-1": "SG",
++ "ap-southeast-2": "AU",
++ "ap-southeast-3": "MY",
++ "ap-southeast-4": "AU",
++ "ap-northeast-1": "JP",
++ "ca-central-1": "CA",
++ "eu-central-1": "DE",
++ "eu-central-2": "CH",
++ "eu-west-1": "IE",
++ "eu-west-2": "GB",
++ "eu-south-1": "IT",
++ "eu-south-2": "ES",
++ "eu-west-3": "FR",
++ "eu-north-1": "SE",
++ "il-central-1": "IL", # XXX: This one is not documented anywhere except for ip-ranges.json itself
++ "me-central-1": "AE",
++ "me-south-1": "BH",
++ "sa-east-1": "BR"
++ }
++
++ # Fetch all valid country codes to check parsed networks aganist...
++ rows = self.db.query("SELECT * FROM countries ORDER BY country_code")
++ validcountries = []
++
++ for row in rows:
++ validcountries.append(row.country_code)
++
++ with self.db.transaction():
++ for snetwork in aws_ip_dump["prefixes"] + aws_ip_dump["ipv6_prefixes"]:
++ try:
++ network = ipaddress.ip_network(snetwork.get("ip_prefix") or snetwork.get("ipv6_prefix"), strict=False)
++ except ValueError:
++ log.warning("Unable to parse line: %s" % snetwork)
++ continue
++
++ # Sanitize parsed networks...
++ if not self._check_parsed_network(network):
++ continue
++
++ # Determine region of this network...
++ region = snetwork["region"]
++ cc = None
++ is_anycast = False
++
++ # Any region name starting with "us-" will get "US" country code assigned straight away...
++ if region.startswith("us-"):
++ cc = "US"
++ elif region.startswith("cn-"):
++ # ... same goes for China ...
++ cc = "CN"
++ elif region == "GLOBAL":
++ # ... funny region name for anycast-like networks ...
++ is_anycast = True
++ elif region in aws_region_country_map:
++ # ... assign looked up country code otherwise ...
++ cc = aws_region_country_map[region]
++ else:
++ # ... and bail out if we are missing something here
++ log.warning("Unable to determine country code for line: %s" % snetwork)
++ continue
++
++ # Skip networks with unknown country codes
++ if not is_anycast and validcountries and cc not in validcountries:
++ log.warning("Skipping Amazon AWS network with bogus country '%s': %s" % \
++ (cc, network))
++ return
++
++ # Conduct SQL statement...
++ self.db.execute("""
++ INSERT INTO network_overrides(
++ network,
++ country,
++ source,
++ is_anonymous_proxy,
++ is_satellite_provider,
++ is_anycast
++ ) VALUES (%s, %s, %s, %s, %s, %s)
++ ON CONFLICT (network) DO NOTHING""",
++ "%s" % network,
++ cc,
++ "Amazon AWS IP feed",
++ None,
++ None,
++ is_anycast,
++ )
++
++
++ def _update_overrides_for_spamhaus_drop(self):
++ downloader = location.importer.Downloader()
++
++ ip_urls = [
++ "https://www.spamhaus.org/drop/drop.txt",
++ "https://www.spamhaus.org/drop/edrop.txt",
++ "https://www.spamhaus.org/drop/dropv6.txt"
++ ]
++
++ asn_urls = [
++ "https://www.spamhaus.org/drop/asndrop.txt"
++ ]
++
++ for url in ip_urls:
++ try:
++ with downloader.request(url, return_blocks=False) as f:
++ fcontent = f.body.readlines()
++ except Exception as e:
++ log.error("Unable to download Spamhaus DROP URL %s: %s" % (url, e))
++ return
++
++ # Iterate through every line, filter comments and add remaining networks to
++ # the override table in case they are valid...
++ with self.db.transaction():
++ for sline in fcontent:
++
++ # The response is assumed to be encoded in UTF-8...
++ sline = sline.decode("utf-8")
++
++ # Comments start with a semicolon...
++ if sline.startswith(";"):
++ continue
++
++ # Extract network and ignore anything afterwards...
++ try:
++ network = ipaddress.ip_network(sline.split()[0], strict=False)
++ except ValueError:
++ log.error("Unable to parse line: %s" % sline)
++ continue
++
++ # Sanitize parsed networks...
++ if not self._check_parsed_network(network):
++ log.warning("Skipping bogus network found in Spamhaus DROP URL %s: %s" % \
++ (url, network))
++ continue
++
++ # Conduct SQL statement...
++ self.db.execute("""
++ INSERT INTO network_overrides(
++ network,
++ source,
++ is_drop
++ ) VALUES (%s, %s, %s)
++ ON CONFLICT (network) DO UPDATE SET is_drop = True""",
++ "%s" % network,
++ "Spamhaus DROP lists",
++ True
++ )
++
++ for url in asn_urls:
++ try:
++ with downloader.request(url, return_blocks=False) as f:
++ fcontent = f.body.readlines()
++ except Exception as e:
++ log.error("Unable to download Spamhaus DROP URL %s: %s" % (url, e))
++ return
++
++ # Iterate through every line, filter comments and add remaining ASNs to
++ # the override table in case they are valid...
++ with self.db.transaction():
++ for sline in fcontent:
++
++ # The response is assumed to be encoded in UTF-8...
++ sline = sline.decode("utf-8")
++
++ # Comments start with a semicolon...
++ if sline.startswith(";"):
++ continue
++
++ # Throw away anything after the first space...
++ sline = sline.split()[0]
++
++ # ... strip the "AS" prefix from it ...
++ sline = sline.strip("AS")
++
++ # ... and convert it into an integer. Voila.
++ asn = int(sline)
++
++ # Filter invalid ASNs...
++ if not self._check_parsed_asn(asn):
++ log.warning("Skipping bogus ASN found in Spamhaus DROP URL %s: %s" % \
++ (url, asn))
++ continue
++
++ # Conduct SQL statement...
++ self.db.execute("""
++ INSERT INTO autnum_overrides(
++ number,
++ source,
++ is_drop
++ ) VALUES (%s, %s, %s)
++ ON CONFLICT (number) DO UPDATE SET is_drop = True""",
++ "%s" % asn,
++ "Spamhaus ASN-DROP list",
++ True
++ )
++
++ @staticmethod
++ def _parse_bool(block, key):
++ val = block.get(key)
++
++ # There is no point to proceed when we got None
++ if val is None:
++ return
++
++ # Convert to lowercase
++ val = val.lower()
++
++ # True
++ if val in ("yes", "1"):
++ return True
++
++ # False
++ if val in ("no", "0"):
++ return False
++
++ # Default to None
++ return None
++
++ def handle_import_countries(self, ns):
++ with self.db.transaction():
++ # Drop all data that we have
++ self.db.execute("TRUNCATE TABLE countries")
++
++ for file in ns.file:
++ for line in file:
++ line = line.rstrip()
++
++ # Ignore any comments
++ if line.startswith("#"):
++ continue
++
++ try:
++ country_code, continent_code, name = line.split(maxsplit=2)
++ except:
++ log.warning("Could not parse line: %s" % line)
++ continue
++
++ self.db.execute("INSERT INTO countries(country_code, name, continent_code) \
++ VALUES(%s, %s, %s) ON CONFLICT DO NOTHING", country_code, name, continent_code)
++
++
++def split_line(line):
++ key, colon, val = line.partition(":")
++
++ # Strip any excess space
++ key = key.strip()
++ val = val.strip()
++
++ return key, val
++
++def main():
++ # Run the command line interface
++ c = CLI()
++ c.run()
++
++main()
+diff --git a/src/scripts/location.in b/src/scripts/location.in
+new file mode 100644
+index 0000000..233cea0
+--- /dev/null
++++ b/src/scripts/location.in
+@@ -0,0 +1,644 @@
++#!/usr/bin/python3
++###############################################################################
++# #
++# libloc - A library to determine the location of someone on the Internet #
++# #
++# Copyright (C) 2017-2021 IPFire Development Team <info@ipfire.org> #
++# #
++# This library is free software; you can redistribute it and/or #
++# modify it under the terms of the GNU Lesser General Public #
++# License as published by the Free Software Foundation; either #
++# version 2.1 of the License, or (at your option) any later version. #
++# #
++# This library is distributed in the hope that it will be useful, #
++# but WITHOUT ANY WARRANTY; without even the implied warranty of #
++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU #
++# Lesser General Public License for more details. #
++# #
++###############################################################################
++
++import argparse
++import datetime
++import ipaddress
++import logging
++import os
++import re
++import shutil
++import socket
++import sys
++import time
++
++# Load our location module
++import location
++import location.downloader
++import location.export
++
++from location.i18n import _
++
++# Setup logging
++log = logging.getLogger("location")
++
++# Output formatters
++
++class CLI(object):
++ def parse_cli(self):
++ parser = argparse.ArgumentParser(
++ description=_("Location Database Command Line Interface"),
++ )
++ subparsers = parser.add_subparsers()
++
++ # Global configuration flags
++ parser.add_argument("--debug", action="store_true",
++ help=_("Enable debug output"))
++ parser.add_argument("--quiet", action="store_true",
++ help=_("Enable quiet mode"))
++
++ # version
++ parser.add_argument("--version", action="version",
++ version="%(prog)s @VERSION@")
++
++ # database
++ parser.add_argument("--database", "-d",
++ default="@databasedir@/database.db", help=_("Path to database"),
++ )
++
++ # public key
++ parser.add_argument("--public-key", "-k",
++ default="@databasedir@/signing-key.pem", help=_("Public Signing Key"),
++ )
++
++ # Show the database version
++ version = subparsers.add_parser("version",
++ help=_("Show database version"))
++ version.set_defaults(func=self.handle_version)
++
++ # lookup an IP address
++ lookup = subparsers.add_parser("lookup",
++ help=_("Lookup one or multiple IP addresses"),
++ )
++ lookup.add_argument("address", nargs="+")
++ lookup.set_defaults(func=self.handle_lookup)
++
++ # Dump the whole database
++ dump = subparsers.add_parser("dump",
++ help=_("Dump the entire database"),
++ )
++ dump.add_argument("output", nargs="?", type=argparse.FileType("w"))
++ dump.set_defaults(func=self.handle_dump)
++
++ # Update
++ update = subparsers.add_parser("update", help=_("Update database"))
++ update.add_argument("--cron",
++ help=_("Update the library only once per interval"),
++ choices=("daily", "weekly", "monthly"),
++ )
++ update.set_defaults(func=self.handle_update)
++
++ # Verify
++ verify = subparsers.add_parser("verify",
++ help=_("Verify the downloaded database"))
++ verify.set_defaults(func=self.handle_verify)
++
++ # Get AS
++ get_as = subparsers.add_parser("get-as",
++ help=_("Get information about one or multiple Autonomous Systems"),
++ )
++ get_as.add_argument("asn", nargs="+")
++ get_as.set_defaults(func=self.handle_get_as)
++
++ # Search for AS
++ search_as = subparsers.add_parser("search-as",
++ help=_("Search for Autonomous Systems that match the string"),
++ )
++ search_as.add_argument("query", nargs=1)
++ search_as.set_defaults(func=self.handle_search_as)
++
++ # List all networks in an AS
++ list_networks_by_as = subparsers.add_parser("list-networks-by-as",
++ help=_("Lists all networks in an AS"),
++ )
++ list_networks_by_as.add_argument("asn", nargs=1, type=int)
++ list_networks_by_as.add_argument("--family", choices=("ipv6", "ipv4"))
++ list_networks_by_as.add_argument("--format",
++ choices=location.export.formats.keys(), default="list")
++ list_networks_by_as.set_defaults(func=self.handle_list_networks_by_as)
++
++ # List all networks in a country
++ list_networks_by_cc = subparsers.add_parser("list-networks-by-cc",
++ help=_("Lists all networks in a country"),
++ )
++ list_networks_by_cc.add_argument("country_code", nargs=1)
++ list_networks_by_cc.add_argument("--family", choices=("ipv6", "ipv4"))
++ list_networks_by_cc.add_argument("--format",
++ choices=location.export.formats.keys(), default="list")
++ list_networks_by_cc.set_defaults(func=self.handle_list_networks_by_cc)
++
++ # List all networks with flags
++ list_networks_by_flags = subparsers.add_parser("list-networks-by-flags",
++ help=_("Lists all networks with flags"),
++ )
++ list_networks_by_flags.add_argument("--anonymous-proxy",
++ action="store_true", help=_("Anonymous Proxies"),
++ )
++ list_networks_by_flags.add_argument("--satellite-provider",
++ action="store_true", help=_("Satellite Providers"),
++ )
++ list_networks_by_flags.add_argument("--anycast",
++ action="store_true", help=_("Anycasts"),
++ )
++ list_networks_by_flags.add_argument("--drop",
++ action="store_true", help=_("Hostile Networks safe to drop"),
++ )
++ list_networks_by_flags.add_argument("--family", choices=("ipv6", "ipv4"))
++ list_networks_by_flags.add_argument("--format",
++ choices=location.export.formats.keys(), default="list")
++ list_networks_by_flags.set_defaults(func=self.handle_list_networks_by_flags)
++
++ # List bogons
++ list_bogons = subparsers.add_parser("list-bogons",
++ help=_("Lists all bogons"),
++ )
++ list_bogons.add_argument("--family", choices=("ipv6", "ipv4"))
++ list_bogons.add_argument("--format",
++ choices=location.export.formats.keys(), default="list")
++ list_bogons.set_defaults(func=self.handle_list_bogons)
++
++ # List countries
++ list_countries = subparsers.add_parser("list-countries",
++ help=_("Lists all countries"),
++ )
++ list_countries.add_argument("--show-name",
++ action="store_true", help=_("Show the name of the country"),
++ )
++ list_countries.add_argument("--show-continent",
++ action="store_true", help=_("Show the continent"),
++ )
++ list_countries.set_defaults(func=self.handle_list_countries)
++
++ # Export
++ export = subparsers.add_parser("export",
++ help=_("Exports data in many formats to load it into packet filters"),
++ )
++ export.add_argument("--format", help=_("Output format"),
++ choices=location.export.formats.keys(), default="list")
++ export.add_argument("--directory", help=_("Output directory"))
++ export.add_argument("--family",
++ help=_("Specify address family"), choices=("ipv6", "ipv4"),
++ )
++ export.add_argument("objects", nargs="*", help=_("List country codes or ASNs to export"))
++ export.set_defaults(func=self.handle_export)
++
++ args = parser.parse_args()
++
++ # Configure logging
++ if args.debug:
++ location.logger.set_level(logging.DEBUG)
++ elif args.quiet:
++ location.logger.set_level(logging.WARNING)
++
++ # Print usage if no action was given
++ if not "func" in args:
++ parser.print_usage()
++ sys.exit(2)
++
++ return args
++
++ def run(self):
++ # Parse command line arguments
++ args = self.parse_cli()
++
++ # Open database
++ try:
++ db = location.Database(args.database)
++ except FileNotFoundError as e:
++ # Allow continuing without a database
++ if args.func == self.handle_update:
++ db = None
++
++ else:
++ sys.stderr.write("location: Could not open database %s: %s\n" \
++ % (args.database, e))
++ sys.exit(1)
++
++ # Translate family (if present)
++ if "family" in args:
++ if args.family == "ipv6":
++ args.family = socket.AF_INET6
++ elif args.family == "ipv4":
++ args.family = socket.AF_INET
++ else:
++ args.family = 0
++
++ # Call function
++ try:
++ ret = args.func(db, args)
++
++ # Catch invalid inputs
++ except ValueError as e:
++ sys.stderr.write("%s\n" % e)
++ ret = 2
++
++ # Catch any other exceptions
++ except Exception as e:
++ sys.stderr.write("%s\n" % e)
++ ret = 1
++
++ # Return with exit code
++ if ret:
++ sys.exit(ret)
++
++ # Otherwise just exit
++ sys.exit(0)
++
++ def handle_version(self, db, ns):
++ """
++ Print the version of the database
++ """
++ t = time.strftime(
++ "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(db.created_at),
++ )
++
++ print(t)
++
++ def handle_lookup(self, db, ns):
++ ret = 0
++
++ format = " %-24s: %s"
++
++ for address in ns.address:
++ try:
++ network = db.lookup(address)
++ except ValueError:
++ print(_("Invalid IP address: %s") % address, file=sys.stderr)
++ return 2
++
++ args = {
++ "address" : address,
++ "network" : network,
++ }
++
++ # Nothing found?
++ if not network:
++ print(_("Nothing found for %(address)s") % args, file=sys.stderr)
++ ret = 1
++ continue
++
++ print("%s:" % address)
++ print(format % (_("Network"), network))
++
++ # Print country
++ if network.country_code:
++ country = db.get_country(network.country_code)
++
++ print(format % (
++ _("Country"),
++ country.name if country else network.country_code),
++ )
++
++ # Print AS information
++ if network.asn:
++ autonomous_system = db.get_as(network.asn)
++
++ print(format % (
++ _("Autonomous System"),
++ autonomous_system or "AS%s" % network.asn),
++ )
++
++ # Anonymous Proxy
++ if network.has_flag(location.NETWORK_FLAG_ANONYMOUS_PROXY):
++ print(format % (
++ _("Anonymous Proxy"), _("yes"),
++ ))
++
++ # Satellite Provider
++ if network.has_flag(location.NETWORK_FLAG_SATELLITE_PROVIDER):
++ print(format % (
++ _("Satellite Provider"), _("yes"),
++ ))
++
++ # Anycast
++ if network.has_flag(location.NETWORK_FLAG_ANYCAST):
++ print(format % (
++ _("Anycast"), _("yes"),
++ ))
++
++ # Hostile Network
++ if network.has_flag(location.NETWORK_FLAG_DROP):
++ print(format % (
++ _("Hostile Network safe to drop"), _("yes"),
++ ))
++
++ return ret
++
++ def handle_dump(self, db, ns):
++ # Use output file or write to stdout
++ f = ns.output or sys.stdout
++
++ # Format everything like this
++ format = "%-24s %s\n"
++
++ # Write metadata
++ f.write("#\n# Location Database Export\n#\n")
++
++ f.write("# Generated: %s\n" % time.strftime(
++ "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(db.created_at),
++ ))
++
++ if db.vendor:
++ f.write("# Vendor: %s\n" % db.vendor)
++
++ if db.license:
++ f.write("# License: %s\n" % db.license)
++
++ f.write("#\n")
++
++ if db.description:
++ for line in db.description.splitlines():
++ line = "# %s" % line
++ f.write("%s\n" % line.rstrip())
++
++ f.write("#\n")
++
++ # Iterate over all ASes
++ for a in db.ases:
++ f.write("\n")
++ f.write(format % ("aut-num:", "AS%s" % a.number))
++ f.write(format % ("name:", a.name))
++
++ flags = {
++ location.NETWORK_FLAG_ANONYMOUS_PROXY : "is-anonymous-proxy:",
++ location.NETWORK_FLAG_SATELLITE_PROVIDER : "is-satellite-provider:",
++ location.NETWORK_FLAG_ANYCAST : "is-anycast:",
++ location.NETWORK_FLAG_DROP : "drop:",
++ }
++
++ # Iterate over all networks
++ for n in db.networks:
++ f.write("\n")
++ f.write(format % ("net:", n))
++
++ if n.country_code:
++ f.write(format % ("country:", n.country_code))
++
++ if n.asn:
++ f.write(format % ("aut-num:", n.asn))
++
++ # Print all flags
++ for flag in flags:
++ if n.has_flag(flag):
++ f.write(format % (flags[flag], "yes"))
++
++ def handle_get_as(self, db, ns):
++ """
++ Gets information about Autonomous Systems
++ """
++ ret = 0
++
++ for asn in ns.asn:
++ try:
++ asn = int(asn)
++ except ValueError:
++ print(_("Invalid ASN: %s") % asn, file=sys.stderr)
++ ret = 1
++ continue
++
++ # Fetch AS from database
++ a = db.get_as(asn)
++
++ # Nothing found
++ if not a:
++ print(_("Could not find AS%s") % asn, file=sys.stderr)
++ ret = 1
++ continue
++
++ print(_("AS%(asn)s belongs to %(name)s") % { "asn" : a.number, "name" : a.name })
++
++ return ret
++
++ def handle_search_as(self, db, ns):
++ for query in ns.query:
++ # Print all matches ASes
++ for a in db.search_as(query):
++ print(a)
++
++ def handle_update(self, db, ns):
++ if ns.cron and db:
++ now = time.time()
++
++ if ns.cron == "daily":
++ delta = datetime.timedelta(days=1)
++ elif ns.cron == "weekly":
++ delta = datetime.timedelta(days=7)
++ elif ns.cron == "monthly":
++ delta = datetime.timedelta(days=30)
++
++ delta = delta.total_seconds()
++
++ # Check if the database has recently been updated
++ if db.created_at >= (now - delta):
++ log.info(
++ _("The database has been updated recently"),
++ )
++ return 3
++
++ # Fetch the timestamp we need from DNS
++ t = location.discover_latest_version()
++
++ # Check the version of the local database
++ if db and t and db.created_at >= t:
++ log.info("Already on the latest version")
++ return
++
++ # Download the database into the correct directory
++ tmpdir = os.path.dirname(ns.database)
++
++ # Create a downloader
++ d = location.downloader.Downloader()
++
++ # Try downloading a new database
++ try:
++ t = d.download(public_key=ns.public_key, timestamp=t, tmpdir=tmpdir)
++
++ # If no file could be downloaded, log a message
++ except FileNotFoundError as e:
++ log.error("Could not download a new database")
++ return 1
++
++ # If we have not received a new file, there is nothing to do
++ if not t:
++ return 3
++
++ # Move temporary file to destination
++ shutil.move(t.name, ns.database)
++
++ return 0
++
++ def handle_verify(self, db, ns):
++ # Verify the database
++ with open(ns.public_key, "r") as f:
++ if not db.verify(f):
++ log.error("Could not verify database")
++ return 1
++
++ # Success
++ log.debug("Database successfully verified")
++ return 0
++
++ def __get_output_formatter(self, ns):
++ try:
++ cls = location.export.formats[ns.format]
++ except KeyError:
++ cls = location.export.OutputFormatter
++
++ return cls
++
++ def handle_list_countries(self, db, ns):
++ for country in db.countries:
++ line = [
++ country.code,
++ ]
++
++ if ns.show_continent:
++ line.append(country.continent_code)
++
++ if ns.show_name:
++ line.append(country.name)
++
++ # Format the output
++ line = " ".join(line)
++
++ # Print the output
++ print(line)
++
++ def handle_list_networks_by_as(self, db, ns):
++ writer = self.__get_output_formatter(ns)
++
++ for asn in ns.asn:
++ f = writer("AS%s" % asn, f=sys.stdout)
++
++ # Print all matching networks
++ for n in db.search_networks(asns=[asn], family=ns.family):
++ f.write(n)
++
++ f.finish()
++
++ def handle_list_networks_by_cc(self, db, ns):
++ writer = self.__get_output_formatter(ns)
++
++ for country_code in ns.country_code:
++ # Open standard output
++ f = writer(country_code, f=sys.stdout)
++
++ # Print all matching networks
++ for n in db.search_networks(country_codes=[country_code], family=ns.family):
++ f.write(n)
++
++ f.finish()
++
++ def handle_list_networks_by_flags(self, db, ns):
++ flags = 0
++
++ if ns.anonymous_proxy:
++ flags |= location.NETWORK_FLAG_ANONYMOUS_PROXY
++
++ if ns.satellite_provider:
++ flags |= location.NETWORK_FLAG_SATELLITE_PROVIDER
++
++ if ns.anycast:
++ flags |= location.NETWORK_FLAG_ANYCAST
++
++ if ns.drop:
++ flags |= location.NETWORK_FLAG_DROP
++
++ if not flags:
++ raise ValueError(_("You must at least pass one flag"))
++
++ writer = self.__get_output_formatter(ns)
++ f = writer("custom", f=sys.stdout)
++
++ for n in db.search_networks(flags=flags, family=ns.family):
++ f.write(n)
++
++ f.finish()
++
++ def handle_list_bogons(self, db, ns):
++ writer = self.__get_output_formatter(ns)
++ f = writer("bogons", f=sys.stdout)
++
++ for n in db.list_bogons(family=ns.family):
++ f.write(n)
++
++ f.finish()
++
++ def handle_export(self, db, ns):
++ countries, asns = [], []
++
++ # Translate family
++ if ns.family:
++ families = [ ns.family ]
++ else:
++ families = [ socket.AF_INET6, socket.AF_INET ]
++
++ for object in ns.objects:
++ m = re.match("^AS(\d+)$", object)
++ if m:
++ object = int(m.group(1))
++
++ asns.append(object)
++
++ elif location.country_code_is_valid(object) \
++ or object in ("A1", "A2", "A3", "XD"):
++ countries.append(object)
++
++ else:
++ log.warning("Invalid argument: %s" % object)
++ continue
++
++ # Default to exporting all countries
++ if not countries and not asns:
++ countries = ["A1", "A2", "A3", "XD"] + [country.code for country in db.countries]
++
++ # Select the output format
++ writer = self.__get_output_formatter(ns)
++
++ e = location.export.Exporter(db, writer)
++ e.export(ns.directory, countries=countries, asns=asns, families=families)
++
++
++def format_timedelta(t):
++ s = []
++
++ if t.days:
++ s.append(
++ _("One Day", "%(days)s Days", t.days) % { "days" : t.days, }
++ )
++
++ hours = t.seconds // 3600
++ if hours:
++ s.append(
++ _("One Hour", "%(hours)s Hours", hours) % { "hours" : hours, }
++ )
++
++ minutes = (t.seconds % 3600) // 60
++ if minutes:
++ s.append(
++ _("One Minute", "%(minutes)s Minutes", minutes) % { "minutes" : minutes, }
++ )
++
++ seconds = t.seconds % 60
++ if t.seconds:
++ s.append(
++ _("One Second", "%(seconds)s Seconds", seconds) % { "seconds" : seconds, }
++ )
++
++ if not s:
++ return _("Now")
++
++ return _("%s ago") % ", ".join(s)
++
++def main():
++ # Run the command line interface
++ c = CLI()
++ c.run()
++
++main()