Merge pull request #4946 from Wei-Shaw/feat/panel-api-rate-limit

feat(security): 面板 API 限流保护——防止高频刷接口打爆数据库
This commit is contained in:
Wesley Liddick
2026-07-27 15:44:06 +08:00
committed by GitHub
23 changed files with 1460 additions and 26 deletions
@@ -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) {
+9
View File
@@ -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"`
+61 -17
View File
@@ -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",
@@ -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)
@@ -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")
}
@@ -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"))
}
+8 -4
View File
@@ -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)
}
+6
View File
@@ -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)
+6 -1
View File
@@ -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)
// 撤销所有会话(需要认证)
@@ -33,6 +33,7 @@ func newAuthRoutesTestRouter(redisClient *redis.Client) *gin.Engine {
}),
redisClient,
nil,
nil,
)
return router
@@ -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",
@@ -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)
@@ -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
+6 -2
View File
@@ -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)
@@ -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
@@ -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(),
})
}
@@ -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")
}
@@ -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
+34
View File
@@ -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<PanelRateLimitSettings> {
const { data } = await apiClient.get<PanelRateLimitSettings>(
"/admin/settings/panel-rate-limit",
);
return data;
}
export async function updatePanelRateLimitSettings(
settings: PanelRateLimitSettings,
): Promise<PanelRateLimitSettings> {
const { data } = await apiClient.put<PanelRateLimitSettings>(
"/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,
@@ -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',
@@ -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: '登录和注册的机器人防护',
+240
View File
@@ -1717,6 +1717,197 @@
</div>
</div>
<!-- Panel API Rate Limit Settings -->
<div class="card">
<div
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
>
<div class="flex items-center gap-2">
<Icon
name="shield"
size="md"
class="text-primary-500"
/>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t("admin.settings.panelRateLimit.title") }}
</h2>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.panelRateLimit.description") }}
</p>
</div>
<div class="space-y-5 p-6">
<div
v-if="panelRateLimitLoading"
class="flex items-center gap-2 text-gray-500"
>
<div
class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"
></div>
{{ t("common.loading") }}
</div>
<template v-else>
<!-- 计数维度说明:按账号计数,反代部署无误伤 -->
<div
class="rounded-lg border border-sky-200 bg-sky-50 p-4 dark:border-sky-800 dark:bg-sky-900/20"
>
<div class="flex items-start">
<Icon
name="infoCircle"
size="md"
class="mt-0.5 flex-shrink-0 text-sky-500"
/>
<p class="ml-3 text-sm text-sky-700 dark:text-sky-300">
{{ t("admin.settings.panelRateLimit.proxySafeNote") }}
</p>
</div>
</div>
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.panelRateLimit.enabled")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.panelRateLimit.enabledHint") }}
</p>
</div>
<Toggle v-model="panelRateLimitForm.enabled" />
</div>
<div
v-if="panelRateLimitForm.enabled"
class="space-y-5 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.panelRateLimit.userRpm") }}
</label>
<div class="flex items-center gap-2">
<input
v-model.number="panelRateLimitForm.user_rpm"
data-testid="panel-rate-limit-user-rpm"
type="number"
min="0"
max="100000"
class="input w-32"
/>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.panelRateLimit.perMinute") }}
</span>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.panelRateLimit.userRpmHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.panelRateLimit.heavyRpm") }}
</label>
<div class="flex items-center gap-2">
<input
v-model.number="panelRateLimitForm.heavy_rpm"
type="number"
min="0"
max="100000"
class="input w-32"
/>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.panelRateLimit.perMinute") }}
</span>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.panelRateLimit.heavyRpmHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.panelRateLimit.publicIpRpm") }}
</label>
<div class="flex items-center gap-2">
<input
v-model.number="panelRateLimitForm.public_ip_rpm"
type="number"
min="0"
max="100000"
class="input w-32"
/>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.panelRateLimit.perMinute") }}
</span>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.panelRateLimit.publicIpRpmHint") }}
</p>
</div>
</div>
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.panelRateLimit.exemptAdmin")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.panelRateLimit.exemptAdminHint") }}
</p>
</div>
<Toggle v-model="panelRateLimitForm.exempt_admin" />
</div>
</div>
<div
class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700"
>
<button
type="button"
data-testid="panel-rate-limit-save"
@click="savePanelRateLimitSettings"
:disabled="panelRateLimitSaving"
class="btn btn-primary btn-sm"
>
<svg
v-if="panelRateLimitSaving"
class="mr-1 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{
panelRateLimitSaving
? t("common.saving")
: t("common.save")
}}
</button>
</div>
</template>
</div>
</div>
<!-- Cloudflare Turnstile Settings -->
<div class="card">
<div
@@ -7973,6 +8164,17 @@ const rateLimit429CooldownForm = reactive({
cooldown_seconds: 5,
});
// Panel API Rate Limit 状态
const panelRateLimitLoading = ref(true);
const panelRateLimitSaving = ref(false);
const panelRateLimitForm = reactive({
enabled: true,
user_rpm: 240,
heavy_rpm: 60,
exempt_admin: true,
public_ip_rpm: 300,
});
// Stream Timeout 状态
const streamTimeoutLoading = ref(true);
const streamTimeoutSaving = ref(false);
@@ -10638,6 +10840,43 @@ async function saveOverloadCooldownSettings() {
}
}
// Panel API Rate Limit 方法
async function loadPanelRateLimitSettings() {
panelRateLimitLoading.value = true;
try {
const settings = await adminAPI.settings.getPanelRateLimitSettings();
Object.assign(panelRateLimitForm, settings);
} catch (_error: unknown) {
// Silent fail - settings will use defaults
} finally {
panelRateLimitLoading.value = false;
}
}
async function savePanelRateLimitSettings() {
panelRateLimitSaving.value = true;
try {
const updated = await adminAPI.settings.updatePanelRateLimitSettings({
enabled: panelRateLimitForm.enabled,
user_rpm: panelRateLimitForm.user_rpm,
heavy_rpm: panelRateLimitForm.heavy_rpm,
exempt_admin: panelRateLimitForm.exempt_admin,
public_ip_rpm: panelRateLimitForm.public_ip_rpm,
});
Object.assign(panelRateLimitForm, updated);
appStore.showSuccess(t("admin.settings.panelRateLimit.saved"));
} catch (error: unknown) {
appStore.showError(
extractApiErrorMessage(
error,
t("admin.settings.panelRateLimit.saveFailed"),
),
);
} finally {
panelRateLimitSaving.value = false;
}
}
// Rate Limit Cooldown (429) 方法
async function loadRateLimit429CooldownSettings() {
rateLimit429CooldownLoading.value = true;
@@ -11304,6 +11543,7 @@ onMounted(() => {
loadOllamaCloudUsageSettings();
loadOverloadCooldownSettings();
loadRateLimit429CooldownSettings();
loadPanelRateLimitSettings();
loadStreamTimeoutSettings();
loadRectifierSettings();
loadBetaPolicySettings();
@@ -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();