d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
175 lines
6.0 KiB
Python
175 lines
6.0 KiB
Python
"""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()
|