1+ import asyncio
12import os
23
4+ import httpx
35import uvicorn
6+ import websockets
47from dotenv import load_dotenv
5- from fastapi import FastAPI
8+ from fastapi import FastAPI , Request , WebSocket , WebSocketDisconnect
69from fastapi .middleware .cors import CORSMiddleware
7- from fastapi .responses import FileResponse
10+ from fastapi .responses import FileResponse , StreamingResponse
811from fastapi .staticfiles import StaticFiles
912
1013# Load environment variables from .env file
2326BUILD_DIR = os .path .join (os .path .dirname (__file__ ), "build" )
2427INDEX_HTML = os .path .join (BUILD_DIR , "index.html" )
2528
29+ # Proxy configuration for WAF/private networking deployments
30+ PROXY_API_REQUESTS = os .getenv ("PROXY_API_REQUESTS" , "false" ).lower () == "true"
31+ BACKEND_API_URL = os .getenv ("BACKEND_API_URL" , "http://localhost:8000" )
32+
2633# Serve static files from build directory
2734app .mount (
2835 "/assets" , StaticFiles (directory = os .path .join (BUILD_DIR , "assets" )), name = "assets"
@@ -36,17 +43,96 @@ async def serve_index():
3643
3744@app .get ("/config" )
3845async def get_config ():
39- backend_url = os .getenv ("BACKEND_API_URL" , "http://localhost:8000" )
4046 auth_enabled = os .getenv ("AUTH_ENABLED" , "false" )
41- backend_url = backend_url + "/api"
47+
48+ if PROXY_API_REQUESTS :
49+ # WAF mode: frontend proxies API calls, so tell browser to use same origin
50+ api_url = "/api"
51+ else :
52+ # Non-WAF mode: browser calls backend directly
53+ backend_url = os .getenv ("BACKEND_API_URL" , "http://localhost:8000" )
54+ api_url = backend_url + "/api"
4255
4356 config = {
44- "API_URL" : backend_url ,
57+ "API_URL" : api_url ,
4558 "ENABLE_AUTH" : auth_enabled ,
4659 }
4760 return config
4861
4962
63+ @app .get ("/health" )
64+ async def health ():
65+ return {"status" : "healthy" }
66+
67+
68+ # API proxy routes for WAF/private networking deployments
69+ if PROXY_API_REQUESTS :
70+
71+ @app .api_route ("/api/{path:path}" , methods = ["GET" , "POST" , "PUT" , "DELETE" , "PATCH" ])
72+ async def proxy_api (request : Request , path : str ):
73+ """Proxy API requests to the private backend over VNet."""
74+ target_url = f"{ BACKEND_API_URL } /api/{ path } "
75+ query_string = str (request .query_params )
76+ if query_string :
77+ target_url = f"{ target_url } ?{ query_string } "
78+
79+ headers = dict (request .headers )
80+ headers .pop ("host" , None )
81+
82+ body = await request .body ()
83+
84+ async with httpx .AsyncClient (timeout = 300.0 ) as client :
85+ response = await client .request (
86+ method = request .method ,
87+ url = target_url ,
88+ headers = headers ,
89+ content = body ,
90+ )
91+
92+ return StreamingResponse (
93+ iter ([response .content ]),
94+ status_code = response .status_code ,
95+ headers = dict (response .headers ),
96+ )
97+
98+ @app .websocket ("/api/{path:path}" )
99+ async def proxy_websocket (websocket : WebSocket , path : str ):
100+ """Proxy WebSocket connections to the private backend over VNet."""
101+ await websocket .accept ()
102+
103+ # Build the backend WebSocket URL
104+ backend_ws_url = BACKEND_API_URL .replace ("https://" , "wss://" ).replace ("http://" , "ws://" )
105+ query_string = str (websocket .query_params )
106+ target_url = f"{ backend_ws_url } /api/{ path } "
107+ if query_string :
108+ target_url = f"{ target_url } ?{ query_string } "
109+
110+ try :
111+ async with websockets .connect (target_url ) as backend_ws :
112+
113+ async def forward_to_backend ():
114+ try :
115+ while True :
116+ data = await websocket .receive_text ()
117+ await backend_ws .send (data )
118+ except WebSocketDisconnect :
119+ await backend_ws .close ()
120+
121+ async def forward_to_client ():
122+ try :
123+ async for message in backend_ws :
124+ await websocket .send_text (message )
125+ except websockets .exceptions .ConnectionClosed :
126+ await websocket .close ()
127+
128+ await asyncio .gather (forward_to_backend (), forward_to_client ())
129+ except Exception :
130+ try :
131+ await websocket .close ()
132+ except Exception :
133+ pass
134+
135+
50136@app .get ("/{full_path:path}" )
51137async def serve_app (full_path : str ):
52138 # Remediation: normalize and check containment before serving
0 commit comments