Skip to content
Merged
22 changes: 22 additions & 0 deletions docs/src/generated/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,28 @@
"type": "typing.Optional[pathlib.Path]",
"validation": {}
},
{
"category": "WEB",
"default": null,
"description": "Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Required when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`); optional when the proxy strips it (set it anyway so openapi/docs URLs are correct). Leave unset when serving at the domain root. Normalized to a single leading slash with no trailing slash.",
"env_var": "INVOKEAI_BASE_URL",
"literal_values": [],
"name": "base_url",
"required": false,
"type": "typing.Optional[str]",
"validation": {}
},
{
"category": "WEB",
"default": "127.0.0.1",
"description": "Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set.",
"env_var": "INVOKEAI_FORWARDED_ALLOW_IPS",
"literal_values": [],
"name": "forwarded_allow_ips",
"required": false,
"type": "<class 'str'>",
"validation": {}
},
{
"category": "MISC FEATURES",
"default": false,
Expand Down
48 changes: 44 additions & 4 deletions invokeai/app/api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from fastapi_events.handlers.local import local_handler
from fastapi_events.middleware import EventHandlerASGIMiddleware
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.types import ASGIApp, Receive, Scope, Send

import invokeai.frontend.web as web_dir
from invokeai.app.api.dependencies import ApiDependencies
Expand Down Expand Up @@ -143,6 +144,41 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
return response


class SubPathASGIMiddleware:
"""Make the app work behind a reverse proxy that serves it under a sub-path (e.g. `/invoke`).

Handles both common proxy styles:
- The proxy STRIPS the sub-path: the incoming path is already `/api/...`. We only advertise the
public prefix via `root_path` so generated openapi/docs URLs include it.
- The proxy PRESERVES the sub-path: the incoming path is `/invoke/api/...`. We strip the prefix
so routing matches, and advertise it via `root_path`.

Note: we deliberately do NOT use uvicorn's `root_path`, because uvicorn PREPENDS it to the request
path, which breaks the preserve-style proxy (the prefix would appear twice). Doing the path
rewrite here covers both styles.
"""

def __init__(self, app: ASGIApp, base_path: str) -> None:
self.app = app
self.base_path = base_path

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] in ("http", "websocket"):
scope = dict(scope)
path: str = scope.get("path", "")
# Preserve-style proxy: strip the prefix from the path so routing matches.
if path == self.base_path or path.startswith(self.base_path + "/"):
scope["path"] = path[len(self.base_path) :] or "/"
Comment thread
lstein marked this conversation as resolved.
Outdated
raw_path = scope.get("raw_path")
if raw_path is not None:
base_bytes = self.base_path.encode("latin-1")
if raw_path.startswith(base_bytes):
scope["raw_path"] = raw_path[len(base_bytes) :] or b"/"
# Advertise the public prefix for both styles (openapi servers, /docs, redirects).
scope["root_path"] = self.base_path
await self.app(scope, receive, send)


# Add the middleware
app.add_middleware(RedirectRootWithQueryStringMiddleware)
app.add_middleware(SlidingWindowTokenMiddleware)
Expand Down Expand Up @@ -193,18 +229,22 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):


@app.get("/docs", include_in_schema=False)
def overridden_swagger() -> HTMLResponse:
def overridden_swagger(request: Request) -> HTMLResponse:
# Prefix with the proxy root_path (if any) so the schema resolves under a sub-path deployment.
root_path = request.scope.get("root_path", "")
return get_swagger_ui_html(
openapi_url=app.openapi_url, # type: ignore [arg-type] # this is always a string
openapi_url=f"{root_path}{app.openapi_url}", # type: ignore [arg-type] # this is always a string
title=f"{app.title} - Swagger UI",
swagger_favicon_url="static/docs/invoke-favicon-docs.svg",
)


@app.get("/redoc", include_in_schema=False)
def overridden_redoc() -> HTMLResponse:
def overridden_redoc(request: Request) -> HTMLResponse:
# Prefix with the proxy root_path (if any) so the schema resolves under a sub-path deployment.
root_path = request.scope.get("root_path", "")
return get_redoc_html(
openapi_url=app.openapi_url, # type: ignore [arg-type] # this is always a string
openapi_url=f"{root_path}{app.openapi_url}", # type: ignore [arg-type] # this is always a string
title=f"{app.title} - Redoc",
redoc_favicon_url="static/docs/invoke-favicon-docs.svg",
)
Expand Down
20 changes: 19 additions & 1 deletion invokeai/app/run_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,33 @@ def run_app() -> None:
# imported.
enable_dev_reload(custom_nodes_path=app_config.custom_nodes_path)

# When running behind a reverse proxy that serves the app under a sub-path (e.g. `/invoke`),
# wrap the app so route matching and openapi/docs work for both proxy styles (strip & preserve).
# Also honor X-Forwarded-* headers. Only enabled when `base_url` is set, so default installations
# are unaffected.
from typing import Any
Comment thread
lstein marked this conversation as resolved.
Outdated

from invokeai.app.api_app import SubPathASGIMiddleware

asgi_app: Any = app
proxy_kwargs = {}
if app_config.base_url:
asgi_app = SubPathASGIMiddleware(app, app_config.base_url)
proxy_kwargs = {
"proxy_headers": True,
"forwarded_allow_ips": app_config.forwarded_allow_ips,
}

# Start the server.
config = uvicorn.Config(
app=app,
app=asgi_app,
host=app_config.host,
port=app_config.port,
loop="asyncio",
log_level=app_config.log_level_network,
ssl_certfile=app_config.ssl_certfile,
ssl_keyfile=app_config.ssl_keyfile,
**proxy_kwargs,
)
server = uvicorn.Server(config)

Expand Down
18 changes: 18 additions & 0 deletions invokeai/app/services/config/config_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ class InvokeAIAppConfig(BaseSettings):
external_openai_base_url: Base URL override for OpenAI image generation.
external_seedream_api_key: API key for Seedream image generation.
external_seedream_base_url: Base URL override for Seedream image generation.
base_url: Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Set only when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`). Leave unset when the proxy strips the sub-path or when serving at the domain root.
forwarded_allow_ips: Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set.
"""

_root: Optional[Path] = PrivateAttr(default=None)
Expand All @@ -155,6 +157,8 @@ class InvokeAIAppConfig(BaseSettings):
allow_headers: list[str] = Field(default=["*"], description="Headers allowed for CORS.")
ssl_certfile: Optional[Path] = Field(default=None, description="SSL certificate file for HTTPS. See https://www.uvicorn.dev/settings/#https.")
ssl_keyfile: Optional[Path] = Field(default=None, description="SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.")
base_url: Optional[str] = Field(default=None, description="Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Required when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`); optional when the proxy strips it (set it anyway so openapi/docs URLs are correct). Leave unset when serving at the domain root. Normalized to a single leading slash with no trailing slash.")
forwarded_allow_ips: str = Field(default="127.0.0.1", description="Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set.")

# MISC FEATURES
log_tokenization: bool = Field(default=False, description="Enable logging of parsed prompt tokens.")
Expand Down Expand Up @@ -257,6 +261,20 @@ class InvokeAIAppConfig(BaseSettings):

model_config = SettingsConfigDict(env_prefix="INVOKEAI_", env_ignore_empty=True)

@field_validator("base_url")
@classmethod
def validate_base_url(cls, v: Optional[str]) -> Optional[str]:
Comment thread
lstein marked this conversation as resolved.
"""Normalize the reverse-proxy base path: ensure a single leading slash, no trailing slash.

Empty values and a bare `/` normalize to `None` (feature disabled).
"""
if v is None:
return None
v = v.strip().strip("/")
if not v:
return None
return f"/{v}"

def update_config(self, config: dict[str, Any] | InvokeAIAppConfig, clobber: bool = True) -> None:
"""Updates the config, overwriting existing values.

Expand Down
Loading
Loading