"""Bounded third-party coordinate fallback for public source IPs. IPAddress.my identifies IP2Location.io as its data provider. We use the provider's documented JSON API instead of scraping the public HTML page. Only globally routable addresses are eligible, and results are cached so the monitoring UI does not turn into a per-refresh third-party lookup fan-out. """ from __future__ import annotations import asyncio import ipaddress import logging import math import time from dataclasses import dataclass from typing import Any, Iterable import httpx from app.core.config import settings from app.services.geo_location_metadata import GeoLocationMetadata from app.services.ip_location import IpLocation logger = logging.getLogger("ctms.ip_geolocation_fallback") _API_URL = "https://api.ip2location.io/" _MAX_RESPONSE_BYTES = 64 * 1024 _MAX_CACHE_ENTRIES = 4096 _NEGATIVE_CACHE_SECONDS = 3600 _cache: dict[str, tuple[float, "ExternalIpLocation | None"]] = {} _cache_lock = asyncio.Lock() def _text(value: Any, limit: int = 160) -> str: candidate = str(value or "").strip() return "" if candidate in {"", "-", "0"} else candidate[:limit] def _coordinate(value: Any, *, minimum: float, maximum: float) -> float | None: try: number = float(value) except (TypeError, ValueError): return None return number if math.isfinite(number) and minimum <= number <= maximum else None def _global_ip(value: str | None) -> str | None: try: address = ipaddress.ip_address((value or "").strip()) except ValueError: return None return str(address) if address.is_global else None @dataclass(frozen=True) class ExternalIpLocation: ip_address: str country: str country_code: str region: str city: str isp: str longitude: float latitude: float def merge_ip_location(self, local: IpLocation) -> IpLocation: country = self.country or local.country province = self.region or local.province city = self.city or local.city isp = self.isp or local.isp location = " / ".join(part for part in [country, province, city, isp] if part) or local.location return IpLocation(location=location, country=country, province=province, city=city, isp=isp) def to_metadata(self) -> GeoLocationMetadata: return GeoLocationMetadata( country=self.country, country_code=self.country_code, region_code="", longitude=self.longitude, latitude=self.latitude, accuracy_level="city" if self.city else "country", ) def _parse_response(expected_ip: str, payload: Any) -> ExternalIpLocation | None: if not isinstance(payload, dict): return None response_ip = _global_ip(_text(payload.get("ip"), 45)) if response_ip != expected_ip: return None latitude = _coordinate(payload.get("latitude"), minimum=-90, maximum=90) longitude = _coordinate(payload.get("longitude"), minimum=-180, maximum=180) if latitude is None or longitude is None: return None country_code = _text(payload.get("country_code"), 2).upper() if len(country_code) != 2: country_code = "" return ExternalIpLocation( ip_address=expected_ip, country=_text(payload.get("country_name"), 100), country_code=country_code, region=_text(payload.get("region_name"), 100), city=_text(payload.get("city_name"), 100), isp=_text(payload.get("isp"), 160), longitude=longitude, latitude=latitude, ) async def _fetch_one( client: httpx.AsyncClient, semaphore: asyncio.Semaphore, ip_address: str, ) -> ExternalIpLocation | None: async with semaphore: try: response = await client.get(_API_URL, params={"ip": ip_address, "format": "json"}) response.raise_for_status() if len(response.content) > _MAX_RESPONSE_BYTES: return None return _parse_response(ip_address, response.json()) except (httpx.HTTPError, ValueError): logger.warning("External IP coordinate fallback unavailable") return None async def resolve_external_ip_locations(ip_addresses: Iterable[str]) -> dict[str, ExternalIpLocation]: if not settings.MONITORING_IP_GEO_FALLBACK_ENABLED or settings.ENV == "test": return {} candidates = list(dict.fromkeys(filter(None, (_global_ip(value) for value in ip_addresses)))) if not candidates: return {} now = time.monotonic() resolved: dict[str, ExternalIpLocation] = {} pending: list[str] = [] async with _cache_lock: for ip_address in candidates: cached = _cache.get(ip_address) if cached and cached[0] > now: if cached[1] is not None: resolved[ip_address] = cached[1] continue pending.append(ip_address) pending = pending[: settings.MONITORING_IP_GEO_FALLBACK_MAX_LOOKUPS] if not pending: return resolved headers = {"Accept": "application/json", "User-Agent": "CTMS-IP-Coordinate-Fallback/1.0"} api_key = (settings.MONITORING_IP_GEO_FALLBACK_API_KEY or "").strip() if api_key: headers["Authorization"] = f"Bearer {api_key}" timeout = httpx.Timeout(settings.MONITORING_IP_GEO_FALLBACK_TIMEOUT_SECONDS) semaphore = asyncio.Semaphore(min(5, len(pending))) async with httpx.AsyncClient(timeout=timeout, follow_redirects=False, headers=headers) as client: fetched = await asyncio.gather(*(_fetch_one(client, semaphore, item) for item in pending)) now = time.monotonic() async with _cache_lock: for ip_address, item in zip(pending, fetched): ttl = settings.MONITORING_IP_GEO_FALLBACK_CACHE_SECONDS if item else _NEGATIVE_CACHE_SECONDS _cache[ip_address] = (now + ttl, item) if item is not None: resolved[ip_address] = item while len(_cache) > _MAX_CACHE_ENTRIES: _cache.pop(next(iter(_cache))) return resolved def reset_ip_geolocation_fallback_cache() -> None: _cache.clear()