Files
crowdsec-bouncer-traefik-pl…/pkg/cache/cache.go
T
bb44aef718 feat: Allow cache reading from replicas (#342)
* feat: Allow cache reading from replicas

* 🍱 fix logic

*  add testing for redis with mock

* 🍱 fix permission

* 📝 test(e2e/redis): fix swapped IP→verdict comments

The mock returns "f" (not banned) for 1.2.3.4 and "t" (banned) for
1.2.3.5, and the run.sh assertions match that. Both doc comments
described the opposite mapping; correct them to match the code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

*  test(e2e/redis): exercise read-from-replica path

The redis scenario only set redisCacheHost, so it validated the writer
but never the round-robin reader path this feature adds. Split the mock
into two roles: the primary (--redis-addr) now answers every GET with a
miss, while the replica (--redis-read-addr) serves the hardcoded
verdicts. The scenario points redisCacheReadHosts at the replica (twice,
to drive round-robin), so the banned-IP-blocked assertion only passes if
the plugin actually reads decisions from the replica rather than the
primary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 📝 docs: note replicas don't fall back to primary on outage

When RedisCacheReadHosts is set, reads are not retried against the
primary if the replicas are unreachable. Document that this, combined
with the default RedisCacheUnreachableBlock=true, means a replica outage
can block traffic while the primary is healthy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(cache): avoid nil-pointer panic on empty redis read

redisCache.get fell through to `switch err.Error()` when Get returned a
nil error with an empty value, panicking on the nil error. simpleredis
never returns that combination today (a miss yields RedisMiss), so it
was unreachable in practice — but the read path is safer treating an
empty, error-free read as a cache miss, which also guarantees err is
non-nil before err.Error() is called.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🍱 add test for rotation

* 🐛 readd redis/run.sh

* 🐛 fix redis/run.sh

* 🐛 fix test label; remove log

* 🍱 add tests for roundRobin

* 🐛 fix test

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
Co-authored-by: mhx <mathieu@hanotaux.fr>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 13:51:22 +02:00

147 lines
3.7 KiB
Go

// Package cache implements utility routines for manipulating cache.
// It supports currently local file and redis cache.
package cache
import (
"errors"
"fmt"
"log/slog"
"sync/atomic"
ttl_map "github.com/leprosus/golang-ttl-map"
simpleredis "github.com/maxlerebourg/simpleredis"
)
const (
// BannedValue Banned string.
BannedValue = "t"
// NoBannedValue No banned string.
NoBannedValue = "f"
// CaptchaValue Need captcha string.
CaptchaValue = "c"
// CaptchaDoneValue Captcha done string.
CaptchaDoneValue = "d"
// CacheMiss error string when cache is miss.
CacheMiss = "cache:miss"
// CacheUnreachable error string when cache is unreachable.
CacheUnreachable = "cache:unreachable"
)
//nolint:gochecknoglobals
var cache = ttl_map.New()
type localCache struct{}
func (localCache) get(key string) (string, error) {
value, isCached := cache.Get(key)
valueString, isValid := value.(string)
if isCached && isValid && len(valueString) > 0 {
return valueString, nil
}
return "", errors.New(CacheMiss)
}
func (localCache) set(key, value string, duration int64) {
cache.Set(key, value, duration)
}
func (localCache) delete(key string) {
cache.Del(key)
}
type redisCache struct {
log *slog.Logger
writer simpleredis.SimpleRedis
readers []simpleredis.SimpleRedis
counter atomic.Uint64
}
func (rc *redisCache) nextReader() *simpleredis.SimpleRedis {
n := len(rc.readers)
if n == 0 {
return &rc.writer
}
idx := rc.counter.Add(1) % uint64(n)
return &rc.readers[idx]
}
func (rc *redisCache) get(key string) (string, error) {
value, err := rc.nextReader().Get(key)
if err != nil {
switch err.Error() {
case simpleredis.RedisMiss:
return "", errors.New(CacheMiss)
case simpleredis.RedisUnreachable:
return "", errors.New(CacheUnreachable)
default:
return "", err
}
}
valueString := string(value)
if len(valueString) > 0 {
return valueString, nil
}
return "", errors.New(CacheMiss)
}
func (rc *redisCache) set(key, value string, duration int64) {
if err := rc.writer.Set(key, []byte(value), duration); err != nil {
rc.log.Error("cache:setDecisionRedisCache" + err.Error())
}
}
func (rc *redisCache) delete(key string) {
if err := rc.writer.Del(key); err != nil {
rc.log.Error("cache:deleteDecisionRedisCache " + err.Error())
}
}
type cacheInterface interface {
set(key, value string, duration int64)
get(key string) (string, error)
delete(key string)
}
// Client Cache client.
type Client struct {
cache cacheInterface
log *slog.Logger
}
// New Initialize cache client.
func (c *Client) New(log *slog.Logger, isRedis bool, writeHost string, readHosts []string, pass, database string) {
c.log = log
if isRedis {
rc := &redisCache{log: log}
rc.writer.Init(writeHost, pass, database)
for _, h := range readHosts {
var r simpleredis.SimpleRedis
r.Init(h, pass, database)
rc.readers = append(rc.readers, r)
}
c.cache = rc
} else {
c.cache = &localCache{}
}
c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v writeHost:%v readHosts:%v", isRedis, writeHost, readHosts))
}
// Delete delete decision in cache.
func (c *Client) Delete(key string) {
c.log.Debug(fmt.Sprintf("cache:Delete key:%v", key))
c.cache.delete(key)
}
// Get check in the cache if the IP has the banned / not banned value.
// Otherwise return with an error to add the IP in cache if we are on.
func (c *Client) Get(key string) (string, error) {
c.log.Debug(fmt.Sprintf("cache:Get key:%v", key))
return c.cache.get(key)
}
// Set update the cache with the IP as key and the value banned / not banned.
func (c *Client) Set(key string, value string, duration int64) {
c.log.Debug(fmt.Sprintf("cache:Set key:%v value:%v duration:%vs", key, value, duration))
c.cache.set(key, value, duration)
}