From 78fbd55e7a59cd693ec247344bf9f82b907d844c Mon Sep 17 00:00:00 2001 From: samwaf Date: Sat, 12 Sep 2026 21:46:37 +0800 Subject: [PATCH] fix: distinguish a cache miss from an unavailable cache backend #938 --- cache/cache_semantics_test.go | 120 +++++++++ cache/cache_store.go | 30 +++ cache/errors.go | 33 +++ cache/factory.go | 10 +- cache/redis_cache.go | 224 ++++++++++++++-- cache/waf_cache.go | 71 ++++- cmd/samwaf/main.go | 17 +- global/global.go | 5 + middleware/auth_api_check.go | 246 +++++++++--------- middleware/auth_backend_error_test.go | 125 +++++++++ model/common/response/response.go | 12 +- .../waf_service/waf_access_session_service.go | 1 + service/waf_service/waf_diagnostic.go | 36 ++- .../waf_httpauthsession_service.go | 12 +- supervisor/proc_alive_unix.go | 30 ++- supervisor/proc_alive_windows.go | 6 + supervisor/supervisor.go | 21 +- wafconfig/wafconfig.go | 9 + wafupgradenotice/upgrade_notes.yaml | 33 +++ 19 files changed, 868 insertions(+), 173 deletions(-) create mode 100644 cache/cache_semantics_test.go create mode 100644 cache/errors.go create mode 100644 middleware/auth_backend_error_test.go diff --git a/cache/cache_semantics_test.go b/cache/cache_semantics_test.go new file mode 100644 index 00000000..c4e6a99e --- /dev/null +++ b/cache/cache_semantics_test.go @@ -0,0 +1,120 @@ +package cache + +import ( + "errors" + "testing" + "time" +) + +// 缓存读取失败必须能区分"键不存在"与"后端不可用"。 +// 内存后端不会有后端故障,所有未命中一律是 ErrCacheMiss; +// 凡是拿缓存做凭证判定的链路都按 errors.Is 分流,这里把语义钉死。 + +type demoVal struct { + Name string `json:"name"` +} + +func TestMemoryCacheMissIsSentinel(t *testing.T) { + c := InitWafCache() + + var out demoVal + if err := c.GetAs("NOT_EXIST", &out); !errors.Is(err, ErrCacheMiss) { + t.Fatalf("GetAs 未命中应返回 ErrCacheMiss,实际 %v", err) + } + if err := c.GetAsEx("NOT_EXIST", &out, time.Minute); !errors.Is(err, ErrCacheMiss) { + t.Fatalf("GetAsEx 未命中应返回 ErrCacheMiss,实际 %v", err) + } + if err := c.Touch("NOT_EXIST", time.Minute); !errors.Is(err, ErrCacheMiss) { + t.Fatalf("Touch 未命中应返回 ErrCacheMiss,实际 %v", err) + } + if _, err := c.GetString("NOT_EXIST"); !errors.Is(err, ErrCacheMiss) { + t.Fatalf("GetString 未命中应返回 ErrCacheMiss,实际 %v", err) + } + if _, err := c.GetInt("NOT_EXIST"); !errors.Is(err, ErrCacheMiss) { + t.Fatalf("GetInt 未命中应返回 ErrCacheMiss,实际 %v", err) + } + if _, err := c.GetBytes("NOT_EXIST"); !errors.Is(err, ErrCacheMiss) { + t.Fatalf("GetBytes 未命中应返回 ErrCacheMiss,实际 %v", err) + } + if _, err := c.GetExpireTime("NOT_EXIST"); !errors.Is(err, ErrCacheMiss) { + t.Fatalf("GetExpireTime 未命中应返回 ErrCacheMiss,实际 %v", err) + } + + // 内存后端永远不会报后端故障 + if err := c.GetAs("NOT_EXIST", &out); errors.Is(err, ErrCacheBackend) { + t.Fatal("内存后端不应出现 ErrCacheBackend") + } + ok, err := c.ExistsE("NOT_EXIST") + if ok || err != nil { + t.Fatalf("ExistsE 未命中应返回 (false, nil),实际 (%v, %v)", ok, err) + } +} + +func TestMemoryGetAsExRenews(t *testing.T) { + c := InitWafCache() + c.SetWithTTl("K", demoVal{Name: "n1"}, 2*time.Second) + + time.Sleep(1200 * time.Millisecond) + + // 读取的同时把有效期重置为 2 秒,原到期时刻(约 800ms 后)作废 + var out demoVal + if err := c.GetAsEx("K", &out, 2*time.Second); err != nil { + t.Fatalf("GetAsEx 应命中,实际 %v", err) + } + if out.Name != "n1" { + t.Fatalf("取到的值不对:%v", out.Name) + } + + time.Sleep(1200 * time.Millisecond) + if err := c.GetAsEx("K", &out, 2*time.Second); err != nil { + t.Fatalf("续期后本应仍然有效,实际 %v", err) + } +} + +func TestMemoryGetAsExWithoutTTlDoesNotRenew(t *testing.T) { + c := InitWafCache() + c.SetWithTTl("K", demoVal{Name: "n1"}, time.Second) + + var out demoVal + // ttl<=0 表示只读不续期 + if err := c.GetAsEx("K", &out, 0); err != nil { + t.Fatalf("应命中,实际 %v", err) + } + time.Sleep(1100 * time.Millisecond) + if err := c.GetAsEx("K", &out, 0); !errors.Is(err, ErrCacheMiss) { + t.Fatalf("未续期则应已过期,实际 %v", err) + } +} + +func TestMemoryTouchKeepsValue(t *testing.T) { + c := InitWafCache() + c.SetWithTTl("K", demoVal{Name: "n1"}, time.Second) + + if err := c.Touch("K", 3*time.Second); err != nil { + t.Fatalf("Touch 应成功,实际 %v", err) + } + time.Sleep(1200 * time.Millisecond) + + var out demoVal + if err := c.GetAs("K", &out); err != nil { + t.Fatalf("Touch 后应仍然有效,实际 %v", err) + } + if out.Name != "n1" { + t.Fatalf("Touch 不应改变值,实际 %v", out.Name) + } +} + +// 键名里可能直接跟着凭证本体,日志只能出现前缀加 8 个字符 +func TestMaskCacheKey(t *testing.T) { + cases := []struct{ in, want string }{ + {"CACHE_TOKEN0d42ce0c1f2a3b4c5d6e7f8091a2b3c4", "CACHE_TOKEN0d42ce0c..."}, + {"CACHE_TOKEN_BINDFAIL_0d42ce0c1f2a3b4c", "CACHE_TOKEN_BINDFAIL_0d42ce0c..."}, + {"CACHE_TOKEN", "CACHE_TOKEN"}, + {"CACHE_X_abc", "CACHE_X_abc"}, + } + for _, tc := range cases { + if got := maskCacheKey(tc.in); got != tc.want { + t.Fatalf("maskCacheKey(%q)=%q,期望 %q", tc.in, got, tc.want) + } + } +} diff --git a/cache/cache_store.go b/cache/cache_store.go index 1a16d2a3..51507c71 100644 --- a/cache/cache_store.go +++ b/cache/cache_store.go @@ -11,13 +11,43 @@ type CacheStore interface { // GetAs 将缓存值反序列化到 out(out 必须为指针)。 // 对于 Redis 等非内存后端,Get() 返回的是 map[string]interface{}, // 无法直接类型断言为具体 struct;应使用 GetAs 代替。 + // + // 返回值区分两种失败:ErrCacheMiss(键不存在)与 ErrCacheBackend(后端不可用), + // 用 errors.Is 判定。其余错误来自反序列化。 GetAs(key string, out interface{}) error + // GetAsEx 读取并同时续期,一次往返完成。ttl<=0 时等价于 GetAs。 + // 每请求都要读一次的凭证类缓存用它,可省掉单独的查剩余时间与续期两次往返。 + GetAsEx(key string, out interface{}, ttl time.Duration) error + // Touch 只续期不读取值。键不存在返回 ErrCacheMiss。 + Touch(key string, ttl time.Duration) error GetBytes(key string) ([]byte, error) GetString(key string) (string, error) GetInt(key string) (int, error) + // IsKeyExist 后端错误一律按 false 返回。 + // 需要区分"确实不存在"与"这次没读到"的场景(尤其是安全判定)用 ExistsE。 IsKeyExist(key string) bool + // ExistsE 与 IsKeyExist 相同,但把后端错误单独返回。 + ExistsE(key string) (bool, error) Remove(key string) interface{} GetExpireTime(key string) (time.Time, error) ListAvailableKeys() map[string]time.Duration ListAvailableKeysWithPrefix(prefix string) map[string]time.Duration } + +// BackendStats 缓存后端运行状况,供运行诊断读取 +type BackendStats struct { + Backend string `json:"backend"` + ErrCount uint64 `json:"err_count"` + LastErrAt time.Time `json:"last_err_at"` + LastErr string `json:"last_err"` +} + +// BackendStater 可选能力:后端实现了才有错误统计(内存后端不会失败,无需实现) +type BackendStater interface { + BackendStats() BackendStats +} + +// BackendDescriber 可选能力:用于启动日志打印当前缓存后端形态(不含口令) +type BackendDescriber interface { + Describe() string +} diff --git a/cache/errors.go b/cache/errors.go new file mode 100644 index 00000000..42430869 --- /dev/null +++ b/cache/errors.go @@ -0,0 +1,33 @@ +package cache + +import "errors" + +// 缓存读取失败分两类,调用方必须能区分: +// 键不存在是正常结果,后端不可用是故障。凡是拿缓存做凭证判定的链路 +// (管理端令牌、访问会话),把后者当成前者就等于把一次网络抖动判成"凭证失效"。 +var ( + // ErrCacheMiss 键不存在(含已过期) + ErrCacheMiss = errors.New("缓存中不存在该键") + // ErrCacheBackend 缓存后端本次不可用(超时、连接异常、连接池等待超时等) + ErrCacheBackend = errors.New("缓存后端不可用") +) + +// maskCacheKey 键名可能直接带凭证(如令牌缓存键就是前缀+令牌本体), +// 日志只保留大写前缀与其后 8 个字符。 +func maskCacheKey(key string) string { + // 前缀扫描上限:现有最长前缀 CACHE_TOKEN_BINDFAIL_ 是 21 个字符。 + // 设上限是为了万一某类键的值本身带大写,也不会被整段当成前缀留在日志里。 + const maxPrefix = 24 + i := 0 + for i < len(key) && i < maxPrefix && (key[i] == '_' || (key[i] >= 'A' && key[i] <= 'Z')) { + i++ + } + if i >= len(key) { + return key + } + rest := key[i:] + if len(rest) > 8 { + return key[:i] + rest[:8] + "..." + } + return key[:i] + rest +} diff --git a/cache/factory.go b/cache/factory.go index aa5de951..21712a6d 100644 --- a/cache/factory.go +++ b/cache/factory.go @@ -1,11 +1,19 @@ package cache +import "time" + // RedisCacheConfig Redis连接配置 type RedisCacheConfig struct { Host string - Port int Password string + Port int DB int + // PoolSize 连接池大小,<=0 时用 go-redis 默认值(10 × GOMAXPROCS) + PoolSize int + // PoolTimeout 等待空闲连接的上限,<=0 时用 go-redis 默认值(ReadTimeout + 1s) + PoolTimeout time.Duration + // OpTimeout 单次操作总上限,<=0 时用 defaultRedisOpTimeout + OpTimeout time.Duration } // NewCacheStore 根据cacheType创建缓存实例 diff --git a/cache/redis_cache.go b/cache/redis_cache.go index 14a015b1..08ff36c1 100644 --- a/cache/redis_cache.go +++ b/cache/redis_cache.go @@ -1,25 +1,42 @@ package cache import ( + "SamWaf/common/zlog" "context" "encoding/json" "errors" "fmt" "strings" + "sync" "time" "github.com/redis/go-redis/v9" ) +const ( + // defaultRedisOpTimeout 单次操作总上限。必须大于 ReadTimeout, + // 否则正常的一次重试就会被掐断。 + defaultRedisOpTimeout = 5 * time.Second + // backendErrLogInterval 后端故障时每请求都会报错,按间隔收敛日志 + backendErrLogInterval = 10 * time.Second +) + // RedisCache Redis缓存实现,满足CacheStore接口 type RedisCache struct { - client *redis.Client - ctx context.Context + client *redis.Client + ctx context.Context + opTimeout time.Duration + + errMu sync.Mutex + errCount uint64 + errAt time.Time + errLast string + errLogAt time.Time } // NewRedisCache 创建Redis缓存实例,连接失败时返回 error func NewRedisCache(cfg *RedisCacheConfig) (*RedisCache, error) { - client := redis.NewClient(&redis.Options{ + opt := &redis.Options{ Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), Password: cfg.Password, DB: cfg.DB, @@ -27,21 +44,87 @@ func NewRedisCache(cfg *RedisCacheConfig) (*RedisCache, error) { ReadTimeout: 3 * time.Second, WriteTimeout: 3 * time.Second, MaxRetries: 3, // 断连后自动重试3次(go-redis 默认行为,此处显式声明) - PoolSize: 10, MinIdleConns: 2, - }) + } + // 连接池留空时用 go-redis 默认值(10 × GOMAXPROCS):管理端鉴权与业务检测共用同一个池, + // 固定小值在多核机器上会先于 Redis 本身成为瓶颈。 + if cfg.PoolSize > 0 { + opt.PoolSize = cfg.PoolSize + } + if cfg.PoolTimeout > 0 { + opt.PoolTimeout = cfg.PoolTimeout + } + client := redis.NewClient(opt) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := client.Ping(ctx).Err(); err != nil { _ = client.Close() return nil, fmt.Errorf("Redis连接失败 %s:%d : %w", cfg.Host, cfg.Port, err) } + opTimeout := cfg.OpTimeout + if opTimeout <= 0 { + opTimeout = defaultRedisOpTimeout + } return &RedisCache{ - client: client, - ctx: context.Background(), + client: client, + ctx: context.Background(), + opTimeout: opTimeout, }, nil } +// opCtx 每次操作独立超时,避免单次调用卡满 ReadTimeout × 重试次数 +func (r *RedisCache) opCtx() (context.Context, context.CancelFunc) { + return context.WithTimeout(r.ctx, r.opTimeout) +} + +// mapErr 把 go-redis 的错误分成"键不存在"与"后端不可用"两类 +func (r *RedisCache) mapErr(err error) error { + if errors.Is(err, redis.Nil) { + return ErrCacheMiss + } + return fmt.Errorf("%w: %v", ErrCacheBackend, err) +} + +// noteErr 记录后端错误并按间隔打日志。键名脱敏后才允许进日志。 +func (r *RedisCache) noteErr(op, key string, err error) { + if err == nil || errors.Is(err, redis.Nil) { + return + } + now := time.Now() + r.errMu.Lock() + r.errCount++ + r.errAt = now + r.errLast = err.Error() + count := r.errCount + shouldLog := now.Sub(r.errLogAt) >= backendErrLogInterval + if shouldLog { + r.errLogAt = now + } + r.errMu.Unlock() + if shouldLog { + zlog.Error(fmt.Sprintf("[缓存] Redis %v 失败 key:%v err:%v 累计失败:%v 次", + op, maskCacheKey(key), err, count)) + } +} + +// BackendStats 供运行诊断读取 +func (r *RedisCache) BackendStats() BackendStats { + r.errMu.Lock() + defer r.errMu.Unlock() + return BackendStats{ + Backend: "redis", + ErrCount: r.errCount, + LastErrAt: r.errAt, + LastErr: r.errLast, + } +} + +// Describe 启动日志用,不含口令 +func (r *RedisCache) Describe() string { + opt := r.client.Options() + return fmt.Sprintf("redis %v db=%v pool=%v", opt.Addr, opt.DB, opt.PoolSize) +} + func (r *RedisCache) encode(value interface{}) (string, error) { b, err := json.Marshal(value) if err != nil { @@ -59,7 +142,11 @@ func (r *RedisCache) SetWithTTl(key string, value interface{}, ttl time.Duration if err != nil { return } - r.client.Set(r.ctx, key, encoded, ttl) + ctx, cancel := r.opCtx() + defer cancel() + if err := r.client.Set(ctx, key, encoded, ttl).Err(); err != nil { + r.noteErr("SET", key, err) + } } // SetWithTTlRenewTime Redis不保留原始createTime,直接等同于SetWithTTl @@ -68,8 +155,11 @@ func (r *RedisCache) SetWithTTlRenewTime(key string, value interface{}, ttl time } func (r *RedisCache) Get(key string) interface{} { - val, err := r.client.Get(r.ctx, key).Result() + ctx, cancel := r.opCtx() + defer cancel() + val, err := r.client.Get(ctx, key).Result() if err != nil { + r.noteErr("GET", key, err) return nil } var result interface{} @@ -80,17 +170,66 @@ func (r *RedisCache) Get(key string) interface{} { } func (r *RedisCache) GetAs(key string, out interface{}) error { - val, err := r.client.Get(r.ctx, key).Result() + ctx, cancel := r.opCtx() + defer cancel() + val, err := r.client.Get(ctx, key).Result() + if err != nil { + r.noteErr("GET", key, err) + return r.mapErr(err) + } + return json.Unmarshal([]byte(val), out) +} + +// GetAsEx 一次往返完成读取与续期:Pipeline 里 GET + EXPIRE。 +// 不用 GETEX 是因为它要求 Redis 6.2+,而 EXPIRE 对不存在的键返回 0、无副作用。 +func (r *RedisCache) GetAsEx(key string, out interface{}, ttl time.Duration) error { + if ttl <= 0 { + return r.GetAs(key, out) + } + ctx, cancel := r.opCtx() + defer cancel() + pipe := r.client.Pipeline() + getCmd := pipe.Get(ctx, key) + expCmd := pipe.Expire(ctx, key, ttl) + if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) { + r.noteErr("GET+EXPIRE", key, err) + } + // 以 GET 自己的结果为准:续期失败只是这次没续上,不该让本来读到的值作废 + val, err := getCmd.Result() if err != nil { - return errors.New("数据不存在") + r.noteErr("GET", key, err) + return r.mapErr(err) + } + if eerr := expCmd.Err(); eerr != nil && !errors.Is(eerr, redis.Nil) { + r.noteErr("EXPIRE", key, eerr) } return json.Unmarshal([]byte(val), out) } +func (r *RedisCache) Touch(key string, ttl time.Duration) error { + if ttl <= 0 { + return nil + } + ctx, cancel := r.opCtx() + defer cancel() + ok, err := r.client.Expire(ctx, key, ttl).Result() + if err != nil { + r.noteErr("EXPIRE", key, err) + return r.mapErr(err) + } + if !ok { + return ErrCacheMiss + } + return nil +} + func (r *RedisCache) GetBytes(key string) ([]byte, error) { - val, err := r.client.Get(r.ctx, key).Bytes() + ctx, cancel := r.opCtx() + defer cancel() + val, err := r.client.Get(ctx, key).Bytes() if err != nil { - return nil, errors.New("数据不存在") + r.noteErr("GET", key, err) + return nil, r.mapErr(err) } var result []byte if err := json.Unmarshal(val, &result); err != nil { @@ -100,9 +239,12 @@ func (r *RedisCache) GetBytes(key string) ([]byte, error) { } func (r *RedisCache) GetString(key string) (string, error) { - val, err := r.client.Get(r.ctx, key).Result() + ctx, cancel := r.opCtx() + defer cancel() + val, err := r.client.Get(ctx, key).Result() if err != nil { - return "", errors.New("数据不存在") + r.noteErr("GET", key, err) + return "", r.mapErr(err) } var result string if err := json.Unmarshal([]byte(val), &result); err != nil { @@ -112,13 +254,16 @@ func (r *RedisCache) GetString(key string) (string, error) { } func (r *RedisCache) GetInt(key string) (int, error) { - val, err := r.client.Get(r.ctx, key).Result() + ctx, cancel := r.opCtx() + defer cancel() + val, err := r.client.Get(ctx, key).Result() if err != nil { - return -1, errors.New("数据不存在") + r.noteErr("GET", key, err) + return -1, r.mapErr(err) } var result interface{} if err := json.Unmarshal([]byte(val), &result); err != nil { - return -1, errors.New("数据不存在") + return -1, ErrCacheMiss } switch v := result.(type) { case float64: @@ -126,23 +271,44 @@ func (r *RedisCache) GetInt(key string) (int, error) { case int: return v, nil } - return -1, errors.New("数据不存在") + return -1, ErrCacheMiss } func (r *RedisCache) IsKeyExist(key string) bool { - n, err := r.client.Exists(r.ctx, key).Result() - return err == nil && n > 0 + exist, _ := r.ExistsE(key) + return exist +} + +func (r *RedisCache) ExistsE(key string) (bool, error) { + ctx, cancel := r.opCtx() + defer cancel() + n, err := r.client.Exists(ctx, key).Result() + if err != nil { + r.noteErr("EXISTS", key, err) + return false, r.mapErr(err) + } + return n > 0, nil } func (r *RedisCache) Remove(key string) interface{} { - r.client.Del(r.ctx, key) + ctx, cancel := r.opCtx() + defer cancel() + if err := r.client.Del(ctx, key).Err(); err != nil { + r.noteErr("DEL", key, err) + } return nil } func (r *RedisCache) GetExpireTime(key string) (time.Time, error) { - ttl, err := r.client.TTL(r.ctx, key).Result() - if err != nil || ttl < 0 { - return time.Time{}, errors.New("数据不存在或已过期") + ctx, cancel := r.opCtx() + defer cancel() + ttl, err := r.client.TTL(ctx, key).Result() + if err != nil { + r.noteErr("TTL", key, err) + return time.Time{}, r.mapErr(err) + } + if ttl < 0 { + return time.Time{}, ErrCacheMiss } return time.Now().Add(ttl), nil } @@ -159,19 +325,23 @@ func (r *RedisCache) ListAvailableKeysWithPrefix(prefix string) map[string]time. result := make(map[string]time.Duration) var cursor uint64 for { - keys, nextCursor, err := r.client.Scan(r.ctx, cursor, pattern, 100).Result() + ctx, cancel := r.opCtx() + keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, 100).Result() if err != nil { + cancel() + r.noteErr("SCAN", prefix, err) break } for _, key := range keys { if prefix != "" && !strings.HasPrefix(key, prefix) { continue } - ttl, err := r.client.TTL(r.ctx, key).Result() + ttl, err := r.client.TTL(ctx, key).Result() if err == nil && ttl > 0 { result[key] = ttl } } + cancel() cursor = nextCursor if cursor == 0 { break diff --git a/cache/waf_cache.go b/cache/waf_cache.go index f5df80f9..f1d38bae 100644 --- a/cache/waf_cache.go +++ b/cache/waf_cache.go @@ -3,7 +3,6 @@ package cache import ( "SamWaf/common/zlog" "encoding/json" - "errors" "strings" "sync" "time" @@ -75,7 +74,7 @@ func (wafCache *WafCache) SetWithTTlRenewTime(key string, value interface{}, ttl func (wafCache *WafCache) GetAs(key string, out interface{}) error { val := wafCache.Get(key) if val == nil { - return errors.New("数据不存在") + return ErrCacheMiss } b, err := json.Marshal(val) if err != nil { @@ -89,21 +88,21 @@ func (wafCache *WafCache) GetBytes(key string) ([]byte, error) { if str, ok := key1Value.([]byte); ok { return str, nil } - return nil, errors.New("数据不存在") + return nil, ErrCacheMiss } func (wafCache *WafCache) GetString(key string) (string, error) { key1Value := wafCache.Get(key) if str, ok := key1Value.(string); ok { return str, nil } - return "", errors.New("数据不存在") + return "", ErrCacheMiss } func (wafCache *WafCache) GetInt(key string) (int, error) { key1Value := wafCache.Get(key) if str, ok := key1Value.(int); ok { return str, nil } - return -1, errors.New("数据不存在") + return -1, ErrCacheMiss } func (wafCache *WafCache) IsKeyExist(key string) bool { wafCache.mu.Lock() @@ -118,6 +117,64 @@ func (wafCache *WafCache) IsKeyExist(key string) bool { delete(wafCache.cache, key) return false } +// ExistsE 内存实现不会有后端故障,error 恒为 nil +func (wafCache *WafCache) ExistsE(key string) (bool, error) { + return wafCache.IsKeyExist(key), nil +} + +// GetAsEx 读取并续期。与 SetWithTTlRenewTime 一致,续期从当前时刻重新计时。 +func (wafCache *WafCache) GetAsEx(key string, out interface{}, ttl time.Duration) error { + wafCache.mu.Lock() + item, found := wafCache.cache[key] + if found && time.Since(item.createTime) > item.ttl { + delete(wafCache.cache, key) + found = false + } + if found && ttl > 0 { + now := time.Now() + wafCache.cache[key] = WafCacheItem{ + value: item.value, + createTime: now, + expireTime: now.Add(ttl), + ttl: ttl, + } + } + wafCache.mu.Unlock() + + if !found { + return ErrCacheMiss + } + b, err := json.Marshal(item.value) + if err != nil { + return err + } + return json.Unmarshal(b, out) +} + +// Touch 只续期不取值 +func (wafCache *WafCache) Touch(key string, ttl time.Duration) error { + wafCache.mu.Lock() + defer wafCache.mu.Unlock() + item, found := wafCache.cache[key] + if !found { + return ErrCacheMiss + } + if time.Since(item.createTime) > item.ttl { + delete(wafCache.cache, key) + return ErrCacheMiss + } + if ttl > 0 { + now := time.Now() + wafCache.cache[key] = WafCacheItem{ + value: item.value, + createTime: now, + expireTime: now.Add(ttl), + ttl: ttl, + } + } + return nil +} + func (wafCache *WafCache) Get(key string) interface{} { wafCache.mu.Lock() defer wafCache.mu.Unlock() @@ -147,14 +204,14 @@ func (wafCache *WafCache) GetExpireTime(key string) (time.Time, error) { defer wafCache.mu.Unlock() item, found := wafCache.cache[key] if !found { - return time.Time{}, errors.New("数据不存在") + return time.Time{}, ErrCacheMiss } if time.Since(item.createTime) <= item.ttl { return item.expireTime, nil } zlog.Debug("GetExpireTime CLEAR CACHE EXPIRE :" + key) delete(wafCache.cache, key) - return time.Time{}, errors.New("数据已过期") + return time.Time{}, ErrCacheMiss } func (wafCache *WafCache) ClearExpirationCache() { wafCache.mu.Lock() diff --git a/cmd/samwaf/main.go b/cmd/samwaf/main.go index 1cd58e37..38c7935f 100644 --- a/cmd/samwaf/main.go +++ b/cmd/samwaf/main.go @@ -233,16 +233,25 @@ func (m *wafSystenService) run() { //初始化cache { cacheStore, err := cache.NewCacheStore(global.GCACHE_TYPE, &cache.RedisCacheConfig{ - Host: global.GCACHE_REDIS_HOST, - Port: global.GCACHE_REDIS_PORT, - Password: global.GCACHE_REDIS_PASSWORD, - DB: global.GCACHE_REDIS_DB, + Host: global.GCACHE_REDIS_HOST, + Port: global.GCACHE_REDIS_PORT, + Password: global.GCACHE_REDIS_PASSWORD, + DB: global.GCACHE_REDIS_DB, + PoolSize: global.GCACHE_REDIS_POOL_SIZE, + PoolTimeout: time.Duration(global.GCACHE_REDIS_POOL_TIMEOUT_SEC) * time.Second, + OpTimeout: time.Duration(global.GCACHE_REDIS_OP_TIMEOUT_SEC) * time.Second, }) if err != nil { zlog.Error("初始化缓存失败,程序退出,请检查conf/config.yml缓存配置是否正确", "error", err) os.Exit(1) } global.GCACHE_WAFCACHE = cacheStore + // 缓存后端决定了令牌等凭证放在哪里,排障时是第一个要确认的事实,启动就写进日志。 + if d, ok := cacheStore.(cache.BackendDescriber); ok { + zlog.Info("缓存后端: " + d.Describe()) + } else { + zlog.Info("缓存后端: memory(进程内)") + } } //初始化验证码服务 wafcaptcha.InitCaptchaService(global.GCACHE_WAFCACHE) diff --git a/global/global.go b/global/global.go index d44372fd..950d6183 100644 --- a/global/global.go +++ b/global/global.go @@ -144,6 +144,11 @@ var ( GCACHE_REDIS_PORT int = 6379 GCACHE_REDIS_PASSWORD string = "" GCACHE_REDIS_DB int = 0 + // 连接池与单次操作上限,0 表示用 go-redis 默认值(池大小 10 × GOMAXPROCS)。 + // 管理端鉴权与业务检测共用同一个池,机器核数多时不宜固定成小值。 + GCACHE_REDIS_POOL_SIZE int = 0 + GCACHE_REDIS_POOL_TIMEOUT_SEC int = 0 + GCACHE_REDIS_OP_TIMEOUT_SEC int = 0 /*********HTTP相关**************/ GWAF_HTTP_SENSITIVE_REPLACE_STRING = "**" //HTTP 敏感内容替换成 diff --git a/middleware/auth_api_check.go b/middleware/auth_api_check.go index 9974597d..b15e6e80 100644 --- a/middleware/auth_api_check.go +++ b/middleware/auth_api_check.go @@ -1,6 +1,7 @@ package middleware import ( + "SamWaf/cache" "SamWaf/common/zlog" "SamWaf/enums" "SamWaf/global" @@ -9,6 +10,7 @@ import ( "SamWaf/service/waf_service" "SamWaf/utils" "SamWaf/wafhostguard" + "errors" "fmt" "strings" "time" @@ -60,140 +62,146 @@ func Auth() gin.HandlerFunc { c.Abort() return } else { - //检查是否存在 - isTokenExist := global.GCACHE_WAFCACHE.IsKeyExist(enums.CACHE_TOKEN + tokenStr) - if !isTokenExist { - // 这条分支以前一行日志都没有,用户遇到"登录后无限跳登录页"时服务端完全是黑盒(issue #938)。 + // 读取令牌并顺带续期,一次往返完成。 + // 读取失败要分清"令牌确实不在"与"这次没读到":后者是缓存后端故障,与登录状态无关, + // 据此要求重新登录会把一次后端抖动变成一次强制下线。 + var tokenInfo model.TokenInfo + tokenErr := global.GCACHE_WAFCACHE.GetAsEx(enums.CACHE_TOKEN+tokenStr, &tokenInfo, + time.Duration(global.GCONFIG_RECORD_TOKEN_EXPIRE_MINTUTES)*time.Minute) + if tokenErr != nil { // 令牌只打前 8 位,避免把可用凭证写进日志。 - zlog.Debug(fmt.Sprintf("令牌不在缓存中(已过期/进程重启/与签发进程不是同一个) token:%v... path:%v 来源IP:%v", - utils.TruncateString(tokenStr, 8), c.Request.URL.Path, utils.GetManageClientIP(c))) - response.AuthFailWithMessage("令牌过期", c) + shortToken := utils.TruncateString(tokenStr, 8) + switch { + case errors.Is(tokenErr, cache.ErrCacheMiss): + zlog.Debug(fmt.Sprintf("令牌不在缓存中(已过期/已注销/被同账号新登录顶掉) token:%v... path:%v 来源IP:%v", + shortToken, c.Request.URL.Path, utils.GetManageClientIP(c))) + response.AuthFailWithMessage("令牌过期", c) + case errors.Is(tokenErr, cache.ErrCacheBackend): + zlog.Error(fmt.Sprintf("令牌读取失败,缓存后端不可用 token:%v... path:%v err:%v", + shortToken, c.Request.URL.Path, tokenErr)) + response.BackendUnavailableWithMessage("服务暂时不可用,请稍后重试", c) + default: + zlog.Error(fmt.Sprintf("令牌缓存内容解析失败 token:%v... err:%v", shortToken, tokenErr)) + response.AuthFailWithMessage("令牌数据异常,请重新登录", c) + } c.Abort() return - } else { - var tokenInfo model.TokenInfo - if err := global.GCACHE_WAFCACHE.GetAs(enums.CACHE_TOKEN+tokenStr, &tokenInfo); err != nil { - zlog.Error(fmt.Sprintf("令牌缓存内容解析失败 token:%v... err:%v", utils.TruncateString(tokenStr, 8), err)) - response.AuthFailWithMessage("令牌解析失败", c) - c.Abort() - return - } + } - // IP检查逻辑 - currentIP := utils.GetManageClientIP(c) - ipMatched := false + // 缓存内容必须是一个完整会话:账号为空说明取到的数据不完整,按无效处理。 + if tokenInfo.LoginAccount == "" { + zlog.Error(fmt.Sprintf("令牌缓存内容不完整(账号为空) token:%v...", utils.TruncateString(tokenStr, 8))) + response.AuthFailWithMessage("令牌数据异常,请重新登录", c) + c.Abort() + return + } - // 如果启用严格IP绑定,进行严格IP检查 - if global.GCONFIG_ENABLE_STRICT_IP_BINDING == 1 { - if tokenInfo.LoginIp == currentIP { - ipMatched = true - } else { - ipMatched = false - } - } else { + // IP检查逻辑 + currentIP := utils.GetManageClientIP(c) + ipMatched := false + + // 如果启用严格IP绑定,进行严格IP检查 + if global.GCONFIG_ENABLE_STRICT_IP_BINDING == 1 { + if tokenInfo.LoginIp == currentIP { ipMatched = true + } else { + ipMatched = false } + } else { + ipMatched = true + } - // 指纹检查逻辑 - // 豁免路径:WebSocket 握手、SSE、带查询串令牌的下载。这三类请求由浏览器直接发起, - // Accept-Encoding/Accept-Language 与普通 XHR 并不一致,参与指纹比对必然误判。 - fingerprintMatched := true - if global.GCONFIG_ENABLE_DEVICE_FINGERPRINT == 1 && tokenInfo.DeviceFingerprint != "" && - !isFingerprintExemptPath(c) { - currentFingerprint := utils.GenerateFingerprint(c.Request) - if tokenInfo.DeviceFingerprint == currentFingerprint { - fingerprintMatched = true - } else { - fingerprintMatched = false - } + // 指纹检查逻辑 + // 豁免路径:WebSocket 握手、SSE、带查询串令牌的下载。这三类请求由浏览器直接发起, + // Accept-Encoding/Accept-Language 与普通 XHR 并不一致,参与指纹比对必然误判。 + fingerprintMatched := true + if global.GCONFIG_ENABLE_DEVICE_FINGERPRINT == 1 && tokenInfo.DeviceFingerprint != "" && + !isFingerprintExemptPath(c) { + currentFingerprint := utils.GenerateFingerprint(c.Request) + if tokenInfo.DeviceFingerprint == currentFingerprint { + fingerprintMatched = true + } else { + fingerprintMatched = false } + } - // 如果指纹不匹配,则拒绝请求 - // 只拒本次请求,不再直接删令牌:反向代理/CDN 改写请求头、浏览器升级等都会造成一次性不匹配, - // 一次不匹配就作废整个会话会让用户莫名其妙被登出(issue #938/#930)。 - // 连续 bindFailThreshold 次不匹配才判定为真的异常并作废会话。 - if !fingerprintMatched { - zlog.Error(fmt.Sprintf("设备指纹不匹配,请求拒绝。原IP:%v 当前IP:%v 原指纹:%v 当前指纹:%v", - tokenInfo.LoginIp, currentIP, tokenInfo.DeviceFingerprint, utils.GenerateFingerprint(c.Request))) - if bumpTokenBindFailure(tokenStr) { - global.GCACHE_WAFCACHE.Remove(enums.CACHE_TOKEN + tokenStr) - response.AuthFailWithMessage("设备验证失败,需要重新登录", c) - } else { - response.AuthFailWithMessage("设备验证失败,请重试", c) - } - c.Abort() - return + // 如果指纹不匹配,则拒绝请求 + // 只拒本次请求,不再直接删令牌:反向代理/CDN 改写请求头、浏览器升级等都会造成一次性不匹配, + // 一次不匹配就作废整个会话会让用户莫名其妙被登出(issue #938/#930)。 + // 连续 bindFailThreshold 次不匹配才判定为真的异常并作废会话。 + if !fingerprintMatched { + zlog.Error(fmt.Sprintf("设备指纹不匹配,请求拒绝。原IP:%v 当前IP:%v 原指纹:%v 当前指纹:%v", + tokenInfo.LoginIp, currentIP, tokenInfo.DeviceFingerprint, utils.GenerateFingerprint(c.Request))) + if bumpTokenBindFailure(tokenStr) { + global.GCACHE_WAFCACHE.Remove(enums.CACHE_TOKEN + tokenStr) + response.AuthFailWithMessage("设备验证失败,需要重新登录", c) + } else { + response.AuthFailWithMessage("设备验证失败,请重试", c) } + c.Abort() + return + } - // N11 修复 - // 开启严格IP绑定即要求令牌与登录时的真实 IP 一致,IP 变化需重新登录。 - // 同样改为"连续多次不匹配才作废":动态IP、双栈 IPv4/IPv6 交替、多出口 NAT 都会偶发不匹配。 - if !ipMatched { - zlog.Warn(fmt.Sprintf("严格IP绑定不匹配,请求拒绝。原IP:%v 当前IP:%v", tokenInfo.LoginIp, currentIP)) - if bumpTokenBindFailure(tokenStr) { - global.GCACHE_WAFCACHE.Remove(enums.CACHE_TOKEN + tokenStr) - response.AuthFailWithMessage("登录环境已变化(IP),需要重新登录", c) - } else { - response.AuthFailWithMessage("登录环境已变化(IP),请重试", c) - } - c.Abort() - return + // N11 修复 + // 开启严格IP绑定即要求令牌与登录时的真实 IP 一致,IP 变化需重新登录。 + // 同样改为"连续多次不匹配才作废":动态IP、双栈 IPv4/IPv6 交替、多出口 NAT 都会偶发不匹配。 + if !ipMatched { + zlog.Warn(fmt.Sprintf("严格IP绑定不匹配,请求拒绝。原IP:%v 当前IP:%v", tokenInfo.LoginIp, currentIP)) + if bumpTokenBindFailure(tokenStr) { + global.GCACHE_WAFCACHE.Remove(enums.CACHE_TOKEN + tokenStr) + response.AuthFailWithMessage("登录环境已变化(IP),需要重新登录", c) + } else { + response.AuthFailWithMessage("登录环境已变化(IP),请重试", c) } + c.Abort() + return + } - // 走到这说明绑定校验全过,清掉之前累计的失败次数,避免跨越很长时间的零星失败被累加成作废 - clearTokenBindFailure(tokenStr) + // 走到这说明绑定校验全过,清掉之前累计的失败次数,避免跨越很长时间的零星失败被累加成作废 + clearTokenBindFailure(tokenStr) - // 记录"当前正在使用管理端的IP",供主机防爆破豁免。 - // 这是防误封的最后一道保险:只要你还在用管理端,你的出口IP就永远进不了封禁名单, - // 哪怕白名单一个字都没配。放在这里是因为走到这说明令牌、指纹、IP绑定都已校验通过。 - wafhostguard.TouchAdminIP(currentIP) + // 记录"当前正在使用管理端的IP",供主机防爆破豁免。 + // 这是防误封的最后一道保险:只要你还在用管理端,你的出口IP就永远进不了封禁名单, + // 哪怕白名单一个字都没配。放在这里是因为走到这说明令牌、指纹、IP绑定都已校验通过。 + wafhostguard.TouchAdminIP(currentIP) - //刷新token时间 - if global.GWAF_RELEASE == "false" { - tokenList := global.GCACHE_WAFCACHE.ListAvailableKeysWithPrefix(enums.CACHE_TOKEN) + // 开发构建下打印令牌缓存剩余时间;续期本身已在上面的 GetAsEx 里完成 + if global.GWAF_RELEASE == "false" { + tokenList := global.GCACHE_WAFCACHE.ListAvailableKeysWithPrefix(enums.CACHE_TOKEN) - for _, duration := range tokenList { - remainTime := fmt.Sprintf("%02d时%02d分", int(duration.Hours()), int(duration.Minutes())%60) - zlog.Debug(fmt.Sprintf("%v 当前token有效缓存剩余时间 %v", innerName, remainTime)) - } - } - expireTime, err := global.GCACHE_WAFCACHE.GetExpireTime(enums.CACHE_TOKEN + tokenStr) - if err == nil { - remainingTime := time.Until(expireTime) // 计算剩余有效时间 - if remainingTime > 0 && remainingTime < 2*time.Minute { - zlog.Debug(fmt.Sprintf("%v 当前token有效缓存剩余时间 %v 小于2分钟进行缓存可用时间延期处理", innerName, expireTime)) - global.GCACHE_WAFCACHE.SetWithTTlRenewTime(enums.CACHE_TOKEN+tokenStr, tokenInfo, time.Duration(global.GCONFIG_RECORD_TOKEN_EXPIRE_MINTUTES)*time.Minute) - } + for _, duration := range tokenList { + remainTime := fmt.Sprintf("%02d时%02d分", int(duration.Hours()), int(duration.Minutes())%60) + zlog.Debug(fmt.Sprintf("%v 当前token有效缓存剩余时间 %v", innerName, remainTime)) } + } - //强制改密门:需改密的令牌只放行改密/注销/ws,其余一律拦截(默认口令/被重置账号在改密前拿不到其他权限) - if tokenInfo.NeedChangePassword == 1 { - p := c.Request.URL.Path - if p != "/api/v1/account/changemypwd" && p != "/api/v1/logout" && p != "/api/v1/ws" { - zlog.Debug(fmt.Sprintf("令牌处于强制改密状态,拦截其他接口 账号:%v path:%v", tokenInfo.LoginAccount, p)) - response.NeedChangePwdWithMessage("请先修改初始/重置密码后再进行其他操作", c) - c.Abort() - return - } + //强制改密门:需改密的令牌只放行改密/注销/ws,其余一律拦截(默认口令/被重置账号在改密前拿不到其他权限) + if tokenInfo.NeedChangePassword == 1 { + p := c.Request.URL.Path + if p != "/api/v1/account/changemypwd" && p != "/api/v1/logout" && p != "/api/v1/ws" { + zlog.Debug(fmt.Sprintf("令牌处于强制改密状态,拦截其他接口 账号:%v path:%v", tokenInfo.LoginAccount, p)) + response.NeedChangePwdWithMessage("请先修改初始/重置密码后再进行其他操作", c) + c.Abort() + return } + } - //检测是否强制2Fa绑定 - if global.GCONFIG_RECORD_FORCE_BIND_2FA == 1 && c.Request.URL.Path != "/api/v1/ws" && c.Request.URL.Path != "/api/v1/logout" { - otpBean := wafOtpService.GetDetailByUserNameApi(tokenInfo.LoginAccount) - if otpBean.UserName == "" { - //需要强制跳转2fa绑定界面 - response.NeedBind2FAWithMessage("系统已开启强制 【双因素认证】 ,请进行绑定", c) - c.Abort() - return - } + //检测是否强制2Fa绑定 + if global.GCONFIG_RECORD_FORCE_BIND_2FA == 1 && c.Request.URL.Path != "/api/v1/ws" && c.Request.URL.Path != "/api/v1/logout" { + otpBean := wafOtpService.GetDetailByUserNameApi(tokenInfo.LoginAccount) + if otpBean.UserName == "" { + //需要强制跳转2fa绑定界面 + response.NeedBind2FAWithMessage("系统已开启强制 【双因素认证】 ,请进行绑定", c) + c.Abort() + return } - - // 将登录账号写入 context,供下游(变更记录、安全审计)取用 - c.Set("loginAccount", tokenInfo.LoginAccount) - c.Set("loginIP", currentIP) - // 写入角色,供 RBAC 鉴权中间件判定(空角色兜底为超级管理员,向后兼容) - c.Set("userRole", enums.NormalizeRole(tokenInfo.Role)) } + + // 将登录账号写入 context,供下游(变更记录、安全审计)取用 + c.Set("loginAccount", tokenInfo.LoginAccount) + c.Set("loginIP", currentIP) + // 写入角色,供 RBAC 鉴权中间件判定(空角色兜底为超级管理员,向后兼容) + c.Set("userRole", enums.NormalizeRole(tokenInfo.Role)) } // 这里执行路由 HandlerFunc @@ -263,10 +271,8 @@ func isFingerprintExemptPath(c *gin.Context) bool { func bumpTokenBindFailure(tokenStr string) bool { key := enums.CACHE_TOKEN_BINDFAIL + tokenStr count := 0 - if global.GCACHE_WAFCACHE.IsKeyExist(key) { - if v, err := global.GCACHE_WAFCACHE.GetInt(key); err == nil { - count = v - } + if v, err := global.GCACHE_WAFCACHE.GetInt(key); err == nil { + count = v } count++ if count >= bindFailThreshold { @@ -277,10 +283,12 @@ func bumpTokenBindFailure(tokenStr string) bool { return false } -// clearTokenBindFailure 绑定校验通过后清零失败计数 +// clearTokenBindFailure 绑定校验通过后清零失败计数。 +// 指纹与严格IP绑定都关闭时不存在该计数,整段跳过——这是每个请求都会走到的路径, +// 缓存后端为 Redis 时一次跳过就少一次往返。 func clearTokenBindFailure(tokenStr string) { - key := enums.CACHE_TOKEN_BINDFAIL + tokenStr - if global.GCACHE_WAFCACHE.IsKeyExist(key) { - global.GCACHE_WAFCACHE.Remove(key) + if global.GCONFIG_ENABLE_STRICT_IP_BINDING != 1 && global.GCONFIG_ENABLE_DEVICE_FINGERPRINT != 1 { + return } + global.GCACHE_WAFCACHE.Remove(enums.CACHE_TOKEN_BINDFAIL + tokenStr) } diff --git a/middleware/auth_backend_error_test.go b/middleware/auth_backend_error_test.go new file mode 100644 index 00000000..284d6620 --- /dev/null +++ b/middleware/auth_backend_error_test.go @@ -0,0 +1,125 @@ +package middleware + +import ( + "SamWaf/cache" + "SamWaf/global" + "SamWaf/model/common/response" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +// 鉴权读不到令牌时,"键不存在"与"缓存后端不可用"必须走不同的出口: +// 前者是登录态失效,后者只是这次没读到——把后者也判成失效,等于让一次 +// 缓存抖动把正在使用管理端的人踢回登录页。下面两个用例把分流钉死。 + +// stubCache 只实现取值分支,其余方法满足接口即可 +type stubCache struct { + err error // GetAsEx 返回的错误 + value map[string]any // err 为 nil 时写入 out 的内容 +} + +func (s *stubCache) Set(string, interface{}) {} +func (s *stubCache) SetWithTTl(string, interface{}, time.Duration) {} +func (s *stubCache) SetWithTTlRenewTime(string, interface{}, time.Duration) {} +func (s *stubCache) Get(string) interface{} { return nil } +func (s *stubCache) GetAs(key string, out interface{}) error { + return s.GetAsEx(key, out, 0) +} +func (s *stubCache) GetAsEx(_ string, out interface{}, _ time.Duration) error { + if s.err != nil { + return s.err + } + b, _ := json.Marshal(s.value) + return json.Unmarshal(b, out) +} +func (s *stubCache) Touch(string, time.Duration) error { return nil } +func (s *stubCache) GetBytes(string) ([]byte, error) { return nil, cache.ErrCacheMiss } +func (s *stubCache) GetString(string) (string, error) { return "", cache.ErrCacheMiss } +func (s *stubCache) GetInt(string) (int, error) { return -1, cache.ErrCacheMiss } +func (s *stubCache) IsKeyExist(string) bool { return false } +func (s *stubCache) ExistsE(string) (bool, error) { return false, nil } +func (s *stubCache) Remove(string) interface{} { return nil } +func (s *stubCache) GetExpireTime(string) (time.Time, error) { + return time.Time{}, cache.ErrCacheMiss +} +func (s *stubCache) ListAvailableKeys() map[string]time.Duration { return nil } +func (s *stubCache) ListAvailableKeysWithPrefix(string) map[string]time.Duration { + return nil +} + +// runAuthWith 用指定缓存跑一次带令牌的请求,返回响应码与后续处理是否被执行 +func runAuthWith(t *testing.T, store cache.CacheStore) (int, bool) { + t.Helper() + gin.SetMode(gin.TestMode) + + oldStore := global.GCACHE_WAFCACHE + oldLegacy := global.GCONFIG_COMM_LEGACY_KEY + global.GCACHE_WAFCACHE = store + // 固定走 legacy 加密分支,避免握手状态影响响应码 + global.GCONFIG_COMM_LEGACY_KEY = true + t.Cleanup(func() { + global.GCACHE_WAFCACHE = oldStore + global.GCONFIG_COMM_LEGACY_KEY = oldLegacy + }) + + reached := false + r := gin.New() + r.GET("/api/v1/ping", Auth(), func(c *gin.Context) { + reached = true + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil) + req.Header.Set("X-Token", "0d42ce0c1f2a3b4c5d6e7f8091a2b3c4") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var body struct { + Code int `json:"code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("响应不是合法 JSON:%v body=%s", err, w.Body.String()) + } + return body.Code, reached +} + +func TestAuthCacheBackendErrorDoesNotForceRelogin(t *testing.T) { + code, reached := runAuthWith(t, &stubCache{err: cache.ErrCacheBackend}) + + if code == response.AUTHFAIL { + t.Fatal("缓存后端不可用被判成了登录态失效,前端会据此清登录态跳登录页") + } + if code != response.BACKEND_UNAVAILABLE { + t.Fatalf("期望响应码 %v,实际 %v", response.BACKEND_UNAVAILABLE, code) + } + if reached { + t.Fatal("后端不可用时不得放行到业务处理") + } +} + +func TestAuthCacheMissStillRequiresRelogin(t *testing.T) { + code, reached := runAuthWith(t, &stubCache{err: cache.ErrCacheMiss}) + + if code != response.AUTHFAIL { + t.Fatalf("令牌确实不存在时应返回 %v,实际 %v", response.AUTHFAIL, code) + } + if reached { + t.Fatal("令牌无效时不得放行到业务处理") + } +} + +// 包装过的后端错误(带原始 Redis 错误信息)同样要认得出来 +func TestAuthWrappedBackendErrorDoesNotForceRelogin(t *testing.T) { + wrapped := &stubCache{err: fmt.Errorf("%w: %v", cache.ErrCacheBackend, "dial tcp 127.0.0.1:6379: i/o timeout")} + code, _ := runAuthWith(t, wrapped) + + if code != response.BACKEND_UNAVAILABLE { + t.Fatalf("包装后的后端错误期望 %v,实际 %v", response.BACKEND_UNAVAILABLE, code) + } +} diff --git a/model/common/response/response.go b/model/common/response/response.go index 1483895b..3651cb76 100644 --- a/model/common/response/response.go +++ b/model/common/response/response.go @@ -23,8 +23,11 @@ const ( // NEED_REHANDSHAKE 告诉客户端本次没有可用的会话密钥,请重新握手后重试。 // 只在 legacy 通道被运维关掉时出现(开着的话直接回落 legacy,旧客户端无感)。 NEED_REHANDSHAKE = -5 - FORBIDDEN = -403 - AUTHFAIL = -999 + // BACKEND_UNAVAILABLE 依赖的存储/缓存后端本次不可用,请求未能完成。 + // 与 AUTHFAIL 的区别:登录状态没有问题,客户端应保留登录态并稍后重试。 + BACKEND_UNAVAILABLE = -6 + FORBIDDEN = -403 + AUTHFAIL = -999 ) // HeaderKeyID 是客户端声明本次会话密钥的请求头,与 X-Sec-Ver: 2 配套。 @@ -133,6 +136,11 @@ func AuthFailWithMessage(message string, c *gin.Context) { func ForbiddenWithMessage(message string, c *gin.Context) { Result(FORBIDDEN, map[string]interface{}{}, message, c) } + +// BackendUnavailableWithMessage 后端存储本次不可用,登录状态不受影响,客户端保留登录态稍后重试 +func BackendUnavailableWithMessage(message string, c *gin.Context) { + Result(BACKEND_UNAVAILABLE, map[string]interface{}{}, message, c) +} func SecretCodeFailWithMessage(message string, c *gin.Context) { Result(INPUT_SECRET_CODE, map[string]interface{}{}, message, c) } diff --git a/service/waf_service/waf_access_session_service.go b/service/waf_service/waf_access_session_service.go index e82d02c6..9f911a10 100644 --- a/service/waf_service/waf_access_session_service.go +++ b/service/waf_service/waf_access_session_service.go @@ -210,6 +210,7 @@ func (receiver *WafAccessSessionService) ValidateToken(plain, host, hostCode, cl if global.GCACHE_WAFCACHE.IsKeyExist(enums.CACHE_ACCESS_BAD + code) { return nil } + // 读取失败(未命中或后端故障)一律回落查库,缓存抖动不会让访客掉线。 var cached AccessState if err := global.GCACHE_WAFCACHE.GetAs(enums.CACHE_ACCESS_TOKEN+code, &cached); err == nil && cached.SessionCode != "" && cached.ExpireUnix > time.Now().Unix() { diff --git a/service/waf_service/waf_diagnostic.go b/service/waf_service/waf_diagnostic.go index b8b7026c..edbcf23e 100644 --- a/service/waf_service/waf_diagnostic.go +++ b/service/waf_service/waf_diagnostic.go @@ -1,6 +1,7 @@ package waf_service import ( + "SamWaf/cache" "SamWaf/global" "SamWaf/model/response" "SamWaf/wafdb" @@ -83,16 +84,22 @@ func (receiver *WafDiagnosticService) GetTrend() response.WafDiagnosticTrend { // diagMeta 诊断包元信息。字段白名单制:只放定位性能问题需要的环境事实, // 不含任何密钥、账号、请求体、站点域名类数据。 type diagMeta struct { - Version string `json:"version"` - VersionTag string `json:"version_tag"` - OS string `json:"os"` - Arch string `json:"arch"` - GoVersion string `json:"go_version"` - GoMaxProcs int `json:"gomaxprocs"` - NumCPU int `json:"num_cpu"` - DbDriver string `json:"db_driver"` - CacheType string `json:"cache_type"` - GeneratedAt string `json:"generated_at"` + Version string `json:"version"` + VersionTag string `json:"version_tag"` + OS string `json:"os"` + Arch string `json:"arch"` + GoVersion string `json:"go_version"` + GoMaxProcs int `json:"gomaxprocs"` + NumCPU int `json:"num_cpu"` + DbDriver string `json:"db_driver"` + CacheType string `json:"cache_type"` + // 缓存后端形态与累计失败次数。后端不可用会表现为管理端频繁提示"服务暂时不可用", + // 这两项能一眼区分是缓存的问题还是业务的问题。 + CacheBackend string `json:"cache_backend,omitempty"` + CacheErrCount uint64 `json:"cache_err_count,omitempty"` + CacheLastErrAt string `json:"cache_last_err_at,omitempty"` + CacheLastErr string `json:"cache_last_err,omitempty"` + GeneratedAt string `json:"generated_at"` } // BuildDiagnosticPackage 把快照/趋势/goroutine dump/heap profile(以及已完成的 @@ -136,6 +143,15 @@ func (receiver *WafDiagnosticService) BuildDiagnosticPackage() ([]byte, error) { CacheType: global.GCACHE_TYPE, GeneratedAt: time.Now().Format("2006-01-02 15:04:05"), } + if stater, ok := global.GCACHE_WAFCACHE.(cache.BackendStater); ok { + st := stater.BackendStats() + meta.CacheBackend = st.Backend + meta.CacheErrCount = st.ErrCount + meta.CacheLastErr = st.LastErr + if !st.LastErrAt.IsZero() { + meta.CacheLastErrAt = st.LastErrAt.Format("2006-01-02 15:04:05") + } + } metaJSON, _ := json.MarshalIndent(meta, "", " ") if err := writeFile("meta.json", metaJSON); err != nil { return nil, err diff --git a/service/waf_service/waf_httpauthsession_service.go b/service/waf_service/waf_httpauthsession_service.go index 03afba2a..37637aa1 100644 --- a/service/waf_service/waf_httpauthsession_service.go +++ b/service/waf_service/waf_httpauthsession_service.go @@ -2,6 +2,7 @@ package waf_service import ( "SamWaf/common/uuid" + "SamWaf/common/zlog" "SamWaf/customtype" "SamWaf/enums" "SamWaf/global" @@ -193,8 +194,16 @@ func (receiver *WafHttpAuthSessionService) TouchBasicSession(host model.Hosts, u // 以「键还在不在」决定挑不挑战,而不是以「值读没读出来」:换 Redis 后端时取回的 // 可能不是 string,类型断言失败就当没被踢,踢下线会静默失效。读不出来就现生成一个 // 新 nonce——realm 变了才是挑战生效的关键,nonce 具体是什么并不重要。 + // + // 缓存后端读不到(故障)时按"未被踢"处理:走到这里凭证已经校验通过, + // 而反过来一律挑战会在故障期间让所有访客反复弹框——冷却期 nonce 自己也写不进缓存, + // realm 每次都变,等于无限重认证。会话是否到期另有数据库兜底,不依赖这个键。 kickKey := enums.CACHE_HTTPAUTH_KICK + tokenCode - if global.GCACHE_WAFCACHE.IsKeyExist(kickKey) { + kicked, kickErr := global.GCACHE_WAFCACHE.ExistsE(kickKey) + if kickErr != nil { + zlog.Debug("[网站密码访问] 踢下线标记读取失败,本次按未被踢处理 err:" + kickErr.Error()) + } + if kicked { nonce, _ := global.GCACHE_WAFCACHE.Get(kickKey).(string) if nonce == "" { nonce = receiver.startKickCooldown(tokenCode) @@ -234,6 +243,7 @@ func (receiver *WafHttpAuthSessionService) TouchBasicSession(host model.Hosts, u // loadSession 先查缓存再回落数据库,未命中不缓存空值(负向缓存另有 CACHE_HTTPAUTH_BAD)。 // 回落查库这一步是重启后会话仍然有效的关键。 +// 缓存读取失败(后端故障)与未命中在这里同样处理:都回落查库,所以缓存抖动不会让访客掉线。 func (receiver *WafHttpAuthSessionService) loadSession(hostCode, tokenCode string) *model.HttpAuthSession { key := httpAuthSessionCacheKey(hostCode, tokenCode) var cached model.HttpAuthSession diff --git a/supervisor/proc_alive_unix.go b/supervisor/proc_alive_unix.go index 5206ad91..db5b4df1 100644 --- a/supervisor/proc_alive_unix.go +++ b/supervisor/proc_alive_unix.go @@ -2,7 +2,12 @@ package supervisor -import "syscall" +import ( + "os" + "strconv" + "strings" + "syscall" +) // isProcessAlive 判断指定 PID 的进程是否仍在运行(Unix)。 // 用途仅限:判断 Supervisor 自身重启后是否有遗留的存活 Worker、以及等待其退出; @@ -18,3 +23,26 @@ func isProcessAlive(pid int) bool { } return err == syscall.EPERM } + +// isWorkerProcessAlive 在"存活"之上再确认该 PID 确实是本系统的 Worker。 +// +// PID 号会被复用:容器重启后 PID 从 1 重新分配,且线程也占用 PID 号段 +// (kill(pid,0) 对线程同样返回成功)。只凭存活判断,上一代 Worker 的号码很容易 +// 落在新进程或其线程身上,于是把毫不相干的东西当成遗留孤儿收编, +// 白白走一遍 takeover(跳过端口占用探测、延迟接管独占单例)。 +func isWorkerProcessAlive(pid int) bool { + if !isProcessAlive(pid) { + return false + } + cmdline, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline") + if err != nil { + // 读不到(非 Linux 或权限不足)时退回存活判断,保持既有行为 + return true + } + for _, arg := range strings.FieldsFunc(string(cmdline), func(r rune) bool { return r == 0 }) { + if strings.HasPrefix(arg, "--worker") { + return true + } + } + return false +} diff --git a/supervisor/proc_alive_windows.go b/supervisor/proc_alive_windows.go index d194bf08..7dfcde24 100644 --- a/supervisor/proc_alive_windows.go +++ b/supervisor/proc_alive_windows.go @@ -25,3 +25,9 @@ func isProcessAlive(pid int) bool { } return code == stillActive } + +// isWorkerProcessAlive Windows 上没有 /proc 可核对命令行,沿用存活判断。 +// 这里 PID 复用的风险远低于容器:Supervisor 以服务形式常驻,PID 不会从 1 重新分配。 +func isWorkerProcessAlive(pid int) bool { + return isProcessAlive(pid) +} diff --git a/supervisor/supervisor.go b/supervisor/supervisor.go index 29d9e197..4cfa411c 100644 --- a/supervisor/supervisor.go +++ b/supervisor/supervisor.go @@ -192,9 +192,28 @@ func (s *Supervisor) Shutdown() { if s.ln != nil { _ = s.ln.Close() } + // 正常退出且确认没有残留 Worker 时才清掉持久化状态:留着的话下次启动会拿这批 + // 已经退出的 PID 去找孤儿,而 PID 号此时多半已被新进程或其线程占用。 + // 仍有 Worker 没退干净时必须保留,否则下次启动收编不回来。 + s.mu.Lock() + remaining := len(s.workers) + s.mu.Unlock() + if remaining == 0 { + s.clearState() + } else { + zlog.Warn("[Supervisor] 仍有 " + strconv.Itoa(remaining) + " 个 Worker 未在超时内退出,保留 supervisor.state 供下次启动收编") + } close(s.done) } +// clearState 删除持久化的监护状态文件 +func (s *Supervisor) clearState() { + if s.opts.DataDir == "" { + return + } + _ = os.Remove(filepath.Join(s.opts.DataDir, "supervisor.state")) +} + // acceptLoop 接受 Worker 的控制连接。 func (s *Supervisor) acceptLoop() { for { @@ -602,7 +621,7 @@ func (s *Supervisor) adoptOrphans(prev *stateFile) bool { self := os.Getpid() var candidates []int for _, pid := range prev.PIDs { - if pid > 0 && pid != self && isProcessAlive(pid) { + if pid > 0 && pid != self && isWorkerProcessAlive(pid) { candidates = append(candidates, pid) } } diff --git a/wafconfig/wafconfig.go b/wafconfig/wafconfig.go index 49edae23..0154d666 100644 --- a/wafconfig/wafconfig.go +++ b/wafconfig/wafconfig.go @@ -433,6 +433,15 @@ func LoadAndInitConfig() { if config.IsSet("cache.redis.password") { global.GCACHE_REDIS_PASSWORD = config.GetString("cache.redis.password") } + if config.IsSet("cache.redis.pool_size") { + global.GCACHE_REDIS_POOL_SIZE = config.GetInt("cache.redis.pool_size") + } + if config.IsSet("cache.redis.pool_timeout_seconds") { + global.GCACHE_REDIS_POOL_TIMEOUT_SEC = config.GetInt("cache.redis.pool_timeout_seconds") + } + if config.IsSet("cache.redis.op_timeout_seconds") { + global.GCACHE_REDIS_OP_TIMEOUT_SEC = config.GetInt("cache.redis.op_timeout_seconds") + } if config.IsSet("cache.redis.db") { global.GCACHE_REDIS_DB = config.GetInt("cache.redis.db") } diff --git a/wafupgradenotice/upgrade_notes.yaml b/wafupgradenotice/upgrade_notes.yaml index 75745aaa..624ac5ce 100644 --- a/wafupgradenotice/upgrade_notes.yaml +++ b/wafupgradenotice/upgrade_notes.yaml @@ -592,3 +592,36 @@ notes: "I am logged in, yet this other port keeps asking me to log in again". Each site now gets its own cookie name, so they no longer collide. Everyone has to authenticate once more after the upgrade; browsers complete that automatically on the next page load, with nothing to do by hand. + + - id: v1_3_25_redis_cache_error_semantics + version: v1.3.25 + kind: notice + level: normal + page: '' + doc: https://doc.samwaf.com/quickstart/Update.html + apply: + type: none + zh: + title: 使用 Redis 缓存时不再因为一次读取失败而要求重新登录 + detail: >- + 只影响把 conf/config.yml 的 cache.type 设成 redis 的部署,默认的 memory 缓存无感。 + 缓存读取失败此前与"令牌不存在"是同一个结果,管理端据此判定登录状态失效, + 表现为登录后随机跳回登录页。现在两者分开:确实读不到令牌才要求重新登录, + 缓存后端本身不可用时提示"服务暂时不可用,请稍后重试"并保留登录状态,恢复后继续操作即可。 + 同时管理端鉴权对 Redis 的访问次数从每个请求 4 次降为 1 次,Redis 连接池大小改为跟随 CPU 核数 + (可用 cache.redis.pool_size 指定),启动日志会打印当前使用的缓存后端,缓存错误也会记入日志与运行诊断包。 + 另外,令牌有效期改为每次访问都续期,与该配置项「空闲多久未操作即失效」的说明一致。 + en: + title: A single failed cache read no longer forces a new login when Redis is used + detail: >- + This only affects deployments that set cache.type to redis in conf/config.yml; the default memory cache + is unaffected. A failed cache read used to be indistinguishable from "the token is not there", so the + console treated it as an expired session - which looked like being bounced back to the login page at + random shortly after signing in. The two cases are now separate: only a genuinely missing token asks for + a new login, while a cache backend that is temporarily unavailable reports "service temporarily + unavailable, please retry" and keeps the session, so work continues once it recovers. Console + authentication also went from four Redis round trips per request down to one, the Redis connection pool + now scales with CPU count (set cache.redis.pool_size to pin it), the startup log states which cache + backend is in use, and cache errors are recorded in the log and in the diagnostic package. Token + lifetime is now refreshed on every request, matching the "expires after this long without activity" + wording of that setting.