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
# "<prefix>/" 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)