mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-30 17:51:44 +08:00
fix: read cached failure records type-safely across cache backends (#1014)
#1002
This commit is contained in:
+27
-19
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
// 创建测试目录
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
@@ -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{},
|
||||
|
||||
Vendored
+203
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 计数")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user