from __future__ import annotations import os import re from collections.abc import AsyncIterator, Mapping from dataclasses import dataclass from ipaddress import ip_address from typing import Annotated, Any, Final from urllib.parse import parse_qsl, urlsplit import httpx from fastapi import APIRouter, HTTPException, Path, Query, Request from fastapi.responses import JSONResponse, Response, StreamingResponse MAP_GATEWAY_URL_ENV: Final = "MISSIONCORE_MAP_GATEWAY_INTERNAL_URL" MAP_SANDBOX_HIDE_CREDITS_ENV: Final = "MISSIONCORE_MAP_SANDBOX_HIDE_CREDITS" MAP_SCHEMA_VERSION: Final = "missioncore.map-gateway/v1" MAP_RUNTIME_SCHEMA_VERSION: Final = "missioncore.map-runtime/v1" MAP_PAGE_VERSION: Final = "0.1.0" CESIUM_RENDERER_VERSION: Final = "1.143.0" MAP_GATEWAY_SUBJECT: Final = "mission-core-loopback-operator" ALLOWED_ION_ASSET_IDS: Final = frozenset({1, 2, 96188}) SAFE_ERROR_CODE = re.compile(r"^[a-z][a-z0-9_]{0,95}$") MAX_JSON_RESPONSE_BYTES: Final = 1024 * 1024 MAX_CACHE_TARGET_LENGTH: Final = 8192 BING_GATEWAY_KEY_MARKER: Final = "nodedc-gateway" REQUEST_HEADER_ALLOWLIST: Final = frozenset( { "accept", "accept-encoding", "if-modified-since", "if-none-match", "range", } ) RESPONSE_HEADER_ALLOWLIST: Final = frozenset( { "accept-ranges", "cache-control", "content-encoding", "content-length", "content-range", "content-type", "etag", "expires", "last-modified", "vary", } ) SENSITIVE_JSON_KEYS: Final = frozenset( { "access_token", "accesstoken", "api_key", "apikey", "authorization", "bing_key", "bingkey", "cookie", "master_token", "mastertoken", "secret", "set_cookie", "setcookie", "token", } ) CREDENTIAL_QUERY_KEYS: Final = frozenset( { "access_token", "access-token", "authorization", "credential", "key", "sig", "signature", "token", } ) @dataclass(frozen=True, slots=True) class MapGatewayConfiguration: internal_url: str | None @classmethod def from_environment(cls) -> MapGatewayConfiguration: raw_url = os.environ.get(MAP_GATEWAY_URL_ENV, "").strip() if not raw_url: return cls(internal_url=None) return cls(internal_url=_validate_internal_url(raw_url)) class MapGatewayProxy: """Narrow same-origin proxy to the shared Platform Map Gateway.""" def __init__( self, configuration: MapGatewayConfiguration, *, client: httpx.AsyncClient | None = None, ) -> None: self._configuration = configuration self._owns_client = client is None self._client = client or httpx.AsyncClient( follow_redirects=False, timeout=httpx.Timeout( connect=15.0, read=30.0, write=15.0, pool=15.0, ), limits=httpx.Limits( max_connections=48, max_keepalive_connections=16, keepalive_expiry=30.0, ), ) @property def configured(self) -> bool: return self._configuration.internal_url is not None def runtime_configuration(self) -> Response: if not self.configured: return _safe_error_response( 503, "map_gateway_not_configured", retryable=False, ) return JSONResponse( content={ "schema_version": MAP_RUNTIME_SCHEMA_VERSION, "map_page_version": MAP_PAGE_VERSION, "renderer": { "id": "cesium", "version": CESIUM_RENDERER_VERSION, }, "gateway": { "configured": True, "health_url": "/api/v1/map/gateway/health", "asset_endpoint_template": ( "/api/v1/map/gateway/ion/assets/{asset_id}/endpoint" ), "cache_proxy_prefix": "/api/v1/map/gateway/cache?url=", }, "assets": { "imagery": 2, "terrain": 1, "buildings": 96188, }, "sandbox": { "hide_credit_overlay": _environment_flag( MAP_SANDBOX_HIDE_CREDITS_ENV ) }, }, headers={"Cache-Control": "no-store"}, ) async def close(self) -> None: if self._owns_client: await self._client.aclose() async def proxy_json( self, request: Request, *, upstream_path: str, ) -> Response: upstream = await self._send(request, upstream_path=upstream_path) try: payload = await _read_bounded(upstream, MAX_JSON_RESPONSE_BYTES) except MapGatewayPayloadTooLarge: return _safe_error_response( 502, "map_gateway_invalid_response", retryable=False, ) finally: await upstream.aclose() if upstream.status_code >= 400: return _safe_upstream_error(upstream.status_code, payload) try: document = _decode_safe_json(upstream, payload) except ValueError: return _safe_error_response( 502, "map_gateway_invalid_response", retryable=False, ) return JSONResponse( content=document, status_code=upstream.status_code, headers={"Cache-Control": "no-store"}, ) async def proxy_cache( self, request: Request, *, target_url: str, ) -> Response: _validate_cache_target(target_url) upstream = await self._send( request, upstream_path="/api/map/cache", query={"url": target_url}, ) if upstream.status_code >= 400: try: payload = await _read_bounded(upstream, MAX_JSON_RESPONSE_BYTES) except MapGatewayPayloadTooLarge: payload = b"" finally: await upstream.aclose() return _safe_upstream_error(upstream.status_code, payload) response_headers = _filtered_response_headers(upstream.headers) if request.method == "HEAD" or upstream.status_code in {204, 304}: await upstream.aclose() return Response( status_code=upstream.status_code, headers=response_headers, ) async def stream_body() -> AsyncIterator[bytes]: try: async for chunk in upstream.aiter_raw(): if chunk: yield chunk finally: await upstream.aclose() return StreamingResponse( stream_body(), status_code=upstream.status_code, headers=response_headers, ) async def _send( self, request: Request, *, upstream_path: str, query: Mapping[str, str] | None = None, ) -> httpx.Response: _require_loopback_request(request) base_url = self._configuration.internal_url if base_url is None: raise HTTPException( status_code=503, detail={ "schema_version": MAP_SCHEMA_VERSION, "code": "map_gateway_not_configured", "retryable": False, }, ) headers = { name: value for name, value in request.headers.items() if name.lower() in REQUEST_HEADER_ALLOWLIST } headers["x-nodedc-user-id"] = MAP_GATEWAY_SUBJECT upstream_request = self._client.build_request( request.method, f"{base_url}{upstream_path}", params=query, headers=headers, ) try: return await self._client.send(upstream_request, stream=True) except (httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ReadTimeout) as exc: raise HTTPException( status_code=504, detail={ "schema_version": MAP_SCHEMA_VERSION, "code": "map_gateway_headers_timeout", "retryable": True, }, ) from exc except httpx.HTTPError as exc: raise HTTPException( status_code=502, detail={ "schema_version": MAP_SCHEMA_VERSION, "code": "map_gateway_unavailable", "retryable": True, }, ) from exc class MapGatewayPayloadTooLarge(ValueError): pass def _validate_internal_url(raw_url: str) -> str: parsed = urlsplit(raw_url) if ( parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment ): raise ValueError(f"{MAP_GATEWAY_URL_ENV} must be an HTTP(S) origin") path = parsed.path.rstrip("/") if path: raise ValueError(f"{MAP_GATEWAY_URL_ENV} must not include a path") return raw_url.rstrip("/") def _environment_flag(name: str) -> bool: return os.environ.get(name, "").strip().lower() in { "1", "true", "yes", "on", } def _validate_cache_target(target_url: str) -> None: if len(target_url) > MAX_CACHE_TARGET_LENGTH: raise HTTPException(status_code=422, detail="Map resource URL is too long.") parsed = urlsplit(target_url) if ( parsed.scheme != "https" or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.fragment ): raise HTTPException( status_code=422, detail="Map resource URL must be a credential-free HTTPS URL.", ) if any( key.lower() in CREDENTIAL_QUERY_KEYS and not ( key.lower() == "key" and value == BING_GATEWAY_KEY_MARKER ) for key, value in parse_qsl(parsed.query, keep_blank_values=True) ): raise HTTPException( status_code=422, detail="Map resource URL must not contain credential parameters.", ) def _require_loopback_request(request: Request) -> None: client = request.client if client is None: raise HTTPException(status_code=403, detail="Map access requires a local session.") try: is_loopback = ip_address(client.host).is_loopback except ValueError: is_loopback = client.host == "localhost" if not is_loopback: raise HTTPException(status_code=403, detail="Map access requires a local session.") async def _read_bounded(response: httpx.Response, limit: int) -> bytes: payload = bytearray() async for chunk in response.aiter_raw(): payload.extend(chunk) if len(payload) > limit: raise MapGatewayPayloadTooLarge return bytes(payload) def _decode_safe_json(response: httpx.Response, payload: bytes) -> dict[str, Any]: content_type = response.headers.get("content-type", "") if "application/json" not in content_type.lower(): raise ValueError("Map Gateway response is not JSON") document = httpx.Response( status_code=response.status_code, content=payload, headers={"content-type": content_type}, ).json() if not isinstance(document, dict) or _contains_sensitive_json(document): raise ValueError("Map Gateway response violates the public contract") return document def _contains_sensitive_json(value: Any) -> bool: if isinstance(value, dict): for raw_key, child in value.items(): key = str(raw_key).replace("-", "_").lower() if key in SENSITIVE_JSON_KEYS or _contains_sensitive_json(child): return True return False if isinstance(value, list): return any(_contains_sensitive_json(child) for child in value) return False def _safe_upstream_error(status_code: int, payload: bytes) -> JSONResponse: code = "map_gateway_request_failed" try: parsed = httpx.Response( status_code=status_code, content=payload, headers={"content-type": "application/json"}, ).json() except ValueError: parsed = None if isinstance(parsed, dict): candidate = parsed.get("code") if not isinstance(candidate, str): error = parsed.get("error") if isinstance(error, dict): candidate = error.get("code") if isinstance(candidate, str) and SAFE_ERROR_CODE.fullmatch(candidate): code = candidate return _safe_error_response( status_code, code, retryable=status_code >= 500 or status_code == 429, ) def _safe_error_response( status_code: int, code: str, *, retryable: bool, ) -> JSONResponse: return JSONResponse( status_code=status_code, content={ "schema_version": MAP_SCHEMA_VERSION, "code": code, "retryable": retryable, }, headers={"Cache-Control": "no-store"}, ) def _filtered_response_headers(headers: httpx.Headers) -> dict[str, str]: return { name: value for name, value in headers.items() if name.lower() in RESPONSE_HEADER_ALLOWLIST } def build_map_router(service: MapGatewayProxy) -> APIRouter: router = APIRouter(prefix="/api/v1/map", tags=["map"]) @router.get("/runtime-config") async def runtime_config(request: Request) -> Response: _require_loopback_request(request) return service.runtime_configuration() @router.get("/gateway/health") async def gateway_health(request: Request) -> Response: return await service.proxy_json(request, upstream_path="/healthz") @router.get("/gateway/ion/assets/{asset_id}/endpoint") async def ion_asset_endpoint( request: Request, asset_id: Annotated[int, Path()], ) -> Response: if asset_id not in ALLOWED_ION_ASSET_IDS: return _safe_error_response( 404, "cesium_asset_not_allowed", retryable=False, ) return await service.proxy_json( request, upstream_path=f"/api/map/ion/assets/{asset_id}/endpoint", ) @router.api_route("/gateway/cache", methods=["GET", "HEAD"]) async def map_cache( request: Request, url: Annotated[str, Query(min_length=1, max_length=MAX_CACHE_TARGET_LENGTH)], ) -> Response: if len(request.query_params.getlist("url")) != 1: raise HTTPException(status_code=422, detail="One map resource URL is required.") return await service.proxy_cache(request, target_url=url) return router