diff --git a/backend/internal/handler/admin/setting_handler_runtime.go b/backend/internal/handler/admin/setting_handler_runtime.go index 4891c9b7a..eb89b5472 100644 --- a/backend/internal/handler/admin/setting_handler_runtime.go +++ b/backend/internal/handler/admin/setting_handler_runtime.go @@ -154,6 +154,70 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { }) } +// GetPanelRateLimitSettings 获取面板 API 限流配置 +// GET /api/v1/admin/settings/panel-rate-limit +func (h *SettingHandler) GetPanelRateLimitSettings(c *gin.Context) { + settings, err := h.settingService.GetPanelRateLimitSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.PanelRateLimitSettings{ + Enabled: settings.Enabled, + UserRPM: settings.UserRPM, + HeavyRPM: settings.HeavyRPM, + ExemptAdmin: settings.ExemptAdmin, + PublicIPRPM: settings.PublicIPRPM, + }) +} + +// UpdatePanelRateLimitSettingsRequest 更新面板 API 限流配置请求 +type UpdatePanelRateLimitSettingsRequest struct { + Enabled bool `json:"enabled"` + UserRPM int `json:"user_rpm"` + HeavyRPM int `json:"heavy_rpm"` + ExemptAdmin bool `json:"exempt_admin"` + PublicIPRPM int `json:"public_ip_rpm"` +} + +// UpdatePanelRateLimitSettings 更新面板 API 限流配置 +// PUT /api/v1/admin/settings/panel-rate-limit +func (h *SettingHandler) UpdatePanelRateLimitSettings(c *gin.Context) { + var req UpdatePanelRateLimitSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + settings := &service.PanelRateLimitSettings{ + Enabled: req.Enabled, + UserRPM: req.UserRPM, + HeavyRPM: req.HeavyRPM, + ExemptAdmin: req.ExemptAdmin, + PublicIPRPM: req.PublicIPRPM, + } + + if err := h.settingService.SetPanelRateLimitSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + updatedSettings, err := h.settingService.GetPanelRateLimitSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.PanelRateLimitSettings{ + Enabled: updatedSettings.Enabled, + UserRPM: updatedSettings.UserRPM, + HeavyRPM: updatedSettings.HeavyRPM, + ExemptAdmin: updatedSettings.ExemptAdmin, + PublicIPRPM: updatedSettings.PublicIPRPM, + }) +} + // GetStreamTimeoutSettings 获取流超时处理配置 // GET /api/v1/admin/settings/stream-timeout func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 181aa07a0..25418b72c 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -394,6 +394,15 @@ type RateLimit429CooldownSettings struct { CooldownSeconds int `json:"cooldown_seconds"` } +// PanelRateLimitSettings 面板 API 限流配置 DTO +type PanelRateLimitSettings struct { + Enabled bool `json:"enabled"` + UserRPM int `json:"user_rpm"` + HeavyRPM int `json:"heavy_rpm"` + ExemptAdmin bool `json:"exempt_admin"` + PublicIPRPM int `json:"public_ip_rpm"` +} + // StreamTimeoutSettings 流超时处理配置 DTO type StreamTimeoutSettings struct { Enabled bool `json:"enabled"` diff --git a/backend/internal/middleware/rate_limiter.go b/backend/internal/middleware/rate_limiter.go index 819d74c27..df18a0234 100644 --- a/backend/internal/middleware/rate_limiter.go +++ b/backend/internal/middleware/rate_limiter.go @@ -8,6 +8,8 @@ import ( "strconv" "time" + ippkg "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" ) @@ -72,6 +74,52 @@ func NewRateLimiter(redisClient *redis.Client) *RateLimiter { } } +// AllowResult 单次固定窗口限流判定结果。 +type AllowResult struct { + // Allowed 是否放行 + Allowed bool + // Count 当前窗口内累计请求数(含本次) + Count int64 + // RetryAfter 超限时距窗口重置的剩余时间(尽力而为;PTTL 不可用时回退为完整窗口) + RetryAfter time.Duration +} + +// Allow 对给定 key(不含 "rate_limit:" 前缀)执行一次固定窗口计数判定。 +// 供需要自定义限流维度(如按用户 ID)的调用方使用;Redis 错误由调用方决定 fail-open/close。 +func (r *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (AllowResult, error) { + redisKey := r.prefix + key + windowMillis := windowTTLMillis(window) + + count, repaired, err := rateLimitRun(ctx, r.redis, redisKey, windowMillis) + if err != nil { + return AllowResult{}, err + } + if repaired { + log.Printf("[RateLimit] ttl repaired: key=%s window_ms=%d", redisKey, windowMillis) + } + + result := AllowResult{Allowed: count <= int64(limit), Count: count} + if !result.Allowed { + result.RetryAfter = window + if ttl, ttlErr := r.redis.PTTL(ctx, redisKey).Result(); ttlErr == nil && ttl > 0 { + result.RetryAfter = ttl + } + } + return result, nil +} + +// clientIPForRateLimit 返回 IP 维度限流使用的客户端地址。 +// 与审计日志/会话绑定/API Key IP ACL 共用同一套安全客户端 IP 解析 +// (SessionBindingContext 快照:兼容开关开启时信任反代转发头,关闭时走 +// server.trusted_proxies 可信链)。避免默认反代部署下 Gin ClientIP 恒等于 +// 代理地址、所有用户坍缩进同一个限流桶造成整体误拦截。 +func clientIPForRateLimit(c *gin.Context) string { + if resolved := ippkg.GetSecurityClientIP(c, false); resolved != "" { + return resolved + } + return c.ClientIP() +} + // Limit 返回速率限制中间件 // key: 限制类型标识 // limit: 时间窗口内最大请求数 @@ -88,32 +136,21 @@ func (r *RateLimiter) LimitWithOptions(key string, limit int, window time.Durati } return func(c *gin.Context) { - ip := c.ClientIP() - redisKey := r.prefix + key + ":" + ip - - ctx := c.Request.Context() - - windowMillis := windowTTLMillis(window) - - // 使用 Lua 脚本原子操作增加计数并设置过期 - count, repaired, err := rateLimitRun(ctx, r.redis, redisKey, windowMillis) + result, err := r.Allow(c.Request.Context(), key+":"+clientIPForRateLimit(c), limit, window) if err != nil { - log.Printf("[RateLimit] redis error: key=%s mode=%s err=%v", redisKey, failureModeLabel(failureMode), err) + log.Printf("[RateLimit] redis error: key=%s mode=%s err=%v", r.prefix+key, failureModeLabel(failureMode), err) if failureMode == RateLimitFailClose { - abortRateLimit(c) + abortRateLimit(c, window) return } // Redis 错误时放行,避免影响正常服务 c.Next() return } - if repaired { - log.Printf("[RateLimit] ttl repaired: key=%s window_ms=%d", redisKey, windowMillis) - } // 超过限制 - if count > int64(limit) { - abortRateLimit(c) + if !result.Allowed { + abortRateLimit(c, result.RetryAfter) return } @@ -129,7 +166,14 @@ func windowTTLMillis(window time.Duration) int64 { return ttl } -func abortRateLimit(c *gin.Context) { +func abortRateLimit(c *gin.Context, retryAfter time.Duration) { + if retryAfter > 0 { + seconds := int64(retryAfter / time.Second) + if retryAfter%time.Second > 0 { + seconds++ + } + c.Header("Retry-After", strconv.FormatInt(seconds, 10)) + } c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ "error": "rate limit exceeded", "message": "Too many requests, please try again later", diff --git a/backend/internal/middleware/rate_limiter_test.go b/backend/internal/middleware/rate_limiter_test.go index e362274f5..ab1e58785 100644 --- a/backend/internal/middleware/rate_limiter_test.go +++ b/backend/internal/middleware/rate_limiter_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + ippkg "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" @@ -103,6 +105,87 @@ func TestRateLimiterDifferentIPsIndependent(t *testing.T) { require.Equal(t, http.StatusTooManyRequests, rec3.Code, "第一个 IP 的第二次请求应被限流") } +func TestRateLimiterAllow(t *testing.T) { + originalRun := rateLimitRun + var gotKey string + count := int64(0) + rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) { + gotKey = key + count++ + return count, false, nil + } + t.Cleanup(func() { + rateLimitRun = originalRun + }) + + // PTTL 走真实客户端(不可达地址)→ 失败后 RetryAfter 应回退为完整窗口 + limiter := NewRateLimiter(redis.NewClient(&redis.Options{ + Addr: "127.0.0.1:1", + DialTimeout: 50 * time.Millisecond, + ReadTimeout: 50 * time.Millisecond, + WriteTimeout: 50 * time.Millisecond, + })) + + res, err := limiter.Allow(context.Background(), "panel:global:user:42", 1, time.Minute) + require.NoError(t, err) + require.True(t, res.Allowed) + require.Equal(t, int64(1), res.Count) + require.Zero(t, res.RetryAfter) + require.Equal(t, "rate_limit:panel:global:user:42", gotKey) + + res, err = limiter.Allow(context.Background(), "panel:global:user:42", 1, time.Minute) + require.NoError(t, err) + require.False(t, res.Allowed) + require.Equal(t, int64(2), res.Count) + require.Equal(t, time.Minute, res.RetryAfter) +} + +func TestRateLimiterHonorsForwardedIPSnapshot(t *testing.T) { + gin.SetMode(gin.TestMode) + + callCounts := make(map[string]int64) + originalRun := rateLimitRun + rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) { + callCounts[key]++ + return callCounts[key], false, nil + } + t.Cleanup(func() { + rateLimitRun = originalRun + }) + + limiter := NewRateLimiter(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"})) + + router := gin.New() + // 模拟 SessionBindingContext:开启转发 IP 兼容模式快照 + router.Use(func(c *gin.Context) { + ippkg.SetForwardedIPSettings(c, true, nil) + c.Next() + }) + router.Use(limiter.Limit("fwd", 1, time.Second)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + send := func(xff string) int { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + // 所有请求都来自同一个反代地址 + req.RemoteAddr = "127.0.0.1:5678" + req.Header.Set("X-Forwarded-For", xff) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec.Code + } + + // 反代后两个不同的真实客户端应各自独立计数,不因共享代理地址被合并限流 + require.Equal(t, http.StatusOK, send("198.51.100.1")) + require.Equal(t, http.StatusOK, send("198.51.100.2")) + // 同一真实客户端第二次请求应被限流 + require.Equal(t, http.StatusTooManyRequests, send("198.51.100.1")) + + require.Contains(t, callCounts, "rate_limit:fwd:198.51.100.1") + require.Contains(t, callCounts, "rate_limit:fwd:198.51.100.2") +} + func TestRateLimiterSuccessAndLimit(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/server/middleware/panel_rate_limit.go b/backend/internal/server/middleware/panel_rate_limit.go new file mode 100644 index 000000000..1d7846997 --- /dev/null +++ b/backend/internal/server/middleware/panel_rate_limit.go @@ -0,0 +1,163 @@ +package middleware + +import ( + "context" + "log/slog" + "net" + "net/http" + "strconv" + "time" + + "github.com/Wei-Shaw/sub2api/internal/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +// panelRateLimitWindow 面板限流固定窗口时长(所有档位均按每分钟计数)。 +const panelRateLimitWindow = time.Minute + +// panelRateLimitAllower 抽象底层限流原语,便于单测注入。 +type panelRateLimitAllower interface { + Allow(ctx context.Context, key string, limit int, window time.Duration) (middleware.AllowResult, error) +} + +// PanelRateLimiter 面板(管理面 /api/v1)API 限流器。 +// +// 设计要点: +// - 认证接口按「用户 ID」维度计数:与客户端 IP 完全无关,反向代理/共享出口 +// (所有请求源 IP 坍缩为 127.0.0.1 等)不会互相误伤。 +// - 公开接口按安全客户端 IP 计数:仅统计全局单播地址,回环/内网/链路本地 +// 地址(反代内部转发地址)直接跳过,避免误拦整条反代链路的流量。 +// - 配置走进程内缓存(60s TTL),热路径零 DB 访问。 +// - Redis 异常一律 fail-open:限流是保护措施,不能反过来把面板打挂。 +type PanelRateLimiter struct { + limiter panelRateLimitAllower + settingService *service.SettingService +} + +// NewPanelRateLimiter 创建面板限流器。 +func NewPanelRateLimiter(redisClient *redis.Client, settingService *service.SettingService) *PanelRateLimiter { + return &PanelRateLimiter{ + limiter: middleware.NewRateLimiter(redisClient), + settingService: settingService, + } +} + +// Global 认证面板接口的全局按用户限流(宽松档,覆盖所有登录后端点)。 +func (p *PanelRateLimiter) Global() gin.HandlerFunc { + return p.userScoped("global", func(s service.PanelRateLimitSettings) int { return s.UserRPM }) +} + +// Heavy 重查询接口的按用户限流(严格档,覆盖 usage/dashboard 等聚合统计端点)。 +// 与 Global 叠加计数:一次重查询同时消耗两档额度。 +func (p *PanelRateLimiter) Heavy() gin.HandlerFunc { + return p.userScoped("heavy", func(s service.PanelRateLimitSettings) int { return s.HeavyRPM }) +} + +func (p *PanelRateLimiter) userScoped(scope string, limitOf func(service.PanelRateLimitSettings) int) gin.HandlerFunc { + return func(c *gin.Context) { + if p == nil || p.limiter == nil || p.settingService == nil { + c.Next() + return + } + settings := p.settingService.GetPanelRateLimitSettingsCached(c.Request.Context()) + if !settings.Enabled { + c.Next() + return + } + limit := limitOf(settings) + if limit <= 0 { + c.Next() + return + } + subject, ok := GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + // 无认证主体(认证中间件缺位时的防御分支):放行,避免误伤 + c.Next() + return + } + if settings.ExemptAdmin { + if role, hasRole := GetUserRoleFromContext(c); hasRole && role == service.RoleAdmin { + c.Next() + return + } + } + + key := "panel:" + scope + ":user:" + strconv.FormatInt(subject.UserID, 10) + result, err := p.limiter.Allow(c.Request.Context(), key, limit, panelRateLimitWindow) + if err != nil { + // fail-open:Redis 异常不阻断面板访问 + slog.Warn("panel rate limit check failed, allowing request", "scope", scope, "error", err) + c.Next() + return + } + if !result.Allowed { + abortPanelRateLimited(c, result.RetryAfter) + return + } + c.Next() + } +} + +// PublicIP 无需认证的公开接口按客户端 IP 限流。 +// 使用与审计日志/会话绑定一致的安全客户端 IP 解析;解析结果为回环/内网/ +// 链路本地地址时跳过计数(这类地址通常是反代内部转发地址,按它计数会把 +// 整条反代链路的所有真实用户合并进同一个桶造成大面积误拦截)。 +func (p *PanelRateLimiter) PublicIP() gin.HandlerFunc { + return func(c *gin.Context) { + if p == nil || p.limiter == nil || p.settingService == nil { + c.Next() + return + } + settings := p.settingService.GetPanelRateLimitSettingsCached(c.Request.Context()) + if !settings.Enabled || settings.PublicIPRPM <= 0 { + c.Next() + return + } + clientIP := SecurityClientIP(c) + if !isPubliclyRoutableClientIP(clientIP) { + c.Next() + return + } + + result, err := p.limiter.Allow(c.Request.Context(), "panel:public:ip:"+clientIP, settings.PublicIPRPM, panelRateLimitWindow) + if err != nil { + slog.Warn("panel public rate limit check failed, allowing request", "error", err) + c.Next() + return + } + if !result.Allowed { + abortPanelRateLimited(c, result.RetryAfter) + return + } + c.Next() + } +} + +// isPubliclyRoutableClientIP 判断地址是否为可作为限流依据的全局单播地址。 +// 回环、RFC1918/ULA 内网、链路本地与未指定地址返回 false。 +func isPubliclyRoutableClientIP(clientIP string) bool { + ip := net.ParseIP(clientIP) + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return false + } + return ip.IsGlobalUnicast() +} + +func abortPanelRateLimited(c *gin.Context, retryAfter time.Duration) { + if retryAfter <= 0 { + retryAfter = panelRateLimitWindow + } + seconds := int64(retryAfter / time.Second) + if retryAfter%time.Second > 0 { + seconds++ + } + c.Header("Retry-After", strconv.FormatInt(seconds, 10)) + AbortWithError(c, http.StatusTooManyRequests, "RATE_LIMITED", "Too many requests, please slow down and try again later") +} diff --git a/backend/internal/server/middleware/panel_rate_limit_test.go b/backend/internal/server/middleware/panel_rate_limit_test.go new file mode 100644 index 000000000..48588bb9d --- /dev/null +++ b/backend/internal/server/middleware/panel_rate_limit_test.go @@ -0,0 +1,315 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// panelRateLimitStubRepo 内存版 SettingRepository,仅覆盖本测试用到的方法。 +type panelRateLimitStubRepo struct { + mu sync.Mutex + values map[string]string +} + +func (r *panelRateLimitStubRepo) Get(_ context.Context, key string) (*service.Setting, error) { + r.mu.Lock() + defer r.mu.Unlock() + value, ok := r.values[key] + if !ok { + return nil, service.ErrSettingNotFound + } + return &service.Setting{Key: key, Value: value}, nil +} + +func (r *panelRateLimitStubRepo) GetValue(_ context.Context, key string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + value, ok := r.values[key] + if !ok { + return "", service.ErrSettingNotFound + } + return value, nil +} + +func (r *panelRateLimitStubRepo) Set(_ context.Context, key, value string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.values == nil { + r.values = make(map[string]string) + } + r.values[key] = value + return nil +} + +func (r *panelRateLimitStubRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := r.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (r *panelRateLimitStubRepo) SetMultiple(_ context.Context, settings map[string]string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.values == nil { + r.values = make(map[string]string) + } + for key, value := range settings { + r.values[key] = value + } + return nil +} + +func (r *panelRateLimitStubRepo) GetAll(_ context.Context) (map[string]string, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]string, len(r.values)) + for key, value := range r.values { + out[key] = value + } + return out, nil +} + +func (r *panelRateLimitStubRepo) Delete(_ context.Context, key string) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.values, key) + return nil +} + +// fakePanelAllower 内存计数版限流原语。 +type fakePanelAllower struct { + mu sync.Mutex + counts map[string]int64 + err error +} + +func (f *fakePanelAllower) Allow(_ context.Context, key string, limit int, window time.Duration) (middleware.AllowResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return middleware.AllowResult{}, f.err + } + if f.counts == nil { + f.counts = make(map[string]int64) + } + f.counts[key]++ + count := f.counts[key] + result := middleware.AllowResult{Allowed: count <= int64(limit), Count: count} + if !result.Allowed { + result.RetryAfter = window + } + return result, nil +} + +func newPanelRateLimitTestService(t *testing.T, settingsJSON string) *service.SettingService { + t.Helper() + repo := &panelRateLimitStubRepo{} + if settingsJSON != "" { + repo.values = map[string]string{"panel_rate_limit_settings": settingsJSON} + } + return service.NewSettingService(repo, &config.Config{}) +} + +type panelTestIdentity struct { + userID int64 + role string +} + +func newPanelTestRouter(limiter gin.HandlerFunc, identity *panelTestIdentity) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + if identity != nil { + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyUser), AuthSubject{UserID: identity.userID}) + c.Set(string(ContextKeyUserRole), identity.role) + c.Next() + }) + } + router.Use(limiter) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + return router +} + +func performPanelRequest(router *gin.Engine, remoteAddr string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = remoteAddr + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestPanelRateLimiterGlobalPerUser(t *testing.T) { + allower := &fakePanelAllower{} + p := &PanelRateLimiter{ + limiter: allower, + settingService: newPanelRateLimitTestService(t, `{"enabled":true,"user_rpm":2,"heavy_rpm":1,"exempt_admin":true,"public_ip_rpm":0}`), + } + + userA := newPanelTestRouter(p.Global(), &panelTestIdentity{userID: 1, role: service.RoleUser}) + userB := newPanelTestRouter(p.Global(), &panelTestIdentity{userID: 2, role: service.RoleUser}) + + require.Equal(t, http.StatusOK, performPanelRequest(userA, "127.0.0.1:1000").Code) + require.Equal(t, http.StatusOK, performPanelRequest(userA, "127.0.0.1:1000").Code) + // 用户 A 超限 + third := performPanelRequest(userA, "127.0.0.1:1000") + require.Equal(t, http.StatusTooManyRequests, third.Code) + require.NotEmpty(t, third.Header().Get("Retry-After")) + require.Contains(t, third.Body.String(), "RATE_LIMITED") + // 用户 B 不受影响(同一来源 IP 也互不干扰) + require.Equal(t, http.StatusOK, performPanelRequest(userB, "127.0.0.1:1000").Code) + + allower.mu.Lock() + defer allower.mu.Unlock() + require.Contains(t, allower.counts, "panel:global:user:1") + require.Contains(t, allower.counts, "panel:global:user:2") +} + +func TestPanelRateLimiterHeavyUsesHeavyRPM(t *testing.T) { + allower := &fakePanelAllower{} + p := &PanelRateLimiter{ + limiter: allower, + settingService: newPanelRateLimitTestService(t, `{"enabled":true,"user_rpm":100,"heavy_rpm":1,"exempt_admin":true,"public_ip_rpm":0}`), + } + + router := newPanelTestRouter(p.Heavy(), &panelTestIdentity{userID: 7, role: service.RoleUser}) + require.Equal(t, http.StatusOK, performPanelRequest(router, "127.0.0.1:1000").Code) + require.Equal(t, http.StatusTooManyRequests, performPanelRequest(router, "127.0.0.1:1000").Code) + + allower.mu.Lock() + defer allower.mu.Unlock() + require.Contains(t, allower.counts, "panel:heavy:user:7") +} + +func TestPanelRateLimiterAdminExemption(t *testing.T) { + // 豁免开启:管理员不计数 + p := &PanelRateLimiter{ + limiter: &fakePanelAllower{}, + settingService: newPanelRateLimitTestService(t, `{"enabled":true,"user_rpm":1,"heavy_rpm":1,"exempt_admin":true,"public_ip_rpm":0}`), + } + admin := newPanelTestRouter(p.Global(), &panelTestIdentity{userID: 9, role: service.RoleAdmin}) + for i := 0; i < 5; i++ { + require.Equal(t, http.StatusOK, performPanelRequest(admin, "127.0.0.1:1000").Code) + } + + // 豁免关闭:管理员一样受限 + p2 := &PanelRateLimiter{ + limiter: &fakePanelAllower{}, + settingService: newPanelRateLimitTestService(t, `{"enabled":true,"user_rpm":1,"heavy_rpm":1,"exempt_admin":false,"public_ip_rpm":0}`), + } + admin2 := newPanelTestRouter(p2.Global(), &panelTestIdentity{userID: 9, role: service.RoleAdmin}) + require.Equal(t, http.StatusOK, performPanelRequest(admin2, "127.0.0.1:1000").Code) + require.Equal(t, http.StatusTooManyRequests, performPanelRequest(admin2, "127.0.0.1:1000").Code) +} + +func TestPanelRateLimiterDisabledOrMissingSubject(t *testing.T) { + // 总开关关闭 + p := &PanelRateLimiter{ + limiter: &fakePanelAllower{}, + settingService: newPanelRateLimitTestService(t, `{"enabled":false,"user_rpm":1,"heavy_rpm":1,"exempt_admin":true,"public_ip_rpm":1}`), + } + router := newPanelTestRouter(p.Global(), &panelTestIdentity{userID: 3, role: service.RoleUser}) + for i := 0; i < 3; i++ { + require.Equal(t, http.StatusOK, performPanelRequest(router, "127.0.0.1:1000").Code) + } + + // 无认证主体:放行(防御分支) + p2 := &PanelRateLimiter{ + limiter: &fakePanelAllower{}, + settingService: newPanelRateLimitTestService(t, `{"enabled":true,"user_rpm":1,"heavy_rpm":1,"exempt_admin":true,"public_ip_rpm":0}`), + } + anonymous := newPanelTestRouter(p2.Global(), nil) + for i := 0; i < 3; i++ { + require.Equal(t, http.StatusOK, performPanelRequest(anonymous, "127.0.0.1:1000").Code) + } + + // nil 限流器(测试环境注入 nil):直接放行 + var nilLimiter *PanelRateLimiter + nilRouter := newPanelTestRouter(nilLimiter.Global(), &panelTestIdentity{userID: 3, role: service.RoleUser}) + require.Equal(t, http.StatusOK, performPanelRequest(nilRouter, "127.0.0.1:1000").Code) +} + +func TestPanelRateLimiterFailOpenOnRedisError(t *testing.T) { + p := &PanelRateLimiter{ + limiter: &fakePanelAllower{err: errors.New("redis down")}, + settingService: newPanelRateLimitTestService(t, `{"enabled":true,"user_rpm":1,"heavy_rpm":1,"exempt_admin":true,"public_ip_rpm":1}`), + } + router := newPanelTestRouter(p.Global(), &panelTestIdentity{userID: 5, role: service.RoleUser}) + for i := 0; i < 3; i++ { + require.Equal(t, http.StatusOK, performPanelRequest(router, "127.0.0.1:1000").Code) + } + + publicRouter := newPanelTestRouter(p.PublicIP(), nil) + for i := 0; i < 3; i++ { + require.Equal(t, http.StatusOK, performPanelRequest(publicRouter, "203.0.113.9:1000").Code) + } +} + +func TestPanelRateLimiterPublicIP(t *testing.T) { + allower := &fakePanelAllower{} + p := &PanelRateLimiter{ + limiter: allower, + settingService: newPanelRateLimitTestService(t, `{"enabled":true,"user_rpm":0,"heavy_rpm":0,"exempt_admin":true,"public_ip_rpm":1}`), + } + router := newPanelTestRouter(p.PublicIP(), nil) + + // 公网 IP:第二次被限 + require.Equal(t, http.StatusOK, performPanelRequest(router, "203.0.113.9:1000").Code) + require.Equal(t, http.StatusTooManyRequests, performPanelRequest(router, "203.0.113.9:1000").Code) + // 其他公网 IP 独立计数 + require.Equal(t, http.StatusOK, performPanelRequest(router, "198.51.100.7:1000").Code) + + // 回环/内网地址(反代内部转发地址):跳过计数,绝不误拦 + for i := 0; i < 5; i++ { + require.Equal(t, http.StatusOK, performPanelRequest(router, "127.0.0.1:1000").Code) + require.Equal(t, http.StatusOK, performPanelRequest(router, "10.0.0.8:1000").Code) + require.Equal(t, http.StatusOK, performPanelRequest(router, "172.17.0.1:1000").Code) + require.Equal(t, http.StatusOK, performPanelRequest(router, "192.168.1.30:1000").Code) + } + + allower.mu.Lock() + defer allower.mu.Unlock() + require.Contains(t, allower.counts, "panel:public:ip:203.0.113.9") + require.Contains(t, allower.counts, "panel:public:ip:198.51.100.7") + for key := range allower.counts { + require.NotContains(t, key, "127.0.0.1") + require.NotContains(t, key, "10.0.0.8") + require.NotContains(t, key, "172.17.0.1") + require.NotContains(t, key, "192.168.1.30") + } +} + +func TestIsPubliclyRoutableClientIP(t *testing.T) { + require.True(t, isPubliclyRoutableClientIP("203.0.113.9")) + require.True(t, isPubliclyRoutableClientIP("2001:db8::1")) + require.False(t, isPubliclyRoutableClientIP("127.0.0.1")) + require.False(t, isPubliclyRoutableClientIP("::1")) + require.False(t, isPubliclyRoutableClientIP("10.1.2.3")) + require.False(t, isPubliclyRoutableClientIP("172.16.0.1")) + require.False(t, isPubliclyRoutableClientIP("192.168.0.1")) + require.False(t, isPubliclyRoutableClientIP("169.254.1.1")) + require.False(t, isPubliclyRoutableClientIP("fe80::1")) + require.False(t, isPubliclyRoutableClientIP("fc00::1")) + require.False(t, isPubliclyRoutableClientIP("0.0.0.0")) + require.False(t, isPubliclyRoutableClientIP("")) + require.False(t, isPubliclyRoutableClientIP("not-an-ip")) +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index ea3600c3f..d70fab299 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -117,12 +117,16 @@ func registerRoutes( // API v1 v1 := r.Group("/api/v1") + // 面板 API 限流器:认证接口按用户 ID、公开接口按安全客户端 IP, + // 防止高频刷管理面接口打爆数据库(阈值可在系统设置中调整)。 + panelRateLimiter := middleware2.NewPanelRateLimiter(redisClient, settingService) + // 注册各模块路由 - routes.RegisterAuthRoutes(v1, h, jwtAuth, auditLog, redisClient, settingService) - routes.RegisterUserRoutes(v1, h, jwtAuth, auditLog, settingService) - routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService) + routes.RegisterAuthRoutes(v1, h, jwtAuth, auditLog, redisClient, settingService, panelRateLimiter) + routes.RegisterUserRoutes(v1, h, jwtAuth, auditLog, settingService, panelRateLimiter) + routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService, panelRateLimiter) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg) - routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService) + routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter) handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService) } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index a2117fe31..981addecb 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -17,9 +17,12 @@ func RegisterAdminRoutes( auditLog middleware.AuditLogMiddleware, stepUpAuth middleware.StepUpAuthMiddleware, settingService *service.SettingService, + panelRateLimiter *middleware.PanelRateLimiter, ) { admin := v1.Group("/admin") admin.Use(gin.HandlerFunc(adminAuth)) + // 面板全局按用户限流(默认管理员豁免,可在系统设置中关闭豁免) + admin.Use(panelRateLimiter.Global()) // 审计中间件挂在认证之后:所有管理面变更类操作 + 敏感读取入审计日志 admin.Use(gin.HandlerFunc(auditLog)) admin.Use(middleware.AdminComplianceGuard(settingService)) @@ -542,6 +545,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // 429默认回避配置 adminSettings.GET("/rate-limit-429-cooldown", h.Admin.Setting.GetRateLimit429CooldownSettings) adminSettings.PUT("/rate-limit-429-cooldown", h.Admin.Setting.UpdateRateLimit429CooldownSettings) + // 面板 API 限流配置 + adminSettings.GET("/panel-rate-limit", h.Admin.Setting.GetPanelRateLimitSettings) + adminSettings.PUT("/panel-rate-limit", h.Admin.Setting.UpdatePanelRateLimitSettings) // 流超时处理配置 adminSettings.GET("/stream-timeout", h.Admin.Setting.GetStreamTimeoutSettings) adminSettings.PUT("/stream-timeout", h.Admin.Setting.UpdateStreamTimeoutSettings) diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index a379edd23..fa29fa67d 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -20,6 +20,7 @@ func RegisterAuthRoutes( auditLog servermiddleware.AuditLogMiddleware, redisClient *redis.Client, settingService *service.SettingService, + panelRateLimiter *servermiddleware.PanelRateLimiter, ) { // 创建速率限制器 rateLimiter := middleware.NewRateLimiter(redisClient) @@ -213,8 +214,10 @@ func RegisterAuthRoutes( ) } - // 公开设置(无需认证) + // 公开设置(无需认证):每次请求都会查询 DB,按客户端 IP 兜底限流, + // 防止匿名高频刷接口打爆数据库(反代内部地址会被自动跳过,不会误伤)。 settings := v1.Group("/settings") + settings.Use(panelRateLimiter.PublicIP()) { settings.GET("/public", h.Setting.GetPublicSettings) settings.GET("/email-unsubscribe", h.Setting.UnsubscribeNotificationEmail) @@ -224,6 +227,8 @@ func RegisterAuthRoutes( authenticated := v1.Group("") authenticated.Use(gin.HandlerFunc(jwtAuth)) authenticated.Use(servermiddleware.BackendModeUserGuard(settingService)) + // 面板全局按用户限流 + authenticated.Use(panelRateLimiter.Global()) { authenticated.GET("/auth/me", h.Auth.GetCurrentUser) // 撤销所有会话(需要认证) diff --git a/backend/internal/server/routes/auth_rate_limit_test.go b/backend/internal/server/routes/auth_rate_limit_test.go index 463a93ba5..26b29e4c0 100644 --- a/backend/internal/server/routes/auth_rate_limit_test.go +++ b/backend/internal/server/routes/auth_rate_limit_test.go @@ -33,6 +33,7 @@ func newAuthRoutesTestRouter(redisClient *redis.Client) *gin.Engine { }), redisClient, nil, + nil, ) return router diff --git a/backend/internal/server/routes/ops_ingress_reject_routes_test.go b/backend/internal/server/routes/ops_ingress_reject_routes_test.go index c8c5a8766..19d111fc1 100644 --- a/backend/internal/server/routes/ops_ingress_reject_routes_test.go +++ b/backend/internal/server/routes/ops_ingress_reject_routes_test.go @@ -25,7 +25,7 @@ func TestIngressRejectAdminRoutesRequireAdminAuthentication(t *testing.T) { }) auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }) stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }) - RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil) + RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil, nil) for _, path := range []string{ "/api/v1/admin/ops/ingress-rejections", diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index f4f912870..ecda25f53 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -20,11 +20,14 @@ func RegisterPaymentRoutes( adminAuth middleware.AdminAuthMiddleware, auditLog middleware.AuditLogMiddleware, settingService *service.SettingService, + panelRateLimiter *middleware.PanelRateLimiter, ) { // --- User-facing payment endpoints (authenticated) --- authenticated := v1.Group("/payment") authenticated.Use(gin.HandlerFunc(jwtAuth)) authenticated.Use(middleware.BackendModeUserGuard(settingService)) + // 面板全局按用户限流 + authenticated.Use(panelRateLimiter.Global()) { authenticated.GET("/config", paymentHandler.GetPaymentConfig) authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo) diff --git a/backend/internal/server/routes/prompt_audit_route_coverage_test.go b/backend/internal/server/routes/prompt_audit_route_coverage_test.go index 1620a1831..e4591ab4f 100644 --- a/backend/internal/server/routes/prompt_audit_route_coverage_test.go +++ b/backend/internal/server/routes/prompt_audit_route_coverage_test.go @@ -115,7 +115,7 @@ func TestPromptAuditAdminRoutesRejectUnauthenticatedAndNonAdminRequests(t *testi }) auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }) stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }) - RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil) + RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil, nil) for _, tc := range []struct { name string diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index f01627b52..ff562e1dc 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -15,10 +15,13 @@ func RegisterUserRoutes( jwtAuth middleware.JWTAuthMiddleware, auditLog middleware.AuditLogMiddleware, settingService *service.SettingService, + panelRateLimiter *middleware.PanelRateLimiter, ) { authenticated := v1.Group("") authenticated.Use(gin.HandlerFunc(jwtAuth)) authenticated.Use(middleware.BackendModeUserGuard(settingService)) + // 面板全局按用户限流:防止单个账号高频刷接口打爆数据库 + authenticated.Use(panelRateLimiter.Global()) // 用户管理面变更类操作入审计(含 TOTP 启用/禁用、step-up 验证、密码修改等安全事件) authenticated.Use(gin.HandlerFunc(auditLog)) { @@ -34,7 +37,7 @@ func RegisterUserRoutes( user.POST("/account-bindings/email", h.User.BindEmailIdentity) user.DELETE("/account-bindings/:provider", h.User.UnbindIdentity) user.POST("/auth-identities/bind/start", h.User.StartIdentityBinding) - user.GET("/api-keys/:id/usage/daily", h.Usage.GetMyAPIKeyDailyUsage) + user.GET("/api-keys/:id/usage/daily", panelRateLimiter.Heavy(), h.Usage.GetMyAPIKeyDailyUsage) user.GET("/platform-quotas", h.User.GetMyPlatformQuotas) // 通知邮箱管理 @@ -83,8 +86,9 @@ func RegisterUserRoutes( channels.GET("/available", h.AvailableChannel.List) } - // 使用记录 + // 使用记录(聚合统计属重查询,叠加更严格的按用户限流) usage := authenticated.Group("/usage") + usage.Use(panelRateLimiter.Heavy()) { usage.GET("", h.Usage.List) usage.GET("/errors", h.Usage.ListErrors) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 755790f0e..f8a7359ae 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -178,6 +178,9 @@ const ( // 敏感操作 step-up 2FA 设置 SettingKeyStepUpEnabled = "step_up_enabled" // 敏感操作(导出/备份/S3配置/提升管理员等)要求 step-up 2FA,默认关闭 + // 面板 API 限流设置(JSON:PanelRateLimitSettings) + SettingKeyPanelRateLimitSettings = "panel_rate_limit_settings" + // 操作审计日志设置 SettingKeyAuditLogRetentionDays = "audit_log_retention_days" // 审计日志保留天数(<=0 永久保留),默认 180 diff --git a/backend/internal/service/setting_panel_rate_limit.go b/backend/internal/service/setting_panel_rate_limit.go new file mode 100644 index 000000000..2515c89a4 --- /dev/null +++ b/backend/internal/service/setting_panel_rate_limit.go @@ -0,0 +1,183 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "time" +) + +// PanelRateLimitSettings 面板 API 限流配置。 +// 认证后的面板接口按「用户 ID」维度限流(与客户端 IP 无关,反向代理/共享出口 +// 不会被误伤);无需认证的公开接口按安全客户端 IP 维度限流(内网/回环地址跳过)。 +type PanelRateLimitSettings struct { + // Enabled 总开关 + Enabled bool `json:"enabled"` + // UserRPM 每用户每分钟请求数上限(认证面板接口全量计数;0 = 不限制) + UserRPM int `json:"user_rpm"` + // HeavyRPM 每用户每分钟重查询上限(usage/dashboard 等聚合统计接口;0 = 不限制) + HeavyRPM int `json:"heavy_rpm"` + // ExemptAdmin 管理员账号是否豁免按用户限流 + ExemptAdmin bool `json:"exempt_admin"` + // PublicIPRPM 无需认证的公开接口每 IP 每分钟上限(0 = 不限制) + PublicIPRPM int `json:"public_ip_rpm"` +} + +// 面板限流 RPM 的取值上限,防止配置异常大的值失去意义。 +const panelRateLimitRPMMax = 100000 + +const ( + panelRateLimitCacheTTL = 60 * time.Second + panelRateLimitErrorTTL = 5 * time.Second + panelRateLimitDBTimeout = 5 * time.Second +) + +// cachedPanelRateLimitSettings 进程内缓存条目(60s TTL)。 +type cachedPanelRateLimitSettings struct { + settings PanelRateLimitSettings + expiresAt int64 // unix nano +} + +// DefaultPanelRateLimitSettings 返回默认面板限流配置。 +// 默认启用但阈值宽松:正常前端交互远达不到,仅拦截脚本高频刷接口打爆数据库的行为。 +func DefaultPanelRateLimitSettings() *PanelRateLimitSettings { + return &PanelRateLimitSettings{ + Enabled: true, + UserRPM: 240, + HeavyRPM: 60, + ExemptAdmin: true, + PublicIPRPM: 300, + } +} + +// normalizePanelRateLimitSettings 修正非法取值(负数归零、超上限截断)。 +func normalizePanelRateLimitSettings(s *PanelRateLimitSettings) { + if s == nil { + return + } + if s.UserRPM < 0 { + s.UserRPM = 0 + } + if s.HeavyRPM < 0 { + s.HeavyRPM = 0 + } + if s.PublicIPRPM < 0 { + s.PublicIPRPM = 0 + } + if s.UserRPM > panelRateLimitRPMMax { + s.UserRPM = panelRateLimitRPMMax + } + if s.HeavyRPM > panelRateLimitRPMMax { + s.HeavyRPM = panelRateLimitRPMMax + } + if s.PublicIPRPM > panelRateLimitRPMMax { + s.PublicIPRPM = panelRateLimitRPMMax + } +} + +// GetPanelRateLimitSettings 获取面板 API 限流配置(直读 DB,供管理端读写路径使用)。 +// 缺失/空/解析失败 → 返回默认配置。 +func (s *SettingService) GetPanelRateLimitSettings(ctx context.Context) (*PanelRateLimitSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyPanelRateLimitSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultPanelRateLimitSettings(), nil + } + return nil, fmt.Errorf("get panel rate limit settings: %w", err) + } + if strings.TrimSpace(value) == "" { + return DefaultPanelRateLimitSettings(), nil + } + + settings := &PanelRateLimitSettings{} + if err := json.Unmarshal([]byte(value), settings); err != nil { + slog.Warn("failed to unmarshal panel rate limit settings, falling back to defaults", + "error", err, "key", SettingKeyPanelRateLimitSettings) + return DefaultPanelRateLimitSettings(), nil + } + normalizePanelRateLimitSettings(settings) + return settings, nil +} + +// SetPanelRateLimitSettings 保存面板 API 限流配置,并立即刷新进程内缓存, +// 使当前节点的下一个请求即生效(多节点部署最迟 60s 内生效)。 +func (s *SettingService) SetPanelRateLimitSettings(ctx context.Context, settings *PanelRateLimitSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + if settings.UserRPM < 0 || settings.HeavyRPM < 0 || settings.PublicIPRPM < 0 { + return fmt.Errorf("rate limit values cannot be negative") + } + if settings.UserRPM > panelRateLimitRPMMax || settings.HeavyRPM > panelRateLimitRPMMax || settings.PublicIPRPM > panelRateLimitRPMMax { + return fmt.Errorf("rate limit values must be at most %d", panelRateLimitRPMMax) + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal panel rate limit settings: %w", err) + } + if err := s.settingRepo.Set(ctx, SettingKeyPanelRateLimitSettings, string(data)); err != nil { + return err + } + + s.storePanelRateLimitCache(*settings, panelRateLimitCacheTTL) + return nil +} + +// GetPanelRateLimitSettingsCached 返回面板限流配置(进程内缓存,60s TTL)。 +// 面板每个认证请求的热路径都会调用,绝不能每次访问 DB; +// DB 错误时返回最近一次已知值(无缓存则返回默认值),并以短 TTL 快速重试。 +func (s *SettingService) GetPanelRateLimitSettingsCached(ctx context.Context) PanelRateLimitSettings { + if s == nil || s.settingRepo == nil { + return *DefaultPanelRateLimitSettings() + } + if cached, ok := s.panelRateLimitCache.Load().(*cachedPanelRateLimitSettings); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.settings + } + } + + result, _, _ := s.panelRateLimitSF.Do("panel_rate_limit_settings", func() (any, error) { + // 二次检查,避免排队的 goroutine 重复查询 + if cached, ok := s.panelRateLimitCache.Load().(*cachedPanelRateLimitSettings); ok && cached != nil { + if time.Now().UnixNano() < cached.expiresAt { + return cached.settings, nil + } + } + if ctx == nil { + ctx = context.Background() + } + // 独立 context:断开请求取消链,避免客户端断连污染缓存 + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), panelRateLimitDBTimeout) + defer cancel() + + settings, err := s.GetPanelRateLimitSettings(dbCtx) + if err != nil { + slog.Warn("failed to get panel rate limit settings", "error", err) + // 保留最近一次已知值,短 TTL 快速重试 + fallback := *DefaultPanelRateLimitSettings() + if prior, ok := s.panelRateLimitCache.Load().(*cachedPanelRateLimitSettings); ok && prior != nil { + fallback = prior.settings + } + s.storePanelRateLimitCache(fallback, panelRateLimitErrorTTL) + return fallback, nil + } + + s.storePanelRateLimitCache(*settings, panelRateLimitCacheTTL) + return *settings, nil + }) + if settings, ok := result.(PanelRateLimitSettings); ok { + return settings + } + return *DefaultPanelRateLimitSettings() +} + +func (s *SettingService) storePanelRateLimitCache(settings PanelRateLimitSettings, ttl time.Duration) { + s.panelRateLimitCache.Store(&cachedPanelRateLimitSettings{ + settings: settings, + expiresAt: time.Now().Add(ttl).UnixNano(), + }) +} diff --git a/backend/internal/service/setting_panel_rate_limit_test.go b/backend/internal/service/setting_panel_rate_limit_test.go new file mode 100644 index 000000000..f2544c6f3 --- /dev/null +++ b/backend/internal/service/setting_panel_rate_limit_test.go @@ -0,0 +1,182 @@ +package service + +import ( + "context" + "sync" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type panelRateLimitSettingRepo struct { + mu sync.Mutex + values map[string]string + getValueErr error + getValueCalls int +} + +func (r *panelRateLimitSettingRepo) Get(_ context.Context, key string) (*Setting, error) { + r.mu.Lock() + defer r.mu.Unlock() + value, ok := r.values[key] + if !ok { + return nil, ErrSettingNotFound + } + return &Setting{Key: key, Value: value}, nil +} + +func (r *panelRateLimitSettingRepo) GetValue(_ context.Context, key string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.getValueCalls++ + if r.getValueErr != nil { + return "", r.getValueErr + } + value, ok := r.values[key] + if !ok { + return "", ErrSettingNotFound + } + return value, nil +} + +func (r *panelRateLimitSettingRepo) Set(_ context.Context, key, value string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.values == nil { + r.values = make(map[string]string) + } + r.values[key] = value + return nil +} + +func (r *panelRateLimitSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := r.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (r *panelRateLimitSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.values == nil { + r.values = make(map[string]string) + } + for key, value := range settings { + r.values[key] = value + } + return nil +} + +func (r *panelRateLimitSettingRepo) GetAll(_ context.Context) (map[string]string, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make(map[string]string, len(r.values)) + for key, value := range r.values { + out[key] = value + } + return out, nil +} + +func (r *panelRateLimitSettingRepo) Delete(_ context.Context, key string) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.values, key) + return nil +} + +func newPanelRateLimitTestService(repo SettingRepository) *SettingService { + return NewSettingService(repo, &config.Config{}) +} + +func TestGetPanelRateLimitSettingsDefaults(t *testing.T) { + svc := newPanelRateLimitTestService(&panelRateLimitSettingRepo{}) + + settings, err := svc.GetPanelRateLimitSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, DefaultPanelRateLimitSettings(), settings) +} + +func TestGetPanelRateLimitSettingsInvalidJSONFallsBack(t *testing.T) { + repo := &panelRateLimitSettingRepo{values: map[string]string{ + SettingKeyPanelRateLimitSettings: "{not-json", + }} + svc := newPanelRateLimitTestService(repo) + + settings, err := svc.GetPanelRateLimitSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, DefaultPanelRateLimitSettings(), settings) +} + +func TestGetPanelRateLimitSettingsNormalizesValues(t *testing.T) { + repo := &panelRateLimitSettingRepo{values: map[string]string{ + SettingKeyPanelRateLimitSettings: `{"enabled":true,"user_rpm":-5,"heavy_rpm":999999999,"exempt_admin":false,"public_ip_rpm":10}`, + }} + svc := newPanelRateLimitTestService(repo) + + settings, err := svc.GetPanelRateLimitSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 0, settings.UserRPM) + require.Equal(t, panelRateLimitRPMMax, settings.HeavyRPM) + require.Equal(t, 10, settings.PublicIPRPM) + require.False(t, settings.ExemptAdmin) +} + +func TestSetPanelRateLimitSettingsValidation(t *testing.T) { + svc := newPanelRateLimitTestService(&panelRateLimitSettingRepo{}) + + require.Error(t, svc.SetPanelRateLimitSettings(context.Background(), nil)) + require.Error(t, svc.SetPanelRateLimitSettings(context.Background(), &PanelRateLimitSettings{UserRPM: -1})) + require.Error(t, svc.SetPanelRateLimitSettings(context.Background(), &PanelRateLimitSettings{HeavyRPM: panelRateLimitRPMMax + 1})) +} + +func TestSetPanelRateLimitSettingsRoundTripAndCacheRefresh(t *testing.T) { + repo := &panelRateLimitSettingRepo{} + svc := newPanelRateLimitTestService(repo) + + // 先填充缓存(默认值) + cached := svc.GetPanelRateLimitSettingsCached(context.Background()) + require.Equal(t, *DefaultPanelRateLimitSettings(), cached) + + want := &PanelRateLimitSettings{ + Enabled: true, + UserRPM: 120, + HeavyRPM: 30, + ExemptAdmin: false, + PublicIPRPM: 60, + } + require.NoError(t, svc.SetPanelRateLimitSettings(context.Background(), want)) + + // 写入后无需等待 TTL,缓存立即反映新值 + cached = svc.GetPanelRateLimitSettingsCached(context.Background()) + require.Equal(t, *want, cached) + + // DB 中持久化的值可直读 + stored, err := svc.GetPanelRateLimitSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, want, stored) +} + +func TestGetPanelRateLimitSettingsCachedAvoidsRepeatedDBReads(t *testing.T) { + repo := &panelRateLimitSettingRepo{values: map[string]string{ + SettingKeyPanelRateLimitSettings: `{"enabled":true,"user_rpm":100,"heavy_rpm":20,"exempt_admin":true,"public_ip_rpm":50}`, + }} + svc := newPanelRateLimitTestService(repo) + + for i := 0; i < 5; i++ { + settings := svc.GetPanelRateLimitSettingsCached(context.Background()) + require.Equal(t, 100, settings.UserRPM) + } + + repo.mu.Lock() + calls := repo.getValueCalls + repo.mu.Unlock() + require.Equal(t, 1, calls, "TTL 内应只读一次 DB") +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 5f02110b4..35bcccb82 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -63,6 +63,11 @@ type SettingService struct { cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime cyberSessionBlockRuntimeSF singleflight.Group + // panelRateLimitCache 面板 API 限流配置进程内缓存(*cachedPanelRateLimitSettings)。 + // 面板每个认证请求都会读取,禁止在热路径上直接访问 DB。 + panelRateLimitCache atomic.Value + panelRateLimitSF singleflight.Group + // openAIQuotaAutoPauseSettingsCache holds the most recently observed quota auto-pause // settings. GetOpenAIQuotaAutoPauseSettings reads this atomic.Value on the request hot // path without ever blocking on the DB; when the cached entry expires, a background diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 8861ce56b..a5b92f4e3 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1206,6 +1206,38 @@ export async function updateRateLimit429CooldownSettings( return data; } +// ==================== Panel Rate Limit Settings ==================== + +/** + * Panel API rate limit settings. + * Authenticated panel endpoints are limited per user account (reverse-proxy + * safe); public endpoints are limited per publicly routable client IP. + */ +export interface PanelRateLimitSettings { + enabled: boolean; + user_rpm: number; + heavy_rpm: number; + exempt_admin: boolean; + public_ip_rpm: number; +} + +export async function getPanelRateLimitSettings(): Promise { + const { data } = await apiClient.get( + "/admin/settings/panel-rate-limit", + ); + return data; +} + +export async function updatePanelRateLimitSettings( + settings: PanelRateLimitSettings, +): Promise { + const { data } = await apiClient.put( + "/admin/settings/panel-rate-limit", + settings, + ); + return data; +} + // ==================== Stream Timeout Settings ==================== /** @@ -1433,6 +1465,8 @@ export const settingsAPI = { updateOverloadCooldownSettings, getRateLimit429CooldownSettings, updateRateLimit429CooldownSettings, + getPanelRateLimitSettings, + updatePanelRateLimitSettings, getStreamTimeoutSettings, updateStreamTimeoutSettings, getRectifierSettings, diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 8bc1a39cf..0fba4cfb7 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -135,6 +135,24 @@ export default { auditRetention: 'Audit Log Retention (days)', auditRetentionHint: 'Audit logs older than this are cleaned up automatically. Set to 0 to keep them forever (manual clear only).' }, + panelRateLimit: { + title: 'Panel API Rate Limiting', + description: 'Throttle panel API requests to keep high-frequency polling (usage stats, dashboard queries) from overwhelming the database', + proxySafeNote: 'Authenticated endpoints are counted per user account, independent of the source IP — reverse proxies and shared NAT egress are never falsely blocked. Public endpoints are counted per real client IP, and loopback/private addresses (internal proxy hops) are skipped automatically.', + enabled: 'Enable panel rate limiting', + enabledHint: 'Limits authenticated panel endpoints per account. Requests over the threshold get HTTP 429 and recover automatically when the window resets.', + userRpm: 'Requests per account', + userRpmHint: 'Total panel API requests allowed per account per minute. Normal UI usage stays far below this. 0 = unlimited.', + heavyRpm: 'Heavy queries per account', + heavyRpmHint: 'Usage/dashboard aggregation queries allowed per account per minute (these are the most expensive for the database). 0 = unlimited.', + publicIpRpm: 'Public endpoints per IP', + publicIpRpmHint: 'Requests per minute allowed per real client IP for unauthenticated endpoints (e.g. public site settings). 0 = unlimited.', + perMinute: 'req/min', + exemptAdmin: 'Exempt administrators', + exemptAdminHint: 'When enabled, admin accounts bypass panel rate limits so bulk operations are never throttled.', + saved: 'Panel rate limit settings saved', + saveFailed: 'Failed to save panel rate limit settings' + }, turnstile: { title: 'Cloudflare Turnstile', description: 'Bot protection for login and registration', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index dccf073c1..199d7a5be 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -135,6 +135,24 @@ export default { auditRetention: '操作日志保留天数', auditRetentionHint: '超过该天数的操作日志将被自动清理;填 0 表示永久保留(仅支持手动清空)。' }, + panelRateLimit: { + title: '面板接口限流', + description: '限制面板 API 的请求频率,防止高频刷接口(如用量统计、仪表盘查询)打爆数据库', + proxySafeNote: '登录后的接口按「用户账号」维度计数,与来源 IP 无关——反向代理、NAT 共享出口等场景不会被误拦截;公开接口按真实客户端 IP 计数,回环与内网地址(反代内部转发地址)会自动跳过。', + enabled: '启用面板接口限流', + enabledHint: '对登录后的面板接口按账号限流;超出阈值返回 429,窗口重置后自动恢复。', + userRpm: '每账号请求上限', + userRpmHint: '单个账号每分钟允许的面板 API 请求总数,正常页面操作远达不到该阈值;0 表示不限制。', + heavyRpm: '重查询请求上限', + heavyRpmHint: '单个账号每分钟允许的用量/仪表盘等聚合统计查询次数(这类请求对数据库压力最大);0 表示不限制。', + publicIpRpm: '公开接口每 IP 上限', + publicIpRpmHint: '无需登录的公开接口(如站点公开设置)每个真实客户端 IP 每分钟的请求上限;0 表示不限制。', + perMinute: '次/分钟', + exemptAdmin: '管理员豁免', + exemptAdminHint: '开启后管理员账号不受面板限流约束,避免批量运维操作被误拦。', + saved: '面板接口限流配置已保存', + saveFailed: '保存面板接口限流配置失败' + }, turnstile: { title: 'Cloudflare Turnstile', description: '登录和注册的机器人防护', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 120a63174..ebb3a3a19 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1717,6 +1717,197 @@ + +
+
+
+ +

+ {{ t("admin.settings.panelRateLimit.title") }} +

+
+

+ {{ t("admin.settings.panelRateLimit.description") }} +

+
+
+
+
+ {{ t("common.loading") }} +
+ + +
+
+
{ loadOllamaCloudUsageSettings(); loadOverloadCooldownSettings(); loadRateLimit429CooldownSettings(); + loadPanelRateLimitSettings(); loadStreamTimeoutSettings(); loadRectifierSettings(); loadBetaPolicySettings(); diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 76ecc6c6b..f00e2f9ab 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -13,6 +13,8 @@ const { getOverloadCooldownSettings, getRateLimit429CooldownSettings, updateRateLimit429CooldownSettings, + getPanelRateLimitSettings, + updatePanelRateLimitSettings, getStreamTimeoutSettings, getRectifierSettings, getBetaPolicySettings, @@ -39,6 +41,14 @@ const { getOverloadCooldownSettings: vi.fn(), getRateLimit429CooldownSettings: vi.fn(), updateRateLimit429CooldownSettings: vi.fn(), + getPanelRateLimitSettings: vi.fn().mockResolvedValue({ + enabled: true, + user_rpm: 240, + heavy_rpm: 60, + exempt_admin: true, + public_ip_rpm: 300, + }), + updatePanelRateLimitSettings: vi.fn().mockImplementation(async (payload) => payload), getStreamTimeoutSettings: vi.fn(), getRectifierSettings: vi.fn(), getBetaPolicySettings: vi.fn(), @@ -78,6 +88,8 @@ vi.mock("@/api", () => ({ getOverloadCooldownSettings, getRateLimit429CooldownSettings, updateRateLimit429CooldownSettings, + getPanelRateLimitSettings, + updatePanelRateLimitSettings, getStreamTimeoutSettings, getRectifierSettings, getBetaPolicySettings, @@ -662,6 +674,44 @@ describe("admin SettingsView payment visible method controls", () => { adminSettingsFetch.mockResolvedValue(undefined); }); + it("renders panel rate limit card and saves settings", async () => { + getPanelRateLimitSettings.mockClear(); + updatePanelRateLimitSettings.mockClear(); + getPanelRateLimitSettings.mockResolvedValue({ + enabled: true, + user_rpm: 240, + heavy_rpm: 60, + exempt_admin: true, + public_ip_rpm: 300, + }); + updatePanelRateLimitSettings.mockImplementation(async (payload) => payload); + + const wrapper = mountView(); + await flushPromises(); + + expect(getPanelRateLimitSettings).toHaveBeenCalled(); + expect(wrapper.text()).toContain("admin.settings.panelRateLimit.title"); + expect(wrapper.text()).toContain("admin.settings.panelRateLimit.proxySafeNote"); + + const userRpmInput = wrapper.find('[data-testid="panel-rate-limit-user-rpm"]'); + expect(userRpmInput.exists()).toBe(true); + await userRpmInput.setValue("120"); + + const saveButton = wrapper.find('[data-testid="panel-rate-limit-save"]'); + expect(saveButton.exists()).toBe(true); + await saveButton.trigger("click"); + await flushPromises(); + + expect(updatePanelRateLimitSettings).toHaveBeenCalledWith({ + enabled: true, + user_rpm: 120, + heavy_rpm: 60, + exempt_admin: true, + public_ip_rpm: 300, + }); + expect(showSuccess).toHaveBeenCalled(); + }); + it("does not render legacy visible payment method controls", async () => { const wrapper = mountView();