From 8048349edaa1a016a3b21e5ddcf04ab560ed9e2f Mon Sep 17 00:00:00 2001 From: "Ashwal Vishwanath (Persistent Systems Inc)" Date: Mon, 27 Apr 2026 16:46:46 +0530 Subject: [PATCH 1/2] Api fix endpoint --- docs/DeploymentGuide.md | 2 ++ infra/main.bicep | 11 +++++++-- infra/main_custom.bicep | 14 ++++++++--- src/frontend/frontend_server.py | 42 ++++++++++++++++++++++++++++++++- src/frontend/requirements.txt | 1 + 5 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 47ac1b7e..15c4cd56 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -191,6 +191,8 @@ Review the configuration options below. You can customize any settings that meet | **Framework** | Basic configuration | [Well-Architected Framework](https://learn.microsoft.com/en-us/azure/well-architected/) | | **Features** | Core functionality | Reliability, security, operational excellence | +When you use the production WAF configuration for Container Migration, only the frontend Container App stays publicly reachable through Application Gateway/WAF. The backend API and processor Container Apps stay on internal ingress inside the Container Apps environment, and the existing VNet subnet/NSG rules continue to limit traffic to internal paths. + **To use production configuration:** Copy the contents from the production configuration file to your main parameters file: diff --git a/infra/main.bicep b/infra/main.bicep index 752669da..5c250eb8 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1220,7 +1220,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = { } ] ingressTargetPort: backendContainerPort - ingressExternal: true + ingressExternal: enablePrivateNetworking ? false : true // WAF: internal-only ingress for backend API scaleSettings: { maxReplicas: enableScalability ? 3 : 1 minReplicas: 1 @@ -1278,7 +1278,14 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.18.1' = { env: [ { name: 'API_URL' - value: 'https://${containerAppBackend.outputs.fqdn}' + // Frontend calls same-origin /api; frontend server proxies to backend. + value: '/api' + } + { + name: 'BACKEND_API_URL' + value: enablePrivateNetworking + ? 'https://${backendContainerAppName}.internal.${containerAppsEnvironment.outputs.defaultDomain}' + : 'https://${containerAppBackend.outputs.fqdn}' } { name: 'APP_ENV' diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index 878b8d1b..8219bf06 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -1165,7 +1165,7 @@ module containerAppBackend 'br/public:avm/res/app/container-app:0.18.1' = { } ] ingressTargetPort: backendContainerPort - ingressExternal: true + ingressExternal: enablePrivateNetworking ? false : true scaleSettings: { maxReplicas: enableScalability ? 3 : 1 minReplicas: 1 @@ -1229,7 +1229,13 @@ module containerAppFrontend 'br/public:avm/res/app/container-app:0.18.1' = { env: [ { name: 'API_URL' - value: 'https://${containerAppBackend.outputs.fqdn}' + value: '/api' + } + { + name: 'BACKEND_API_URL' + value: enablePrivateNetworking + ? 'https://${backendContainerAppName}.internal.${containerAppsEnvironment.outputs.defaultDomain}' + : 'https://${containerAppBackend.outputs.fqdn}' } { name: 'APP_ENV' @@ -1382,7 +1388,9 @@ output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.logi output SERVICE_BACKEND_NAME string = containerAppBackend.outputs.name @description('Backend service container app URI') -output SERVICE_BACKEND_URI string = 'https://${containerAppBackend.outputs.fqdn}' +output SERVICE_BACKEND_URI string = enablePrivateNetworking + ? 'https://${backendContainerAppName}.internal.${containerAppsEnvironment.outputs.defaultDomain}' + : 'https://${containerAppBackend.outputs.fqdn}' @description('Processor service container app name') output SERVICE_PROCESSOR_NAME string = containerAppProcessor.outputs.name diff --git a/src/frontend/frontend_server.py b/src/frontend/frontend_server.py index 1ab61f5a..03ab5c9c 100644 --- a/src/frontend/frontend_server.py +++ b/src/frontend/frontend_server.py @@ -1,10 +1,11 @@ import os +import httpx import uvicorn from dotenv import load_dotenv from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles # Load environment variables from .env file @@ -12,6 +13,8 @@ app = FastAPI() +BACKEND_API_URL = os.getenv("BACKEND_API_URL", "").rstrip("/") + # Read allowed origins from environment; fall back to same-origin only _allowed_origins = os.getenv("ALLOWED_ORIGINS", "").split(",") _allowed_origins = [o.strip() for o in _allowed_origins if o.strip()] @@ -72,6 +75,43 @@ async def get_config(request: Request): return config +@app.api_route("/api/{full_path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) +async def proxy_api(full_path: str, request: Request): + if not BACKEND_API_URL: + return JSONResponse(status_code=503, content={"detail": "Backend API URL is not configured"}) + + target_url = f"{BACKEND_API_URL}/api/{full_path}" + if request.url.query: + target_url = f"{target_url}?{request.url.query}" + + headers = dict(request.headers) + headers.pop("host", None) + headers.pop("content-length", None) + + body = await request.body() + + async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client: + proxied = await client.request( + method=request.method, + url=target_url, + headers=headers, + content=body, + ) + + passthrough_headers = { + key: value + for key, value in proxied.headers.items() + if key.lower() not in {"content-encoding", "transfer-encoding", "connection"} + } + + return Response( + content=proxied.content, + status_code=proxied.status_code, + headers=passthrough_headers, + media_type=proxied.headers.get("content-type"), + ) + + @app.get("/{full_path:path}") async def serve_app(full_path: str): # Resolve the requested path and ensure it stays within BUILD_DIR diff --git a/src/frontend/requirements.txt b/src/frontend/requirements.txt index a8bc36ae..ca3183ae 100644 --- a/src/frontend/requirements.txt +++ b/src/frontend/requirements.txt @@ -1,6 +1,7 @@ fastapi==0.116.1 uvicorn[standard]==0.35.0 # uvicorn removed and added above to allow websocket support +httpx==0.28.1 jinja2==3.1.6 azure-identity==1.24.0 python-dotenv==1.1.1 From f3ae6e3c615b713a0b15bc371a8a8f9684b9ead7 Mon Sep 17 00:00:00 2001 From: "Ashwal Vishwanath (Persistent Systems Inc)" Date: Wed, 6 May 2026 16:35:01 +0530 Subject: [PATCH 2/2] Fix proxy: add error handling and proper header passthrough - Add try/except for httpx.TimeoutException (504) and httpx.RequestError (502) - Drop content-length from passthrough headers to avoid truncated responses - Use multi_items() to preserve multi-value headers like set-cookie Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/frontend/frontend_server.py | 41 +++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/frontend/frontend_server.py b/src/frontend/frontend_server.py index 03ab5c9c..b9be3995 100644 --- a/src/frontend/frontend_server.py +++ b/src/frontend/frontend_server.py @@ -90,26 +90,43 @@ async def proxy_api(full_path: str, request: Request): body = await request.body() - async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client: - proxied = await client.request( - method=request.method, - url=target_url, - headers=headers, - content=body, + try: + async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client: + proxied = await client.request( + method=request.method, + url=target_url, + headers=headers, + content=body, + ) + except httpx.TimeoutException: + return JSONResponse( + status_code=504, + content={"detail": "Upstream backend request timed out"}, + ) + except httpx.RequestError: + return JSONResponse( + status_code=502, + content={"detail": "Failed to reach upstream backend"}, ) - passthrough_headers = { - key: value - for key, value in proxied.headers.items() - if key.lower() not in {"content-encoding", "transfer-encoding", "connection"} + excluded_headers = { + "content-encoding", + "transfer-encoding", + "connection", + "content-length", + "content-type", } - return Response( + response = Response( content=proxied.content, status_code=proxied.status_code, - headers=passthrough_headers, media_type=proxied.headers.get("content-type"), ) + for key, value in proxied.headers.multi_items(): + if key.lower() not in excluded_headers: + response.raw_headers.append((key.encode("latin-1"), value.encode("latin-1"))) + + return response @app.get("/{full_path:path}")