Files
links/nginxmon/geo.py
T
junvandCopilot 3955e95b9e geo: replace ip-api.com with local DB-IP City Lite MMDB
- Rewrite nginxmon/geo.py to use maxminddb + DB-IP City Lite MMDB
  (https://cdn.jsdelivr.net/npm/dbip-city-lite/dbip-city-lite.mmdb.gz)
  instead of ip-api.com HTTP API — eliminates all network timeouts and
  rate-limit issues; lookups are now sub-millisecond in-process
- Keep _lookup_batch() signature unchanged so routermon receiver
  requires no changes
- MMDB downloaded on first use (lazy) and refreshed monthly via
  APScheduler; reader cached module-level (thread-safe for reads)
- Add management command: manage.py download_geo_db
- Update k8s init container to download MMDB on first deploy if absent
- Add maxminddb==3.1.1 dependency

CC BY 4.0 — DB-IP (https://db-ip.com)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 16:44:02 +10:00

201 lines
6.5 KiB
Python

"""
Geo-IP enrichment using a local DB-IP City Lite MMDB database.
The MMDB file is downloaded from jsDelivr on first use and refreshed
monthly via an APScheduler job. All lookups are in-process (no network
round-trips after the initial download), so there are no timeouts or
rate-limit concerns. Results are still cached in IPGeoCache so the
routermon receiver and other callers only do MMDB lookups for IPs that
aren't already in the database.
"""
import gzip
import ipaddress
import logging
import os
import shutil
import threading
import requests
from .models import IPGeoCache, NginxAccessLog
logger = logging.getLogger(__name__)
# DB-IP City Lite MMDB — CC BY 4.0, ~19 MB compressed, ~125 MB on disk.
_DB_URL = 'https://cdn.jsdelivr.net/npm/dbip-city-lite/dbip-city-lite.mmdb.gz'
_PRIVATE_RANGES = (
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fc00::/7'),
)
# Module-level reader cache — maxminddb readers are thread-safe for reads.
_reader = None
_reader_lock = threading.Lock()
def _db_path() -> str:
from django.conf import settings
return os.path.join(str(settings.BASE_DIR), 'data', 'dbip-city-lite.mmdb')
def _get_reader():
"""Return the cached MMDB reader, downloading the DB if not yet present."""
global _reader
if _reader is not None:
return _reader
with _reader_lock:
if _reader is not None:
return _reader
path = _db_path()
if not os.path.exists(path):
logger.info('nginxmon: geo MMDB not found — downloading now')
try:
_download(path)
except Exception as exc:
logger.warning('nginxmon: geo MMDB download failed: %s', exc)
return None
try:
import maxminddb
_reader = maxminddb.open_database(path)
logger.info('nginxmon: geo MMDB loaded (%s)', path)
except Exception as exc:
logger.warning('nginxmon: failed to open geo MMDB: %s', exc)
return _reader
def _reload_reader():
"""Invalidate the cached reader so it is reopened on next use."""
global _reader
with _reader_lock:
if _reader is not None:
try:
_reader.close()
except Exception:
pass
_reader = None
def _download(dest: str):
"""Download and decompress the MMDB gzip from jsDelivr."""
os.makedirs(os.path.dirname(dest), exist_ok=True)
tmp = dest + '.tmp'
logger.info('nginxmon: fetching %s', _DB_URL)
with requests.get(_DB_URL, stream=True, timeout=120) as resp:
resp.raise_for_status()
resp.raw.decode_content = False # keep raw compressed bytes
with gzip.GzipFile(fileobj=resp.raw) as gz, open(tmp, 'wb') as out:
shutil.copyfileobj(gz, out)
os.replace(tmp, dest)
logger.info('nginxmon: geo MMDB saved to %s', dest)
def download_geo_db():
"""Public entry point: download/refresh the MMDB and reload the reader."""
_download(_db_path())
_reload_reader()
def _is_private(ip: str) -> bool:
try:
addr = ipaddress.ip_address(ip)
return any(addr in net for net in _PRIVATE_RANGES)
except ValueError:
return False
def _lookup_ip(ip: str) -> dict | None:
"""Return a geo dict for a single IP, or None if unavailable."""
reader = _get_reader()
if reader is None:
return None
try:
rec = reader.get(ip)
if rec is None:
return None
country = rec.get('country') or {}
city_rec = rec.get('city') or {}
subdivisions = rec.get('subdivisions') or []
location = rec.get('location') or {}
return {
'country': (country.get('names') or {}).get('en', ''),
'countryCode': country.get('iso_code', ''),
'regionName': ((subdivisions[0].get('names') or {}).get('en', '')
if subdivisions else ''),
'city': (city_rec.get('names') or {}).get('en', ''),
'lat': location.get('latitude'),
'lon': location.get('longitude'),
'isp': '',
'status': 'success',
'query': ip,
}
except Exception as exc:
logger.debug('nginxmon: MMDB lookup error for %s: %s', ip, exc)
return None
def _lookup_batch(ips: list[str]) -> dict[str, dict]:
"""Look up IPs in the local MMDB. Returns {ip: geo_dict} (same interface as before)."""
results = {}
for ip in ips:
data = _lookup_ip(ip)
if data:
results[ip] = data
return results
def enrich_geo_batch(log_objs: list[NginxAccessLog]):
"""
Look up geo data for IPs in `log_objs`, update cache and log records.
Uses the local cache first; only queries the API for unknown IPs.
"""
unique_ips = {obj.remote_addr for obj in log_objs}
# Split private vs public
private_ips = {ip for ip in unique_ips if _is_private(ip)}
public_ips = unique_ips - private_ips
# Ensure private IPs are in cache
for ip in private_ips:
IPGeoCache.objects.get_or_create(ip=ip, defaults={'is_private': True})
# Load cached public IPs
cached = {
g.ip: g
for g in IPGeoCache.objects.filter(ip__in=public_ips)
}
missing = [ip for ip in public_ips if ip not in cached]
# Fetch missing from API in batches of 100
api_results: dict[str, dict] = {}
for i in range(0, len(missing), 100):
api_results.update(_lookup_batch(missing[i:i + 100]))
# Upsert cache
for ip, data in api_results.items():
obj, _ = IPGeoCache.objects.get_or_create(ip=ip)
obj.country = data.get('country', '')
obj.country_code = data.get('countryCode', '')
obj.region = data.get('regionName', '')
obj.city = data.get('city', '')
obj.lat = data.get('lat')
obj.lon = data.get('lon')
obj.isp = data.get('isp', '')
obj.save()
cached[ip] = obj
# Apply geo back via per-IP queryset update (avoids needing PKs on in-memory objects)
for ip, geo in cached.items():
NginxAccessLog.objects.filter(
remote_addr=ip, country=''
).update(
country=geo.country or '',
country_code=geo.country_code or '',
region=geo.region or '',
city=geo.city or '',
)