mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
用户可高频刷面板接口(usage/dashboard 等重聚合查询)直接打爆数据库:
现有限流器只覆盖登录/注册等公开认证入口,登录后的全部面板端点无任何限流。
三层防护(阈值均可在后台可视化配置,panel_rate_limit_settings):
1. 认证面板接口按「用户 ID」限流,与来源 IP 无关——反向代理/NAT 共享出口
(所有请求源地址坍缩为 127.0.0.1 等)不会互相误伤:
- Global 档(默认 240 rpm/账号):user/auth/payment/admin 全部登录后路由
- Heavy 档(默认 60 rpm/账号):/usage、/usage/dashboard/*、
/user/api-keys/:id/usage/daily 等重 SQL 聚合端点叠加计数
- 管理员默认豁免(可关闭)
2. 无认证公开接口(/api/v1/settings/*,每次请求都查 DB)按安全客户端 IP
限流(默认 300 rpm/IP);回环/私网/链路本地地址(反代内部转发地址)
一律跳过计数,杜绝把整条反代链路合并进同一个桶造成大面积误拦截。
3. 修复既有隐患:auth 入口限流的 IP 取值从 c.ClientIP() 切换到与审计日志/
会话绑定/API Key ACL 同源的安全客户端 IP 解析(尊重后台「信任反代转发
IP」开关快照)。原实现下默认反代部署(未配置 server.trusted_proxies)
所有用户共享同一个登录限流桶,既会全员误拦也可被单人恶意占满形成登录
DoS;开关关闭时行为与原来完全一致。
工程约束:
- 配置热路径走进程内缓存(atomic.Value + singleflight,60s TTL),
限流中间件零 DB 访问;保存后当前节点立即生效
- 面板限流 Redis 故障 fail-open(auth 入口保持原有 fail-close)
- 429 响应携带 Retry-After;错误码 RATE_LIMITED
- 支付 webhook / 公开支付回调有意不挂限流
- 新增 GET/PUT /api/v1/admin/settings/panel-rate-limit;设置页安全 tab
新增「面板接口限流」卡片(zh/en i18n 全量)
测试:rate_limiter/panel_rate_limit/setting_panel_rate_limit 单测全绿;
routes、handler/admin、-tags unit 契约测试通过;前端 vue-tsc/ESLint/
SettingsView spec(26/26,含新增交互用例)/i18n 守卫全部通过。
206 lines
5.5 KiB
Go
206 lines
5.5 KiB
Go
package middleware
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
ippkg "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/redis/go-redis/v9"
|
||
)
|
||
|
||
// RateLimitFailureMode Redis 故障策略
|
||
type RateLimitFailureMode int
|
||
|
||
const (
|
||
RateLimitFailOpen RateLimitFailureMode = iota
|
||
RateLimitFailClose
|
||
)
|
||
|
||
// RateLimitOptions 限流可选配置
|
||
type RateLimitOptions struct {
|
||
FailureMode RateLimitFailureMode
|
||
}
|
||
|
||
var rateLimitScript = redis.NewScript(`
|
||
local current = redis.call('INCR', KEYS[1])
|
||
local ttl = redis.call('PTTL', KEYS[1])
|
||
local repaired = 0
|
||
if current == 1 then
|
||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||
elseif ttl == -1 then
|
||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||
repaired = 1
|
||
end
|
||
return {current, repaired}
|
||
`)
|
||
|
||
// rateLimitRun 允许测试覆写脚本执行逻辑
|
||
var rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
|
||
values, err := rateLimitScript.Run(ctx, client, []string{key}, windowMillis).Slice()
|
||
if err != nil {
|
||
return 0, false, err
|
||
}
|
||
if len(values) < 2 {
|
||
return 0, false, fmt.Errorf("rate limit script returned %d values", len(values))
|
||
}
|
||
count, err := parseInt64(values[0])
|
||
if err != nil {
|
||
return 0, false, err
|
||
}
|
||
repaired, err := parseInt64(values[1])
|
||
if err != nil {
|
||
return 0, false, err
|
||
}
|
||
return count, repaired == 1, nil
|
||
}
|
||
|
||
// RateLimiter Redis 速率限制器
|
||
type RateLimiter struct {
|
||
redis *redis.Client
|
||
prefix string
|
||
}
|
||
|
||
// NewRateLimiter 创建速率限制器实例
|
||
func NewRateLimiter(redisClient *redis.Client) *RateLimiter {
|
||
return &RateLimiter{
|
||
redis: redisClient,
|
||
prefix: "rate_limit:",
|
||
}
|
||
}
|
||
|
||
// 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: 时间窗口内最大请求数
|
||
// window: 时间窗口
|
||
func (r *RateLimiter) Limit(key string, limit int, window time.Duration) gin.HandlerFunc {
|
||
return r.LimitWithOptions(key, limit, window, RateLimitOptions{})
|
||
}
|
||
|
||
// LimitWithOptions 返回速率限制中间件(带可选配置)
|
||
func (r *RateLimiter) LimitWithOptions(key string, limit int, window time.Duration, opts RateLimitOptions) gin.HandlerFunc {
|
||
failureMode := opts.FailureMode
|
||
if failureMode != RateLimitFailClose {
|
||
failureMode = RateLimitFailOpen
|
||
}
|
||
|
||
return func(c *gin.Context) {
|
||
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", r.prefix+key, failureModeLabel(failureMode), err)
|
||
if failureMode == RateLimitFailClose {
|
||
abortRateLimit(c, window)
|
||
return
|
||
}
|
||
// Redis 错误时放行,避免影响正常服务
|
||
c.Next()
|
||
return
|
||
}
|
||
|
||
// 超过限制
|
||
if !result.Allowed {
|
||
abortRateLimit(c, result.RetryAfter)
|
||
return
|
||
}
|
||
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
func windowTTLMillis(window time.Duration) int64 {
|
||
ttl := window.Milliseconds()
|
||
if ttl < 1 {
|
||
return 1
|
||
}
|
||
return ttl
|
||
}
|
||
|
||
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",
|
||
})
|
||
}
|
||
|
||
func failureModeLabel(mode RateLimitFailureMode) string {
|
||
if mode == RateLimitFailClose {
|
||
return "fail-close"
|
||
}
|
||
return "fail-open"
|
||
}
|
||
|
||
func parseInt64(value any) (int64, error) {
|
||
switch v := value.(type) {
|
||
case int64:
|
||
return v, nil
|
||
case int:
|
||
return int64(v), nil
|
||
case string:
|
||
parsed, err := strconv.ParseInt(v, 10, 64)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return parsed, nil
|
||
default:
|
||
return 0, fmt.Errorf("unexpected value type %T", value)
|
||
}
|
||
}
|