import asyncio import logging from urllib.parse import urlparse import httpx import uuid_utils import websockets from starlette.datastructures import Headers from starlette.requests import Request from starlette.responses import Response from starlette.routing import Route from starlette.websockets import WebSocket from devplacepy_services.base.config import service_url from devplacepy_services.base.errors import error_response from devplacepy_services.base.manifest import INGRESS_ROUTES as _INGRESS_SPECS from devplacepy_services.base.proxy import HOP_HEADERS logger = logging.getLogger(__name__) INGRESS_ROUTES = [(route.prefix, route.service) for route in _INGRESS_SPECS] TIMEOUTS = { "xmlrpc": 120.0, } def _forward_headers_from_scope(scope) -> dict[str, str]: original = Headers(scope=scope) headers = { k: v for k, v in original.items() if k.lower() not in HOP_HEADERS } headers["X-Request-Id"] = headers.get("X-Request-Id") or uuid_utils.uuid7().hex headers["Accept-Encoding"] = "identity" # Mirror nginx's `proxy_set_header Host $host` (Appendix F) so an # upstream-generated absolute URL (redirect, url_for) reflects the # public :10500 endpoint the browser is actually talking to, not the # upstream service's own internal bind address/port. original_host = original.get("host") if original_host: headers["Host"] = original_host return headers def _forward_headers(request: Request) -> dict[str, str]: return _forward_headers_from_scope(request.scope) _WS_HANDSHAKE_HEADERS = frozenset( { "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", "sec-websocket-protocol", "sec-websocket-accept", } ) def _forward_ws_headers(scope) -> dict[str, str]: headers = _forward_headers_from_scope(scope) for key in list(headers): if key.lower() in _WS_HANDSHAKE_HEADERS: del headers[key] return headers def _target_path(scope, prefix: str) -> str: # Starlette's Mount rewrites scope["root_path"] to the cumulative mount # prefix but leaves scope["path"] as the FULL original request path (it # does not strip the prefix) - so the full path alone is already the # correct upstream path; concatenating root_path in front double-prefixes it. return scope.get("path", "") or prefix def _upstream_http_url(service: str, path: str, query: str) -> str: base = service_url(service).rstrip("/") url = f"{base}{path}" if query: url = f"{url}?{query}" return url def _upstream_ws_url(service: str, path: str, query: str) -> str: parsed = urlparse(service_url(service)) scheme = "wss" if parsed.scheme == "https" else "ws" netloc = parsed.netloc url = f"{scheme}://{netloc}{path}" if query: url = f"{url}?{query}" return url class IngressProxy: def __init__(self, prefix: str, service: str) -> None: self.prefix = prefix self.service = service self.timeout = TIMEOUTS.get(service, 30.0) async def __call__(self, scope, receive, send) -> None: if scope["type"] == "http": await self._proxy_http(scope, receive, send) elif scope["type"] == "websocket": await self._proxy_ws(scope, receive, send) async def _proxy_http(self, scope, receive, send) -> None: request = Request(scope, receive) path = _target_path(scope, self.prefix) query = scope.get("query_string", b"").decode() url = _upstream_http_url(self.service, path, query) body = await request.body() headers = _forward_headers(request) try: async with httpx.AsyncClient( timeout=self.timeout, follow_redirects=False ) as client: upstream = await client.request( request.method, url, headers=headers, content=body, ) except httpx.HTTPError as exc: logger.warning("ingress %s upstream error: %s", self.prefix, exc) response = error_response( 502, "Upstream service unavailable", "upstream_error" ) await response(scope, receive, send) return out_headers = { k: v for k, v in upstream.headers.items() if k.lower() not in HOP_HEADERS and k.lower() != "set-cookie" } response = Response( content=upstream.content, status_code=upstream.status_code, headers=out_headers, media_type=upstream.headers.get("content-type"), ) for cookie in upstream.headers.get_list("set-cookie"): response.headers.append("set-cookie", cookie) await response(scope, receive, send) async def _proxy_ws(self, scope, receive, send) -> None: client_ws = WebSocket(scope, receive, send) path = _target_path(scope, self.prefix) query = scope.get("query_string", b"").decode() upstream_url = _upstream_ws_url(self.service, path, query) headers = _forward_ws_headers(scope) await client_ws.accept() try: async with websockets.connect( upstream_url, open_timeout=10, max_size=None, additional_headers=headers, ) as upstream: await _pump(client_ws, upstream) except Exception as exc: logger.debug("ingress ws %s failed: %s", self.prefix, exc) try: await client_ws.close(code=1011) except Exception: pass async def _pump(client_ws: WebSocket, upstream) -> None: async def client_to_upstream(): try: while True: message = await client_ws.receive() if message["type"] == "websocket.disconnect": break if message.get("text") is not None: await upstream.send(message["text"]) elif message.get("bytes") is not None: await upstream.send(message["bytes"]) except Exception: pass finally: await upstream.close() async def upstream_to_client(): try: async for message in upstream: if isinstance(message, (bytes, bytearray)): await client_ws.send_bytes(bytes(message)) else: await client_ws.send_text(message) except Exception: pass finally: try: await client_ws.close() except Exception: pass await asyncio.gather(client_to_upstream(), upstream_to_client()) def mount_ingress(app) -> None: for prefix, service in INGRESS_ROUTES: proxy = IngressProxy(prefix, service) # A bare hit on the prefix itself (no trailing slash, nothing after - # e.g. a POST to /xmlrpc or GET /tools) never matches Mount's own # path regex (it requires a "/" plus content after the prefix), so # Starlette's router-level redirect_slashes fallback 307s it to # "/" before the Mount ever sees it. If the upstream service's # own router registers an exact route at its mount root (as /tools # and /xmlrpc both do), THAT redirects back to the bare prefix - # an infinite loop between the two opposite trailing-slash # conventions. Registering an explicit Route at the exact prefix # bypasses Mount's regex/redirect fallback entirely for that one path. app.router.routes.append(Route(prefix, endpoint=proxy, methods=None)) app.mount(prefix, proxy)