mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
- 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>
201 lines
6.5 KiB
Python
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 '',
|
|
)
|