diff --git a/infra/main.bicep b/infra/main.bicep index 2b4868715..025285eb4 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1142,8 +1142,8 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2 tags: tags enableTelemetry: enableTelemetry // WAF aligned configuration for Private Networking - publicNetworkAccess: 'Enabled' // Always enabling the publicNetworkAccess for Container App Environment - internal: false // Must be false when publicNetworkAccess is'Enabled' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + internal: enablePrivateNetworking ? true : false infrastructureSubnetResourceId: enablePrivateNetworking ? virtualNetwork.?outputs.?containerSubnetResourceId : null // WAF aligned configuration for Monitoring appLogsConfiguration: enableMonitoring @@ -1177,6 +1177,32 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2 } } +// ========== Private DNS Zone for internal Container App Environment ========== // +// When the CAE is internal, its FQDN is only resolvable within the VNet via this DNS zone. +module caeDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enablePrivateNetworking) { + name: 'avm.res.network.private-dns-zone.cae' + params: { + name: containerAppEnvironment.outputs.defaultDomain + tags: tags + enableTelemetry: enableTelemetry + a: [ + { + name: '*' + aRecords: [ + { ipv4Address: containerAppEnvironment.outputs.staticIp } + ] + ttl: 300 + } + ] + virtualNetworkLinks: [ + { + name: take('vnetlink-${virtualNetworkResourceName}-cae', 80) + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + } + ] + } +} + // ========== Backend Container App Service ========== // // WAF best practices for container apps: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-container-apps // PSRule for Container App: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#container-app @@ -1535,6 +1561,7 @@ module webSite 'modules/web-sites.bicep' = { WEBSITES_CONTAINER_START_TIME_LIMIT: '1800' // 30 minutes, adjust as needed BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}' AUTH_ENABLED: 'false' + PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false' } // WAF aligned configuration for Monitoring applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index b7961b9b3..251f9030f 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -1140,8 +1140,8 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2 tags: tags enableTelemetry: enableTelemetry // WAF aligned configuration for Private Networking - publicNetworkAccess: 'Enabled' // Always enabling the publicNetworkAccess for Container App Environment - internal: false // Must be false when publicNetworkAccess is'Enabled' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + internal: enablePrivateNetworking ? true : false infrastructureSubnetResourceId: enablePrivateNetworking ? virtualNetwork.?outputs.?containerSubnetResourceId : null // WAF aligned configuration for Monitoring appLogsConfiguration: enableMonitoring @@ -1175,6 +1175,32 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2 } } +// ========== Private DNS Zone for internal Container App Environment ========== // +// When the CAE is internal, its FQDN is only resolvable within the VNet via this DNS zone. +module caeDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enablePrivateNetworking) { + name: 'avm.res.network.private-dns-zone.cae' + params: { + name: containerAppEnvironment.outputs.defaultDomain + tags: tags + enableTelemetry: enableTelemetry + a: [ + { + name: '*' + aRecords: [ + { ipv4Address: containerAppEnvironment.outputs.staticIp } + ] + ttl: 300 + } + ] + virtualNetworkLinks: [ + { + name: take('vnetlink-${virtualNetworkResourceName}-cae', 80) + virtualNetworkResourceId: virtualNetwork!.outputs.resourceId + } + ] + } +} + // ========== Container Registry ========== // module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.1' = { name: 'registryDeployment' @@ -1587,6 +1613,7 @@ module webSite 'modules/web-sites.bicep' = { BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}' AUTH_ENABLED: 'false' ENABLE_ORYX_BUILD: 'True' + PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false' } // WAF aligned configuration for Monitoring applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null diff --git a/infra/scripts/Selecting-Team-Config-And-Data.ps1 b/infra/scripts/Selecting-Team-Config-And-Data.ps1 index 9f9480cbb..de70af82d 100644 --- a/infra/scripts/Selecting-Team-Config-And-Data.ps1 +++ b/infra/scripts/Selecting-Team-Config-And-Data.ps1 @@ -445,6 +445,27 @@ 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 + $proxyEnabled = az webapp config appsettings list --name "app-$solutionSuffix" --resource-group $ResourceGroup ` + --query "[?name=='PROXY_API_REQUESTS'].value" -o tsv 2>$null + if ($isExternal -eq "false" -or [int]$hasIpRestrictions -gt 0 -or $proxyEnabled -eq "true") { + $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..2f50781af 100644 --- a/infra/scripts/selecting_team_config_and_data.sh +++ b/infra/scripts/selecting_team_config_and_data.sh @@ -453,6 +453,27 @@ 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) + proxyEnabled=$(az webapp config appsettings list --name "app-${solutionSuffix}" --resource-group "$ResourceGroup" \ + --query "[?name=='PROXY_API_REQUESTS'].value" -o tsv 2>/dev/null) + if [[ "$isExternal" == "false" ]] || [[ "$hasIpRestrictions" -gt 0 ]] || [[ "$proxyEnabled" == "true" ]]; 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..c79162c9d 100644 --- a/src/App/frontend_server.py +++ b/src/App/frontend_server.py @@ -1,10 +1,13 @@ +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, StreamingResponse from fastapi.staticfiles import StaticFiles # Load environment variables from .env file @@ -23,6 +26,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 +43,96 @@ 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.websocket("/api/{path:path}") + async def proxy_websocket(websocket: WebSocket, path: str): + """Proxy WebSocket connections to the private backend over VNet.""" + await websocket.accept() + + # Build the backend WebSocket URL + backend_ws_url = BACKEND_API_URL.replace("https://", "wss://").replace("http://", "ws://") + query_string = str(websocket.query_params) + target_url = f"{backend_ws_url}/api/{path}" + if query_string: + target_url = f"{target_url}?{query_string}" + + try: + async with websockets.connect(target_url) as backend_ws: + + async def forward_to_backend(): + try: + while True: + data = await websocket.receive_text() + await backend_ws.send(data) + except WebSocketDisconnect: + await backend_ws.close() + + async def forward_to_client(): + try: + async for message in backend_ws: + await websocket.send_text(message) + except websockets.exceptions.ConnectionClosed: + await websocket.close() + + await asyncio.gather(forward_to_backend(), forward_to_client()) + except Exception: + try: + await websocket.close() + except Exception: + pass + + @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..1d273c6b9 100644 --- a/src/App/requirements.txt +++ b/src/App/requirements.txt @@ -4,4 +4,6 @@ uvicorn[standard] jinja2 azure-identity python-dotenv -python-multipart \ No newline at end of file +python-multipart +httpx +websockets \ No newline at end of file