diff --git a/docs/images/readme/1.png b/docs/images/readme/1.png new file mode 100644 index 000000000..fd22ae775 Binary files /dev/null and b/docs/images/readme/1.png differ diff --git a/docs/images/readme/2.png b/docs/images/readme/2.png new file mode 100644 index 000000000..b4d5976b6 Binary files /dev/null and b/docs/images/readme/2.png differ diff --git a/infra/main.bicep b/infra/main.bicep index 2b4868715..1f2952c31 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1534,6 +1534,7 @@ module webSite 'modules/web-sites.bicep' = { WEBSITES_PORT: '3000' WEBSITES_CONTAINER_START_TIME_LIMIT: '1800' // 30 minutes, adjust as needed BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}' + PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false' AUTH_ENABLED: 'false' } // WAF aligned configuration for Monitoring diff --git a/infra/main.parameters.json b/infra/main.parameters.json index ed6ba532a..cc48cf086 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -48,17 +48,35 @@ "value": "${AZURE_ENV_REASONING_MODEL_CAPACITY}" }, "backendContainerImageTag": { - "value": "${AZURE_ENV_IMAGE_TAG}" + "value": "${AZURE_ENV_IMAGE_TAG=latest_v4}" }, "frontendContainerImageTag": { - "value": "${AZURE_ENV_IMAGE_TAG}" + "value": "${AZURE_ENV_IMAGE_TAG=latest_v4}" }, "MCPContainerImageTag": { - "value": "${AZURE_ENV_IMAGE_TAG}" + "value": "${AZURE_ENV_IMAGE_TAG=latest_v4}" }, "enableTelemetry": { "value": "${AZURE_ENV_ENABLE_TELEMETRY}" }, + "enableMonitoring": { + "value": true + }, + "enablePrivateNetworking": { + "value": true + }, + "enableScalability": { + "value": false + }, + "virtualMachineAdminUsername": { + "value": "${AZURE_ENV_VM_ADMIN_USERNAME}" + }, + "virtualMachineAdminPassword": { + "value": "${AZURE_ENV_VM_ADMIN_PASSWORD}" + }, + "virtualMachineSize": { + "value": "${AZURE_ENV_VM_SIZE}" + }, "existingLogAnalyticsWorkspaceId": { "value": "${AZURE_ENV_LOG_ANALYTICS_WORKSPACE_ID}" }, @@ -73,6 +91,25 @@ }, "MCPContainerRegistryHostname": { "value": "${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT}" + }, + "allowedFqdnList": { + "value": [ + "mcr.microsoft.com", + "openai.azure.com", + "cognitiveservices.azure.com", + "login.microsoftonline.com", + "management.azure.com", + "aiinfra.azure.com", + "aiinfra.azure.net", + "aiinfra.azureedge.net", + "blob.core.windows.net", + "database.windows.net", + "vault.azure.net", + "monitoring.azure.com", + "dc.services.visualstudio.com", + "azconfig.io", + "azconfig.azure.net" + ] } } } \ No newline at end of file diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index b7961b9b3..c571774a9 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -1585,6 +1585,7 @@ module webSite 'modules/web-sites.bicep' = { WEBSITES_PORT: '8000' //WEBSITES_CONTAINER_START_TIME_LIMIT: '1800' // 30 minutes, adjust as needed BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}' + PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false' AUTH_ENABLED: 'false' ENABLE_ORYX_BUILD: 'True' } diff --git a/infra/scripts/Selecting-Team-Config-And-Data.ps1 b/infra/scripts/Selecting-Team-Config-And-Data.ps1 index 9f9480cbb..34988cb1f 100644 --- a/infra/scripts/Selecting-Team-Config-And-Data.ps1 +++ b/infra/scripts/Selecting-Team-Config-And-Data.ps1 @@ -445,6 +445,25 @@ do { } } while (-not $useCaseValid) +# WAF/Private Networking: If the Container App has IP restrictions or internal ingress, +# the backendUrl is not reachable from the developer's machine. Route through the frontend +# App Service proxy instead, which is public and forwards /api/* to the private backend over VNet. +$solutionSuffix = az group show --name $ResourceGroup --query "tags.SolutionSuffix" -o tsv 2>$null +if ($solutionSuffix) { + $containerAppName = "ca-$solutionSuffix" + $isExternal = az containerapp show --name $containerAppName --resource-group $ResourceGroup ` + --query "properties.configuration.ingress.external" -o tsv 2>$null + $hasIpRestrictions = az containerapp show --name $containerAppName --resource-group $ResourceGroup ` + --query "length(properties.configuration.ingress.ipSecurityRestrictions || ``[]``)" -o tsv 2>$null + if ($isExternal -eq "false" -or [int]$hasIpRestrictions -gt 0) { + $frontendHostname = "app-$solutionSuffix" + $frontendUrl = "https://${frontendHostname}.azurewebsites.net" + Write-Host "Private networking detected: Container App has restricted access." + Write-Host "Routing API calls through frontend App Service: $frontendUrl" + $script:backendUrl = $frontendUrl + } +} + Write-Host "" Write-Host "===============================================" Write-Host "Values to be used:" diff --git a/infra/scripts/selecting_team_config_and_data.sh b/infra/scripts/selecting_team_config_and_data.sh index ee6b273a1..b90ba5abc 100644 --- a/infra/scripts/selecting_team_config_and_data.sh +++ b/infra/scripts/selecting_team_config_and_data.sh @@ -453,6 +453,25 @@ while [[ "$useCaseValid" != true ]]; do fi done +# WAF/Private Networking: If the Container App has IP restrictions or internal ingress, +# the backendUrl is not reachable from the developer's machine. Route through the frontend +# App Service proxy instead, which is public and forwards /api/* to the private backend over VNet. +solutionSuffix=$(az group show --name "$ResourceGroup" --query "tags.SolutionSuffix" -o tsv 2>/dev/null) +if [[ -n "$solutionSuffix" ]]; then + containerAppName="ca-${solutionSuffix}" + isExternal=$(az containerapp show --name "$containerAppName" --resource-group "$ResourceGroup" \ + --query "properties.configuration.ingress.external" -o tsv 2>/dev/null) + hasIpRestrictions=$(az containerapp show --name "$containerAppName" --resource-group "$ResourceGroup" \ + --query "length(properties.configuration.ingress.ipSecurityRestrictions || \`[]\`)" -o tsv 2>/dev/null) + if [[ "$isExternal" == "false" ]] || [[ "$hasIpRestrictions" -gt 0 ]]; then + frontendHostname="app-${solutionSuffix}" + frontendUrl="https://${frontendHostname}.azurewebsites.net" + echo "Private networking detected: Container App has restricted access." + echo "Routing API calls through frontend App Service: $frontendUrl" + backendUrl="$frontendUrl" + fi +fi + echo "" echo "===============================================" echo "Values to be used:" diff --git a/src/App/Dockerfile b/src/App/Dockerfile index bed65fc5a..185cd87b5 100644 --- a/src/App/Dockerfile +++ b/src/App/Dockerfile @@ -34,8 +34,7 @@ WORKDIR /app COPY pyproject.toml requirements.txt* uv.lock* ./ # Install Python dependencies using UV -RUN --mount=type=cache,target=/root/.cache/uv \ - if [ -f "requirements.txt" ]; then \ +RUN if [ -f "requirements.txt" ]; then \ uv pip install --system -r requirements.txt && uv pip install --system "uvicorn[standard]"; \ else \ uv pip install --system pyproject.toml && uv pip install --system "uvicorn[standard]"; \ diff --git a/src/App/frontend_server.py b/src/App/frontend_server.py index d95c4535b..d7ba2cef1 100644 --- a/src/App/frontend_server.py +++ b/src/App/frontend_server.py @@ -1,10 +1,11 @@ import os +import httpx import uvicorn from dotenv import load_dotenv -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles # Load environment variables from .env file @@ -23,6 +24,10 @@ BUILD_DIR = os.path.join(os.path.dirname(__file__), "build") INDEX_HTML = os.path.join(BUILD_DIR, "index.html") +# Proxy configuration for WAF/private networking deployments +PROXY_API_REQUESTS = os.getenv("PROXY_API_REQUESTS", "false").lower() == "true" +BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000") + # Serve static files from build directory app.mount( "/assets", StaticFiles(directory=os.path.join(BUILD_DIR, "assets")), name="assets" @@ -36,17 +41,59 @@ async def serve_index(): @app.get("/config") async def get_config(): - backend_url = os.getenv("BACKEND_API_URL", "http://localhost:8000") auth_enabled = os.getenv("AUTH_ENABLED", "false") - backend_url = backend_url + "/api" + + if PROXY_API_REQUESTS: + # WAF mode: frontend proxies API calls, so tell browser to use same origin + api_url = "/api" + else: + # Non-WAF mode: browser calls backend directly + backend_url = os.getenv("BACKEND_API_URL", "http://localhost:8000") + api_url = backend_url + "/api" config = { - "API_URL": backend_url, + "API_URL": api_url, "ENABLE_AUTH": auth_enabled, } return config +@app.get("/health") +async def health(): + return {"status": "healthy"} + + +# API proxy routes for WAF/private networking deployments +if PROXY_API_REQUESTS: + + @app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) + async def proxy_api(request: Request, path: str): + """Proxy API requests to the private backend over VNet.""" + target_url = f"{BACKEND_API_URL}/api/{path}" + query_string = str(request.query_params) + if query_string: + target_url = f"{target_url}?{query_string}" + + headers = dict(request.headers) + headers.pop("host", None) + + body = await request.body() + + async with httpx.AsyncClient(timeout=300.0) as client: + response = await client.request( + method=request.method, + url=target_url, + headers=headers, + content=body, + ) + + return StreamingResponse( + iter([response.content]), + status_code=response.status_code, + headers=dict(response.headers), + ) + + @app.get("/{full_path:path}") async def serve_app(full_path: str): # Remediation: normalize and check containment before serving diff --git a/src/App/requirements.txt b/src/App/requirements.txt index 35c4db535..f5022145e 100644 --- a/src/App/requirements.txt +++ b/src/App/requirements.txt @@ -1,6 +1,7 @@ fastapi uvicorn[standard] # uvicorn removed and added above to allow websocket support +httpx jinja2 azure-identity python-dotenv