feat(map): add guarded map gateway BFF

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 16:45:11 +03:00
parent d95a13059c
commit 66f8d7a1fa
7 changed files with 1118 additions and 0 deletions
+14
View File
@@ -42,6 +42,12 @@ from k1link.web.e30_review_api import build_e30_review_router
from k1link.web.environment_api import build_environment_router
from k1link.web.laboratory_api import build_laboratory_router
from k1link.web.lidar_api import build_lidar_router
from k1link.web.map_api import (
MapGatewayConfiguration,
MapGatewayProxy,
build_map_router,
)
from k1link.web.map_view_api import build_map_view_router
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
from k1link.web.plugin_runtime import (
STATE_READ_ACTION_ID,
@@ -151,6 +157,7 @@ session_perception_epoch_store = (
if _ffprobe is not None
else None
)
map_gateway_proxy = MapGatewayProxy(MapGatewayConfiguration.from_environment())
def _prepare_recorded_media_for_launch(
@@ -291,6 +298,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
reconciler = asyncio.create_task(_recording_preparation_reconciler())
yield
finally:
await map_gateway_proxy.close()
if reconciler is not None:
reconciler.cancel()
with suppress(asyncio.CancelledError):
@@ -417,6 +425,12 @@ app.include_router(
root_provider=lambda: session_store.data_dir / "ui-environment"
)
)
app.include_router(build_map_router(map_gateway_proxy))
app.include_router(
build_map_view_router(
root_provider=lambda: session_store.data_dir / "map-view",
)
)
app.include_router(
build_polygon_router(
root_provider=lambda: configured_polygon_runs_root()
+470
View File
@@ -0,0 +1,470 @@
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_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
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,
},
},
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 _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
for key, _ 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
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import json
import os
from collections.abc import Callable
from pathlib import Path
from threading import Lock
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field, model_validator
MAP_VIEW_SCHEMA_VERSION: Literal["missioncore.map-view/v1"] = "missioncore.map-view/v1"
MapInspectorSection = Literal[
"base-terrain",
"atmosphere-light",
"buildings",
"targets",
"grid-lod",
"camera",
"tile-cache",
"selection",
]
class StrictMapModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class MapCamera(StrictMapModel):
longitude: float = Field(ge=-180.0, le=180.0)
latitude: float = Field(ge=-90.0, le=90.0)
height: float = Field(ge=1.0, le=100_000_000.0)
heading: float = Field(ge=-360.0, le=360.0)
pitch: float = Field(ge=-90.0, le=90.0)
roll: float = Field(ge=-360.0, le=360.0)
class MapVisualSettings(StrictMapModel):
atmosphere_enabled: bool = True
lighting_enabled: bool = True
monochrome_enabled: bool = False
terrain_exaggeration: float = Field(default=1.0, ge=0.1, le=20.0)
buildings_maximum_screen_space_error: float = Field(
default=16.0,
ge=1.0,
le=64.0,
)
camera_animation_enabled: bool = True
class MapLayerVisibility(StrictMapModel):
imagery: bool = True
terrain: bool = True
buildings: bool = True
grid: bool = False
targets: bool = True
class MapCacheIntent(StrictMapModel):
enabled: bool = True
no_overwrite: bool = True
class MapViewContent(StrictMapModel):
camera: MapCamera | None = None
visual_settings: MapVisualSettings = Field(default_factory=MapVisualSettings)
map_height: int = Field(default=720, ge=420, le=2160)
inspector_open_sections: list[MapInspectorSection] = Field(
default_factory=list,
max_length=1,
)
cache_intent: MapCacheIntent = Field(default_factory=MapCacheIntent)
selected_subject_id: str | None = Field(
default=None,
max_length=160,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$",
)
layer_visibility: MapLayerVisibility = Field(default_factory=MapLayerVisibility)
@model_validator(mode="after")
def validate_selection_section(self) -> MapViewContent:
if self.selected_subject_id is None and "selection" in self.inspector_open_sections:
raise ValueError("selection section requires a selected stable subject")
return self
class MapViewPut(StrictMapModel):
revision: int = Field(ge=0)
view: MapViewContent
class MapViewDocument(MapViewPut):
schema_version: Literal["missioncore.map-view/v1"] = MAP_VIEW_SCHEMA_VERSION
def default_map_view() -> MapViewDocument:
return MapViewDocument(
revision=0,
view=MapViewContent(),
)
class MapViewStore:
def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve()
self.document_path = self.root / "view.json"
self._lock = Lock()
def read(self) -> MapViewDocument:
with self._lock:
return self._read_unlocked()
def save(self, request: MapViewPut) -> MapViewDocument:
with self._lock:
current = self._read_unlocked()
if request.revision != current.revision:
raise RuntimeError("map view revision changed")
document = MapViewDocument(
revision=current.revision + 1,
view=request.view,
)
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = self.document_path.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(
document.model_dump(mode="json"),
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
os.replace(temporary, self.document_path)
return document
def _read_unlocked(self) -> MapViewDocument:
if not self.document_path.is_file():
return default_map_view()
try:
return MapViewDocument.model_validate_json(
self.document_path.read_text(encoding="utf-8")
)
except (OSError, ValueError) as exc:
raise RuntimeError("map view is corrupt") from exc
def build_map_view_router(root_provider: Callable[[], Path]) -> APIRouter:
router = APIRouter(prefix="/api/v1/map/view", tags=["map"])
def store() -> MapViewStore:
return MapViewStore(root_provider())
@router.get("")
def get_map_view() -> MapViewDocument:
try:
return store().read()
except RuntimeError as exc:
raise HTTPException(
status_code=500,
detail="Сохранённое состояние карты повреждено.",
) from exc
@router.put("")
def put_map_view(request: MapViewPut) -> MapViewDocument:
try:
return store().save(request)
except RuntimeError as exc:
if "revision changed" in str(exc):
raise HTTPException(
status_code=412,
detail="Состояние карты было изменено в другом окне.",
) from exc
raise HTTPException(
status_code=500,
detail="Сохранённое состояние карты повреждено.",
) from exc
return router