Files
NODEDC_MISSION_CORE/src/k1link/web/map_api.py
T

491 lines
15 KiB
Python

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