Merge pull request #4946 from Wei-Shaw/feat/panel-api-rate-limit
feat(security): 面板 API 限流保护——防止高频刷接口打爆数据库
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '登录和注册的机器人防护',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user