diff --git a/.env.example b/.env.example index 0033aca..74f6e59 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,9 @@ OT_PROXY_DEFAULT_MODEL=gpt-4o-mini # 非流式 = 整段请求上限;流式 = 只约束首包,正文不限时长 OT_PROXY_TIMEOUT=300s +# /v1 请求体上限(MB):超限按客户端协议返回 413 +OT_PROXY_MAX_BODY_MB=32 + # 渠道健康检查 OT_PROXY_HEALTH_INTERVAL=60s OT_PROXY_HEALTH_FAIL_THRESHOLD=2 @@ -40,6 +43,18 @@ OT_PROXY_LOG_RAW=false # 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制) OT_RATELIMIT_USER_RPS=20 +# 认证端点限速(每分钟,0=不限制):登录是密码爆破面,注册是批量建号面 +OT_RATELIMIT_LOGIN_IP_PER_MIN=30 +OT_RATELIMIT_LOGIN_USER_PER_MIN=10 +OT_RATELIMIT_REGISTER_IP_PER_MIN=5 + +# 跨域白名单(逗号分隔 origin)。留空 = 不输出任何 CORS 头: +# 网关自托管 web/dist 时前后端同源、vite dev 走 proxy,都不需要跨域。 +# 只有把 web/ 单独部署到别的域名时才需要配。 +OT_CORS_ALLOW_ORIGINS= +# 反向代理(CIDR 或 IP,逗号分隔)。配置后 gin 才信任 X-Forwarded-For 取客户端 IP, +# 上面的认证限速依赖它;直连部署留空(此时按连接源地址计数,伪造头无效)。 +OT_SERVER_TRUSTED_PROXIES= # Passkey(WebAuthn):RPID 为域名,RPOrigin 为前端来源(需 HTTPS 或 localhost) OT_WEBAUTHN_RP_ID=localhost @@ -51,5 +66,3 @@ OT_ADMIN_USERNAME=admin OT_ADMIN_EMAIL=admin@localhost OT_ADMIN_PASSWORD=admin123 -# 记录原始请求日志 -OT_PROXY_LOG_RAW=false diff --git a/README.md b/README.md index 73d8841..ed1e574 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - **渠道体系**:按模型绑定选渠道 + 加权负载均衡;模型已有绑定时只用绑定渠道(都不可用则 503,不会把请求发给不认识它的渠道);每渠道并发信号量(满载溢出);后台健康检查(探测 `/models`,连续失败进 cooldown、恢复放回)+ 运行期健康(连续 3 次网络错误/超时/429/5xx 进 30s 冷却,成功即清零);可安全重试的失败自动故障转移(网络错误/429/5xx/超时且未写出响应头) - **上游超时**:渠道 `timeout_ms` 生效——非流式为整段请求上限,流式仅约束首包(正文不限时长);上游请求绑定客户端 context,断连即取消 - **限流/配额**(内存计数):密钥级每日请求数 / Token 数配额、用户级每秒速率(`OT_RATELIMIT_USER_RPS`);超限返回 429 +- **认证面防护**:登录按「单 IP」+「单 IP×账号」双桶滑动窗口限速、注册按 IP 限速(429 + `Retry-After`);`/v1` 请求体上限 `OT_PROXY_MAX_BODY_MB`(超限按客户端协议返回 413);CORS 改为显式 origin 白名单(默认不发跨域头);`env=production` 启动时断言 JWT secret / 主密钥 / 管理员密码不能是仓库默认值,否则拒绝启动 - **前端**:Dashboard / 管理总览骨架屏加载态 - **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码 - **API Key**:`sk-ot-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段 diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index 069319d..4760102 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -4,6 +4,7 @@ package api import ( "encoding/json" "net/http" + "strconv" "strings" "time" @@ -27,6 +28,20 @@ func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler { return &Handler{a: a, gw: gw, passkeys: pk} } +// authLimited 认证端点滑动窗口限速:超限返回 429 并带 Retry-After。 +// limit<=0 或限速器缺席时放行。 +func (h *Handler) authLimited(c *gin.Context, key string, limit int, window time.Duration) bool { + if h.a.Limit == nil || limit <= 0 { + return false + } + if h.a.Limit.AllowWindow(key, limit, window) { + return false + } + c.Header("Retry-After", strconv.Itoa(int(window.Seconds()))) + resp.Fail(c, http.StatusTooManyRequests, "尝试过于频繁,请稍后再试") + return true +} + // --------------------------------------------------------------------------- // 认证 @@ -39,6 +54,10 @@ type registerReq struct { // Register POST /api/v1/auth/register func (h *Handler) Register(c *gin.Context) { + // 先限速再解析/哈希:注册走 argon2,未限速时光靠建号请求就能把 CPU 打满 + if h.authLimited(c, "register:ip:"+c.ClientIP(), h.a.Cfg.RateLimit.RegisterIPPerMin, time.Minute) { + return + } var req registerReq if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error()) @@ -105,6 +124,14 @@ func (h *Handler) Login(c *gin.Context) { resp.Fail(c, http.StatusBadRequest, "invalid input") return } + // 两个桶:单 IP 总量挡扫库式爆破,单 IP×账号挡换 IP 定向爆破同一个账号 + ip := c.ClientIP() + if h.authLimited(c, "login:ip:"+ip, h.a.Cfg.RateLimit.LoginIPPerMin, time.Minute) { + return + } + if h.authLimited(c, "login:u:"+ip+"|"+strings.ToLower(req.Username), h.a.Cfg.RateLimit.LoginUserPerMin, time.Minute) { + return + } var u store.User err := h.a.DB.Where("username = ? OR email = ?", req.Username, strings.ToLower(req.Username)).First(&u).Error if err != nil || u.Status != store.UserStatusActive { @@ -219,15 +246,15 @@ func (h *Handler) setRefreshCookie(c *gin.Context, value string) { func (h *Handler) publicUser(u *store.User) gin.H { return gin.H{ - "id": u.ID, - "username": u.Username, - "email": u.Email, - "role": u.Role, - "balance": u.Balance, - "status": u.Status, + "id": u.ID, + "username": u.Username, + "email": u.Email, + "role": u.Role, + "balance": u.Balance, + "status": u.Status, "allowed_models": u.AllowedModels, "denied_models": u.DeniedModels, - "created_at": u.CreatedAt, + "created_at": u.CreatedAt, } } diff --git a/server/internal/api/middleware/cors_test.go b/server/internal/api/middleware/cors_test.go new file mode 100644 index 0000000..3cf3374 --- /dev/null +++ b/server/internal/api/middleware/cors_test.go @@ -0,0 +1,127 @@ +package middleware + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func newServer(allow []string) *httptest.Server { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(CORS(allow)) + r.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") }) + r.POST("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") }) + return httptest.NewServer(r) +} + +func TestCORSWhitelist(t *testing.T) { + srv := newServer([]string{"http://ok.com"}) + defer srv.Close() + + get := func(origin string) http.Header { + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ping", nil) + if origin != "" { + req.Header.Set("Origin", origin) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + return resp.Header + } + + h := get("http://ok.com") + if h.Get("Access-Control-Allow-Origin") != "http://ok.com" { + t.Fatalf("白名单内应回显 origin,实际 %q", h.Get("Access-Control-Allow-Origin")) + } + if h.Get("Access-Control-Allow-Credentials") != "true" { + t.Fatal("白名单内应允许凭据") + } + // Anthropic / OpenAI 客户端要带的头必须在允许列表里,否则浏览器预检直接失败 + // (头名大小写不敏感,浏览器实际发的是小写 x-api-key) + ah := strings.ToLower(h.Get("Access-Control-Allow-Headers")) + if !strings.Contains(ah, "x-api-key") || !strings.Contains(ah, "anthropic-version") { + t.Fatalf("允许的请求头缺失:%s", ah) + } + + if h := get("http://evil.com"); h.Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("白名单外不应发任何 CORS 头,实际 %q", h.Get("Access-Control-Allow-Origin")) + } + if h := get(""); h.Get("Access-Control-Allow-Origin") != "" { + t.Fatal("同源请求(无 Origin 头)不该带 CORS 头") + } +} + +func TestWildcardNeverCarriesCredentials(t *testing.T) { + srv := newServer([]string{"*"}) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ping", nil) + req.Header.Set("Origin", "http://anywhere.test") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { + t.Fatalf("通配应回 *,实际 %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" { + t.Fatalf("通配模式下不能允许凭据,实际 %q", got) + } +} + +func TestEmptyWhitelistDisablesCORS(t *testing.T) { + srv := newServer(nil) + defer srv.Close() + req, _ := http.NewRequest(http.MethodOptions, srv.URL+"/ping", nil) + req.Header.Set("Origin", "http://evil.com") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("预检应短路为 204,实际 %d", resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "" { + t.Fatal("空白名单不应发 CORS 头") + } +} + +func TestBodyLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(BodyLimit(16)) + var readErr error + r.POST("/echo", func(c *gin.Context) { + _, readErr = io.ReadAll(c.Request.Body) + c.Status(http.StatusOK) + }) + srv := httptest.NewServer(r) + defer srv.Close() + + // 未超限:正常读到 + if _, err := http.Post(srv.URL+"/echo", "text/plain", strings.NewReader("tiny")); err != nil { + t.Fatal(err) + } + if readErr != nil { + t.Fatalf("小 body 读取失败:%v", readErr) + } + + // 超限:读的时候拿到 MaxBytesError(代理层据此回 413) + if _, err := http.Post(srv.URL+"/echo", "text/plain", strings.NewReader(strings.Repeat("x", 64))); err != nil { + t.Fatal(err) + } + var mb *http.MaxBytesError + if !errors.As(readErr, &mb) { + t.Fatalf("应报 MaxBytesError,实际 %T: %v", readErr, readErr) + } +} diff --git a/server/internal/api/middleware/middleware.go b/server/internal/api/middleware/middleware.go index 37d465e..720b625 100644 --- a/server/internal/api/middleware/middleware.go +++ b/server/internal/api/middleware/middleware.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" + "strings" "github.com/gin-gonic/gin" "github.com/openteam/server/internal/app" @@ -59,16 +60,37 @@ func AdminOnly(c *gin.Context) { c.Next() } -// CORS 开发期放开;生产按配置白名单(PLANNING §8)。 -func CORS() gin.HandlerFunc { +// CORS 按显式 origin 白名单放行跨域;allow 为空则完全不输出 CORS 头。 +// +// 同源部署(网关自己托管 web/dist)和 vite dev 的 proxy 都不需要 CORS,所以默认关是安全的。 +// 之前无条件反射 Origin 并带 Allow-Credentials: true,唯一挡住跨站凭据读取的是 +// refresh cookie 的 SameSite=Lax——那是巧合而非防线,一旦有人为了嵌 iframe 把它改成 +// None,任意站点就能带着 cookie 调 /auth/refresh 读回新 token。 +func CORS(allow []string) gin.HandlerFunc { + allowed := make(map[string]bool, len(allow)) + wildcard := false + for _, o := range allow { + if o == "*" { + wildcard = true + continue + } + allowed[strings.TrimRight(o, "/")] = true + } return func(c *gin.Context) { origin := c.GetHeader("Origin") - if origin != "" { - c.Header("Access-Control-Allow-Origin", origin) + if origin != "" && (wildcard || allowed[strings.TrimRight(origin, "/")]) { + if wildcard { + // 通配不配凭据:/v1 靠 Authorization / x-api-key 头认证,不吃 cookie + c.Header("Access-Control-Allow-Origin", "*") + } else { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Credentials", "true") + } c.Header("Vary", "Origin") - c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With") + c.Header("Access-Control-Allow-Headers", + "Authorization, Content-Type, X-Requested-With, X-Api-Key, Anthropic-Version, OpenAI-Beta, OpenAI-Organization, OpenAI-Project") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Max-Age", "600") } if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) @@ -77,3 +99,14 @@ func CORS() gin.HandlerFunc { c.Next() } } + +// BodyLimit 限制请求体字节数。超限后 ReadAll 会拿到 *http.MaxBytesError, +// 由代理层按客户端协议返回 413;0 表示不限制。 +func BodyLimit(maxBytes int64) gin.HandlerFunc { + return func(c *gin.Context) { + if maxBytes > 0 { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) + } + c.Next() + } +} diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 8983b27..7a5f4fd 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -19,12 +19,19 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine { gin.SetMode(gin.ReleaseMode) } r := gin.New() - r.Use(gin.Logger(), gin.Recovery(), middleware.CORS()) + // 只有显式配置了反代列表才信任 X-Forwarded-For;否则 ClientIP 退回 RemoteAddr, + // 避免伪造头把认证限速绕开。 + if len(a.Cfg.TrustedProxies) > 0 { + if err := r.SetTrustedProxies(a.Cfg.TrustedProxies); err != nil { + panic("invalid trusted proxies: " + err.Error()) + } + } + r.Use(gin.Logger(), gin.Recovery(), middleware.CORS(a.Cfg.CORSAllowOrigins)) h := NewHandler(a, gw, a.Passkeys) // --- 代理端点(对外)--- - proxyGroup := r.Group("/v1") + proxyGroup := r.Group("/v1", middleware.BodyLimit(int64(a.Cfg.Proxy.MaxBodyMB)*1024*1024)) { proxyGroup.Any("/chat/completions", gw.Auth, gw.Handle) proxyGroup.Any("/responses", gw.Auth, gw.Handle) diff --git a/server/internal/cli/serve.go b/server/internal/cli/serve.go index 7ac6b91..1c8defd 100644 --- a/server/internal/cli/serve.go +++ b/server/internal/cli/serve.go @@ -31,7 +31,8 @@ func serve() error { } defer a.Shutdown(context.Background()) - gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS, cfg.Proxy.LogRaw, cfg.Proxy.Timeout) + gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS, cfg.Proxy.LogRaw, + cfg.Proxy.Timeout, cfg.Proxy.MaxBodyMB) router := api.NewRouter(a, gw) srv := &http.Server{ diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 03769c5..467a046 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( + "fmt" "os" "strings" "time" @@ -20,6 +21,15 @@ type Config struct { RateLimit RateLimitConfig WebAuthn WebAuthnConfig Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量) + // 管理员初始密码:仅用于生产启动断言,Seed 仍按原样直接读环境变量 + AdminPassword string + + // 跨域白名单(逗号分隔 origin)。为空 = 不输出任何 CORS 头: + // 网关自己托管 web/dist 时前后端同源,本就不需要 CORS;开发用 vite proxy 也不需要。 + CORSAllowOrigins []string + // 反向代理列表(CIDR 或 IP)。只有配置它,gin 才信任 X-Forwarded-For 取客户端 IP, + // 认证限速依赖这个 IP;直连部署留空即可。 + TrustedProxies []string } // WebAuthnConfig Passkey(WebAuthn)配置。 @@ -32,6 +42,10 @@ type WebAuthnConfig struct { // RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。 type RateLimitConfig struct { UserRPS int // 用户级每秒请求数上限(0=不限制) + // 认证端点限速(每分钟):登录是密码爆破面,注册是批量建号面。0=不限制。 + LoginIPPerMin int // 单 IP 登录尝试数 + LoginUserPerMin int // 单 IP 对同一账号的登录尝试数(挡住换 IP 定向爆破) + RegisterIPPerMin int // 单 IP 注册次数 } type DBConfig struct { @@ -64,6 +78,7 @@ type ProxyConfig struct { UpstreamKey string // 渠道上游 key 默认值 DefaultModel string // 渠道模型导入时使用的模型名 Timeout time.Duration + MaxBodyMB int // /v1 请求体上限(MB),超限按客户端协议返回 413 HealthInterval time.Duration // 渠道健康检查周期 HealthFailThreshold int // 连续失败 N 次进 cooldown LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关) @@ -128,17 +143,24 @@ func Load() (*Config, error) { v.SetDefault("proxy.upstream_key", "") v.SetDefault("proxy.default_model", "gpt-4o-mini") v.SetDefault("proxy.timeout", "300s") + v.SetDefault("proxy.max_body_mb", 32) v.SetDefault("proxy.health_interval", "60s") v.SetDefault("proxy.health_fail_threshold", 2) v.SetDefault("proxy.log_raw", false) v.SetDefault("ratelimit.user_rps", 20) + v.SetDefault("ratelimit.login_ip_per_min", 30) + v.SetDefault("ratelimit.login_user_per_min", 10) + v.SetDefault("ratelimit.register_ip_per_min", 5) + + v.SetDefault("cors.allow_origins", "") + v.SetDefault("server.trusted_proxies", "") v.SetDefault("webauthn.rp_id", "localhost") v.SetDefault("webauthn.rp_origin", "http://localhost:5173") v.SetDefault("webauthn.rp_name", "openteam") - return &Config{ + cfg := &Config{ Env: v.GetString("env"), Port: v.GetInt("port"), DB: DBConfig{ @@ -168,18 +190,71 @@ func Load() (*Config, error) { UpstreamKey: v.GetString("proxy.upstream_key"), DefaultModel: v.GetString("proxy.default_model"), Timeout: v.GetDuration("proxy.timeout"), + MaxBodyMB: v.GetInt("proxy.max_body_mb"), HealthInterval: v.GetDuration("proxy.health_interval"), HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"), LogRaw: v.GetBool("proxy.log_raw"), }, RateLimit: RateLimitConfig{ - UserRPS: v.GetInt("ratelimit.user_rps"), + UserRPS: v.GetInt("ratelimit.user_rps"), + LoginIPPerMin: v.GetInt("ratelimit.login_ip_per_min"), + LoginUserPerMin: v.GetInt("ratelimit.login_user_per_min"), + RegisterIPPerMin: v.GetInt("ratelimit.register_ip_per_min"), }, WebAuthn: WebAuthnConfig{ RPID: v.GetString("webauthn.rp_id"), RPOrigin: v.GetString("webauthn.rp_origin"), RPName: v.GetString("webauthn.rp_name"), }, - Master: v.GetString("master_key"), - }, nil + Master: v.GetString("master_key"), + AdminPassword: v.GetString("admin_password"), + CORSAllowOrigins: splitList(v.GetString("cors.allow_origins")), + TrustedProxies: splitList(v.GetString("server.trusted_proxies")), + } + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil +} + +// splitList 解析逗号分隔列表(去空白、忽略空项)。 +func splitList(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// validate 生产环境的密钥断言:这些默认值是公开已知的,忘配就等于没有认证。 +// +// master key 缺失时 crypto.NewEncryptor 会用 sha256("") 派生密钥(结果同样公开), +// 渠道上游密钥的密文就变成谁都能解;jwt.secret 用默认值则能直接伪造管理员 token。 +func (c *Config) validate() error { + if c.Env != "production" { + return nil + } + var problems []string + if c.JWT.Secret == "" || c.JWT.Secret == "dev-only-secret-change-me" { + problems = append(problems, "OT_JWT_SECRET 未设置或仍是仓库里的默认值(可伪造会话 token)") + } + if len(c.Master) < 16 { + problems = append(problems, "OT_MASTER_KEY 未设置或少于 16 字节(缺失时会用 sha256(\"\") 派生,渠道密文可被公开还原)") + } + if c.AdminPassword == "" || c.AdminPassword == "admin123" { + problems = append(problems, "OT_ADMIN_PASSWORD 未设置或仍是 admin123") + } + if len(problems) > 0 { + return fmt.Errorf("生产环境启动被拒绝:%s", strings.Join(problems, ";")) + } + return nil } diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go new file mode 100644 index 0000000..6020de7 --- /dev/null +++ b/server/internal/config/config_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "strings" + "testing" +) + +func prodCfg() *Config { + c := &Config{Env: "production"} + c.JWT.Secret = "a-real-random-secret-not-the-default" + c.Master = "0123456789abcdef0123456789abcdef" + c.AdminPassword = "strong-enough-password" + return c +} + +func TestValidateProduction(t *testing.T) { + if err := prodCfg().validate(); err != nil { + t.Fatalf("配齐密钥应通过:%v", err) + } + + cases := []struct { + name string + mutate func(*Config) + want string + }{ + {"默认 jwt secret", func(c *Config) { c.JWT.Secret = "dev-only-secret-change-me" }, "OT_JWT_SECRET"}, + {"空 jwt secret", func(c *Config) { c.JWT.Secret = "" }, "OT_JWT_SECRET"}, + {"缺失 master key", func(c *Config) { c.Master = "" }, "OT_MASTER_KEY"}, + {"过短 master key", func(c *Config) { c.Master = "short" }, "OT_MASTER_KEY"}, + {"默认管理员密码", func(c *Config) { c.AdminPassword = "admin123" }, "OT_ADMIN_PASSWORD"}, + {"未设管理员密码", func(c *Config) { c.AdminPassword = "" }, "OT_ADMIN_PASSWORD"}, + } + for _, tc := range cases { + c := prodCfg() + tc.mutate(c) + err := c.validate() + if err == nil { + t.Fatalf("%s:应被拒绝", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("%s:报错应点名 %s,实际 %v", tc.name, tc.want, err) + } + } + + // 多项同时缺失要一次全说清,别让人改一个试一个 + c := prodCfg() + c.JWT.Secret = "" + c.Master = "" + err := c.validate() + if err == nil || !strings.Contains(err.Error(), "OT_JWT_SECRET") || !strings.Contains(err.Error(), "OT_MASTER_KEY") { + t.Fatalf("应一次列出全部问题:%v", err) + } + + // 开发环境不拦(本地跑默认值就是设计如此) + dev := prodCfg() + dev.Env = "development" + dev.JWT.Secret = "dev-only-secret-change-me" + dev.Master = "" + dev.AdminPassword = "admin123" + if err := dev.validate(); err != nil { + t.Fatalf("development 不应拦截:%v", err) + } +} + +func TestSplitList(t *testing.T) { + if got := splitList(""); got != nil { + t.Fatalf("空串应为 nil,实际 %v", got) + } + if got := splitList(" , ,, "); got != nil { + t.Fatalf("纯分隔符应为 nil,实际 %v", got) + } + got := splitList("https://a.com, https://b.com ,,http://c.com") + if len(got) != 3 || got[0] != "https://a.com" || got[2] != "http://c.com" { + t.Fatalf("解析错误:%v", got) + } +} diff --git a/server/internal/pkg/ratelimit/ratelimit.go b/server/internal/pkg/ratelimit/ratelimit.go index 890a7a9..2e4435e 100644 --- a/server/internal/pkg/ratelimit/ratelimit.go +++ b/server/internal/pkg/ratelimit/ratelimit.go @@ -24,6 +24,7 @@ type Limiter struct { reqDaily map[uint64]*dayCounter // 密钥每日请求数 tokDaily map[uint64]*dayCounter // 密钥每日 token 用量 userHits map[uint64]*hitWindow // 用户速率窗口 + authHits map[string]*hitWindow // 认证端点速率窗口(按 IP / IP+账号) } func New() *Limiter { @@ -31,6 +32,7 @@ func New() *Limiter { reqDaily: map[uint64]*dayCounter{}, tokDaily: map[uint64]*dayCounter{}, userHits: map[uint64]*hitWindow{}, + authHits: map[string]*hitWindow{}, } } @@ -110,3 +112,52 @@ func (l *Limiter) AllowUserRate(userID uint64, limit int) bool { w.times = append(w.times, now) return true } + +// AllowWindow 字符串键滑动窗口限速;limit<=0 表示不限制,返回 false 表示已超限。 +// 用于认证端点(登录爆破、批量注册),键由调用方拼出(IP、IP+用户名等)。 +// 窗口内已无命中时删除条目,避免陌生键把 map 撑大。 +func (l *Limiter) AllowWindow(key string, limit int, window time.Duration) bool { + if limit <= 0 { + return true + } + if window <= 0 { + window = time.Minute + } + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + w, ok := l.authHits[key] + if !ok { + w = &hitWindow{limit: limit, window: window} + l.authHits[key] = w + } + cutoff := now.Add(-w.window) + keep := w.times[:0] + for _, t := range w.times { + if t.After(cutoff) { + keep = append(keep, t) + } + } + w.times = keep + if len(w.times) >= w.limit { + return false + } + w.times = append(w.times, now) + // 键由外部拼出(IP 等),条目只增不减会长期堆积:规模到阈值就整体扫一遍过期键 + if len(l.authHits) > 4096 { + for k, v := range l.authHits { + vcut := now.Add(-v.window) + alive := v.times[:0] + for _, t := range v.times { + if t.After(vcut) { + alive = append(alive, t) + } + } + v.times = alive + if len(v.times) == 0 { + delete(l.authHits, k) + } + } + } + return true +} diff --git a/server/internal/pkg/ratelimit/window_test.go b/server/internal/pkg/ratelimit/window_test.go new file mode 100644 index 0000000..6776d48 --- /dev/null +++ b/server/internal/pkg/ratelimit/window_test.go @@ -0,0 +1,39 @@ +package ratelimit + +import ( + "testing" + "time" +) + +func TestAllowWindow(t *testing.T) { + l := New() + const win = 80 * time.Millisecond + + // 窗口内到量就拒 + for i := 0; i < 3; i++ { + if !l.AllowWindow("login:ip:1.2.3.4", 3, win) { + t.Fatalf("第 %d 次应放行", i+1) + } + } + if l.AllowWindow("login:ip:1.2.3.4", 3, win) { + t.Fatal("超过限额应拒绝") + } + // 不同键互不影响(换 IP 不会被同一个桶牵连) + if !l.AllowWindow("login:ip:5.6.7.8", 3, win) { + t.Fatal("另一个 IP 应独立计数") + } + if !l.AllowWindow("login:u:1.2.3.4|admin", 3, win) { + t.Fatal("同一 IP 的另一个账号桶应独立") + } + // 窗口滑过即恢复 + time.Sleep(120 * time.Millisecond) + if !l.AllowWindow("login:ip:1.2.3.4", 3, win) { + t.Fatal("窗口过期后应重新放行") + } + // limit<=0 不限制 + for i := 0; i < 5; i++ { + if !l.AllowWindow("free", 0, win) { + t.Fatal("limit<=0 应始终放行") + } + } +} diff --git a/server/internal/proxy/gateway.go b/server/internal/proxy/gateway.go index 6c5e422..9b368cb 100644 --- a/server/internal/proxy/gateway.go +++ b/server/internal/proxy/gateway.go @@ -41,6 +41,7 @@ type Gateway struct { // 上游超时兜底值(渠道 timeout_ms 非法时使用);按超时值缓存 Transport 复用连接池。 gwTimeout time.Duration + maxBodyMB int // /v1 请求体上限(MB),仅用于超限报错文案 tpMu sync.Mutex tps map[time.Duration]*http.Transport @@ -131,7 +132,7 @@ func contains(list []string, s string) bool { return false } -func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int, logRaw bool, gw_timeout time.Duration) *Gateway { +func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int, logRaw bool, gw_timeout time.Duration, maxBodyMB int) *Gateway { return &Gateway{ db: db, ch: channel.NewService(db, enc), @@ -141,6 +142,7 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra userRPS: userRPS, logRaw: logRaw, gwTimeout: gw_timeout, + maxBodyMB: maxBodyMB, tps: map[time.Duration]*http.Transport{}, } } diff --git a/server/internal/proxy/handlers.go b/server/internal/proxy/handlers.go index e4e6c80..b7e5f2f 100644 --- a/server/internal/proxy/handlers.go +++ b/server/internal/proxy/handlers.go @@ -1,6 +1,8 @@ package proxy import ( + "errors" + "fmt" "net/http" "github.com/gin-gonic/gin" @@ -8,6 +10,17 @@ import ( "github.com/openteam/server/internal/store" ) +// failBodyRead 读取请求体失败时的报错:超限单独回 413,让客户端能区分"体太大"和"体畸形"。 +func (g *Gateway) failBodyRead(c *gin.Context, err error) { + var mb *http.MaxBytesError + if errors.As(err, &mb) { + apiError(c, http.StatusRequestEntityTooLarge, "request_too_large", + fmt.Sprintf("请求体超过网关上限 %d MB", g.maxBodyMB)) + return + } + apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body") +} + // chatCompletions POST /v1/chat/completions func (g *Gateway) chatCompletions(c *gin.Context) { u, ok := g.resolveUser(c) @@ -19,7 +32,7 @@ func (g *Gateway) chatCompletions(c *gin.Context) { } br, body, err := parseBody(c) if err != nil { - apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body") + g.failBodyRead(c, err) return } c.Set("protocol", convert.ProtoChat) @@ -52,7 +65,7 @@ func (g *Gateway) responses(c *gin.Context) { } br, body, err := parseBody(c) if err != nil { - apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body") + g.failBodyRead(c, err) return } c.Set("protocol", convert.ProtoResponses) @@ -85,7 +98,7 @@ func (g *Gateway) messages(c *gin.Context) { } br, body, err := parseBody(c) if err != nil { - apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body") + g.failBodyRead(c, err) return } c.Set("protocol", convert.ProtoMessages) diff --git a/server/internal/proxy/proxy_test.go b/server/internal/proxy/proxy_test.go index d55fc5e..93b2843 100644 --- a/server/internal/proxy/proxy_test.go +++ b/server/internal/proxy/proxy_test.go @@ -76,7 +76,7 @@ func newHarness(t *testing.T, baseURL string, timeoutMS int, bind bool, keyModel rec := usage.NewRecorder(db) t.Cleanup(rec.Close) return &harness{ - gw: NewGateway(db, enc, rec, ratelimit.New(), 0, false, 300*time.Second), + gw: NewGateway(db, enc, rec, ratelimit.New(), 0, false, 300*time.Second, 32), db: db, rec: rec, key: plain, } } @@ -294,7 +294,7 @@ func TestRuntimeFailureCounterResetsOnSuccess(t *testing.T) { // 回归:全局兜底超时值不再被二次换算。 func TestGatewayTimeoutIsNotMultiplied(t *testing.T) { - gw := NewGateway(nil, nil, nil, nil, 0, false, 30*time.Second) + gw := NewGateway(nil, nil, nil, nil, 0, false, 30*time.Second, 8) if got := gw.channelTimeout(&store.Channel{TimeoutMS: 0}); got != 30*time.Second { t.Fatalf("兜底超时 = %s,期望 30s", got) } @@ -305,3 +305,31 @@ func TestGatewayTimeoutIsNotMultiplied(t *testing.T) { t.Fatalf("client.Timeout 应为 0(由 context 控制整段时长),实际 %s", c.Timeout) } } + +// 请求体超过上限时应按客户端协议返回 413(上限由 /v1 组的 BodyLimit 中间件把住)。 +func TestRequestBodyTooLarge(t *testing.T) { + h := newHarness(t, "http://127.0.0.1:1", 0, true, nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", + strings.NewReader(`{"model":"m1","messages":[{"role":"user","content":"`+strings.Repeat("x", 4096)+`"}]}`)) + req.Header.Set("Authorization", "Bearer "+h.key) + // 模拟 middleware.BodyLimit:超限后 ReadAll 拿到 *http.MaxBytesError + req.Body = http.MaxBytesReader(w, req.Body, 64) + c.Request = req + + h.gw.Auth(c) + if !c.IsAborted() { + h.gw.Handle(c) + } + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("应返回 413,实际 %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "request_too_large") { + t.Fatalf("错误码不符:%s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "32 MB") { + t.Fatalf("报错应点名上限,实际:%s", w.Body.String()) + } +}