"""Lazy GeoIP lookup wrapper around the MaxMind GeoLite2-City DB. Used by the auditor's signin-anomaly service to populate per-event ``source_country`` / ``source_city`` and to compute geo-distance for the ``impossible_travel`` anomaly reason. Deployment: * The MaxMind ``GeoLite2-City.mmdb`` file is downloaded out-of-band (free MaxMind account; ``geoipupdate`` cron job in production). * Path resolved via the ``HELIX_GEOIP_DB_PATH`` env var; defaults to ``/var/helix/geoip/GeoLite2-City.mmdb`` in production and ``./infra/geoip/GeoLite2-City.mmdb`` for local dev. * If the file is absent OR the ``geoip2`` library isn't installed (Helix's pyproject pins ``geoip2>=4.8.0`` so the second case shouldn't happen in a fresh deploy), :func:`lookup` returns :data:`None` — the caller MUST treat this as "geo data unavailable" and store ``country=None / city=None``. We never fabricate a fallback country. """ from __future__ import annotations import logging import math import os import threading from dataclasses import dataclass logger = logging.getLogger(__name__) _DB_PATH_ENV = "HELIX_GEOIP_DB_PATH" _DEFAULT_DB_PATH = "/var/helix/geoip/GeoLite2-City.mmdb" # Process-global reader cache. ``geoip2.database.Reader`` is thread-safe # for read but not for re-init; the lock guards the open/close flip # during DB hot-swap (when ``geoipupdate`` replaces the file the # ``mtime`` check tracks the new inode). _lock = threading.Lock() _reader = None # type: ignore[var-annotated] _db_path: str | None = None _db_mtime: float | None = None @dataclass(slots=True, frozen=True) class GeoResult: country: str | None # ISO-3166-1 alpha-2 (e.g. "US", "CN") city: str | None latitude: float | None longitude: float | None def _resolve_db_path() -> str: return os.environ.get(_DB_PATH_ENV) or _DEFAULT_DB_PATH def _ensure_reader(): """Open or refresh the reader if the underlying DB file has been swapped on disk. Returns the reader or :data:`None` if the DB is unavailable / library missing. """ global _reader, _db_path, _db_mtime # noqa: PLW0603 path = _resolve_db_path() if not os.path.exists(path): # Whichever reader we had is stale (file was deleted) — close. if _reader is not None: with _lock: if _reader is not None: try: _reader.close() except Exception: # noqa: BLE001 pass _reader = None _db_mtime = None return None try: mtime = os.path.getmtime(path) except OSError: return None if _reader is not None and _db_path == path and _db_mtime == mtime: return _reader try: import geoip2.database except ImportError: logger.warning( "geoip2 library not installed — GeoIP lookups disabled." ) return None with _lock: if _reader is not None and _db_path == path and _db_mtime == mtime: return _reader if _reader is not None: try: _reader.close() except Exception: # noqa: BLE001 pass _reader = None try: _reader = geoip2.database.Reader(path) _db_path = path _db_mtime = mtime logger.info( "GeoIP DB opened: path=%s mtime=%s", path, mtime, ) except Exception as exc: # noqa: BLE001 logger.warning( "Failed to open GeoIP DB at %s: %s", path, exc, ) _reader = None return None return _reader def is_available() -> bool: """Cheap probe — does NOT open the DB. Returns ``True`` if the file exists on disk; the lookup itself may still fail if the file is corrupt, in which case :func:`lookup` returns ``None``. """ return os.path.exists(_resolve_db_path()) def lookup(ip: str | None) -> GeoResult | None: """Geo-locate one IPv4 / IPv6 string. Returns :data:`None` when: * ``ip`` is empty / not parseable * GeoIP DB is not deployed * The IP is in a private / unspecified range (RFC1918 / loopback / link-local) — these have no public-IP geo * The DB lookup raises (corrupt entry, AddressNotFoundError, …) """ if not ip: return None # Explicitly reject non-public addresses BEFORE the DB lookup. The # docstring promises ``None`` for private/loopback/link-local ranges, # but relying on MaxMind to raise ``AddressNotFoundError`` is not a # guarantee — a customized/enterprise DB can resolve RFC1918 space, # which would then feed a bogus geo into the auditor's impossible-travel # anomaly detector. Fail closed here so the guarantee actually holds. import ipaddress try: parsed = ipaddress.ip_address(ip.strip()) except ValueError: return None if ( parsed.is_private or parsed.is_loopback or parsed.is_link_local or parsed.is_unspecified or parsed.is_reserved or parsed.is_multicast ): return None reader = _ensure_reader() if reader is None: return None try: # geoip2 is sync. SigninAnomalyService runs in the worker # process where blocking ~1ms per lookup is fine; if we ever # need to fan-out across thousands of events the right move is # ``asyncio.to_thread`` per call. For now keep it simple. resp = reader.city(ip) except Exception: # noqa: BLE001 — geoip2.errors.AddressNotFoundError + ValueError return None country = (resp.country.iso_code or "").upper() or None city = resp.city.name or None lat = resp.location.latitude lon = resp.location.longitude return GeoResult( country=country, city=city, latitude=float(lat) if lat is not None else None, longitude=float(lon) if lon is not None else None, ) def haversine_km( a_lat: float, a_lon: float, b_lat: float, b_lon: float, ) -> float: """Great-circle distance between two points in km.""" earth_r_km = 6371.0 phi1 = math.radians(a_lat) phi2 = math.radians(b_lat) d_phi = math.radians(b_lat - a_lat) d_lambda = math.radians(b_lon - a_lon) a = ( math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2 ) c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return earth_r_km * c