"""Resolve the monitoring deployment's public network location. The map server marker is derived from the deployment's public IP instead of a frontend or configuration coordinate. An explicit public IP is accepted for restricted networks; otherwise the public frontend hostname and, finally, a small set of public-IP discovery endpoints are used. Results are cached so the monitoring API never performs per-request network discovery. """ from __future__ import annotations import asyncio import ipaddress import logging import socket import time from dataclasses import dataclass from urllib.parse import urlparse import httpx from app.core.config import settings from app.services.geo_location_metadata import resolve_geo_location_metadata from app.services.ip_geolocation_fallback import resolve_external_ip_locations from app.services.ip_location import resolve_ip_location logger = logging.getLogger("ctms.monitoring_server_location") @dataclass(frozen=True) class MonitoringServerLocation: name: str longitude: float latitude: float accuracy_level: str resolution_source: str def to_public_dict(self) -> dict: return { "name": self.name, "longitude": self.longitude, "latitude": self.latitude, "accuracy_level": self.accuracy_level, "resolution_source": self.resolution_source, } _cached_location: MonitoringServerLocation | None = None _cache_initialized = False _cache_expires_at = 0.0 _cache_lock = asyncio.Lock() def _normalize_public_ip(value: str | None) -> str | None: candidate = (value or "").strip().splitlines()[0] if (value or "").strip() else "" try: address = ipaddress.ip_address(candidate) except ValueError: return None return str(address) if address.is_global else None async def _resolve_frontend_public_ip() -> str | None: hostname = urlparse(settings.FRONTEND_PUBLIC_URL).hostname if not hostname: return None literal_ip = _normalize_public_ip(hostname) if literal_ip: return literal_ip try: loop = asyncio.get_running_loop() records = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) except (OSError, socket.gaierror): return None candidates = [] for _, _, _, _, socket_address in records: candidate = _normalize_public_ip(str(socket_address[0])) if candidate and candidate not in candidates: candidates.append(candidate) return next((item for item in candidates if ":" not in item), candidates[0] if candidates else None) async def _discover_public_ip() -> tuple[str | None, str]: configured_ip = _normalize_public_ip(settings.MONITORING_SERVER_PUBLIC_IP) if configured_ip: return configured_ip, "configured_public_ip" frontend_ip = await _resolve_frontend_public_ip() if frontend_ip: return frontend_ip, "frontend_dns" if settings.ENV == "test": return None, "unavailable" urls = [ item.strip() for item in settings.MONITORING_PUBLIC_IP_DISCOVERY_URLS.split(",") if item.strip() ] timeout = httpx.Timeout(settings.MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS) async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: for url in urls: try: response = await client.get(url, headers={"Accept": "text/plain"}) response.raise_for_status() except httpx.HTTPError: logger.warning("Public IP discovery endpoint unavailable", extra={"endpoint": url}) continue discovered_ip = _normalize_public_ip(response.text[:128]) if discovered_ip: return discovered_ip, "public_ip_discovery" return None, "unavailable" async def resolve_monitoring_server_location(*, force: bool = False) -> MonitoringServerLocation | None: global _cached_location, _cache_initialized, _cache_expires_at now = time.monotonic() if not force and _cache_initialized and now < _cache_expires_at: return _cached_location async with _cache_lock: now = time.monotonic() if not force and _cache_initialized and now < _cache_expires_at: return _cached_location public_ip, resolution_source = await _discover_public_ip() location = None if public_ip: ip_location = resolve_ip_location(public_ip) metadata = resolve_geo_location_metadata(ip_location) if metadata.longitude is None or metadata.latitude is None: external_location = (await resolve_external_ip_locations([public_ip])).get(public_ip) if external_location is not None: ip_location = external_location.merge_ip_location(ip_location) metadata = external_location.to_metadata() if metadata.longitude is not None and metadata.latitude is not None: country = metadata.country or ip_location.country name = " / ".join( part for part in [country, ip_location.province, ip_location.city] if part ) or "公网服务器" location = MonitoringServerLocation( name=name, longitude=metadata.longitude, latitude=metadata.latitude, accuracy_level=metadata.accuracy_level, resolution_source=resolution_source, ) logger.info( "Monitoring server location resolved", extra={"resolution_source": resolution_source, "accuracy_level": metadata.accuracy_level}, ) else: logger.warning("Deployment public IP has no usable geo coordinates") else: logger.warning("Unable to discover the deployment public IP") _cached_location = location _cache_initialized = True cache_seconds = settings.MONITORING_SERVER_LOCATION_CACHE_SECONDS if location else 300 _cache_expires_at = now + cache_seconds return location def reset_monitoring_server_location_cache() -> None: """Reset process-local state for configuration reloads and tests.""" global _cached_location, _cache_initialized, _cache_expires_at _cached_location = None _cache_initialized = False _cache_expires_at = 0.0