✨ 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
+31
-28
@@ -40,10 +40,10 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer fqapi.CloseRedis()
|
defer fqapi.CloseRedis()
|
||||||
|
|
||||||
// 加载统计数据
|
// 加载统计数据
|
||||||
handlers.LoadStatistics()
|
handlers.LoadStatistics()
|
||||||
|
|
||||||
// 启动统计数据定期保存
|
// 启动统计数据定期保存
|
||||||
handlers.StartStatisticsSaver()
|
handlers.StartStatisticsSaver()
|
||||||
defer handlers.StopStatisticsSaver() // 程序退出时保存统计数据
|
defer handlers.StopStatisticsSaver() // 程序退出时保存统计数据
|
||||||
@@ -99,9 +99,9 @@ func initAlgorithm() {
|
|||||||
if config.GetAppConfig() != nil {
|
if config.GetAppConfig() != nil {
|
||||||
algos.GlobalAlgorithmSwitcher.SwitchAlgorithm(config.GetAppConfig().Fanqie.Algorithm)
|
algos.GlobalAlgorithmSwitcher.SwitchAlgorithm(config.GetAppConfig().Fanqie.Algorithm)
|
||||||
logger.Info(fmt.Sprintf("当前算法: %s", config.GetAppConfig().Fanqie.Algorithm))
|
logger.Info(fmt.Sprintf("当前算法: %s", config.GetAppConfig().Fanqie.Algorithm))
|
||||||
|
|
||||||
algos.GlobalSignatureSwitcher.SwitchSignature(config.GetAppConfig().Fanqie.Signature)
|
algos.GlobalSignatureSwitcher.SwitchSignature(config.GetAppConfig().Fanqie.Signature)
|
||||||
|
|
||||||
// 根据签名方法显示详细信息
|
// 根据签名方法显示详细信息
|
||||||
var signatureDetails string
|
var signatureDetails string
|
||||||
if config.GetAppConfig().Fanqie.Signature == "sign4" {
|
if config.GetAppConfig().Fanqie.Signature == "sign4" {
|
||||||
@@ -121,7 +121,7 @@ func initDeviceKeyPool() error {
|
|||||||
pool := fqapi.GetDeviceKeyPool()
|
pool := fqapi.GetDeviceKeyPool()
|
||||||
if err := pool.Initialize(); err != nil {
|
if err := pool.Initialize(); err != nil {
|
||||||
return fmt.Errorf("初始化设备密钥池失败: %w", err)
|
return fmt.Errorf("初始化设备密钥池失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示池状态
|
// 显示池状态
|
||||||
stats := pool.GetStats()
|
stats := pool.GetStats()
|
||||||
@@ -140,29 +140,32 @@ func setupRoutes() {
|
|||||||
|
|
||||||
http.HandleFunc("/healthz", handlers.HandleHealth)
|
http.HandleFunc("/healthz", handlers.HandleHealth)
|
||||||
|
|
||||||
// API端点(protect_all 开启时要求访问密钥;代理路由不包,URL 由服务端生成不含 key)
|
// API端点:限流 + protect_all 鉴权链(代理路由不鉴权,URL 由服务端生成不含 key)
|
||||||
http.HandleFunc("/search", handlers.OptionalAccessKey(handlers.HandleSearch))
|
wrap := func(h http.HandlerFunc) http.HandlerFunc {
|
||||||
http.HandleFunc("/detail", handlers.OptionalAccessKey(handlers.HandleDetail))
|
return handlers.RateLimit(handlers.OptionalAccessKey(h))
|
||||||
http.HandleFunc("/info", handlers.OptionalAccessKey(handlers.HandleDetail))
|
}
|
||||||
http.HandleFunc("/catalog", handlers.OptionalAccessKey(handlers.HandleCatalog))
|
http.HandleFunc("/search", wrap(handlers.HandleSearch))
|
||||||
http.HandleFunc("/directory/all_items", handlers.OptionalAccessKey(handlers.HandleCatalog))
|
http.HandleFunc("/detail", wrap(handlers.HandleDetail))
|
||||||
http.HandleFunc("/multi-detail", handlers.OptionalAccessKey(handlers.HandleMultiDetail))
|
http.HandleFunc("/info", wrap(handlers.HandleDetail))
|
||||||
http.HandleFunc("/multi-content", handlers.OptionalAccessKey(handlers.HandleMultiContent))
|
http.HandleFunc("/catalog", wrap(handlers.HandleCatalog))
|
||||||
http.HandleFunc("/content", handlers.OptionalAccessKey(handlers.HandleContent))
|
http.HandleFunc("/directory/all_items", wrap(handlers.HandleCatalog))
|
||||||
http.HandleFunc("/item-summary", handlers.OptionalAccessKey(handlers.HandleItemSummary))
|
http.HandleFunc("/multi-detail", wrap(handlers.HandleMultiDetail))
|
||||||
http.HandleFunc("/category-front", handlers.OptionalAccessKey(handlers.HandleCategoryFront))
|
http.HandleFunc("/multi-content", wrap(handlers.HandleMultiContent))
|
||||||
http.HandleFunc("/category-landing", handlers.OptionalAccessKey(handlers.HandleCategoryLanding))
|
http.HandleFunc("/content", wrap(handlers.HandleContent))
|
||||||
http.HandleFunc("/audio", handlers.OptionalAccessKey(handlers.HandleAudio))
|
http.HandleFunc("/item-summary", wrap(handlers.HandleItemSummary))
|
||||||
http.HandleFunc("/audio/proxy/", handlers.HandleAudioProxy)
|
http.HandleFunc("/category-front", wrap(handlers.HandleCategoryFront))
|
||||||
http.HandleFunc("/video", handlers.OptionalAccessKey(handlers.HandleVideo))
|
http.HandleFunc("/category-landing", wrap(handlers.HandleCategoryLanding))
|
||||||
http.HandleFunc("/manga", handlers.OptionalAccessKey(handlers.HandleManga))
|
http.HandleFunc("/audio", wrap(handlers.HandleAudio))
|
||||||
http.HandleFunc("/manga/image/", handlers.HandleMangaImageProxy)
|
http.HandleFunc("/audio/proxy/", wrap(handlers.HandleAudioProxy))
|
||||||
|
http.HandleFunc("/video", wrap(handlers.HandleVideo))
|
||||||
|
http.HandleFunc("/manga", wrap(handlers.HandleManga))
|
||||||
|
http.HandleFunc("/manga/image/", wrap(handlers.HandleMangaImageProxy))
|
||||||
|
|
||||||
// 评论相关端点
|
// 评论相关端点
|
||||||
http.HandleFunc("/chapter_review", handlers.OptionalAccessKey(handlers.HandleChapterReview))
|
http.HandleFunc("/chapter_review", wrap(handlers.HandleChapterReview))
|
||||||
http.HandleFunc("/review", handlers.OptionalAccessKey(handlers.HandleReview))
|
http.HandleFunc("/review", wrap(handlers.HandleReview))
|
||||||
http.HandleFunc("/review_html", handlers.OptionalAccessKey(handlers.HandleReviewHTML))
|
http.HandleFunc("/review_html", wrap(handlers.HandleReviewHTML))
|
||||||
|
|
||||||
// 表情图片静态文件服务
|
// 表情图片静态文件服务
|
||||||
http.Handle("/emoji/", http.StripPrefix("/emoji/", http.FileServer(http.Dir("emoji"))))
|
http.Handle("/emoji/", http.StripPrefix("/emoji/", http.FileServer(http.Dir("emoji"))))
|
||||||
}
|
}
|
||||||
@@ -171,7 +174,7 @@ func setupRoutes() {
|
|||||||
func startServer() {
|
func startServer() {
|
||||||
host := "0.0.0.0"
|
host := "0.0.0.0"
|
||||||
port := "40180"
|
port := "40180"
|
||||||
|
|
||||||
if config.GetAppConfig() != nil {
|
if config.GetAppConfig() != nil {
|
||||||
if config.GetAppConfig().Server.Host != "" {
|
if config.GetAppConfig().Server.Host != "" {
|
||||||
host = config.GetAppConfig().Server.Host
|
host = config.GetAppConfig().Server.Host
|
||||||
@@ -180,7 +183,7 @@ func startServer() {
|
|||||||
port = config.GetAppConfig().Server.Port
|
port = config.GetAppConfig().Server.Port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := host + ":" + port
|
addr := host + ":" + port
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ require (
|
|||||||
github.com/mattn/go-sqlite3 v1.14.18
|
github.com/mattn/go-sqlite3 v1.14.18
|
||||||
github.com/redis/go-redis/v9 v9.22.0
|
github.com/redis/go-redis/v9 v9.22.0
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/spf13/viper v1.18.2
|
||||||
|
golang.org/x/time v0.5.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||||
|
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ type Config struct {
|
|||||||
|
|
||||||
// ServerConfig 服务器配置
|
// ServerConfig 服务器配置
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port string `mapstructure:"port"`
|
Port string `mapstructure:"port"`
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
AccessKey string `mapstructure:"access_key"`
|
AccessKey string `mapstructure:"access_key"`
|
||||||
ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启)
|
ProtectAll bool `mapstructure:"protect_all"` // 业务接口也要求访问密钥(公网部署建议开启)
|
||||||
|
RateLimitQPS float64 `mapstructure:"rate_limit_qps"` // 每 IP 每秒请求上限,0=不限制
|
||||||
}
|
}
|
||||||
|
|
||||||
// FanqieConfig 番茄小说配置
|
// FanqieConfig 番茄小说配置
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ import (
|
|||||||
"fanqie/internal/config"
|
"fanqie/internal/config"
|
||||||
"fanqie/internal/fqapi"
|
"fanqie/internal/fqapi"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -62,7 +67,7 @@ func LoadStatistics() {
|
|||||||
fmt.Printf("加载统计数据失败: %v\n", err)
|
fmt.Printf("加载统计数据失败: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range stats {
|
for k, v := range stats {
|
||||||
if c, ok := requestStats[k]; ok {
|
if c, ok := requestStats[k]; ok {
|
||||||
c.Store(v)
|
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})
|
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 验证访问密钥中间件
|
// RequireAccessKey 验证访问密钥中间件
|
||||||
func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc {
|
func RequireAccessKey(handler http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
// 如果未配置 access_key 或为空,则不启用验证
|
// 如果未配置 access_key 或为空,则不启用验证
|
||||||
if cfg == nil || cfg.Server.AccessKey == "" {
|
if cfg == nil || cfg.Server.AccessKey == "" {
|
||||||
handler(w, r)
|
handler(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从查询参数或 Header 中获取 key
|
// 从查询参数或 Header 中获取 key
|
||||||
key := r.URL.Query().Get("key")
|
key := r.URL.Query().Get("key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = r.Header.Get("X-Access-Key")
|
key = r.Header.Get("X-Access-Key")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证密钥(常量时间比较,防时序攻击)
|
// 验证密钥(常量时间比较,防时序攻击)
|
||||||
if subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.AccessKey)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(key), []byte(cfg.Server.AccessKey)) != 1 {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
@@ -164,11 +231,11 @@ func HandleMain(w http.ResponseWriter, r *http.Request) {
|
|||||||
// HandleSelfInfo 设备信息处理
|
// HandleSelfInfo 设备信息处理
|
||||||
func HandleSelfInfo(w http.ResponseWriter, r *http.Request) {
|
func HandleSelfInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
// 获取设备池统计信息
|
// 获取设备池统计信息
|
||||||
pool := fqapi.GetDeviceKeyPool()
|
pool := fqapi.GetDeviceKeyPool()
|
||||||
poolStats := pool.GetStats()
|
poolStats := pool.GetStats()
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "SUCCESS",
|
"message": "SUCCESS",
|
||||||
|
|||||||
Reference in new issue
Block a user