491 lines
15 KiB
Python
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
|