Harden gateway config and upstream routing
This commit is contained in:
12
README.md
12
README.md
@@ -65,13 +65,13 @@ app/
|
|||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `NODE_ENV` | No (default `development`) | Deployment environment label. |
|
| `NODE_ENV` | No (default `development`) | Deployment environment label. |
|
||||||
| `PUBLIC_API_URL` | Yes | External URL for this gateway. |
|
| `PUBLIC_API_URL` | Yes (non-dev) | External URL for this gateway. |
|
||||||
| `CORE_API_URL` | Yes | Upstream Core backend base URL. |
|
| `CORE_API_URL` | Yes (non-dev) | Upstream Core backend base URL. |
|
||||||
| `AGENTS_API_URL` | No | Upstream Agents API base URL. |
|
| `AGENTS_API_URL` | No | Upstream Agents API base URL. |
|
||||||
| `API_KEYS` | Yes | Comma-separated list of API keys authorized for `/v1` routes. |
|
| `API_KEYS` | Yes (non-dev) | Comma-separated list of API keys authorized for `/v1` routes. |
|
||||||
| `RATE_LIMIT_WINDOW` | No | Optional rate limit window (seconds). |
|
| `LOG_LEVEL` | No | Application log level (default `info`). |
|
||||||
| `RATE_LIMIT_MAX` | No | Optional rate limit max requests per window. |
|
| `REQUEST_TIMEOUT_MS` | No | Default upstream request timeout in milliseconds (default `10000`). |
|
||||||
| `GIT_COMMIT` | No | Commit SHA used for `/version`. |
|
| `GIT_COMMIT` / `RAILWAY_GIT_COMMIT_SHA` | No | Commit SHA used for `/version`. |
|
||||||
| `BUILD_TIME` | No | Build timestamp used for `/version`. |
|
| `BUILD_TIME` | No | Build timestamp used for `/version`. |
|
||||||
|
|
||||||
## Railway deployment
|
## Railway deployment
|
||||||
|
|||||||
12
app/clients/agents_client.py
Normal file
12
app/clients/agents_client.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.clients.upstream import UpstreamClient
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_agents_client(settings: Settings) -> UpstreamClient:
|
||||||
|
return UpstreamClient(
|
||||||
|
"agents",
|
||||||
|
settings.agents_api_url,
|
||||||
|
default_timeout=settings.request_timeout_seconds,
|
||||||
|
)
|
||||||
12
app/clients/core_client.py
Normal file
12
app/clients/core_client.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.clients.upstream import UpstreamClient
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_core_client(settings: Settings) -> UpstreamClient:
|
||||||
|
return UpstreamClient(
|
||||||
|
"core",
|
||||||
|
settings.core_api_url,
|
||||||
|
default_timeout=settings.request_timeout_seconds,
|
||||||
|
)
|
||||||
@@ -17,7 +17,7 @@ class UpstreamClient:
|
|||||||
name (str): The name of the upstream service.
|
name (str): The name of the upstream service.
|
||||||
base_url (Optional[str]): The base URL of the upstream service.
|
base_url (Optional[str]): The base URL of the upstream service.
|
||||||
"""
|
"""
|
||||||
def __init__(self, name: str, base_url: Optional[str]):
|
def __init__(self, name: str, base_url: Optional[str], default_timeout: float = DEFAULT_TIMEOUT):
|
||||||
"""
|
"""
|
||||||
Initialize an UpstreamClient.
|
Initialize an UpstreamClient.
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ class UpstreamClient:
|
|||||||
"""
|
"""
|
||||||
self.name = name
|
self.name = name
|
||||||
self.base_url = base_url.rstrip("/") if base_url else None
|
self.base_url = base_url.rstrip("/") if base_url else None
|
||||||
|
self.default_timeout = default_timeout
|
||||||
|
|
||||||
async def request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
async def request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -51,7 +52,7 @@ class UpstreamClient:
|
|||||||
raise UpstreamError(source=self.name, message=f"{self.name} upstream not configured", status_code=502)
|
raise UpstreamError(source=self.name, message=f"{self.name} upstream not configured", status_code=502)
|
||||||
|
|
||||||
url = f"{self.base_url}/{path.lstrip('/')}"
|
url = f"{self.base_url}/{path.lstrip('/')}"
|
||||||
timeout = kwargs.pop("timeout", DEFAULT_TIMEOUT)
|
timeout = kwargs.pop("timeout", self.default_timeout)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
@@ -75,11 +76,3 @@ class UpstreamClient:
|
|||||||
)
|
)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise UpstreamError(self.name, message=f"{self.name} upstream request failed", details={"path": path}) from exc
|
raise UpstreamError(self.name, message=f"{self.name} upstream request failed", details={"path": path}) from exc
|
||||||
|
|
||||||
|
|
||||||
def get_core_client(core_api_url: Optional[str]) -> UpstreamClient:
|
|
||||||
return UpstreamClient("core", core_api_url)
|
|
||||||
|
|
||||||
|
|
||||||
def get_agents_client(agents_api_url: Optional[str]) -> UpstreamClient:
|
|
||||||
return UpstreamClient("agents", agents_api_url)
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import AnyHttpUrl, Field, field_validator
|
from pydantic import AnyHttpUrl, Field, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -12,20 +13,31 @@ class Settings(BaseSettings):
|
|||||||
"""Application configuration loaded from environment variables."""
|
"""Application configuration loaded from environment variables."""
|
||||||
|
|
||||||
env: str = Field("development", alias="NODE_ENV")
|
env: str = Field("development", alias="NODE_ENV")
|
||||||
public_api_url: AnyHttpUrl = Field(..., alias="PUBLIC_API_URL")
|
public_api_url: Optional[AnyHttpUrl] = Field(None, alias="PUBLIC_API_URL")
|
||||||
core_api_url: AnyHttpUrl = Field(..., alias="CORE_API_URL")
|
core_api_url: Optional[AnyHttpUrl] = Field(None, alias="CORE_API_URL")
|
||||||
agents_api_url: Optional[AnyHttpUrl] = Field(None, alias="AGENTS_API_URL")
|
agents_api_url: Optional[AnyHttpUrl] = Field(None, alias="AGENTS_API_URL")
|
||||||
|
|
||||||
api_keys: List[str] = Field(default_factory=list, alias="API_KEYS")
|
api_keys: List[str] = Field(default_factory=list, alias="API_KEYS")
|
||||||
app_version: str = "0.1.0"
|
app_version: str = Field("0.1.0", alias="APP_VERSION")
|
||||||
build_time: str = Field(default_factory=lambda: datetime.utcnow().isoformat())
|
build_time: str = Field(
|
||||||
|
default_factory=lambda: datetime.utcnow().isoformat(), alias="BUILD_TIME"
|
||||||
|
)
|
||||||
commit: Optional[str] = Field(None, alias="GIT_COMMIT")
|
commit: Optional[str] = Field(None, alias="GIT_COMMIT")
|
||||||
|
|
||||||
|
log_level: str = Field("info", alias="LOG_LEVEL")
|
||||||
|
port: int = Field(8000, alias="PORT")
|
||||||
|
request_timeout_ms: int = Field(10000, alias="REQUEST_TIMEOUT_MS")
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"case_sensitive": False,
|
"case_sensitive": False,
|
||||||
"populate_by_name": True,
|
"populate_by_name": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@field_validator("commit", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def prefer_railway_commit(cls, value: Optional[str]) -> Optional[str]:
|
||||||
|
return value or os.getenv("RAILWAY_GIT_COMMIT_SHA")
|
||||||
|
|
||||||
@field_validator("api_keys", mode="before")
|
@field_validator("api_keys", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def split_keys(cls, value: Optional[str]) -> List[str]:
|
def split_keys(cls, value: Optional[str]) -> List[str]:
|
||||||
@@ -35,6 +47,22 @@ class Settings(BaseSettings):
|
|||||||
return [key.strip() for key in value.split(",") if key.strip()]
|
return [key.strip() for key in value.split(",") if key.strip()]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_required(cls, values: "Settings") -> "Settings":
|
||||||
|
if values.env.lower() != "development":
|
||||||
|
missing = []
|
||||||
|
if not values.public_api_url:
|
||||||
|
missing.append("PUBLIC_API_URL")
|
||||||
|
if not values.core_api_url:
|
||||||
|
missing.append("CORE_API_URL")
|
||||||
|
if not values.api_keys:
|
||||||
|
missing.append("API_KEYS")
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing required environment variables: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
return values
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def core_configured(self) -> bool:
|
def core_configured(self) -> bool:
|
||||||
return bool(self.core_api_url)
|
return bool(self.core_api_url)
|
||||||
@@ -43,6 +71,10 @@ class Settings(BaseSettings):
|
|||||||
def agents_configured(self) -> bool:
|
def agents_configured(self) -> bool:
|
||||||
return bool(self.agents_api_url)
|
return bool(self.agents_api_url)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request_timeout_seconds(self) -> float:
|
||||||
|
return max(self.request_timeout_ms, 0) / 1000
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
|
|
||||||
class UpstreamError(HTTPException):
|
class UpstreamError(HTTPException):
|
||||||
def __init__(self, source: str, status_code: int = 502, message: str = "Upstream service error", details: Optional[Dict[str, Any]] = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
status_code: int = 502,
|
||||||
|
message: str = "Upstream service error",
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
self.source = source
|
self.source = source
|
||||||
self.details = details or {}
|
self.details = details or {}
|
||||||
super().__init__(status_code=status_code, detail=message)
|
super().__init__(status_code=status_code, detail=message)
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ def api_key_auth(
|
|||||||
api_keys: List[str] = Depends(get_api_keys),
|
api_keys: List[str] = Depends(get_api_keys),
|
||||||
):
|
):
|
||||||
if not api_keys:
|
if not api_keys:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key required")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"code": "UNAUTHORIZED", "message": "Invalid or missing API key."},
|
||||||
|
)
|
||||||
|
|
||||||
provided_key = x_api_key
|
provided_key = x_api_key
|
||||||
if not provided_key and authorization and authorization.startswith("Bearer "):
|
if not provided_key and authorization and authorization.startswith("Bearer "):
|
||||||
@@ -26,4 +29,7 @@ def api_key_auth(
|
|||||||
if provided_key in api_keys:
|
if provided_key in api_keys:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"code": "UNAUTHORIZED", "message": "Invalid or missing API key."},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from starlette import status
|
from starlette import status
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
@@ -20,6 +20,17 @@ class ErrorHandlerMiddleware(BaseHTTPMiddleware):
|
|||||||
request_id=getattr(request.state, "request_id", None),
|
request_id=getattr(request.state, "request_id", None),
|
||||||
)
|
)
|
||||||
return JSONResponse(status_code=exc.status_code, content=payload)
|
return JSONResponse(status_code=exc.status_code, content=payload)
|
||||||
|
except HTTPException as exc:
|
||||||
|
detail = exc.detail if isinstance(exc.detail, dict) else None
|
||||||
|
code = detail.get("code") if detail else "BAD_REQUEST"
|
||||||
|
message = detail.get("message") if detail else str(exc.detail)
|
||||||
|
payload = build_error_response(
|
||||||
|
code=code or "BAD_REQUEST",
|
||||||
|
message=message or "Request error",
|
||||||
|
details=detail.get("details") if detail else None,
|
||||||
|
request_id=getattr(request.state, "request_id", None),
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=exc.status_code, content=payload)
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
payload = build_error_response(
|
payload = build_error_response(
|
||||||
code="INTERNAL_ERROR",
|
code="INTERNAL_ERROR",
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ from typing import Any, Dict
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, status
|
||||||
|
|
||||||
from app.clients.upstream import UpstreamClient, get_agents_client
|
from app.clients.agents_client import build_agents_client
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.middleware.auth import api_key_auth
|
router = APIRouter()
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(api_key_auth)])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/agents/health", summary="Proxy agents health", status_code=status.HTTP_200_OK)
|
@router.get("/agents/ping", summary="Proxy agents ping", status_code=status.HTTP_200_OK)
|
||||||
async def agents_health(settings: Settings = Depends(get_settings)) -> Dict[str, Any]:
|
async def agents_ping(settings: Settings = Depends(get_settings)) -> Dict[str, Any]:
|
||||||
agents_client: UpstreamClient = get_agents_client(settings.agents_api_url)
|
agents_client = build_agents_client(settings)
|
||||||
return await agents_client.request("GET", "/health")
|
return await agents_client.request("GET", "/ping")
|
||||||
|
|||||||
@@ -4,16 +4,12 @@ from typing import Any, Dict
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, status
|
||||||
|
|
||||||
from app.clients.upstream import UpstreamClient, get_core_client
|
from app.clients.core_client import build_core_client
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.middleware.auth import api_key_auth
|
router = APIRouter()
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(api_key_auth)])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/core/health", summary="Proxy core health", status_code=status.HTTP_200_OK)
|
@router.get("/core/ping", summary="Proxy core ping", status_code=status.HTTP_200_OK)
|
||||||
async def core_health(
|
async def core_ping(settings: Settings = Depends(get_settings)) -> Dict[str, Any]:
|
||||||
settings: Settings = Depends(get_settings),
|
core_client = build_core_client(settings)
|
||||||
) -> Dict[str, Any]:
|
return await core_client.request("GET", "/ping")
|
||||||
core_client: UpstreamClient = get_core_client(settings.core_api_url)
|
|
||||||
return await core_client.request("GET", "/health")
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.middleware.auth import api_key_auth
|
||||||
from app.routes.v1 import agents, core, health
|
from app.routes.v1 import agents, core, health
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1")
|
router = APIRouter(prefix="/v1", dependencies=[Depends(api_key_auth)])
|
||||||
router.include_router(health.router)
|
router.include_router(health.router)
|
||||||
router.include_router(core.router)
|
router.include_router(core.router)
|
||||||
router.include_router(agents.router)
|
router.include_router(agents.router)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"CORE_API_URL",
|
"CORE_API_URL",
|
||||||
"AGENTS_API_URL",
|
"AGENTS_API_URL",
|
||||||
"API_KEYS",
|
"API_KEYS",
|
||||||
"RATE_LIMIT_WINDOW",
|
"LOG_LEVEL",
|
||||||
"RATE_LIMIT_MAX"
|
"REQUEST_TIMEOUT_MS"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user