| """
|
| Reverse proxy for virtual ports.
|
|
|
| Each zone can run web servers on internal ports (e.g. 3000, 8080).
|
| Since HF Spaces only exposes port 7860, this module proxies
|
| /port/{zone}/{port}/... → localhost:{port} with proper path rewriting.
|
|
|
| Port mappings are stored in zones_meta.json under each zone's "ports" key.
|
| """
|
|
|
| import json
|
| import re
|
| from pathlib import Path
|
|
|
| import httpx
|
| from fastapi import Request, WebSocket, WebSocketDisconnect
|
| from fastapi.responses import Response
|
|
|
|
|
| MIN_PORT = 1024
|
| MAX_PORT = 65535
|
|
|
|
|
| _client: httpx.AsyncClient | None = None
|
|
|
|
|
| def _get_client() -> httpx.AsyncClient:
|
| global _client
|
| if _client is None:
|
| _client = httpx.AsyncClient(
|
| timeout=httpx.Timeout(30.0, connect=5.0),
|
| follow_redirects=False,
|
| limits=httpx.Limits(max_connections=50),
|
| )
|
| return _client
|
|
|
|
|
| def _meta_path() -> Path:
|
| from zones import ZONES_META
|
| return ZONES_META
|
|
|
|
|
| def _load_meta() -> dict:
|
| p = _meta_path()
|
| if p.exists():
|
| return json.loads(p.read_text(encoding="utf-8"))
|
| return {}
|
|
|
|
|
| def _save_meta(meta: dict):
|
| _meta_path().write_text(json.dumps(meta, indent=2, default=str), encoding="utf-8")
|
|
|
|
|
| def _validate_port(port: int):
|
| if not (MIN_PORT <= port <= MAX_PORT):
|
| raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
|
|
|
|
|
| def _validate_zone(meta: dict, zone_name: str):
|
| if zone_name not in meta:
|
| raise ValueError(f"Zone '{zone_name}' does not exist")
|
|
|
|
|
|
|
|
|
| def list_ports(zone_name: str) -> list[dict]:
|
| """List all port mappings for a zone."""
|
| meta = _load_meta()
|
| _validate_zone(meta, zone_name)
|
| ports = meta[zone_name].get("ports", [])
|
| return ports
|
|
|
|
|
| def add_port(zone_name: str, port: int, label: str = "") -> dict:
|
| """Add a port mapping to a zone."""
|
| _validate_port(port)
|
| meta = _load_meta()
|
| _validate_zone(meta, zone_name)
|
|
|
| ports = meta[zone_name].setdefault("ports", [])
|
|
|
|
|
| for p in ports:
|
| if p["port"] == port:
|
| raise ValueError(f"Port {port} already mapped in zone '{zone_name}'")
|
|
|
| entry = {"port": port, "label": label or f"Port {port}"}
|
| ports.append(entry)
|
| _save_meta(meta)
|
| return entry
|
|
|
|
|
| def remove_port(zone_name: str, port: int):
|
| """Remove a port mapping from a zone."""
|
| meta = _load_meta()
|
| _validate_zone(meta, zone_name)
|
|
|
| ports = meta[zone_name].get("ports", [])
|
| before = len(ports)
|
| meta[zone_name]["ports"] = [p for p in ports if p["port"] != port]
|
| if len(meta[zone_name]["ports"]) == before:
|
| raise ValueError(f"Port {port} not found in zone '{zone_name}'")
|
| _save_meta(meta)
|
|
|
|
|
|
|
|
|
|
|
| _HOP_HEADERS = frozenset({
|
| "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
| "te", "trailers", "transfer-encoding", "upgrade",
|
| })
|
|
|
|
|
| async def proxy_http(request: Request, zone_name: str, port: int, subpath: str = "") -> Response:
|
| """Proxy an HTTP request to localhost:{port}."""
|
| _validate_port(port)
|
| meta = _load_meta()
|
| _validate_zone(meta, zone_name)
|
|
|
|
|
| ports = meta[zone_name].get("ports", [])
|
| if not any(p["port"] == port for p in ports):
|
| return Response(content="Port not mapped", status_code=404)
|
|
|
| target_url = f"http://127.0.0.1:{port}/{subpath}"
|
| if request.url.query:
|
| target_url += f"?{request.url.query}"
|
|
|
|
|
| headers = {}
|
| for key, value in request.headers.items():
|
| if key.lower() not in _HOP_HEADERS and key.lower() != "host":
|
| headers[key] = value
|
| headers["host"] = f"127.0.0.1:{port}"
|
| headers["x-forwarded-for"] = request.client.host if request.client else "127.0.0.1"
|
| headers["x-forwarded-proto"] = request.url.scheme
|
|
|
| headers["x-forwarded-prefix"] = f"/port/{zone_name}/{port}"
|
|
|
| body = await request.body()
|
| client = _get_client()
|
|
|
| try:
|
| resp = await client.request(
|
| method=request.method,
|
| url=target_url,
|
| headers=headers,
|
| content=body,
|
| )
|
| except httpx.ConnectError:
|
| return Response(
|
| content=f"Cannot connect to port {port}. Make sure your server is running.",
|
| status_code=502,
|
| media_type="text/plain",
|
| )
|
| except httpx.TimeoutException:
|
| return Response(content=f"Timeout connecting to port {port}", status_code=504, media_type="text/plain")
|
|
|
|
|
| resp_headers = {}
|
| for key, value in resp.headers.items():
|
| if key.lower() not in _HOP_HEADERS and key.lower() != "content-encoding":
|
| resp_headers[key] = value
|
|
|
| return Response(
|
| content=resp.content,
|
| status_code=resp.status_code,
|
| headers=resp_headers,
|
| )
|
|
|
|
|
|
|
|
|
| async def proxy_ws(websocket: WebSocket, zone_name: str, port: int, subpath: str = ""):
|
| """Proxy a WebSocket connection to localhost:{port}."""
|
| _validate_port(port)
|
| meta = _load_meta()
|
| _validate_zone(meta, zone_name)
|
|
|
| ports = meta[zone_name].get("ports", [])
|
| if not any(p["port"] == port for p in ports):
|
| await websocket.close(code=4004, reason="Port not mapped")
|
| return
|
|
|
| await websocket.accept()
|
|
|
| target_url = f"ws://127.0.0.1:{port}/{subpath}"
|
|
|
| try:
|
| async with httpx.AsyncClient() as client:
|
| async with client.stream("GET", target_url.replace("ws://", "http://")) as _:
|
| pass
|
| except Exception:
|
| pass
|
|
|
|
|
| import asyncio
|
| import websockets
|
|
|
| try:
|
| async with websockets.connect(target_url) as backend_ws:
|
| async def client_to_backend():
|
| try:
|
| while True:
|
| msg = await websocket.receive()
|
| if msg.get("type") == "websocket.disconnect":
|
| break
|
| if "text" in msg:
|
| await backend_ws.send(msg["text"])
|
| elif "bytes" in msg:
|
| await backend_ws.send(msg["bytes"])
|
| except (WebSocketDisconnect, Exception):
|
| pass
|
|
|
| async def backend_to_client():
|
| try:
|
| async for message in backend_ws:
|
| if isinstance(message, str):
|
| await websocket.send_text(message)
|
| else:
|
| await websocket.send_bytes(message)
|
| except (WebSocketDisconnect, Exception):
|
| pass
|
|
|
| await asyncio.gather(client_to_backend(), backend_to_client())
|
| except Exception:
|
| try:
|
| await websocket.send_text(json.dumps({"error": f"Cannot connect WebSocket to port {port}"}))
|
| except Exception:
|
| pass
|
| finally:
|
| try:
|
| await websocket.close()
|
| except Exception:
|
| pass
|
|
|