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
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ codex
| OpenAI | `POST /v1/chat/completions` |
| Codex | `POST /v1/responses` |
| Gemini | `POST /v1beta/models/{model}:generateContent` |
| Project Proxy | `/{project-slug}/v1/messages` (etc.) |
| Project Proxy | `/project/{project-slug}/v1/messages` (etc.) |
| Admin API | `/api/admin/*` |
| WebSocket | `ws://localhost:9880/ws` |
| Health Check | `GET /health` |
Expand All @@ -304,8 +304,65 @@ codex
| `MAXX_ADMIN_PASSWORD` | Enable admin authentication with JWT. Default username: `admin`, password: the value of this variable |
| `MAXX_DSN` | Database connection string |
| `MAXX_DATA_DIR` | Custom data directory path |
| `MAXX_DISABLE_UI` | Headless mode: when truthy (`1`/`true`/`yes`/`on`), do not serve the web UI — only the API and proxy endpoints are exposed. Equivalent to the `-no-ui` flag (the flag takes precedence when set). Project proxy routes (`/project/{slug}/...`) remain available. |
| `MAXX_CORS_ALLOW_ORIGINS` | Comma-separated list of allowed origins (or `*`) for cross-origin requests. Enables a separately-hosted frontend to point at this backend; unset disables CORS (same-origin only). |
| `MAXX_ROUTING_SEED_SALT` | Optional shared secret for the `weighted_random` routing strategy. If unset, each process generates its own random salt — anti-grinding still holds and Redis sticky bindings still converge after the first successful request, but the pre-sticky first-pick order for the same `(token, session)` can differ across instances. Set the **same value on every instance** when you need consistent first-pick behavior in multi-instance deployments. |

### Headless Mode (API-only, no Web UI)

Run maxx as a pure API gateway without serving the admin Web UI — useful for
server/production deployments where you configure everything through the Admin
API and want a smaller attack surface.

Enable it with **either** the `-no-ui` flag **or** the `MAXX_DISABLE_UI`
environment variable (the flag wins if both are set):

```bash
# Flag (local build)
maxx -no-ui

# Env var (Docker / compose)
docker run -e MAXX_DISABLE_UI=true -p 9880:9880 ghcr.io/awsl-project/maxx
```

In headless mode:

- `/` and all web UI routes return `404` (no static files are served).
- The API (`/api/admin/*`), proxy endpoints (`/v1/messages`, `/v1/chat/completions`, …), project proxy (`/project/{slug}/...`), `/health`, and `/ws` all keep working.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- Configure providers, routes, tokens, etc. via the Admin API. Set `MAXX_ADMIN_PASSWORD` to protect it.

### Separately-hosted Frontend (point the UI at a remote backend)

You can host the Web UI on one origin (e.g. a CDN, a dev server, or a headless
maxx's sibling) and have it talk to a backend on a **different** origin.

**1. Allow the frontend's origin on the backend** via CORS (otherwise the
browser blocks cross-origin requests):

```bash
# Single origin
MAXX_CORS_ALLOW_ORIGINS=https://ui.example.com maxx

# Multiple origins (comma-separated), or "*" to allow any
MAXX_CORS_ALLOW_ORIGINS=https://ui.example.com,http://localhost:3000 maxx
```

> ⚠️ **CORS is not a substitute for authentication.** `*` lets *any* website read
> and call your API from a browser, including the admin API. Only use `*` for
> trusted/local setups, and always set `MAXX_ADMIN_PASSWORD` so the admin API
> requires a token. Prefer listing explicit origins over `*`. maxx logs a warning
> at startup if `*` is combined with an unauthenticated admin API.

**2. Point the UI at the backend.** Open the Web UI and either:

- On the **login screen**, expand **Connection settings** and enter the backend URL (e.g. `https://api.example.com`); or
- After login, go to **Settings → Backend address**.

The value is stored in the browser (`localStorage`), so each user/browser can
target a different backend. Leave it empty to use the same origin that served
the page (the default). Build-time default: set `VITE_BACKEND_URL` when building
the frontend.

### System Settings

Configurable via Admin UI:
Expand Down
47 changes: 46 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ codex
| OpenAI | `POST /v1/chat/completions` |
| Codex | `POST /v1/responses` |
| Gemini | `POST /v1beta/models/{model}:generateContent` |
| 项目代理 | `/{project-slug}/v1/messages` (等) |
| 项目代理 | `/project/{project-slug}/v1/messages` (等) |
| 管理 API | `/api/admin/*` |
| WebSocket | `ws://localhost:9880/ws` |
| 健康检查 | `GET /health` |
Expand All @@ -302,8 +302,53 @@ codex
| `MAXX_ADMIN_PASSWORD` | 启用管理员 JWT 认证。默认用户名:`admin`,密码为该变量的值 |
| `MAXX_DSN` | 数据库连接字符串 |
| `MAXX_DATA_DIR` | 自定义数据目录路径 |
| `MAXX_DISABLE_UI` | 无前端模式:取值为真(`1`/`true`/`yes`/`on`)时不再提供 Web UI,仅暴露 API 与代理接口。等价于 `-no-ui` 命令行参数(同时设置时以 flag 为准)。项目代理路由(`/project/{slug}/...`)仍然可用 |
| `MAXX_CORS_ALLOW_ORIGINS` | 允许跨源访问的来源列表(逗号分隔,或 `*`)。用于让单独托管的前端连接此后端;未设置时关闭 CORS(仅同源) |
| `MAXX_ROUTING_SEED_SALT` | 可选共享密钥,用于 `weighted_random` 路由策略。未设置时每个进程会生成自己的随机盐——防 SessionID 枚举仍然成立,Redis sticky 绑定也会在首次成功后跨实例收敛;但相同 `(token, session)` 在 sticky 写入前的首选顺序在各实例间可能不一致。**多实例部署且需要一致首选顺序**时,请在所有实例上设置相同的值 |

### 无前端模式(仅 API,不提供 Web UI)

把 maxx 作为纯 API 网关运行,不再提供管理 Web UI——适合服务端/生产部署:所有配置都通过 Admin API 完成,同时减小攻击面。

用 `-no-ui` 命令行参数**或** `MAXX_DISABLE_UI` 环境变量开启(两者同时设置时以 flag 为准):

```bash
# 命令行参数(本地构建)
maxx -no-ui

# 环境变量(Docker / compose)
docker run -e MAXX_DISABLE_UI=true -p 9880:9880 ghcr.io/awsl-project/maxx
```

无前端模式下:

- `/` 及所有 Web UI 路由返回 `404`(不提供静态文件)。
- API(`/api/admin/*`)、代理接口(`/v1/messages`、`/v1/chat/completions` 等)、项目代理(`/project/{slug}/...`)、`/health`、`/ws` 均照常工作。
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 通过 Admin API 配置 provider、路由、token 等。建议设置 `MAXX_ADMIN_PASSWORD` 保护接口。

### 前端单独托管(让 UI 连接远程后端)

可以把 Web UI 托管在一个来源(如 CDN、开发服务器),让它连接**另一个来源**上的后端。

**1. 在后端放行前端来源**(CORS,否则浏览器会拦截跨源请求):

```bash
# 单个来源
MAXX_CORS_ALLOW_ORIGINS=https://ui.example.com maxx

# 多个来源(逗号分隔),或用 "*" 放行任意来源
MAXX_CORS_ALLOW_ORIGINS=https://ui.example.com,http://localhost:3000 maxx
```

> ⚠️ **CORS 不能替代鉴权。** `*` 会让*任意*网站都能从浏览器读取并调用你的 API(包括管理 API)。只在受信/本地环境用 `*`,并务必设置 `MAXX_ADMIN_PASSWORD` 让管理 API 需要 token。尽量列举明确来源而非 `*`。当 `*` 与未鉴权的管理 API 同时出现时,maxx 启动时会打印告警。

**2. 让 UI 指向后端。** 打开 Web UI,二选一:

- 在**登录页**展开 **连接设置**,填入后端地址(如 `https://api.example.com`);或
- 登录后进入 **设置 → 后端地址**。

该值保存在浏览器(`localStorage`),所以不同用户/浏览器可以连接不同后端。留空则使用提供页面的同源地址(默认)。构建期默认值:构建前端时设置 `VITE_BACKEND_URL`。

### 系统设置

通过管理界面配置:
Expand Down
67 changes: 58 additions & 9 deletions cmd/maxx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import (

"github.com/awsl-project/maxx/internal/adapter/client"
"github.com/awsl-project/maxx/internal/adapter/provider/bedrock"
_ "github.com/awsl-project/maxx/internal/adapter/provider/claude" // Register claude adapter
_ "github.com/awsl-project/maxx/internal/adapter/provider/custom" // Register custom adapter
_ "github.com/awsl-project/maxx/internal/adapter/provider/kiro" // Register kiro adapter
_ "github.com/awsl-project/maxx/internal/adapter/provider/claude" // Register claude adapter
_ "github.com/awsl-project/maxx/internal/adapter/provider/custom" // Register custom adapter
_ "github.com/awsl-project/maxx/internal/adapter/provider/kiro" // Register kiro adapter
"github.com/awsl-project/maxx/internal/converter"
"github.com/awsl-project/maxx/internal/cooldown"
"github.com/awsl-project/maxx/internal/core"
Expand Down Expand Up @@ -51,13 +51,43 @@ func generateInstanceID() string {
return fmt.Sprintf("%s-%d", hostname, time.Now().UnixNano())
}

// flagPassed reports whether the named flag was explicitly set on the command
// line, so an explicit flag can take precedence over an environment variable.
func flagPassed(name string) bool {
found := false
flag.Visit(func(f *flag.Flag) {
if f.Name == name {
found = true
}
})
return found
}

// isTruthyEnv interprets common truthy spellings of a boolean env var.
func isTruthyEnv(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}

func main() {
// Parse flags
addr := flag.String("addr", ":9880", "Server address")
dataDir := flag.String("data", "", "Data directory for database and logs (default: ~/.config/maxx)")
showVersion := flag.Bool("version", false, "Show version information and exit")
noUI := flag.Bool("no-ui", false, "Headless mode: do not serve the web UI, expose API/proxy only (env: MAXX_DISABLE_UI)")
flag.Parse()

// Headless mode: an explicit -no-ui flag wins; otherwise fall back to the
// MAXX_DISABLE_UI env var (truthy = 1/true/yes/on) for container deployments.
disableUI := *noUI
if !flagPassed("no-ui") {
disableUI = isTruthyEnv(os.Getenv("MAXX_DISABLE_UI"))
}

// Show version and exit if requested
if *showVersion {
fmt.Println("maxx", version.Full())
Expand Down Expand Up @@ -525,13 +555,32 @@ func main() {
// WebSocket endpoint
mux.HandleFunc("/ws", wsHub.HandleWebSocket)

// Serve static files (Web UI) with project proxy support - must be last (default route)
staticHandler := handler.NewStaticHandler()
combinedHandler := handler.NewCombinedHandler(projectProxyHandler, staticHandler)
mux.Handle("/", combinedHandler)
// Default route ("/"). In headless mode we skip the web UI entirely and
// only keep the project-prefixed proxy (/project/{slug}/...); everything
// else 404s. Otherwise serve the web UI with project proxy support.
if disableUI {
mux.Handle("/", projectProxyHandler)
log.Printf("Web UI: disabled (headless mode)")
} else {
staticHandler := handler.NewStaticHandler()
combinedHandler := handler.NewCombinedHandler(projectProxyHandler, staticHandler)
mux.Handle("/", combinedHandler)
}

// Wrap with logging middleware
loggedMux := handler.LoggingMiddleware(mux)
// Wrap with logging middleware, then CORS (outermost so preflight responses
// also carry the headers). CORS is a no-op unless MAXX_CORS_ALLOW_ORIGINS
// is set — this lets a separately-hosted frontend point at this backend.
corsConfig := handler.ParseCORSOrigins(os.Getenv("MAXX_CORS_ALLOW_ORIGINS"))
if corsConfig.Enabled() {
log.Printf("CORS: allowing origins %v", corsConfig.AllowOrigins)
// A wildcard origin makes every route — including the admin API —
// readable from any website. CORS is not a substitute for auth, so warn
// loudly when "*" is combined with an unauthenticated admin API.
if corsConfig.HasWildcard() && os.Getenv("MAXX_ADMIN_PASSWORD") == "" {
log.Printf("WARNING: MAXX_CORS_ALLOW_ORIGINS=* with no MAXX_ADMIN_PASSWORD — any website can read/modify the admin API from a browser. Set MAXX_ADMIN_PASSWORD or list explicit trusted origins instead of '*'.")
}
}
loggedMux := handler.CORSMiddleware(corsConfig, handler.LoggingMiddleware(mux))

// Create HTTP server
server := &http.Server{
Expand Down
4 changes: 3 additions & 1 deletion internal/core/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type ServerConfig struct {
SettingRepo repository.SystemSettingRepository
ServeStatic bool
AuthMiddleware *handler.AuthMiddleware
// CORS controls cross-origin access. Zero value (no origins) disables it.
CORS handler.CORSConfig
}

// ManagedServer 可管理的服务器(支持启动/停止)
Expand Down Expand Up @@ -135,7 +137,7 @@ func (s *ManagedServer) Start(ctx context.Context) error {

s.httpServer = &http.Server{
Addr: s.config.Addr,
Handler: s.mux,
Handler: handler.CORSMiddleware(s.config.CORS, s.mux),
ErrorLog: nil,
}

Expand Down
2 changes: 2 additions & 0 deletions internal/desktop/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"time"

"github.com/awsl-project/maxx/internal/core"
"github.com/awsl-project/maxx/internal/handler"
"github.com/awsl-project/maxx/internal/version"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/runtime"
Expand Down Expand Up @@ -224,6 +225,7 @@ func (a *LauncherApp) startServerAsync() {
Components: components,
SettingRepo: dbRepos.SettingRepo,
ServeStatic: true, // 关键:启用静态文件服务
CORS: handler.ParseCORSOrigins(os.Getenv("MAXX_CORS_ALLOW_ORIGINS")),
}

server, err := core.NewManagedServer(serverConfig)
Expand Down
114 changes: 114 additions & 0 deletions internal/handler/cors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package handler

import (
"net/http"
"strings"
)

// CORSConfig holds the cross-origin policy for the HTTP server. It is populated
// from the MAXX_CORS_ALLOW_ORIGINS environment variable so that a frontend
// hosted on a different origin (e.g. a static build served from a CDN) can talk
// to this backend.
type CORSConfig struct {
// AllowOrigins is the set of permitted request origins. A single "*" entry
// allows any origin. An empty slice disables CORS entirely (same-origin
// only), which is the default.
AllowOrigins []string
}

// ParseCORSOrigins parses a comma-separated origin list (the value of
// MAXX_CORS_ALLOW_ORIGINS) into a CORSConfig. Whitespace around entries is
// trimmed and empty entries are dropped. A trailing slash is stripped from each
// entry so a configured "https://ui.example.com/" still matches the browser's
// slash-less "Origin: https://ui.example.com" header.
func ParseCORSOrigins(raw string) CORSConfig {
var origins []string
for _, part := range strings.Split(raw, ",") {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
if trimmed != "*" {
trimmed = strings.TrimRight(trimmed, "/")
}
origins = append(origins, trimmed)
}
return CORSConfig{AllowOrigins: origins}
}

// HasWildcard reports whether any configured origin is the "*" wildcard.
func (c CORSConfig) HasWildcard() bool {
for _, o := range c.AllowOrigins {
if o == "*" {
return true
}
}
return false
}

// Enabled reports whether any origins are configured. When false, CORSMiddleware
// is a no-op pass-through.
func (c CORSConfig) Enabled() bool {
return len(c.AllowOrigins) > 0
}

// allows reports whether the given request Origin is permitted.
func (c CORSConfig) allows(origin string) bool {
for _, allowed := range c.AllowOrigins {
if allowed == "*" || strings.EqualFold(allowed, origin) {
return true
}
}
return false
}

// CORSMiddleware wraps a handler with cross-origin headers driven by cfg. When
// cfg has no origins it returns next unchanged so there is zero overhead in the
// common same-origin deployment. Requests carry a Bearer token rather than
// cookies, so the origin is reflected without Allow-Credentials.
func CORSMiddleware(cfg CORSConfig, next http.Handler) http.Handler {
if !cfg.Enabled() {
return next
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Advertise that the response varies by Origin whenever one is present —
// even for disallowed origins — so a shared cache never reuses a no-CORS
// response for an allowlisted origin (or vice versa).
if origin != "" {
w.Header().Add("Vary", "Origin")
}
allowed := origin != "" && cfg.allows(origin)
isPreflight := r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != ""

if allowed {
// Reflect the concrete origin (even for "*") so the response stays
// valid if a caller later adds credentials, and so Vary is honored.
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")

reqHeaders := r.Header.Get("Access-Control-Request-Headers")
if reqHeaders == "" {
reqHeaders = "Authorization, Content-Type"
}
w.Header().Set("Access-Control-Allow-Headers", reqHeaders)
w.Header().Set("Access-Control-Max-Age", "86400")
}

// Short-circuit only valid preflights from an allowed origin. Disallowed
// origins (and non-preflight OPTIONS) fall through to normal handling so
// they get no CORS headers — the browser then blocks the cross-origin
// read — and so unknown routes/methods still surface their real status.
if isPreflight && allowed {
// The Allow-Headers value is reflected from the request, so the
// response varies on the preflight request headers/method too.
w.Header().Add("Vary", "Access-Control-Request-Method")
w.Header().Add("Vary", "Access-Control-Request-Headers")
w.WriteHeader(http.StatusNoContent)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

next.ServeHTTP(w, r)
})
}
Loading
Loading