File size: 14,709 Bytes
8cd4896
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18855f0
 
 
 
 
 
 
 
ef38f88
 
18855f0
 
 
8cd4896
 
98d2cb8
 
 
58d7eda
98d2cb8
 
d33b171
8cd4896
 
 
 
58d7eda
8cd4896
 
 
98d2cb8
fe0e480
98d2cb8
 
58d7eda
98d2cb8
 
8cd4896
 
 
 
 
 
 
 
 
cfca72b
8cd4896
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3956c72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8cd4896
cbe6b6e
f57a7f8
 
 
 
3956c72
39538e8
8cd4896
 
 
3956c72
 
cbe6b6e
f9f73ed
 
 
 
8cd4896
7386ea5
 
 
 
 
8cd4896
 
5c53b9d
 
30c9900
fe55787
6167f56
8cd4896
 
 
30c9900
 
 
fe55787
 
30c9900
 
 
 
 
 
 
 
 
 
 
 
8cd4896
 
5c53b9d
30c9900
 
 
 
fe55787
 
 
 
 
5c53b9d
8cd4896
 
 
 
 
 
 
 
f8533a9
8cd4896
 
 
f8533a9
8cd4896
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0c31b3
8cd4896
 
c0491f7
8cd4896
58d7eda
 
0bf0144
c6dd5de
c0491f7
 
 
c6dd5de
0bf0144
c0491f7
0bf0144
42fc0fd
c6dd5de
 
 
 
c0491f7
 
 
 
 
c6dd5de
0bf0144
c0491f7
 
 
6794fc2
0bf0144
8cd4896
42fc0fd
8cd4896
 
 
 
 
 
 
5c53b9d
8cd4896
 
 
 
 
 
 
 
 
 
 
 
 
5c53b9d
8cd4896
 
 
 
5c53b9d
 
8cd4896
 
 
 
 
 
6167f56
8cd4896
 
6167f56
8cd4896
 
 
 
5c53b9d
8cd4896
 
 
 
5c53b9d
 
8cd4896
 
 
 
2cd48d2
 
 
 
 
 
 
8cd4896
 
fe55787
 
 
 
8cd4896
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
"""
Gateway Proxy Core Utilities
============================
High-Performance Async HTTP client, Header Sanitization, Location Header Rewriting,
and Core HTTP & WebSocket proxying logic.
"""

import os
import re
import asyncio
import logging
from typing import Callable, Optional, Dict, Any

import httpx
import aiohttp
from fastapi import Request, Response, WebSocket
from fastapi.responses import StreamingResponse

def get_structured_logger(name: str) -> logging.Logger:
    log = logging.getLogger(name)
    if not log.handlers:
        handler = logging.StreamHandler()
        formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
        handler.setFormatter(formatter)
        log.addHandler(handler)
        log.setLevel(logging.INFO)
    for noisy in ("httpx", "httpcore", "urllib3", "huggingface_hub", "multipart"):
        logging.getLogger(noisy).setLevel(logging.WARNING)
    return log

logger = get_structured_logger("GatewayUtils")

# Port definitions
OMNIROUTE_PORT     = int(os.environ.get("OMNIROUTE_PORT", 20128))
OMNIROUTE_API_PORT = int(os.environ.get("OMNIROUTE_API_PORT", 20129))
OMNIROUTE_WS_PORT  = int(os.environ.get("OMNIROUTE_WS_PORT", 20132))
HERMES_PORT        = int(os.environ.get("HERMES_PORT", 8642))
JELLYFIN_PORT      = int(os.environ.get("JELLYFIN_PORT", 8096))
TG_PORT            = int(os.environ.get("TG_PORT", 8080))
GATEWAY_PORT       = int(os.environ.get("GATEWAY_PORT", 8000))

PUBLIC_HOST   = os.environ.get("PUBLIC_HOST", "jishnupg-opencode-cli.hf.space")
PUBLIC_ORIGIN = f"https://{PUBLIC_HOST}"

_INTERNAL_PORTS = {GATEWAY_PORT, OMNIROUTE_PORT, OMNIROUTE_API_PORT, OMNIROUTE_WS_PORT, HERMES_PORT, JELLYFIN_PORT, TG_PORT}
_PORT_STRIP_RE  = re.compile(r"(https?://[^/:]+):(" + "|".join(str(p) for p in _INTERNAL_PORTS) + r")")

_PORT_PREFIX_MAP = {
    GATEWAY_PORT:       "",
    OMNIROUTE_PORT:     "",
    OMNIROUTE_API_PORT: "",
    OMNIROUTE_WS_PORT:  "/live-ws",
    HERMES_PORT:        "/hermes",
    JELLYFIN_PORT:      "/jellyfin",
    TG_PORT:            "/tg-stream",
}

# Shared Async HTTPX Client Instance
http_client: Optional[httpx.AsyncClient] = None

def get_http_client() -> httpx.AsyncClient:
    global http_client
    if http_client is None or http_client.is_closed:
        http_client = httpx.AsyncClient(
            timeout=httpx.Timeout(connect=10.0, read=300.0, write=300.0, pool=30.0),
            follow_redirects=False,
            limits=httpx.Limits(max_connections=1000, max_keepalive_connections=200, keepalive_expiry=30.0),
        )
    return http_client

def fix_location_header(location: str, default_prefix: str = "") -> str:
    """Rewrite Location header so internal ports or root paths stay prefixed correctly."""
    if not location:
        return location

    # If full URL containing internal port:
    def _replace_port(match: re.Match[str]) -> str:
        port = int(match.group(2))
        prefix = _PORT_PREFIX_MAP.get(port, default_prefix)
        return f"{PUBLIC_ORIGIN}{prefix}"

    rewritten = _PORT_STRIP_RE.sub(_replace_port, location)

    # Handle relative redirects (e.g., Location: /dashboard or Location: /auth)
    if default_prefix and rewritten.startswith("/") and not rewritten.startswith(default_prefix) and not rewritten.startswith("http"):
        rewritten = f"{default_prefix}{rewritten}"

    return rewritten


_HOP_BY_HOP_HEADERS = {
    "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
    "te", "trailers", "transfer-encoding", "upgrade",
    "content-length", "content-encoding"
}

def get_client_ip(request: Request) -> str:
    cf_ip = request.headers.get("cf-connecting-ip")
    if cf_ip:
        return cf_ip.strip()
    xff = request.headers.get("x-forwarded-for")
    if xff:
        ips = [ip.strip() for ip in xff.split(",") if ip.strip() and ip.strip() != "127.0.0.1"]
        if ips:
            return ips[0]
    x_real = request.headers.get("x-real-ip")
    if x_real and x_real != "127.0.0.1":
        return x_real.strip()
    if request.client and request.client.host and request.client.host != "127.0.0.1":
        return request.client.host
    return "127.0.0.1"

def build_upstream_headers(request: Request, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
    headers = {
    k: v for k, v in request.headers.items()
    if k.lower() not in _HOP_BY_HOP_HEADERS
    and k.lower() not in ("host", "user-agent", "accept-encoding")
} 
    client_ip = get_client_ip(request)
    headers["Host"]              = PUBLIC_HOST
    headers["X-Forwarded-Host"]  = PUBLIC_HOST
    headers["X-Forwarded-Proto"] = "https"
    headers["X-Forwarded-Port"]  = "443"
    headers["X-Real-IP"]         = client_ip
    headers["X-Forwarded-For"]   = f"{client_ip}, 127.0.0.1"
    headers["User-Agent"]        = request.headers.get("user-agent") or "Python-urllib/3.11"
    # Prevent compressed upstream responses from being forwarded incorrectly.
    # The proxy strips Content-Encoding from downstream responses.
    headers["Accept-Encoding"] = "identity"

    if extra_headers:
        for ek, ev in extra_headers.items():
            for existing_k in list(headers.keys()):
                if existing_k.lower() == ek.lower():
                    del headers[existing_k]
            headers[ek] = ev
    return headers

def build_downstream_raw_headers(resp_headers: httpx.Headers, default_prefix: str = "") -> list:
    raw_headers = []
    has_csp = False
    has_cors = False
    for key, value in resp_headers.multi_items():
        lk = key.lower()
        if lk in _HOP_BY_HOP_HEADERS:
            continue
        if lk == "x-frame-options":
            # Strip restrictive frame options to allow Hugging Face Space iframe embedding
            continue
        if lk == "access-control-allow-origin":
            has_cors = True
        if lk == "content-security-policy":
            has_csp = True
            # Update frame-ancestors directive to permit https://huggingface.co
            if "frame-ancestors" in value.lower():
                value = re.sub(
                    r"frame-ancestors\s+[^;]+",
                    "frame-ancestors 'self' https://huggingface.co https://*.hf.space",
                    value,
                    flags=re.IGNORECASE,
                )
            else:
                value = f"{value}; frame-ancestors 'self' https://huggingface.co https://*.hf.space;"
        if lk == "location":
            value = fix_location_header(value, default_prefix=default_prefix)
        raw_headers.append((key.lower().encode("latin-1"), value.encode("latin-1")))

    if not has_csp:
        raw_headers.append((b"content-security-policy", b"frame-ancestors 'self' https://huggingface.co https://*.hf.space;"))

    if not has_cors:
        raw_headers.append((b"access-control-allow-origin", b"*"))
        raw_headers.append((b"access-control-allow-methods", b"GET, POST, PUT, DELETE, PATCH, OPTIONS"))
        raw_headers.append((b"access-control-allow-headers", b"*"))

    return raw_headers


async def proxy_http_request(
    target_url: str,
    request: Request,
    default_prefix: str = "",
    extra_headers: Optional[Dict[str, str]] = None,
    html_fixup: Optional[Callable[[str], str]] = None,
    body_override: Optional[bytes] = None,
) -> Response:
    client = get_http_client()
    headers = build_upstream_headers(request, extra_headers)
    body    = body_override if body_override is not None else await request.body()
    method  = request.method
    params  = dict(request.query_params)

    resp: Optional[httpx.Response] = None
    last_exc: Optional[Exception] = None

    for attempt in range(1, 4):
        try:
            req = client.build_request(
                method=method,
                url=target_url,
                headers=headers,
                params=params,
                content=body,
            )
            resp = await client.send(req, stream=True)
            if resp.status_code in (502, 503) and attempt < 3:
                await resp.aclose()
                await asyncio.sleep(0.1 * attempt)
                continue
            break
        except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError, httpx.HTTPError) as exc:
            last_exc = exc
            if attempt < 3:
                await asyncio.sleep(0.3 * attempt)
                continue
            if method in ("GET", "HEAD") and ("html" in request.headers.get("accept", "").lower() or request.url.path in ("/", "/index.html", "/healthz", "/health", "/omniroute", "/dashboard", "/jellyfin", "/hermes") or request.url.path.startswith("/dashboard") or request.url.path.startswith("/omniroute") or request.url.path.startswith("/jellyfin") or request.url.path.startswith("/hermes")):
                service_name = "OmniRoute AI Gateway" if default_prefix == "/omniroute" or "omniroute" in request.url.path or "dashboard" in request.url.path else ("Jellyfin Media Server" if default_prefix == "/jellyfin" or "jellyfin" in request.url.path else ("Hermes Autonomous Agent" if default_prefix == "/hermes" or "hermes" in request.url.path else "OpenCode Space"))

                html_retry = f"""<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="refresh" content="3">
    <title>{service_name} Initializing...</title>

    <style>
        body {{ font-family: system-ui, -apple-system, sans-serif; background: #0d1117; color: #c9d1d9; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 20px; box-sizing: border-box; }}
        .card {{ text-align: center; background: #161b22; padding: 2.5rem; border-radius: 12px; border: 1px solid #30363d; max-width: 500px; width: 100%; }}
        .spinner {{ width: 40px; height: 40px; border: 4px solid #30363d; border-top-color: #58a6ff; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1.5rem; }}
        @keyframes spin {{ to {{ transform: rotate(360deg); }} }}
        h2 {{ margin: 0 0 0.5rem; color: #f0f6fc; font-size: 1.25rem; }}
        p {{ color: #8b949e; font-size: 0.9rem; margin: 0; }}
    </style>
</head>
<body>
    <div class="card">
        <div class="spinner"></div>
        <h2>Initializing {service_name}...</h2>
        <p>The service is completing backend startup. This page will refresh automatically in 3 seconds.</p>
    </div>
</body>
</html>"""
                return Response(content=html_retry, status_code=200, headers={"Retry-After": "3", "Refresh": "3"}, media_type="text/html")

            return Response(
                content=f"<h2>502 Service Unavailable</h2><p>Service initializing. Please retry in a few seconds.</p>",
                status_code=502,
                media_type="text/html",
            )

    if resp is None:
        return Response(content=f"<h2>502 Bad Gateway</h2><p>{last_exc}</p>", status_code=502, media_type="text/html")

    raw_headers = build_downstream_raw_headers(resp.headers, default_prefix=default_prefix)
    content_type = resp.headers.get("content-type", "")
    status_code  = resp.status_code

    is_stream = "text/event-stream" in content_type or "video/" in content_type or "audio/" in content_type or resp.headers.get("transfer-encoding") == "chunked"

    if is_stream:
        async def stream_generator():
            try:
                async for chunk in resp.aiter_bytes():
                    yield chunk
            finally:
                await resp.aclose()

        streaming_resp = StreamingResponse(
            stream_generator(),
            status_code=status_code,
            media_type=content_type or None,
        )
        streaming_resp.raw_headers = raw_headers
        return streaming_resp

    try:
        content = await resp.aread()
    finally:
        await resp.aclose()

    if "text/html" in content_type and html_fixup and content:
        try:
            text = content.decode("utf-8", errors="replace")
            text = html_fixup(text)
            content = text.encode("utf-8")
        except Exception:
            pass

    normal_resp = Response(
        content=content,
        status_code=status_code,
        media_type=content_type or None,
    )
    normal_resp.raw_headers = raw_headers
    return normal_resp


async def proxy_websocket_stream(websocket: WebSocket, target_ws_url: str):
    await websocket.accept()
    
    # Preserve query parameters from client WebSocket request (e.g. ?EIO=4&transport=websocket)
    query_string = websocket.scope.get("query_string", b"").decode("utf-8")
    if query_string:
        sep = "&" if "?" in target_ws_url else "?"
        target_ws_url = f"{target_ws_url}{sep}{query_string}"

    skip_headers = {"host", "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions"}
    forward_headers = {k: v for k, v in websocket.headers.items() if k.lower() not in skip_headers}
    forward_headers["Host"] = PUBLIC_HOST
    forward_headers["X-Forwarded-Host"] = PUBLIC_HOST
    forward_headers["X-Forwarded-Proto"] = "https"
    forward_headers["X-Forwarded-Port"] = "443"

    try:
        async with aiohttp.ClientSession() as session:
            async with session.ws_connect(target_ws_url, headers=forward_headers) as upstream_ws:
                async def downstream_to_upstream():
                    async for msg in upstream_ws:
                        if msg.type == aiohttp.WSMsgType.TEXT:
                            await websocket.send_text(msg.data)
                        elif msg.type == aiohttp.WSMsgType.BINARY:
                            await websocket.send_bytes(msg.data)
                        elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED):
                            break

                async def upstream_to_downstream():
                    while True:
                        try:
                            msg = await websocket.receive()
                            if "text" in msg:
                                await upstream_ws.send_str(msg["text"])
                            elif "bytes" in msg:
                                await upstream_ws.send_bytes(msg["bytes"])
                            elif msg.get("type") == "websocket.disconnect":
                                break
                        except Exception:
                            break

                await asyncio.gather(downstream_to_upstream(), upstream_to_downstream(), return_exceptions=True)
    except Exception as exc:
        logger.warning(f"WebSocket Proxy Exception for {target_ws_url}: {exc}")
    finally:
        try:
            await websocket.close()
        except Exception:
            pass