import httpx import re import asyncio from typing import AsyncGenerator, Dict, Any, Optional, List, Tuple from fastapi import Request, Response, WebSocket, WebSocketDisconnect from fastapi.responses import StreamingResponse import websockets from websockets.exceptions import ConnectionClosed import logging logger = logging.getLogger("hermes_hub.proxy") HOP_BY_HOP_HEADERS = { "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "content-length" } WS_HOP_BY_HOP_HEADERS = { "connection", "upgrade", "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", "sec-websocket-accept", "host", "origin" } _http_transport: Optional[httpx.AsyncHTTPTransport] = None def get_http_transport() -> httpx.AsyncHTTPTransport: global _http_transport if _http_transport is None: _http_transport = httpx.AsyncHTTPTransport( retries=1, limits=httpx.Limits(max_keepalive_connections=50, max_connections=100, keepalive_expiry=30.0) ) return _http_transport async def close_http_client(): global _http_transport if _http_transport: await _http_transport.aclose() _http_transport = None def rewrite_cookie_path(cookie_header: str, universe_id: str) -> str: target_path = f"/u/{universe_id}/" if re.search(r'(?i)\bpath=[^;]*', cookie_header): return re.sub(r'(?i)\bpath=[^;]*', f'Path={target_path}', cookie_header) else: return f"{cookie_header}; Path={target_path}" async def check_backend_health(backend_url: str) -> Dict[str, Any]: transport = get_http_transport() try: req = httpx.Request("GET", backend_url) res = await transport.handle_async_request(req) status_code = res.status_code await res.aclose() return { "status": "online" if status_code < 500 else "degraded", "status_code": status_code, "latency_ms": 100 } except Exception as e: return { "status": "offline", "error": str(e), "latency_ms": None } async def proxy_request( request: Request, backend_url: str, path: str, universe_id: Optional[str] = None ) -> Response: transport = get_http_transport() base_url = backend_url.rstrip("/") sub_path = path.lstrip("/") target_url = f"{base_url}/{sub_path}" if sub_path else base_url if request.url.query: target_url = f"{target_url}?{request.url.query}" is_dsh = "dsh-vps:3080" in backend_url or universe_id == "dsh" req_headers: List[Tuple[bytes, bytes]] = [] for raw_k, raw_v in request.headers.raw: k_str = raw_k.decode("latin-1").lower() if k_str in HOP_BY_HOP_HEADERS or k_str == "host": continue if is_dsh and k_str == "origin": continue req_headers.append((raw_k, raw_v)) # If DSH backend, replicate localhost:3080 Host/Origin for internal origin check if is_dsh: req_headers.append((b"host", b"localhost:3080")) req_headers.append((b"origin", b"http://localhost:3080")) else: req_headers.append((b"x-forwarded-host", request.headers.get("host", "").encode("latin-1"))) req_headers.append((b"x-forwarded-proto", (request.url.scheme or "http").encode("latin-1"))) body = await request.body() is_path_proxied = universe_id and request.url.path.startswith(f"/u/{universe_id}") try: upstream_req = httpx.Request( method=request.method, url=target_url, headers=req_headers, content=body ) upstream_res = await transport.handle_async_request(upstream_req) raw_headers: List[Tuple[bytes, bytes]] = [] media_type = upstream_res.headers.get("content-type") for raw_k, raw_v in upstream_res.headers.raw: k_str = raw_k.decode("latin-1").lower() v_str = raw_v.decode("latin-1") if k_str in HOP_BY_HOP_HEADERS: continue if k_str == "set-cookie" and is_path_proxied: v_str = rewrite_cookie_path(v_str, universe_id) raw_headers.append((b"set-cookie", v_str.encode("latin-1"))) elif k_str == "location" and is_path_proxied: if v_str.startswith("/") and not v_str.startswith(f"/u/{universe_id}"): v_str = f"/u/{universe_id}{v_str}" raw_headers.append((b"location", v_str.encode("latin-1"))) else: raw_headers.append((raw_k, v_str.encode("latin-1"))) async def stream_content() -> AsyncGenerator[bytes, None]: try: async for chunk in upstream_res.aiter_raw(): yield chunk finally: await upstream_res.aclose() response = StreamingResponse( stream_content(), status_code=upstream_res.status_code, media_type=media_type ) response.raw_headers = raw_headers return response except httpx.ConnectError: logger.error(f"Failed to connect to backend at {target_url}") return Response( content=f"
Le backend sur {backend_url} ne répond pas. Vérifiez le réseau interne ou l'état du conteneur.