fix: read cached failure records type-safely across cache backends (#1014)

#1002
This commit is contained in:
samwafgo
2026-10-13 13:32:29 +08:00
committed by GitHub
parent 1cd8afb35d
commit 6f4d9e8364
13 changed files with 874 additions and 104 deletions
+27 -19
View File
@@ -40,6 +40,23 @@ func (w *WafFileApi) generateFileID(fullPath string) string {
return hex.EncodeToString(hash[:])
}
// dataFilesCache 文件列表的缓存值形态。结构体必须能 JSON 往返:
// JSON 类缓存后端(Redis)经 JSON 存取,读侧统一用 GetAs 还原,不做裸类型断言。
// 字段名与前端约定一致(files/total),改动需同步前端。
type dataFilesCache struct {
Files []FileInfo `json:"files"`
Total int `json:"total"`
}
// loadDataFilesCache 读取文件列表缓存;未命中或读取失败返回 false
func loadDataFilesCache() (*dataFilesCache, bool) {
var cached dataFilesCache
if err := global.GCACHE_WAFCACHE.GetAs(enums.CACHE_FILE_INFO, &cached); err != nil {
return nil, false
}
return &cached, true
}
// GetDataFilesApi 获取data目录下的文件列表
func (w *WafFileApi) GetDataFilesApi(c *gin.Context) {
dataDir := utils.GetCurrentDir() + "/data"
@@ -57,10 +74,9 @@ func (w *WafFileApi) GetDataFilesApi(c *gin.Context) {
cacheKey := enums.CACHE_FILE_INFO
// 如果不是强制刷新,先尝试从缓存获取
if !isForce && global.GCACHE_WAFCACHE.IsKeyExist(cacheKey) {
cachedData := global.GCACHE_WAFCACHE.Get(cacheKey)
if cachedResult, ok := cachedData.(map[string]interface{}); ok {
response.OkWithDetailed(cachedResult, "获取成功(缓存)", c)
if !isForce {
if cached, ok := loadDataFilesCache(); ok {
response.OkWithDetailed(cached, "获取成功(缓存)", c)
return
}
}
@@ -107,10 +123,6 @@ func (w *WafFileApi) GetDataFilesApi(c *gin.Context) {
IsDir: info.IsDir(),
}
// 将单个文件信息缓存到内存中,缓存1小时
fileCacheKey := enums.CACHE_FILE_INFO
global.GCACHE_WAFCACHE.SetWithTTl(fileCacheKey, fileInfo, 1*time.Hour)
fileInfos = append(fileInfos, fileInfo)
return nil
})
@@ -121,9 +133,9 @@ func (w *WafFileApi) GetDataFilesApi(c *gin.Context) {
}
// 构建返回结果
result := map[string]interface{}{
"files": fileInfos,
"total": len(fileInfos),
result := dataFilesCache{
Files: fileInfos,
Total: len(fileInfos),
}
// 将文件列表结果缓存30分钟
@@ -141,16 +153,12 @@ func (w *WafFileApi) DeleteFileByIdApi(c *gin.Context) {
}
// 从缓存中获取文件列表
cacheKey := enums.CACHE_FILE_INFO
if !global.GCACHE_WAFCACHE.IsKeyExist(cacheKey) {
cached, ok := loadDataFilesCache()
if !ok {
response.FailWithMessage("文件信息不存在或已过期,请重新获取文件列表", c)
return
}
// 获取缓存的文件列表结果
cachedResult := global.GCACHE_WAFCACHE.Get(cacheKey).(map[string]interface{})
filesInterface := cachedResult["files"]
fileInfos := filesInterface.([]FileInfo)
fileInfos := cached.Files
// 遍历文件列表找到对应ID的文件
var targetFileInfo *FileInfo
@@ -196,7 +204,7 @@ func (w *WafFileApi) DeleteFileByIdApi(c *gin.Context) {
}
// 删除缓存
global.GCACHE_WAFCACHE.Remove(cacheKey)
global.GCACHE_WAFCACHE.Remove(enums.CACHE_FILE_INFO)
response.OkWithMessage(fmt.Sprintf("文件 %s 删除成功", targetFileInfo.Name), c)
}
+84
View File
@@ -1,6 +1,11 @@
package api
import (
"SamWaf/cache"
"SamWaf/cache/cachetest"
"SamWaf/enums"
"SamWaf/global"
"SamWaf/model/common/response"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
@@ -10,6 +15,7 @@ import (
"os"
"path/filepath"
"testing"
"time"
)
// 响应结构体定义
@@ -156,6 +162,84 @@ func cleanupTestDataDir(tempDir string) {
os.RemoveAll(tempDir)
}
// TestDataFilesCacheRoundTrip 文件列表缓存值在两种缓存后端下都必须能正确往返:
// 缓存值是结构体,JSON 类后端(Redis)经 JSON 存取,读侧用 GetAs 还原。
func TestDataFilesCacheRoundTrip(t *testing.T) {
stores := map[string]cache.CacheStore{
"内存后端": func() cache.CacheStore { return cache.InitWafCache() }(),
"JSON泛型后端": cachetest.NewJSONRoundTrip(),
}
for name, store := range stores {
t.Run(name, func(t *testing.T) {
old := global.GCACHE_WAFCACHE
global.GCACHE_WAFCACHE = store
t.Cleanup(func() { global.GCACHE_WAFCACHE = old })
want := dataFilesCache{Files: []FileInfo{
{ID: "id1", Name: "local_log_20240101.db", FullPath: `/data/local_log_20240101.db`, CanDelete: true, SizeBytes: 100},
}, Total: 1}
store.SetWithTTl(enums.CACHE_FILE_INFO, want, 30*time.Minute)
got, ok := loadDataFilesCache()
if !ok {
t.Fatal("缓存命中时应读到文件列表")
}
if got.Total != 1 || len(got.Files) != 1 {
t.Fatalf("缓存往返后结构不对:%+v", got)
}
f := got.Files[0]
if f.ID != "id1" || f.Name != "local_log_20240101.db" || f.FullPath != `/data/local_log_20240101.db` || !f.CanDelete || f.SizeBytes != 100 {
t.Fatalf("缓存往返后文件信息不一致:%+v", f)
}
store.Remove(enums.CACHE_FILE_INFO)
if _, ok := loadDataFilesCache(); ok {
t.Fatal("缓存未命中时应返回 false")
}
})
}
}
// TestDeleteFileByIdReadsCacheAcrossBackends 删除流程要能从两种后端的缓存里正确读出列表。
// 查一个不存在的 ID:流程应走到「未找到指定ID的文件」业务错误(code=-1),
// 而不是在缓存读取处断言 panic(gin Recovery 会把它变成 HTTP 500)。
func TestDeleteFileByIdReadsCacheAcrossBackends(t *testing.T) {
stores := map[string]cache.CacheStore{
"内存后端": func() cache.CacheStore { return cache.InitWafCache() }(),
"JSON泛型后端": cachetest.NewJSONRoundTrip(),
}
for name, store := range stores {
t.Run(name, func(t *testing.T) {
old := global.GCACHE_WAFCACHE
global.GCACHE_WAFCACHE = store
t.Cleanup(func() { global.GCACHE_WAFCACHE = old })
store.SetWithTTl(enums.CACHE_FILE_INFO, dataFilesCache{Files: []FileInfo{
{ID: "id1", Name: "local_log_20240101.db", FullPath: `/data/local_log_20240101.db`, CanDelete: true},
}, Total: 1}, 30*time.Minute)
gin.SetMode(gin.TestMode)
r := gin.Default()
r.DELETE("/file", new(WafFileApi).DeleteFileByIdApi)
req := httptest.NewRequest(http.MethodDelete, "/file?id=not-exist", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HTTP 状态应为 200,实际 %d(缓存读取 panic 会被 Recovery 变成 500)", rec.Code)
}
var body WafFileResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("响应解析失败:%v", err)
}
if body.Code != response.ERROR || body.Msg != "未找到指定ID的文件" {
t.Fatalf("应走到「未找到指定ID的文件」业务分支,实际 code=%d msg=%s", body.Code, body.Msg)
}
})
}
}
// BenchmarkGetDataFilesApi 性能测试
func BenchmarkGetDataFilesApi(b *testing.B) {
// 创建测试目录
+2 -2
View File
@@ -128,8 +128,8 @@ func (w *WafIPFailureApi) GetBanIpListApi(c *gin.Context) {
continue
}
// 只显示满足条件的IP(即有阈值记录的IP,表示是被规则封禁的)
if record.TriggerMinutes == 0 || record.TriggerCount == 0 {
// 只显示满足条件的IP(带封禁阈值戳,即规则真实命中过;判据与归属地查询一致)
if !wafipban.IsBannedRecord(record) {
continue
}
+3
View File
@@ -7,6 +7,9 @@ type CacheStore interface {
Set(key string, value interface{})
SetWithTTl(key string, value interface{}, ttl time.Duration)
SetWithTTlRenewTime(key string, value interface{}, ttl time.Duration)
// Get 返回缓存里的原始值。注意后端差异:内存后端返回存入的 Go 值(类型保留),
// JSON 类后端(RedisCache)返回解码到 interface{} 的泛型(对象是 map[string]interface{},
// 数组是 []interface{})——结构体一律用 GetAs 读取,不要对 Get 的返回值做结构体断言。
Get(key string) interface{}
// GetAs 将缓存值反序列化到 out(out 必须为指针)。
// 对于 Redis 等非内存后端,Get() 返回的是 map[string]interface{},
+203
View File
@@ -0,0 +1,203 @@
// Package cachetest 提供模拟 JSON 类缓存后端(如 RedisCache)存取语义的测试替身:
// 写入时 JSON 编码,读取时 Get 解码到 interface{}(对象回来是 map[string]interface{}
// 泛型,不是原始结构体),GetAs 才解码到指定类型。
//
// 仅测试使用。不 import cache 包:方法签名与 CacheStore 结构性兼容,
// 由各测试在需要 CacheStore 的地方显式声明。
package cachetest
import (
"encoding/json"
"errors"
"sync"
"time"
)
// ErrMiss 键不存在或已过期
var ErrMiss = errors.New("cachetest: key miss")
type item struct {
value string
expiresAt time.Time
}
// JSONRoundTrip 模拟 RedisCache 的值语义(不含网络与后端错误)
type JSONRoundTrip struct {
mu sync.Mutex
data map[string]item
}
func NewJSONRoundTrip() *JSONRoundTrip {
return &JSONRoundTrip{data: make(map[string]item)}
}
func (s *JSONRoundTrip) getRaw(key string) (item, bool) {
s.mu.Lock()
defer s.mu.Unlock()
it, ok := s.data[key]
if !ok || time.Now().After(it.expiresAt) {
return item{}, false
}
return it, true
}
func (s *JSONRoundTrip) Set(key string, value interface{}) {
s.SetWithTTl(key, value, 100*365*24*time.Hour)
}
func (s *JSONRoundTrip) SetWithTTl(key string, value interface{}, ttl time.Duration) {
b, err := json.Marshal(value)
if err != nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.data[key] = item{value: string(b), expiresAt: time.Now().Add(ttl)}
}
func (s *JSONRoundTrip) SetWithTTlRenewTime(key string, value interface{}, ttl time.Duration) {
s.SetWithTTl(key, value, ttl)
}
// Get 与 RedisCache.Get 同语义:JSON 解码到 interface{},对象回来是 map 泛型
func (s *JSONRoundTrip) Get(key string) interface{} {
it, ok := s.getRaw(key)
if !ok {
return nil
}
var result interface{}
if err := json.Unmarshal([]byte(it.value), &result); err != nil {
return it.value
}
return result
}
// GetAs 与 RedisCache.GetAs 同语义:JSON 解码到调用方指定的类型
func (s *JSONRoundTrip) GetAs(key string, out interface{}) error {
it, ok := s.getRaw(key)
if !ok {
return ErrMiss
}
return json.Unmarshal([]byte(it.value), out)
}
func (s *JSONRoundTrip) GetAsEx(key string, out interface{}, ttl time.Duration) error {
if err := s.GetAs(key, out); err != nil {
return err
}
if ttl > 0 {
s.mu.Lock()
if it, ok := s.data[key]; ok {
it.expiresAt = time.Now().Add(ttl)
s.data[key] = it
}
s.mu.Unlock()
}
return nil
}
func (s *JSONRoundTrip) Touch(key string, ttl time.Duration) error {
it, ok := s.getRaw(key)
if !ok {
return ErrMiss
}
if ttl > 0 {
s.mu.Lock()
it.expiresAt = time.Now().Add(ttl)
s.data[key] = it
s.mu.Unlock()
}
return nil
}
func (s *JSONRoundTrip) GetBytes(key string) ([]byte, error) {
it, ok := s.getRaw(key)
if !ok {
return nil, ErrMiss
}
var result []byte
if err := json.Unmarshal([]byte(it.value), &result); err != nil {
return []byte(it.value), nil
}
return result, nil
}
func (s *JSONRoundTrip) GetString(key string) (string, error) {
it, ok := s.getRaw(key)
if !ok {
return "", ErrMiss
}
var result string
if err := json.Unmarshal([]byte(it.value), &result); err != nil {
return it.value, nil
}
return result, nil
}
func (s *JSONRoundTrip) GetInt(key string) (int, error) {
it, ok := s.getRaw(key)
if !ok {
return -1, ErrMiss
}
var result interface{}
if err := json.Unmarshal([]byte(it.value), &result); err != nil {
return -1, ErrMiss
}
switch v := result.(type) {
case float64:
return int(v), nil
case int:
return v, nil
}
return -1, ErrMiss
}
func (s *JSONRoundTrip) IsKeyExist(key string) bool {
exist, _ := s.ExistsE(key)
return exist
}
func (s *JSONRoundTrip) ExistsE(key string) (bool, error) {
_, ok := s.getRaw(key)
return ok, nil
}
func (s *JSONRoundTrip) Remove(key string) interface{} {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, key)
return nil
}
func (s *JSONRoundTrip) GetExpireTime(key string) (time.Time, error) {
it, ok := s.getRaw(key)
if !ok {
return time.Time{}, ErrMiss
}
return it.expiresAt, nil
}
func (s *JSONRoundTrip) ListAvailableKeys() map[string]time.Duration {
return s.ListAvailableKeysWithPrefix("")
}
func (s *JSONRoundTrip) ListAvailableKeysWithPrefix(prefix string) map[string]time.Duration {
result := make(map[string]time.Duration)
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for key, it := range s.data {
if now.After(it.expiresAt) {
continue
}
if prefix != "" && !stringsHasPrefix(key, prefix) {
continue
}
result[key] = it.expiresAt.Sub(now)
}
return result
}
func stringsHasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
+94
View File
@@ -0,0 +1,94 @@
package cachetest_test
import (
"SamWaf/cache"
"SamWaf/cache/cachetest"
"testing"
"time"
)
type demoVal struct {
Name string `json:"name"`
At time.Time `json:"at"`
}
// 钉死跨后端语义:结构体经缓存往返必须用 GetAs 读取,两种后端结果一致;
// 而 Get 对结构体的返回形态是后端相关的(内存保留原类型,JSON 类后端是 map 泛型),
// 这正是"结构体禁止对 Get 做裸断言"契约的回归依据。
func TestStructRoundTripAcrossBackends(t *testing.T) {
var _ cache.CacheStore = cachetest.NewJSONRoundTrip() // 编译期确认满足接口
stores := map[string]cache.CacheStore{
"内存后端": cache.InitWafCache(),
"JSON泛型后端": cachetest.NewJSONRoundTrip(),
}
for name, store := range stores {
t.Run(name, func(t *testing.T) {
want := demoVal{Name: "n1", At: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
store.SetWithTTl("K", want, time.Minute)
var got demoVal
if err := store.GetAs("K", &got); err != nil {
t.Fatalf("GetAs 应命中,实际 %v", err)
}
if got != want {
t.Fatalf("结构体往返后不一致:got %+v want %+v", got, want)
}
var missing demoVal
if err := store.GetAs("NOT_EXIST", &missing); err == nil {
t.Fatal("未命中应返回错误")
}
if !store.IsKeyExist("K") || store.IsKeyExist("NOT_EXIST") {
t.Fatal("IsKeyExist 判定不对")
}
store.Remove("K")
if store.IsKeyExist("K") {
t.Fatal("Remove 后不应再存在")
}
})
}
}
// Get 的后端差异是设计现状,写在这里防止有人误把它当成一致语义:
// 读侧要类型安全一律走 GetAs。
func TestGetReturnsBackendSpecificShape(t *testing.T) {
want := demoVal{Name: "n1", At: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
jsonStore := cachetest.NewJSONRoundTrip()
jsonStore.SetWithTTl("K", want, time.Minute)
got := jsonStore.Get("K")
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("JSON 类后端 Get 对结构体应返回 map 泛型,实际 %T", got)
}
if m["name"] != "n1" {
t.Fatalf("泛型 map 内容不对:%v", m)
}
memStore := cache.InitWafCache()
memStore.SetWithTTl("K", want, time.Minute)
if v, ok := memStore.Get("K").(demoVal); !ok || v != want {
t.Fatalf("内存后端 Get 应保留原始结构体类型,实际 %T", memStore.Get("K"))
}
}
func TestScalarRoundTripAcrossBackends(t *testing.T) {
stores := map[string]cache.CacheStore{
"内存后端": cache.InitWafCache(),
"JSON泛型后端": cachetest.NewJSONRoundTrip(),
}
for name, store := range stores {
t.Run(name, func(t *testing.T) {
store.SetWithTTl("S", "hello", time.Minute)
if s, err := store.GetString("S"); err != nil || s != "hello" {
t.Fatalf("GetString 应取回 hello,实际 %q err=%v", s, err)
}
store.SetWithTTl("N", 42, time.Minute)
if n, err := store.GetInt("N"); err != nil || n != 42 {
t.Fatalf("GetInt 应取回 42,实际 %d err=%v", n, err)
}
})
}
}
@@ -0,0 +1,132 @@
package waf_service
import (
"SamWaf/cache"
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/wafipban"
"strings"
"testing"
)
// TestIPFailureEffectByRecord 查询展示的 effect/文案判定:
// 带阈值戳(规则真实命中过)才是 block,纯失败记分卡报 none——
// 与封禁列表的收录判据一致,避免"有失败记录"被误读成"已被拦截"。
func TestIPFailureEffectByRecord(t *testing.T) {
oldLock := global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME
global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME = 10
t.Cleanup(func() { global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME = oldLock })
cases := []struct {
name string
record *wafipban.IPFailureRecord
remain string
wantEffect string
wantDetail []string
}{
{
name: "无记录兜底",
record: nil,
wantEffect: "none",
wantDetail: []string{"有失败记录"},
},
{
name: "仅有失败未触发规则",
record: &wafipban.IPFailureRecord{
Count: 3, TriggerMinutes: 0, TriggerCount: 0,
},
wantEffect: "none",
wantDetail: []string{"失败3次", "未触发封禁规则"},
},
{
name: "规则命中过带阈值戳",
record: &wafipban.IPFailureRecord{
Count: 12, TriggerMinutes: 5, TriggerCount: 10,
},
wantEffect: "block",
wantDetail: []string{"5分钟内失败10次触发封禁"},
},
{
name: "剩余时间拼在文案末尾",
record: &wafipban.IPFailureRecord{
Count: 12, TriggerMinutes: 5, TriggerCount: 10,
},
remain: "9分钟",
wantEffect: "block",
wantDetail: []string{"触发封禁", ",剩余9分钟"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
effect, detail := ipFailureEffect(tc.record, tc.remain)
if effect != tc.wantEffect {
t.Fatalf("effect 应为 %s,实际 %s(detail=%s)", tc.wantEffect, effect, detail)
}
for _, want := range tc.wantDetail {
if !strings.Contains(detail, want) {
t.Fatalf("文案应包含 %q,实际 %q", want, detail)
}
}
})
}
}
// TestLookupIPFailureEffect 端到端:真实管理器 + 内存缓存,验证 Lookup 的 effect 判定
// 跟随"是否带阈值戳"切换。InitIPBanManager 是 sync.Once,本包测试只在此初始化一次。
func TestLookupIPFailureEffect(t *testing.T) {
store := cache.InitWafCache()
oldCache := global.GCACHE_WAFCACHE
oldEnabled := global.GCONFIG_IP_FAILURE_BAN_ENABLED
global.GCACHE_WAFCACHE = store
global.GCONFIG_IP_FAILURE_BAN_ENABLED = 1
t.Cleanup(func() {
global.GCACHE_WAFCACHE = oldCache
global.GCONFIG_IP_FAILURE_BAN_ENABLED = oldEnabled
})
wafipban.InitIPBanManager(store)
ip := "192.0.2.20"
manager := wafipban.GetIPFailureManager()
lookup := func() (string, string) {
t.Helper()
resp, err := WafIPLookupServiceApp.Lookup(ip, []string{"ip_failure"})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(resp.Hits) != 1 {
t.Fatalf("应命中1条 IP失败封禁,实际 %d 条", len(resp.Hits))
}
return resp.Hits[0].Effect, resp.Hits[0].Detail
}
// 阶段1:只有失败记录,未达阈值 → none
manager.RecordFailure(&innerbean.WebLog{SRC_IP: ip, STATUS_CODE: 403})
effect, detail := lookup()
if effect != "none" {
t.Fatalf("未触发规则应为 none,实际 %s(detail=%s)", effect, detail)
}
if !strings.Contains(detail, "未触发封禁规则") {
t.Fatalf("文案应说明未触发规则,实际 %q", detail)
}
// 阶段2:规则命中盖阈值戳 → block
manager.RecordFailureThreshold(ip, 5, 10)
effect, detail = lookup()
if effect != "block" {
t.Fatalf("带阈值戳应为 block,实际 %s(detail=%s)", effect, detail)
}
if !strings.Contains(detail, "5分钟内失败10次触发封禁") {
t.Fatalf("文案应包含触发信息,实际 %q", detail)
}
// 阶段3:清除记录 → 不再命中
manager.ClearIPFailure(ip)
resp, err := WafIPLookupServiceApp.Lookup(ip, []string{"ip_failure"})
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(resp.Hits) != 0 {
t.Fatalf("清除后不应再命中,实际 %+v", resp.Hits)
}
}
+26 -8
View File
@@ -505,6 +505,28 @@ func landTargetText(v string) string {
return v
}
// ipFailureEffect 由失败记录算出查询展示的 effect 与文案。
// 有失败记录≠被封禁:记分卡只说明窗口内有失败请求,只有自定义规则真实命中
// (记录上带阈值戳)才是 block;否则报 none,避免管理员把"有失败记录"当成"已被拦截"。
// 判据与封禁列表(api.GetBanIpListApi)的收录条件一致。
func ipFailureEffect(record *wafipban.IPFailureRecord, remain string) (string, string) {
effect := "none"
detail := ""
if wafipban.IsBannedRecord(record) {
effect = "block"
detail = fmt.Sprintf("%d分钟内失败%d次触发封禁", record.TriggerMinutes, record.TriggerCount)
} else if record != nil {
detail = fmt.Sprintf("近%d分钟失败%d次,未触发封禁规则", global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME, record.Count)
}
if detail == "" {
detail = "有失败记录"
}
if remain != "" {
detail += ",剩余" + remain
}
return effect, detail
}
// matchIPFailure IP 失败封禁是缓存态的临时封禁,键就是完整 IP,直接查
func (r *WafIPLookupService) matchIPFailure(ip string, resp *response2.IPLookupResp) {
if global.GCACHE_WAFCACHE == nil {
@@ -516,21 +538,17 @@ func (r *WafIPLookupService) matchIPFailure(ip string, resp *response2.IPLookupR
return
}
detail := "自动封禁中"
var record *wafipban.IPFailureRecord
if manager := wafipban.GetIPFailureManager(); manager != nil {
if record := manager.GetFailureInfo(ip); record != nil && record.TriggerCount > 0 {
detail = fmt.Sprintf("%d分钟内失败%d次触发封禁", record.TriggerMinutes, record.TriggerCount)
}
}
if remain := r.remainText(enums.CACHE_IP_FAILURE_PRE, ip); remain != "" {
detail += ",剩余" + remain
record = manager.GetFailureInfo(ip)
}
effect, detail := ipFailureEffect(record, r.remainText(enums.CACHE_IP_FAILURE_PRE, ip))
resp.Hits = append(resp.Hits, response2.IPLookupHit{
Source: srcIPFailure,
SourceName: "IP失败封禁",
Scope: "全局",
Effect: "block",
Effect: effect,
Detail: detail,
})
}
+30 -19
View File
@@ -4,6 +4,7 @@ import (
"SamWaf/cache"
"SamWaf/common/zlog"
"SamWaf/enums"
"sync"
"time"
)
@@ -29,6 +30,8 @@ type HostLoginFailureRecord struct {
// HostLoginFailureManager 主机登录失败计数器
type HostLoginFailureManager struct {
cache cache.CacheStore
// Record 是读-改-写,多来源采集协程可能并发调用,整体互斥
mu sync.RWMutex
}
var hostLoginFailureManagerInstance *HostLoginFailureManager
@@ -54,6 +57,21 @@ func hostLoginKey(source, ip string) string {
return enums.CACHE_HOST_LOGIN_FAIL_PRE + source + ":" + ip
}
// load 读取计数记录(共享快照,只读)
func (m *HostLoginFailureManager) load(source, ip string) *HostLoginFailureRecord {
return loadRecord[HostLoginFailureRecord](m.cache, hostLoginKey(source, ip))
}
// cloneHostLoginRecord 克隆记录及其事件切片,供写路径改副本用
func cloneHostLoginRecord(r *HostLoginFailureRecord) *HostLoginFailureRecord {
if r == nil {
return nil
}
c := *r
c.Events = append([]time.Time(nil), r.Events...)
return &c
}
// Record 记录一次失败并返回窗口内累计次数。
// 一次调用完成"读-改-写",调用方不需要再单独 GetCount,少一次缓存往返。
// retentionMinutes 是事件保留窗口,取 max(统计窗口, 封禁时长),保证解封后
@@ -72,12 +90,10 @@ func (m *HostLoginFailureManager) Record(source, ip string, windowMinutes, reten
key := hostLoginKey(source, ip)
now := time.Now()
var record *HostLoginFailureRecord
if val := m.cache.Get(key); val != nil {
if r, ok := val.(*HostLoginFailureRecord); ok {
record = r
}
}
m.mu.Lock()
defer m.mu.Unlock()
record := cloneHostLoginRecord(m.load(source, ip))
if record == nil {
record = &HostLoginFailureRecord{
Source: source,
@@ -118,12 +134,10 @@ func (m *HostLoginFailureManager) GetCount(source, ip string, windowMinutes int6
if m == nil || ip == "" {
return 0
}
val := m.cache.Get(hostLoginKey(source, ip))
if val == nil {
return 0
}
record, ok := val.(*HostLoginFailureRecord)
if !ok {
m.mu.RLock()
defer m.mu.RUnlock()
record := m.load(source, ip)
if record == nil {
return 0
}
windowStart := time.Now().Add(-time.Duration(windowMinutes) * time.Minute)
@@ -144,15 +158,12 @@ func (m *HostLoginFailureManager) Clear(source, ip string) {
m.cache.Remove(hostLoginKey(source, ip))
}
// GetRecord 取原始记录(展示/调试用)
// GetRecord 取原始记录(展示/调试用)。返回只读快照,调用方不得修改。
func (m *HostLoginFailureManager) GetRecord(source, ip string) *HostLoginFailureRecord {
if m == nil || ip == "" {
return nil
}
val := m.cache.Get(hostLoginKey(source, ip))
if val == nil {
return nil
}
record, _ := val.(*HostLoginFailureRecord)
return record
m.mu.RLock()
defer m.mu.RUnlock()
return m.load(source, ip)
}
@@ -0,0 +1,53 @@
package wafipban
import (
"SamWaf/cache"
"SamWaf/cache/cachetest"
"testing"
)
// 双后端回归:与 IPFailureManager 同构,计数记录必须跨写入累计、能读回、能清除。
// JSON 类后端(Redis)下若读回丢类型,Record 的返回值会永远停在 1、GetCount 恒 0。
func TestHostLoginRecordLifecycle(t *testing.T) {
stores := map[string]func() cache.CacheStore{
"内存后端": func() cache.CacheStore { return cache.InitWafCache() },
"JSON泛型后端": func() cache.CacheStore { return cachetest.NewJSONRoundTrip() },
}
for name, newStore := range stores {
t.Run(name, func(t *testing.T) {
m := &HostLoginFailureManager{cache: newStore()}
const src = "ssh"
// 连续 3 次失败,Record 返回的窗口内累计应逐次 +1
for want := int64(1); want <= 3; want++ {
if got := m.Record(src, "192.0.2.10", 10, 30); got != want {
t.Fatalf("第 %d 次失败应累计为 %d,实际 %d", want, want, got)
}
}
if got := m.GetCount(src, "192.0.2.10", 10); got != 3 {
t.Fatalf("GetCount 应为 3,实际 %d", got)
}
// 来源隔离:RDP 的计数不受 SSH 影响
if got := m.Record("rdp", "192.0.2.10", 10, 30); got != 1 {
t.Fatalf("另一来源应独立计数,实际 %d", got)
}
// 记录可读回
rec := m.GetRecord(src, "192.0.2.10")
if rec == nil || rec.Count != 3 {
t.Fatalf("记录应可读回且为3次,实际 %+v", rec)
}
// 清除后归零
m.Clear(src, "192.0.2.10")
if m.GetRecord(src, "192.0.2.10") != nil || m.GetCount(src, "192.0.2.10", 10) != 0 {
t.Fatal("清除后不应再有记录")
}
// 另一来源不受影响
if m.GetCount("rdp", "192.0.2.10", 10) != 1 {
t.Fatal("清除 SSH 不应影响 RDP 计数")
}
})
}
}
+44 -56
View File
@@ -70,6 +70,21 @@ func GetIPFailureManager() *IPFailureManager {
return ipFailureManagerInstance
}
// load 读取 IP 的失败记录(共享快照,只读)
func (m *IPFailureManager) load(ip string) *IPFailureRecord {
return loadRecord[IPFailureRecord](m.cache, enums.CACHE_IP_FAILURE_PRE+ip)
}
// cloneIPFailureRecord 克隆记录及其事件切片,供写路径改副本用
func cloneIPFailureRecord(r *IPFailureRecord) *IPFailureRecord {
if r == nil {
return nil
}
c := *r
c.Events = append([]time.Time(nil), r.Events...)
return &c
}
// initStatusCodes 初始化状态码配置
func (m *IPFailureManager) initStatusCodes() {
m.mu.Lock()
@@ -160,15 +175,13 @@ func (m *IPFailureManager) RecordFailure(webLog *innerbean.WebLog) {
key := enums.CACHE_IP_FAILURE_PRE + ip
now := time.Now()
// 获取现有记录
var record *IPFailureRecord
if val := m.cache.Get(key); val != nil {
if r, ok := val.(*IPFailureRecord); ok {
record = r
}
}
// 读-改-写整体互斥:日志协程(本函数)与请求路径(RecordFailureThreshold)
// 会并发交错,不加锁后写者会整份覆盖先写者,丢阈值戳或失败事件
m.mu.Lock()
defer m.mu.Unlock()
// 如果记录不存在或已过期,创建新记录
// 获取现有记录(克隆副本再改,缓存里的快照保持不可变)
record := cloneIPFailureRecord(m.load(ip))
if record == nil {
record = &IPFailureRecord{
IP: ip,
@@ -207,14 +220,11 @@ func (m *IPFailureManager) GetFailureCount(ip string, minutes int64) int64 {
return 0
}
key := enums.CACHE_IP_FAILURE_PRE + ip
val := m.cache.Get(key)
if val == nil {
return 0
}
record, ok := val.(*IPFailureRecord)
if !ok {
// 只读快照,RLock 即可:规则引擎每条命中规则的请求都会求值一次
m.mu.RLock()
defer m.mu.RUnlock()
record := m.load(ip)
if record == nil {
return 0
}
@@ -238,24 +248,11 @@ func (m *IPFailureManager) ClearIPFailure(ip string) {
m.cache.Remove(key)
}
// GetFailureInfo 获取IP失败信息(用于调试)
// GetFailureInfo 获取IP失败信息(返回只读快照,调用方不得修改)
func (m *IPFailureManager) GetFailureInfo(ip string) *IPFailureRecord {
if ip == "" {
return nil
}
key := enums.CACHE_IP_FAILURE_PRE + ip
val := m.cache.Get(key)
if val == nil {
return nil
}
record, ok := val.(*IPFailureRecord)
if !ok {
return nil
}
return record
m.mu.RLock()
defer m.mu.RUnlock()
return m.load(ip)
}
// RecordFailureThreshold 记录IP失败封禁的阈值信息(当规则匹配时调用)
@@ -268,10 +265,14 @@ func (m *IPFailureManager) RecordFailureThreshold(ip string, minutes int64, coun
}
key := enums.CACHE_IP_FAILURE_PRE + ip
val := m.cache.Get(key)
if val == nil {
// 如果记录不存在,创建一个新记录
record := &IPFailureRecord{
m.mu.Lock()
defer m.mu.Unlock()
record := cloneIPFailureRecord(m.load(ip))
if record == nil {
// 记录不存在:新建一条并盖上阈值戳
record = &IPFailureRecord{
IP: ip,
Events: []time.Time{},
TriggerMinutes: minutes,
@@ -279,29 +280,16 @@ func (m *IPFailureManager) RecordFailureThreshold(ip string, minutes int64, coun
FirstTime: time.Now(),
LastTime: time.Now(),
}
ttl := time.Duration(global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME) * time.Minute
m.cache.SetWithTTlRenewTime(key, record, ttl)
return
}
record, ok := val.(*IPFailureRecord)
if !ok {
return
}
// 更新阈值信息(如果新的阈值更严格,则更新)
if record.TriggerMinutes == 0 || record.TriggerCount == 0 {
} else if record.TriggerMinutes == 0 || record.TriggerCount == 0 {
record.TriggerMinutes = minutes
record.TriggerCount = count
} else if minutes < record.TriggerMinutes || (minutes == record.TriggerMinutes && count < record.TriggerCount) {
// 新的阈值更严格(时间窗口更小或次数更少)则更新
record.TriggerMinutes = minutes
record.TriggerCount = count
} else {
// 如果新的阈值更严格(时间窗口更小或次数更少),则更新
if minutes < record.TriggerMinutes || (minutes == record.TriggerMinutes && count < record.TriggerCount) {
record.TriggerMinutes = minutes
record.TriggerCount = count
}
}
// 保存更新后的记录
// 保存记录
ttl := time.Duration(global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME) * time.Minute
m.cache.SetWithTTlRenewTime(key, record, ttl)
}
+143
View File
@@ -0,0 +1,143 @@
package wafipban
import (
"SamWaf/cache"
"SamWaf/cache/cachetest"
"SamWaf/global"
"SamWaf/innerbean"
"testing"
)
// 双后端回归:内存后端原样存取 interface{},JSON 类后端(RedisCache)经 JSON 编解码、
// Get 对结构体返回泛型 map。IP失败封禁的计数累计、阈值戳、列表判定在两种后端下
// 必须结果一致——否则换缓存类型会让规则计数静默归零、封禁永不触发。
// newTestManager 直接构造管理器(绕过单例),接指定的缓存后端
func newTestManager(t *testing.T, store cache.CacheStore) *IPFailureManager {
t.Helper()
oldEnabled, oldLock := global.GCONFIG_IP_FAILURE_BAN_ENABLED, global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME
global.GCONFIG_IP_FAILURE_BAN_ENABLED = 1
global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME = 10
t.Cleanup(func() {
global.GCONFIG_IP_FAILURE_BAN_ENABLED, global.GCONFIG_IP_FAILURE_BAN_LOCK_TIME = oldEnabled, oldLock
})
return &IPFailureManager{cache: store}
}
func failureLog(ip string, status int) *innerbean.WebLog {
return &innerbean.WebLog{SRC_IP: ip, STATUS_CODE: status}
}
// 双后端套件:每个用例分别跑在内存后端与 JSON 泛型后端上
func bothBackends() map[string]func() cache.CacheStore {
return map[string]func() cache.CacheStore{
"内存后端": func() cache.CacheStore { return cache.InitWafCache() },
"JSON泛型后端": func() cache.CacheStore { return cachetest.NewJSONRoundTrip() },
}
}
// 失败记录必须跨写入累计:连续 5 次失败,窗口内计数应为 5。
// JSON 后端若读回时丢类型,计数会永远停在 1。
func TestRecordFailureAccumulates(t *testing.T) {
for name, newStore := range bothBackends() {
t.Run(name, func(t *testing.T) {
m := newTestManager(t, newStore())
ip := "192.0.2.1"
for i := 0; i < 5; i++ {
m.RecordFailure(failureLog(ip, 403))
}
if got := m.GetFailureCount(ip, 10); got != 5 {
t.Fatalf("5次失败应累计为5,实际 %d", got)
}
})
}
}
// 规则命中盖阈值戳 → 记录带阈值信息,满足封禁列表的收录判据
// (与 api.GetBanIpListApi 一致:TriggerMinutes/TriggerCount 非 0 才显示)。
func TestFailureThresholdStampsRecord(t *testing.T) {
for name, newStore := range bothBackends() {
t.Run(name, func(t *testing.T) {
m := newTestManager(t, newStore())
ip := "192.0.2.2"
for i := 0; i < 10; i++ {
m.RecordFailure(failureLog(ip, 403))
}
m.RecordFailureThreshold(ip, 5, 10)
record := m.GetFailureInfo(ip)
if record == nil {
t.Fatal("应有失败记录")
}
if record.TriggerMinutes != 5 || record.TriggerCount != 10 {
t.Fatalf("阈值戳应为 5分钟/10次,实际 %d分钟/%d次", record.TriggerMinutes, record.TriggerCount)
}
// 封禁列表收录判据(对齐 api 层的过滤条件)
if record.TriggerMinutes == 0 || record.TriggerCount == 0 {
t.Fatal("带阈值戳的记录应出现在封禁列表")
}
m.ClearIPFailure(ip)
if m.GetFailureInfo(ip) != nil {
t.Fatal("清除后不应再有记录")
}
})
}
}
// 先盖阈值戳(规则先命中、失败记录后到)再累计失败,两种顺序下阈值戳都不能丢
func TestThresholdStampSurvivesLaterFailures(t *testing.T) {
for name, newStore := range bothBackends() {
t.Run(name, func(t *testing.T) {
m := newTestManager(t, newStore())
ip := "192.0.2.3"
m.RecordFailureThreshold(ip, 5, 10)
m.RecordFailure(failureLog(ip, 404))
record := m.GetFailureInfo(ip)
if record == nil {
t.Fatal("应有失败记录")
}
if record.TriggerCount != 10 {
t.Fatalf("后续失败记录不应抹掉阈值戳,实际阈值 %d", record.TriggerCount)
}
if record.Count != 1 {
t.Fatalf("应有1次失败事件,实际 %d", record.Count)
}
})
}
}
// 取出的记录快照不可变:后续写入必须生成新对象,不能原地改缓存里的快照。
// 旧代码在内存后端下原地追加共享切片,先取的引用会被悄悄改掉。
func TestRecordSnapshotImmutable(t *testing.T) {
for name, newStore := range bothBackends() {
t.Run(name, func(t *testing.T) {
m := newTestManager(t, newStore())
ip := "192.0.2.4"
m.RecordFailure(failureLog(ip, 403))
m.RecordFailure(failureLog(ip, 403))
snap := m.GetFailureInfo(ip)
if snap == nil || snap.Count != 2 {
t.Fatalf("快照应有2次失败,实际 %+v", snap)
}
// 后续写入不应污染已取出的快照
m.RecordFailure(failureLog(ip, 403))
m.RecordFailureThreshold(ip, 5, 10)
if snap.Count != 2 {
t.Fatalf("已取出的快照计数被后续写入污染:%d", snap.Count)
}
if snap.TriggerCount != 0 {
t.Fatalf("已取出的快照阈值戳被后续写入污染:%d", snap.TriggerCount)
}
// 新读取应看到最新状态
fresh := m.GetFailureInfo(ip)
if fresh.Count != 3 || !IsBannedRecord(fresh) {
t.Fatalf("新读取应看到3次失败且带阈值戳,实际 %+v", fresh)
}
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package wafipban
import "SamWaf/cache"
// loadRecord 从缓存读取结构体记录,两种缓存后端语义一致:
// 内存后端(WafCache) Get 原样返回存入的 Go 值,断言快路径一次命中;
// JSON 类后端(RedisCache) Get 返回 map[string]interface{} 泛型——断言必然失败
// 且白浪费一次往返,直接走 GetAs 反序列化。
//
// 返回的是缓存中的共享快照,调用方不得原地修改:写路径必须先克隆副本、
// 改完再整体回写,读路径才能在无锁状态下安全遍历。
func loadRecord[T any](store cache.CacheStore, key string) *T {
if _, isMem := store.(*cache.WafCache); isMem {
val := store.Get(key)
if val == nil {
return nil
}
// 类型不符说明私有 keyspace 被写入了非预期形态,按不存在处理
rec, _ := val.(*T)
return rec
}
var rec *T
if err := store.GetAs(key, &rec); err != nil || rec == nil {
return nil
}
return rec
}
// IsBannedRecord 失败记录是否带封禁阈值戳(自定义规则真实命中过)。
// 封禁列表与归属地查询共用这一判据,避免两处谓词写法漂移。
func IsBannedRecord(r *IPFailureRecord) bool {
return r != nil && r.TriggerMinutes > 0 && r.TriggerCount > 0
}