From 6f4d9e83642d3f1337a400f2a9ed47202d5132d7 Mon Sep 17 00:00:00 2001 From: samwafgo <138433335+samwafgo@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:32:29 +0800 Subject: [PATCH] fix: read cached failure records type-safely across cache backends (#1014) #1002 --- api/waf_file.go | 46 ++-- api/waf_file_test.go | 84 ++++++++ api/waf_ip_failure.go | 4 +- cache/cache_store.go | 3 + cache/cachetest/jsonroundtrip.go | 203 ++++++++++++++++++ cache/cachetest/jsonroundtrip_test.go | 94 ++++++++ .../waf_ip_lookup_ipfailure_test.go | 132 ++++++++++++ service/waf_service/waf_ip_lookup_service.go | 34 ++- wafipban/host_login_failure_manager.go | 49 +++-- wafipban/host_login_failure_manager_test.go | 53 +++++ wafipban/ip_failure_manager.go | 100 ++++----- wafipban/ip_failure_manager_test.go | 143 ++++++++++++ wafipban/record_cache.go | 33 +++ 13 files changed, 874 insertions(+), 104 deletions(-) create mode 100644 cache/cachetest/jsonroundtrip.go create mode 100644 cache/cachetest/jsonroundtrip_test.go create mode 100644 service/waf_service/waf_ip_lookup_ipfailure_test.go create mode 100644 wafipban/host_login_failure_manager_test.go create mode 100644 wafipban/ip_failure_manager_test.go create mode 100644 wafipban/record_cache.go diff --git a/api/waf_file.go b/api/waf_file.go index e5c9b71..2793d1a 100644 --- a/api/waf_file.go +++ b/api/waf_file.go @@ -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) } diff --git a/api/waf_file_test.go b/api/waf_file_test.go index f0630ac..3576905 100644 --- a/api/waf_file_test.go +++ b/api/waf_file_test.go @@ -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) { // 创建测试目录 diff --git a/api/waf_ip_failure.go b/api/waf_ip_failure.go index 900031e..bfb7c8c 100644 --- a/api/waf_ip_failure.go +++ b/api/waf_ip_failure.go @@ -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 } diff --git a/cache/cache_store.go b/cache/cache_store.go index 51507c7..9c71632 100644 --- a/cache/cache_store.go +++ b/cache/cache_store.go @@ -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{}, diff --git a/cache/cachetest/jsonroundtrip.go b/cache/cachetest/jsonroundtrip.go new file mode 100644 index 0000000..510e28b --- /dev/null +++ b/cache/cachetest/jsonroundtrip.go @@ -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 +} diff --git a/cache/cachetest/jsonroundtrip_test.go b/cache/cachetest/jsonroundtrip_test.go new file mode 100644 index 0000000..50b98c1 --- /dev/null +++ b/cache/cachetest/jsonroundtrip_test.go @@ -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) + } + }) + } +} diff --git a/service/waf_service/waf_ip_lookup_ipfailure_test.go b/service/waf_service/waf_ip_lookup_ipfailure_test.go new file mode 100644 index 0000000..9a86c49 --- /dev/null +++ b/service/waf_service/waf_ip_lookup_ipfailure_test.go @@ -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) + } +} diff --git a/service/waf_service/waf_ip_lookup_service.go b/service/waf_service/waf_ip_lookup_service.go index a2cd6c5..4d9ff82 100644 --- a/service/waf_service/waf_ip_lookup_service.go +++ b/service/waf_service/waf_ip_lookup_service.go @@ -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, }) } diff --git a/wafipban/host_login_failure_manager.go b/wafipban/host_login_failure_manager.go index 539770c..ed8b858 100644 --- a/wafipban/host_login_failure_manager.go +++ b/wafipban/host_login_failure_manager.go @@ -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) } diff --git a/wafipban/host_login_failure_manager_test.go b/wafipban/host_login_failure_manager_test.go new file mode 100644 index 0000000..970880b --- /dev/null +++ b/wafipban/host_login_failure_manager_test.go @@ -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 计数") + } + }) + } +} diff --git a/wafipban/ip_failure_manager.go b/wafipban/ip_failure_manager.go index 0dfa0dc..6e83251 100644 --- a/wafipban/ip_failure_manager.go +++ b/wafipban/ip_failure_manager.go @@ -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) } diff --git a/wafipban/ip_failure_manager_test.go b/wafipban/ip_failure_manager_test.go new file mode 100644 index 0000000..f1f987c --- /dev/null +++ b/wafipban/ip_failure_manager_test.go @@ -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) + } + }) + } +} diff --git a/wafipban/record_cache.go b/wafipban/record_cache.go new file mode 100644 index 0000000..256ba7b --- /dev/null +++ b/wafipban/record_cache.go @@ -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 +}