Skip to content
Open
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
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 "/"
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

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]:
"""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