✨ feat(server): 新增每 IP 请求限流
- 新增 RateLimit 中间件:golang.org/x/time/rate 令牌桶,超出返回 429 - server.rate_limit_qps 配置(默认 0 不限制,突发 10),公网部署建议开启 - 业务路由统一接入 限流 → 鉴权 中间件链;/healthz 与静态资源不限流 - X-Forwarded-For 优先取真实客户端 IP(反代场景),visitor 表定时清理防泄漏 - config.yaml 补齐 protect_all 与 rate_limit_qps 配置项(此前仅存在于代码)
This commit is contained in:
1 parent
822d3fe444
commit
f89bee8ddc
5 files changed
+112
-38
No files matched your search
@@ -6,9 +6,14 @@ import (
|
||||
"fanqie/internal/config"
|
||||
"fanqie/internal/fqapi"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -62,7 +67,7 @@ func LoadStatistics() {
|
||||
fmt.Printf("加载统计数据失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
for k, v := range stats {
|
||||
if c, ok := requestStats[k]; ok {
|
||||
c.Store(v)
|
||||
@@ -106,23 +111,85 @@ func respondError(w http.ResponseWriter, msg string, isClientErr bool) {
|
||||
respondJSON(w, status, map[string]interface{}{"code": -1, "msg": msg})
|
||||
}
|
||||
|
||||
// RateLimit 每 IP 限流(server.rate_limit_qps > 0 时启用)
|
||||
// ponytail: 配置启动时读取一次,热更新不生效;visitor 表由后台协程清理防泄漏
|
||||
func RateLimit(handler http.HandlerFunc) http.HandlerFunc {
|
||||
cfg := config.GetAppConfig()
|
||||
qps := 0.0
|
||||
if cfg != nil {
|
||||
qps = cfg.Server.RateLimitQPS
|
||||
}
|
||||
if qps <= 0 {
|
||||
return handler
|
||||
}
|
||||
|
||||
const burst = 10
|
||||
var mu sync.Mutex
|
||||
limitters := map[string]*rate.Limiter{}
|
||||
lastSeen := map[string]time.Time{}
|
||||
|
||||
go func() {
|
||||
for range time.Tick(time.Minute) {
|
||||
mu.Lock()
|
||||
for ip, t := range lastSeen {
|
||||
if time.Since(t) > 10*time.Minute {
|
||||
delete(lastSeen, ip)
|
||||
delete(limitters, ip)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
mu.Lock()
|
||||
l, ok := limitters[ip]
|
||||
if !ok {
|
||||
l = rate.NewLimiter(rate.Limit(qps), burst)
|
||||
limitters[ip] = l
|
||||
}
|
||||
lastSeen[ip] = time.Now()
|
||||
mu.Unlock()
|
||||
|
||||
if !l.Allow() {
|
||||
respondJSON(w, http.StatusTooManyRequests, map[string]interface{}{
|
||||
"code": -1, "msg": "请求过于频繁,请稍后再试",
|
||||
})
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP 取客户端 IP:优先 X-Forwarded-For 首段(反代场景),否则 RemoteAddr
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// RequireAccessKey 验证访问密钥中间件
|
||||
func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
|
||||
// 如果未配置 access_key 或为空,则不启用验证
|
||||
if cfg == nil || cfg.Server.AccessKey == "" {
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 从查询参数或 Header 中获取 key
|
||||
key := r.URL.Query().Get("key")
|
||||
if key == "" {
|
||||
key = r.Header.Get("X-Access-Key")
|
||||
}
|
||||
|
||||
|
||||
// 验证密钥(常量时间比较,防时序攻击)
|
||||
if subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.AccessKey)) != 1 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -164,11 +231,11 @@ func HandleMain(w http.ResponseWriter, r *http.Request) {
|
||||
// HandleSelfInfo 设备信息处理
|
||||
func HandleSelfInfo(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
|
||||
// 获取设备池统计信息
|
||||
pool := fqapi.GetDeviceKeyPool()
|
||||
poolStats := pool.GetStats()
|
||||
|
||||
|
||||
response := map[string]interface{}{
|
||||
"code": 0,
|
||||
"message": "SUCCESS",
|
||||
|
||||
Reference in new issue
Block a user