Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.19.0' = {
}
]
ingressTargetPort: 8000
ingressExternal: true
ingressExternal: !enablePrivateNetworking
scaleSettings: {
Comment on lines 1112 to 1116
// maxReplicas: enableScalability ? 3 : 1
maxReplicas: 1 // maxReplicas set to 1 (not 3) due to multiple agents created per type during WAF deployment
Expand Down
9 changes: 9 additions & 0 deletions infra/main.parameters.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@
"vmAdminPassword": {
"value": "${AZURE_ENV_VM_ADMIN_PASSWORD}"
},
"enableMonitoring": {
"value": true
},
"enablePrivateNetworking": {
"value": true
},
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated
"enableScalability": {
"value": true
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated
},
"aiModelDeployments": {
"value": [
{
Expand Down
75 changes: 75 additions & 0 deletions infra/main.waf.parameters copy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"solutionName": {
"value": "${AZURE_ENV_NAME}"
},
"location": {
"value": "${AZURE_LOCATION}"
},
"deploymentType": {
"value": "${AZURE_ENV_MODEL_DEPLOYMENT_TYPE}"
},
"gptModelName": {
"value": "${AZURE_ENV_GPT_MODEL_NAME}"
},
"gptDeploymentCapacity": {
"value": "${AZURE_ENV_GPT_MODEL_CAPACITY}"
},
"gptModelVersion": {
"value": "${AZURE_ENV_GPT_MODEL_VERSION}"
},
"imageTag": {
"value": "${AZURE_ENV_IMAGE_TAG=latest}"
},
"containerRegistryEndpoint": {
"value": "${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT=cmsacontainerreg.azurecr.io}"
},
"existingLogAnalyticsWorkspaceId": {
"value": "${AZURE_ENV_EXISTING_LOG_ANALYTICS_WORKSPACE_RID}"
},
"existingFoundryProjectResourceId": {
"value": "${AZURE_EXISTING_AIPROJECT_RESOURCE_ID}"
},
"secondaryLocation": {
"value": "${AZURE_ENV_SECONDARY_LOCATION}"
},
"azureAiServiceLocation": {
"value": "${AZURE_ENV_AI_SERVICE_LOCATION}"
},
"vmSize": {
"value": "${AZURE_ENV_VM_SIZE}"
},
"vmAdminUsername": {
"value": "${AZURE_ENV_VM_ADMIN_USERNAME}"
},
"vmAdminPassword": {
"value": "${AZURE_ENV_VM_ADMIN_PASSWORD}"
},
"enableMonitoring": {
"value": true
},
"enablePrivateNetworking": {
"value": true
},
"enableScalability": {
"value": true
},
"aiModelDeployments": {
"value": [
{
"name": "${AZURE_ENV_GPT_MODEL_NAME}",
"model": {
"name": "${AZURE_ENV_GPT_MODEL_NAME}",
"version": "${AZURE_ENV_GPT_MODEL_VERSION}"
},
"sku": {
"name": "${AZURE_ENV_MODEL_DEPLOYMENT_TYPE}",
"capacity": "${AZURE_ENV_GPT_MODEL_CAPACITY}"
}
}
]
}
}
}
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated
2 changes: 1 addition & 1 deletion infra/main_custom.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -1063,7 +1063,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.19.0' = {
}
]
ingressTargetPort: 8000
ingressExternal: true
ingressExternal: !enablePrivateNetworking
scaleSettings: {
// maxReplicas: enableScalability ? 3 : 1
maxReplicas: 1 // maxReplicas set to 1 (not 3) due to multiple agents created per type during WAF deployment
Expand Down
115 changes: 112 additions & 3 deletions src/frontend/frontend_server.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import asyncio
import os

import httpx
import uvicorn
import websockets
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles

# Load environment variables from .env file
load_dotenv()

# Internal backend URL used by the server-side proxy.
# The browser never contacts this URL directly.
BACKEND_API_URL = os.getenv("API_URL", "http://localhost:8000").rstrip("/")

app = FastAPI()

app.add_middleware(
Expand Down Expand Up @@ -38,7 +45,11 @@ async def serve_index():
@app.get("/config")
async def get_config():
config = {
"API_URL": os.getenv("API_URL", "API_URL not set"),
# Return empty string so the browser uses relative /api/* paths
# which are proxied server-side to BACKEND_API_URL. This ensures
# backend Container Apps with internal-only ingress are never
# contacted directly from the browser.
"API_URL": "",
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated
"REACT_APP_MSAL_AUTH_CLIENTID": os.getenv(
"REACT_APP_MSAL_AUTH_CLIENTID", "Client ID not set"
),
Expand All @@ -56,6 +67,104 @@ async def get_config():
return config


# ---------------------------------------------------------------------------
# Reverse proxy: WebSocket (must be declared before the HTTP catch-all below)
# ---------------------------------------------------------------------------

@app.websocket("/api/socket/{batch_id}")
async def proxy_websocket(websocket: WebSocket, batch_id: str):
"""Proxy WebSocket connections from the browser to the internal backend."""
await websocket.accept()

backend_ws_url = (
BACKEND_API_URL
.replace("https://", "wss://")
.replace("http://", "ws://")
)
backend_ws_url = f"{backend_ws_url}/api/socket/{batch_id}"

try:
async with websockets.connect(backend_ws_url) as backend_ws:

async def forward_to_backend():
try:
while True:
data = await websocket.receive_text()
await backend_ws.send(data)
except (WebSocketDisconnect, Exception):
pass

async def forward_to_client():
try:
async for message in backend_ws:
await websocket.send_text(message)
except (WebSocketDisconnect, Exception):
pass

await asyncio.gather(forward_to_backend(), forward_to_client())
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated
except Exception:
pass
finally:
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated
try:
await websocket.close()
except Exception:
pass
Comment on lines +101 to +144


# ---------------------------------------------------------------------------
# Reverse proxy: HTTP (all /api/* routes proxied to the internal backend)
# ---------------------------------------------------------------------------

_PROXY_CLIENT = httpx.AsyncClient(timeout=300.0)
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated


@app.api_route(
"/api/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_api(request: Request, path: str):
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated
"""Proxy HTTP API requests from the browser to the internal backend."""
target_url = f"{BACKEND_API_URL}/api/{path}"
if request.url.query:
target_url = f"{target_url}?{request.url.query}"

# Forward all headers except 'host' (would confuse the backend)
headers = {
k: v for k, v in request.headers.items()
if k.lower() != "host"
}

body = await request.body()

response = await _PROXY_CLIENT.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
)
Comment on lines +168 to +175

# Strip hop-by-hop headers that must not be forwarded
excluded_headers = {
"content-encoding", "transfer-encoding", "connection",
"keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "upgrade",
}
forwarded_headers = {
k: v for k, v in response.headers.items()
if k.lower() not in excluded_headers
}
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated

return Response(
content=response.content,
status_code=response.status_code,
headers=forwarded_headers,
)
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated
Comment on lines +170 to +193


# ---------------------------------------------------------------------------
# SPA catch-all (must be last)
# ---------------------------------------------------------------------------

@app.get("/{full_path:path}")
async def serve_app(full_path: str):
# Remediation: normalize and check containment before serving
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ uvicorn[standard]
jinja2
azure-identity
python-dotenv
python-multipart
python-multipart
httpx
websockets
7 changes: 5 additions & 2 deletions src/frontend/src/api/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ export function getApiUrl() {
}

if (!API_URL) {
console.warn('API URL not yet configured');
return null;
// API_URL is not configured (e.g. WAF deployment where the backend is
// internal-only). Fall back to the browser's own origin so that all
// /api/* requests are routed through the frontend server's reverse proxy
// instead of attempting to reach the internal backend URL directly.
return `${window.location.origin}/api`;
}

return API_URL;
Expand Down
3 changes: 1 addition & 2 deletions src/frontend/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ export default defineConfig({
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/config': {
target: 'http://localhost:8000',
target: 'http://localhost:3000',
changeOrigin: true
}
Comment thread
Ashwal-Microsoft marked this conversation as resolved.
Outdated
}
Expand Down
Loading