panel_rate_limit.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. package middleware
  2. import (
  3. "context"
  4. "log/slog"
  5. "net"
  6. "net/http"
  7. "strconv"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/middleware"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/gin-gonic/gin"
  12. "github.com/redis/go-redis/v9"
  13. )
  14. // panelRateLimitWindow 面板限流固定窗口时长(所有档位均按每分钟计数)。
  15. const panelRateLimitWindow = time.Minute
  16. // panelRateLimitAllower 抽象底层限流原语,便于单测注入。
  17. type panelRateLimitAllower interface {
  18. Allow(ctx context.Context, key string, limit int, window time.Duration) (middleware.AllowResult, error)
  19. }
  20. // PanelRateLimiter 面板(管理面 /api/v1)API 限流器。
  21. //
  22. // 设计要点:
  23. // - 认证接口按「用户 ID」维度计数:与客户端 IP 完全无关,反向代理/共享出口
  24. // (所有请求源 IP 坍缩为 127.0.0.1 等)不会互相误伤。
  25. // - 公开接口按安全客户端 IP 计数:仅统计全局单播地址,回环/内网/链路本地
  26. // 地址(反代内部转发地址)直接跳过,避免误拦整条反代链路的流量。
  27. // - 配置走进程内缓存(60s TTL),热路径零 DB 访问。
  28. // - Redis 异常一律 fail-open:限流是保护措施,不能反过来把面板打挂。
  29. type PanelRateLimiter struct {
  30. limiter panelRateLimitAllower
  31. settingService *service.SettingService
  32. }
  33. // NewPanelRateLimiter 创建面板限流器。
  34. func NewPanelRateLimiter(redisClient *redis.Client, settingService *service.SettingService) *PanelRateLimiter {
  35. return &PanelRateLimiter{
  36. limiter: middleware.NewRateLimiter(redisClient),
  37. settingService: settingService,
  38. }
  39. }
  40. // Global 认证面板接口的全局按用户限流(宽松档,覆盖所有登录后端点)。
  41. func (p *PanelRateLimiter) Global() gin.HandlerFunc {
  42. return p.userScoped("global", func(s service.PanelRateLimitSettings) int { return s.UserRPM })
  43. }
  44. // Heavy 重查询接口的按用户限流(严格档,覆盖 usage/dashboard 等聚合统计端点)。
  45. // 与 Global 叠加计数:一次重查询同时消耗两档额度。
  46. func (p *PanelRateLimiter) Heavy() gin.HandlerFunc {
  47. return p.userScoped("heavy", func(s service.PanelRateLimitSettings) int { return s.HeavyRPM })
  48. }
  49. func (p *PanelRateLimiter) userScoped(scope string, limitOf func(service.PanelRateLimitSettings) int) gin.HandlerFunc {
  50. return func(c *gin.Context) {
  51. if p == nil || p.limiter == nil || p.settingService == nil {
  52. c.Next()
  53. return
  54. }
  55. settings := p.settingService.GetPanelRateLimitSettingsCached(c.Request.Context())
  56. if !settings.Enabled {
  57. c.Next()
  58. return
  59. }
  60. limit := limitOf(settings)
  61. if limit <= 0 {
  62. c.Next()
  63. return
  64. }
  65. subject, ok := GetAuthSubjectFromContext(c)
  66. if !ok || subject.UserID <= 0 {
  67. // 无认证主体(认证中间件缺位时的防御分支):放行,避免误伤
  68. c.Next()
  69. return
  70. }
  71. if settings.ExemptAdmin {
  72. if role, hasRole := GetUserRoleFromContext(c); hasRole && service.IsAdminRole(role) {
  73. c.Next()
  74. return
  75. }
  76. }
  77. key := "panel:" + scope + ":user:" + strconv.FormatInt(subject.UserID, 10)
  78. result, err := p.limiter.Allow(c.Request.Context(), key, limit, panelRateLimitWindow)
  79. if err != nil {
  80. // fail-open:Redis 异常不阻断面板访问
  81. slog.Warn("panel rate limit check failed, allowing request", "scope", scope, "error", err)
  82. c.Next()
  83. return
  84. }
  85. if !result.Allowed {
  86. abortPanelRateLimited(c, result.RetryAfter)
  87. return
  88. }
  89. c.Next()
  90. }
  91. }
  92. // PublicIP 无需认证的公开接口按客户端 IP 限流。
  93. // 使用与审计日志/会话绑定一致的安全客户端 IP 解析;解析结果为回环/内网/
  94. // 链路本地地址时跳过计数(这类地址通常是反代内部转发地址,按它计数会把
  95. // 整条反代链路的所有真实用户合并进同一个桶造成大面积误拦截)。
  96. func (p *PanelRateLimiter) PublicIP() gin.HandlerFunc {
  97. return func(c *gin.Context) {
  98. if p == nil || p.limiter == nil || p.settingService == nil {
  99. c.Next()
  100. return
  101. }
  102. settings := p.settingService.GetPanelRateLimitSettingsCached(c.Request.Context())
  103. if !settings.Enabled || settings.PublicIPRPM <= 0 {
  104. c.Next()
  105. return
  106. }
  107. clientIP := SecurityClientIP(c)
  108. if !isPubliclyRoutableClientIP(clientIP) {
  109. c.Next()
  110. return
  111. }
  112. result, err := p.limiter.Allow(c.Request.Context(), "panel:public:ip:"+clientIP, settings.PublicIPRPM, panelRateLimitWindow)
  113. if err != nil {
  114. slog.Warn("panel public rate limit check failed, allowing request", "error", err)
  115. c.Next()
  116. return
  117. }
  118. if !result.Allowed {
  119. abortPanelRateLimited(c, result.RetryAfter)
  120. return
  121. }
  122. c.Next()
  123. }
  124. }
  125. // isPubliclyRoutableClientIP 判断地址是否为可作为限流依据的全局单播地址。
  126. // 回环、RFC1918/ULA 内网、链路本地与未指定地址返回 false。
  127. func isPubliclyRoutableClientIP(clientIP string) bool {
  128. ip := net.ParseIP(clientIP)
  129. if ip == nil {
  130. return false
  131. }
  132. if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
  133. ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
  134. return false
  135. }
  136. return ip.IsGlobalUnicast()
  137. }
  138. func abortPanelRateLimited(c *gin.Context, retryAfter time.Duration) {
  139. if retryAfter <= 0 {
  140. retryAfter = panelRateLimitWindow
  141. }
  142. seconds := int64(retryAfter / time.Second)
  143. if retryAfter%time.Second > 0 {
  144. seconds++
  145. }
  146. c.Header("Retry-After", strconv.FormatInt(seconds, 10))
  147. AbortWithError(c, http.StatusTooManyRequests, "RATE_LIMITED", "Too many requests, please slow down and try again later")
  148. }