Skip to content

Commit e16c21d

Browse files
committed
Add tests for proxy and router digest functionality
- Implement comprehensive tests for the ingress proxy, validating request handling and response generation. - Introduce usage observer tests to ensure accurate tracking of token usage across various scenarios. - Create a new digest module in the auto router to manage conversation summaries, enforcing size limits and maintaining relevant context. - Add tests for the digest functionality to verify that it correctly summarizes user interactions and adheres to defined byte limits.
1 parent 9f0bf7d commit e16c21d

70 files changed

Lines changed: 3281 additions & 426 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ docker run -d \
1717

1818
打开 <http://127.0.0.1:8080/admin/>,按页面提示设置管理员密码。之后在管理页面添加提供商和模型、配置模型组,并创建推理密钥。上游 API 密钥与管理员密码在管理页面配置。
1919

20+
首次设置需要证明你是部署者:尚未设置密码时,服务启动会在日志中打印一次性初始化令牌 `bootstrap_token`。从非本机连接(包括经 Docker 端口映射或反向代理访问)设置密码时,页面会要求填写该令牌;设置成功后令牌立即失效。获取方式:
21+
22+
```sh
23+
docker logs auto-router 2>&1 | grep bootstrap_token
24+
```
25+
26+
直接在本机运行二进制并通过 loopback 访问时无需令牌。
27+
2028
数据保存在 Docker 命名卷 `auto-router-data` 中,容器重建或升级不会删除该卷。升级到最新镜像:
2129

2230
```sh
@@ -28,6 +36,22 @@ docker rm auto-router
2836

2937
不要删除数据卷;删除 `auto-router-data` 会同时删除数据库及管理配置。默认端口只绑定本机。若需对外提供服务,请先配置 TLS 反向代理,不要直接将管理页面暴露到公网。
3038

39+
### 反向代理与启动参数
40+
41+
以下参数在启动时通过命令行设置(Docker 中直接追加在镜像名之后,镜像入口已包含 `-listen 0.0.0.0:8080`):
42+
43+
| 参数 | 默认 | 说明 |
44+
| --- | --- | --- |
45+
| `-trusted-proxies` | 空 | 逗号分隔的反向代理 CIDR 或 IP。只有来自这些地址的 `X-Forwarded-For` / `X-Forwarded-Proto` 才会被采信;为空时一律以 TCP 对端地址为客户端地址。多级代理时从右向左跳过可信跳,取第一个不可信地址。`Forwarded`、`X-Forwarded-*`、`X-Real-IP` 永远不会转发给上游提供商。 |
46+
| `-secure-cookies` | `auto` | 管理会话 Cookie 的 `Secure` 属性:`auto` 在 TLS 直连或可信代理声明 `https` 时设置;`always` / `never` 强制。 |
47+
| `-upstream-body-timeout` | `10m` | 非流式上游响应在收到响应头后,两次读取之间允许的最长停顿;`0` 关闭。 |
48+
| `-stream-idle-timeout` | `0` | 流式上游响应两次读取之间允许的最长停顿;默认关闭,避免截断长时间推理。 |
49+
| `-max-buffered-response-bytes` | 32 MiB | 需要整体缓冲以转换协议(Anthropic/Gemini 非流式)的上游响应上限;超出返回 502 `upstream_response_too_large`。 |
50+
51+
例如 Nginx 与服务同机、对外提供 HTTPS:`-trusted-proxies 127.0.0.1`。Docker 网络内的代理请填写其所在网段,例如 `-trusted-proxies 172.16.0.0/12`。
52+
53+
管理登录限流按客户端地址分别计数(每地址每分钟 10 次失败),另有全局每分钟 100 次失败的兜底上限。
54+
3155
## 自动路由
3256

3357
将请求中的 `model` 设为 `auto`,即可请求自动路由。启用 Jev 时,它只在当前有合格成员的 `simple`、`medium`、`complex` 组中选择任务组;本地策略先按请求特征、模型能力、上下文窗口及硬限制过滤成员,再严格按组内配置顺序选择主模型。不会跨组重试,组内也不做评分排序。

‎cmd/auto-router/main.go‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/nextroad-dev/Auto-Router/internal/logging"
2323
"github.com/nextroad-dev/Auto-Router/internal/models"
2424
"github.com/nextroad-dev/Auto-Router/internal/modelsdev"
25+
"github.com/nextroad-dev/Auto-Router/internal/netx"
2526
"github.com/nextroad-dev/Auto-Router/internal/providers"
2627
"github.com/nextroad-dev/Auto-Router/internal/providers/direct"
2728
"github.com/nextroad-dev/Auto-Router/internal/proxy"
@@ -87,6 +88,11 @@ func run(ctx context.Context, args []string, output io.Writer) error {
8788
flags := flag.NewFlagSet("auto-router", flag.ContinueOnError)
8889
flags.SetOutput(output)
8990
listenAddress := flags.String("listen", "", "override the default HTTP listen address")
91+
trustedProxies := flags.String("trusted-proxies", "", "comma-separated reverse proxy CIDRs or IPs whose X-Forwarded-For/X-Forwarded-Proto are trusted (default: none)")
92+
secureCookies := flags.String("secure-cookies", "", "session cookie Secure attribute: auto (HTTPS or trusted proxy https), always or never (default auto)")
93+
upstreamBodyTimeout := flags.Duration("upstream-body-timeout", -1, "maximum stall between reads of a non-stream upstream body; 0 disables (default 10m)")
94+
streamIdleTimeout := flags.Duration("stream-idle-timeout", -1, "maximum stall between reads of a streaming upstream body; 0 disables (default 0)")
95+
maxBufferedResponseBytes := flags.Int64("max-buffered-response-bytes", 0, "maximum converted (buffered) upstream response size in bytes (default 32 MiB)")
9096
configPath := flags.String("config", "", "deprecated; ignored")
9197
syncModels := flags.Bool("sync-models", false, "deprecated; use the WebUI to synchronize models.dev")
9298
recoverAdmin := flags.Bool("recover-admin", false, "offline recovery: rotate administrator credentials while holding the fixed listener port")
@@ -147,6 +153,24 @@ func run(ctx context.Context, args []string, output io.Writer) error {
147153
if *listenAddress != "" {
148154
cfg.HTTP.Address = *listenAddress
149155
}
156+
if *trustedProxies != "" {
157+
cfg.HTTP.TrustedProxies = strings.Split(*trustedProxies, ",")
158+
}
159+
if *secureCookies != "" {
160+
cfg.Admin.SecureCookies = *secureCookies
161+
}
162+
if *upstreamBodyTimeout >= 0 {
163+
cfg.HTTP.UpstreamBodyTimeout = config.Duration(*upstreamBodyTimeout)
164+
}
165+
if *streamIdleTimeout >= 0 {
166+
cfg.HTTP.StreamIdleTimeout = config.Duration(*streamIdleTimeout)
167+
}
168+
if *maxBufferedResponseBytes != 0 {
169+
cfg.HTTP.MaxBufferedResponseBytes = *maxBufferedResponseBytes
170+
}
171+
if err := cfg.Validate(); err != nil {
172+
return err
173+
}
150174
sources := config.Sources{}
151175
if *recoverAdmin {
152176
return runAdminRecovery(ctx, cfg, output)
@@ -236,6 +260,11 @@ func run(ctx context.Context, args []string, output io.Writer) error {
236260
// Build the direct provider executor set from the catalog already loaded from
237261
// SQLite. Providers without a base_url remain routable in the catalog but
238262
// return provider_not_configured when selected.
263+
globalExecutorRegistry.Configure(direct.Options{
264+
StreamIdleTimeout: time.Duration(cfg.HTTP.StreamIdleTimeout),
265+
BodyTimeout: time.Duration(cfg.HTTP.UpstreamBodyTimeout),
266+
MaxBufferedResponseBytes: cfg.HTTP.MaxBufferedResponseBytes,
267+
})
239268
globalExecutorRegistry.Build(store.Load())
240269
executor := &providerRouter{registry: globalExecutorRegistry}
241270
defer func() {
@@ -277,11 +306,15 @@ func run(ctx context.Context, args []string, output io.Writer) error {
277306
if err != nil {
278307
return err
279308
}
309+
bootstrap, err := newBootstrapToken(ctx, db, logger)
310+
if err != nil {
311+
return err
312+
}
280313
syncGate := newRegistrySyncGate()
281314
server := newHTTPServer(serverAssembly{
282315
Config: cfg, Store: store, DB: db, Checker: db, Executor: executor, Jev: jevClient,
283316
Recorder: recorder, Settings: settingsStore, Authenticator: authenticator, Sessions: sessions,
284-
Groups: &groupStore, ResolveModelMetadata: modelMetadataResolver.Resolve,
317+
Groups: &groupStore, ResolveModelMetadata: modelMetadataResolver.Resolve, Bootstrap: bootstrap,
285318
SyncRegistry: func(syncCtx context.Context) (any, error) {
286319
return syncGate.Do(syncCtx, func(syncCtx context.Context) (any, error) {
287320
current := settingsStore.Config()
@@ -309,6 +342,27 @@ func run(ctx context.Context, args []string, output io.Writer) error {
309342
return serve(ctx, server, listener, time.Duration(cfg.HTTP.ShutdownTimeout), logger)
310343
}
311344

345+
// newBootstrapToken issues the one-time first-run setup token when no owner
346+
// password exists yet, and prints it to the process log: reading the log is the
347+
// proof a remote operator gives that they control this deployment. A local
348+
// browser does not need it.
349+
func newBootstrapToken(ctx context.Context, db *sql.DB, logger *slog.Logger) (*api.BootstrapToken, error) {
350+
set, err := storage.PasswordSet(ctx, db)
351+
if err != nil {
352+
return nil, fmt.Errorf("read password setup state: %w", err)
353+
}
354+
if set {
355+
return nil, nil
356+
}
357+
token, value, err := api.NewBootstrapToken()
358+
if err != nil {
359+
return nil, fmt.Errorf("generate bootstrap token: %w", err)
360+
}
361+
logger.Warn("the management password is not set; remote first-run setup requires this one-time bootstrap token",
362+
"bootstrap_token", value, "setup", "/admin/")
363+
return token, nil
364+
}
365+
312366
// providerRouter selects the direct executor for the provider chosen by the
313367
// catalog/router. It does not execute requests itself; the registry lookup and
314368
// the executor call are deliberately one small boundary.
@@ -325,7 +379,9 @@ func (r *providerRouter) Do(ctx context.Context, request *providers.Request) (*p
325379
if r == nil || r.registry == nil {
326380
return nil, fmt.Errorf("%w: no direct provider registry", providers.ErrProviderNotConfigured)
327381
}
328-
executor, ok := r.registry.Get(request.ProviderKey)
382+
// The executor comes from the catalog generation the request was routed
383+
// against, never from whatever is newest when the call is made.
384+
executor, ok := r.registry.GetGeneration(request.CatalogGeneration, request.ProviderKey)
329385
if !ok {
330386
return nil, fmt.Errorf("%w: provider %q has no configured executor", providers.ErrProviderNotConfigured, request.ProviderKey)
331387
}
@@ -403,8 +459,9 @@ func reloadCatalog(ctx context.Context, db *sql.DB, logger *slog.Logger, store *
403459
// to publish an empty one.
404460
return err
405461
}
406-
published := store.Swap(catalog)
407-
globalExecutorRegistry.Build(published)
462+
// The executor set is built inside the publication, before the catalog is
463+
// visible, so catalog and executors form one request-consistent generation.
464+
published := store.SwapWith(catalog, globalExecutorRegistry.Build)
408465
logCatalogSnapshot(logger, published, true)
409466
return nil
410467
}
@@ -551,6 +608,9 @@ type serverAssembly struct {
551608
SyncRegistry func(context.Context) (any, error)
552609
ResolveModelMetadata func(context.Context, string, string) (modelsdev.MetadataMatch, bool, error)
553610
Groups *atomic.Pointer[storage.ModelGroups]
611+
// Bootstrap authorizes first-run setup from a non-local client. Nil when the
612+
// owner password already exists.
613+
Bootstrap *api.BootstrapToken
554614
}
555615

556616
// newHTTPServer builds the HTTP handler. The debug endpoints are mounted only when
@@ -587,6 +647,12 @@ func newHTTPServer(assembly serverAssembly, logger *slog.Logger) *http.Server {
587647
routeDebugHandler := gateDebugHandler(api.NewDebugRoute(api.RouteDebugOptions{
588648
Router: autoRouter, MaxRequestBytes: cfg.HTTP.MaxRequestBytes,
589649
}), settingsStore, false)
650+
// The configuration was validated at startup, so the list parses.
651+
trustedProxies, err := netx.ParseTrustedProxies(cfg.HTTP.TrustedProxies)
652+
if err != nil {
653+
logger.Error("http.trusted_proxies could not be parsed; no proxy is trusted", "error", err)
654+
trustedProxies = nil
655+
}
590656
authenticator := assembly.Authenticator
591657
if authenticator == nil {
592658
authenticator, _ = api.NewDatabaseAuthenticator(nil, logger)
@@ -604,6 +670,7 @@ func newHTTPServer(assembly serverAssembly, logger *slog.Logger) *http.Server {
604670
Recorder: recorder,
605671
Live: liveProxyValues{store: settingsStore, groups: assembly.Groups},
606672
OverrideAuthorizer: api.OverrideAuthorizer(authenticator),
673+
TrustedProxies: trustedProxies,
607674
})
608675
var adminHandler http.Handler
609676
// sessionStore is created only when the management surface is mounted, and it is
@@ -648,6 +715,9 @@ func newHTTPServer(assembly serverAssembly, logger *slog.Logger) *http.Server {
648715
},
649716
SyncRegistry: assembly.SyncRegistry,
650717
ResolveModelMetadata: assembly.ResolveModelMetadata,
718+
TrustedProxies: trustedProxies,
719+
SecureCookies: cfg.Admin.SecureCookies,
720+
Bootstrap: assembly.Bootstrap,
651721
})
652722
sessionStore = sessions
653723
logger.Info("the Admin API is mounted",

‎docs/openapi.yaml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,9 +497,12 @@ paths:
497497
application/json:
498498
schema:
499499
type: object
500-
required: [password_set]
500+
required: [password_set, bootstrap_token_required]
501501
properties:
502502
password_set: {type: boolean}
503+
bootstrap_token_required:
504+
type: boolean
505+
description: True when the password is not set and this client is not local, so setup must present the one-time bootstrap token printed in the service log.
503506
'503':
504507
$ref: '#/components/responses/AdminError'
505508

@@ -508,7 +511,7 @@ paths:
508511
tags: [Setup]
509512
summary: Set the initial owner password
510513
operationId: setInitialPassword
511-
description: Available only until the initial password is set. Browser requests must be same-origin; requests identified as cross-site are rejected. Password length is 12–1024 bytes.
514+
description: Available only until the initial password is set. Browser requests must be same-origin; requests identified as cross-site are rejected. A client that is not on the local machine (a loopback peer without forwarding headers, or a loopback client reported by a trusted proxy) must also present the one-time bootstrap token printed in the service log at startup; it is invalidated by a successful setup. Password length is 12–1024 bytes.
512515
security: []
513516
requestBody:
514517
required: true
@@ -519,6 +522,7 @@ paths:
519522
required: [password]
520523
properties:
521524
password: {type: string, minLength: 12, maxLength: 1024, writeOnly: true}
525+
bootstrap_token: {type: string, writeOnly: true, description: Required from a non-local client.}
522526
responses:
523527
'201':
524528
description: Initial password set.
@@ -2423,6 +2427,7 @@ components:
24232427
- truncated_evidence
24242428
- upstream_unavailable
24252429
- upstream_timeout
2430+
- upstream_response_too_large
24262431
- client_closed_request
24272432
- internal_error
24282433
- invalid_api_key
@@ -2447,6 +2452,7 @@ components:
24472452
- invalid_api_key
24482453
- insufficient_scope
24492454
- cross_site_request
2455+
- bootstrap_token_required
24502456
- too_many_attempts
24512457
- request_too_large
24522458
- unknown_model

‎internal/api/admin.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/nextroad-dev/Auto-Router/internal/logging"
2020
"github.com/nextroad-dev/Auto-Router/internal/models"
2121
"github.com/nextroad-dev/Auto-Router/internal/modelsdev"
22+
"github.com/nextroad-dev/Auto-Router/internal/netx"
2223
"github.com/nextroad-dev/Auto-Router/internal/proxy"
2324
"github.com/nextroad-dev/Auto-Router/internal/settings"
2425
"github.com/nextroad-dev/Auto-Router/internal/storage"
@@ -103,6 +104,15 @@ type AdminOptions struct {
103104
StartedAt time.Time
104105
// Logger receives one INFO line per accepted write. Nil disables it.
105106
Logger *slog.Logger
107+
// TrustedProxies decides whose forwarding headers are believed for the login
108+
// throttle's source address, the local-client check of first-run setup and the
109+
// browser-facing scheme. Nil trusts nobody.
110+
TrustedProxies *netx.TrustedProxies
111+
// SecureCookies is admin.secure_cookies: auto, always or never. Empty is auto.
112+
SecureCookies string
113+
// Bootstrap authorizes first-run setup from a non-local client. Nil means no
114+
// token was issued, so only a local client can set the initial password.
115+
Bootstrap *BootstrapToken
106116
}
107117

108118
// adminHandler serves /admin/v1.
@@ -121,6 +131,9 @@ type adminHandler struct {
121131
maxPageSize int
122132
startedAt time.Time
123133
logger *slog.Logger
134+
trustedProxies *netx.TrustedProxies
135+
secureCookies string
136+
bootstrap *BootstrapToken
124137
// authenticator validates a login submission through the same constant-time
125138
// loop the header path uses. It is nil when the surface was built without one.
126139
authenticator *Authenticator
@@ -150,6 +163,9 @@ func NewAdmin(options AdminOptions) http.Handler {
150163
logger: options.Logger,
151164
authenticator: options.Authenticator,
152165
sessions: options.Sessions,
166+
trustedProxies: options.TrustedProxies,
167+
secureCookies: options.SecureCookies,
168+
bootstrap: options.Bootstrap,
153169
}
154170
if handler.authenticator != nil && handler.authenticator.Enabled() && handler.sessions == nil {
155171
// The management surface accepts a session only when it can validate one. A

‎internal/api/admin_password.go‎

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ func (h *adminHandler) handleSetupStatus(w http.ResponseWriter, r *http.Request)
1818
storageFailure(w, h, "read password setup state", err)
1919
return
2020
}
21+
// bootstrap_token_required tells the setup page whether it must ask for the
22+
// token printed in the process log. It is only meaningful before setup.
2123
writeAdminJSON(w, http.StatusOK, struct {
22-
PasswordSet bool `json:"password_set"`
23-
}{set})
24+
PasswordSet bool `json:"password_set"`
25+
BootstrapTokenRequired bool `json:"bootstrap_token_required"`
26+
}{set, !set && !h.trustedProxies.IsLocalRequest(r)})
2427
}
2528

2629
type setPasswordRequest struct {
27-
Password *string `json:"password"`
30+
Password *string `json:"password"`
31+
BootstrapToken *string `json:"bootstrap_token"`
2832
}
2933

3034
// handleSetupPassword sets the owner's password exactly once. Registration is
@@ -38,18 +42,34 @@ func (h *adminHandler) handleSetupPassword(w http.ResponseWriter, r *http.Reques
3842
writeAdminError(w, http.StatusForbidden, "cross_site_request", "the request did not originate from this management surface", "")
3943
return
4044
}
41-
if h.sessions != nil && !h.sessions.AllowLogin() {
45+
source := h.trustedProxies.ClientIP(r)
46+
if h.sessions != nil && !h.sessions.AllowLogin(source) {
4247
writeAdminError(w, http.StatusTooManyRequests, "too_many_attempts", "too many password setup attempts; try again later", "")
4348
return
4449
}
4550
var body setPasswordRequest
4651
if _, err := readAdminBody(r, &body); err != nil {
47-
if h.sessions != nil {
48-
h.sessions.RecordFailure()
49-
}
52+
h.sessions.RecordFailure(source)
5053
writeBodyError(w, err)
5154
return
5255
}
56+
// Before a password exists there is no credential to check, so a caller that
57+
// is not on this machine must prove it can read the process log by presenting
58+
// the one-time bootstrap token. A local caller is trusted as the operator.
59+
if !h.trustedProxies.IsLocalRequest(r) {
60+
presented := ""
61+
if body.BootstrapToken != nil {
62+
presented = *body.BootstrapToken
63+
}
64+
if !h.bootstrap.Verify(presented) {
65+
h.sessions.RecordFailure(source)
66+
if h.logger != nil {
67+
h.logger.Warn("initial password setup refused", "path", r.URL.Path, "reason", "bootstrap_token_required")
68+
}
69+
writeAdminError(w, http.StatusForbidden, "bootstrap_token_required", "initial setup from a remote client requires the bootstrap token printed in the service log", "bootstrap_token")
70+
return
71+
}
72+
}
5373
if body.Password == nil {
5474
writeAdminError(w, http.StatusBadRequest, "invalid_request", "the request body must carry a string password field", "password")
5575
return
@@ -65,9 +85,8 @@ func (h *adminHandler) handleSetupPassword(w http.ResponseWriter, r *http.Reques
6585
}
6686
return
6787
}
68-
if h.sessions != nil {
69-
h.sessions.RecordSuccess()
70-
}
88+
h.bootstrap.Consume()
89+
h.sessions.RecordSuccess(source)
7190
writeAdminJSON(w, http.StatusCreated, struct {
7291
PasswordSet bool `json:"password_set"`
7392
}{true})
@@ -117,19 +136,8 @@ func (h *adminHandler) handlePasswordChange(w http.ResponseWriter, r *http.Reque
117136
if h.sessions != nil {
118137
h.sessions.Clear()
119138
}
120-
clearSessionCookie(w)
139+
h.clearSessionCookie(w, r)
121140
writeAdminJSON(w, http.StatusOK, struct {
122141
Changed bool `json:"changed"`
123142
}{true})
124143
}
125-
126-
func clearSessionCookie(w http.ResponseWriter) {
127-
http.SetCookie(w, &http.Cookie{
128-
Name: sessionCookieName,
129-
Value: "",
130-
Path: "/admin",
131-
HttpOnly: true,
132-
SameSite: http.SameSiteStrictMode,
133-
MaxAge: -1,
134-
})
135-
}

0 commit comments

Comments
 (0)