Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 29 additions & 2 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions infra/main_custom.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions infra/scripts/Selecting-Team-Config-And-Data.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
21 changes: 21 additions & 0 deletions infra/scripts/selecting_team_config_and_data.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
3 changes: 1 addition & 2 deletions src/App/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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]"; \
Expand Down
96 changes: 91 additions & 5 deletions src/App/frontend_server.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/App/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
Loading