ab59476d10
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验 - 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移 - 更新桌面发布检查、运维文档和前后端测试覆盖
171 lines
6.3 KiB
Python
171 lines
6.3 KiB
Python
"""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
|