114 lines
3.8 KiB
Go
114 lines
3.8 KiB
Go
package middleware
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/net/ghttp"
|
|
|
|
"github.com/qianfree/team-api/internal/consts"
|
|
"github.com/qianfree/team-api/internal/logic/common"
|
|
)
|
|
|
|
// ipBlacklistMatcher 按配置原始串编译好的黑名单条目快照。
|
|
// 配置经 ConfigService 的 L1 内存缓存读取本身够快,但 JSON 反解析不值得每请求重复,
|
|
// 以原始串为版本号做原子快照:配置变更(Pub/Sub 失效缓存)后的首个请求重新编译。
|
|
type ipBlacklistMatcher struct {
|
|
raw string
|
|
entries []common.IpBlacklistEntry
|
|
}
|
|
|
|
var ipBlacklistMatcherSnapshot atomic.Value
|
|
|
|
// loadIpBlacklistMatcher 取黑名单快照,配置串未变化时零开销复用。
|
|
// 并发下可能重复编译一次,原子覆盖无锁竞争,结果一致,无需消重。
|
|
func loadIpBlacklistMatcher(raw string) *ipBlacklistMatcher {
|
|
if m, ok := ipBlacklistMatcherSnapshot.Load().(*ipBlacklistMatcher); ok && m.raw == raw {
|
|
return m
|
|
}
|
|
m := &ipBlacklistMatcher{raw: raw, entries: common.ParseIpBlacklistEntries(raw)}
|
|
ipBlacklistMatcherSnapshot.Store(m)
|
|
return m
|
|
}
|
|
|
|
func (m *ipBlacklistMatcher) match(ip net.IP) bool {
|
|
if ip == nil || len(m.entries) == 0 {
|
|
return false
|
|
}
|
|
for i := range m.entries {
|
|
if m.entries[i].Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IpBlacklist 全局中间件:命中黑名单 IP 的请求在任何业务路由之前直接拒绝(403)。
|
|
// 挂在 Recovery/RequestId/ServiceName 之后(保证 panic 兜底与 request_id 就绪)、
|
|
// setup 守卫与所有路由组之前,是请求进入业务链路的第一道闸门。
|
|
// /api/health 豁免:存活探针来自 LB/编排器,探针 IP 一旦被误拉黑会引发实例被
|
|
// 连锁摘流的级联故障,而健康端点本身无业务价值,不值得拦。
|
|
func IpBlacklist(r *ghttp.Request) {
|
|
if r.URL.Path == "/api/health" {
|
|
r.Middleware.Next()
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
if !common.Config().GetBool(ctx, common.OptionKeyIpBlacklistEnabled) {
|
|
r.Middleware.Next()
|
|
return
|
|
}
|
|
|
|
m := loadIpBlacklistMatcher(common.Config().GetString(ctx, common.OptionKeyIpBlacklistList))
|
|
// GetClientIp 与全站限流/审计同一信任模型(取 X-Forwarded-For 等代理头),
|
|
// 部署必须保证代理层覆写这些头,否则客户端可伪造绕过
|
|
ipText := r.GetClientIp()
|
|
if !m.match(net.ParseIP(ipText)) {
|
|
r.Middleware.Next()
|
|
return
|
|
}
|
|
|
|
// 拦截计数 best-effort:决策已定,统计失败不影响响应
|
|
common.IncrIpBlacklistBlocked(ctx, ipText)
|
|
writeIpBlacklistedResponse(r)
|
|
}
|
|
|
|
// writeIpBlacklistedResponse 按端点协议返回拒绝响应:
|
|
// AI 代理端点(/v1 /v1beta /v2 /suno)保持供应商原生错误格式(/v1/messages 用 Claude 格式,
|
|
// 其余用 OpenAI 格式),管理类端点走平台统一 JSON 格式——与 ApiMaintenance 的分流方式一致。
|
|
func writeIpBlacklistedResponse(r *ghttp.Request) {
|
|
const message = "IP 已被列入黑名单,禁止访问"
|
|
path := r.URL.Path
|
|
|
|
r.Response.Header().Set("Content-Type", "application/json")
|
|
r.Response.WriteHeader(http.StatusForbidden)
|
|
|
|
switch {
|
|
case strings.HasPrefix(path, "/v1/messages"):
|
|
body, _ := json.Marshal(map[string]any{
|
|
"type": "error",
|
|
"error": map[string]string{"type": "forbidden", "message": message},
|
|
})
|
|
r.Response.Write(body)
|
|
case strings.HasPrefix(path, "/v1/") || strings.HasPrefix(path, "/v1beta/") ||
|
|
strings.HasPrefix(path, "/v2/") || strings.HasPrefix(path, "/suno/"):
|
|
body, _ := json.Marshal(map[string]any{
|
|
"error": map[string]string{"type": "forbidden", "message": message},
|
|
})
|
|
r.Response.Write(body)
|
|
default:
|
|
r.Response.WriteJson(g.Map{
|
|
"code": consts.CodeForbidden,
|
|
"message": message,
|
|
"data": nil,
|
|
"request_id": r.GetCtxVar("RequestId"),
|
|
})
|
|
}
|
|
r.Exit()
|
|
}
|