diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go index 92fe1a26..db18f355 100644 --- a/cmd/anubis/main.go +++ b/cmd/anubis/main.go @@ -67,6 +67,7 @@ var ( ogCacheConsiderHost = flag.Bool("og-cache-consider-host", false, "enable or disable the use of the host in the Open Graph tag cache") extractResources = flag.String("extract-resources", "", "if set, extract the static resources to the specified folder") webmasterEmail = flag.String("webmaster-email", "", "if set, displays webmaster's email on the reject page for appeals") + valkeyURL = flag.String("valkey-url", "", "Valkey URL for Anubis' state layer") versionFlag = flag.Bool("version", false, "print Anubis version") xffStripPrivate = flag.Bool("xff-strip-private", true, "if set, strip private addresses from X-Forwarded-For") ) diff --git a/go.mod b/go.mod index 051f04ae..d707fb0d 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/cel-go v0.25.0 github.com/playwright-community/playwright-go v0.5200.0 github.com/prometheus/client_golang v1.22.0 + github.com/redis/go-redis/v9 v9.9.0 github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a github.com/yl2chen/cidranger v1.0.2 golang.org/x/net v0.41.0 @@ -41,6 +42,7 @@ require ( github.com/cloudflare/circl v1.6.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c // indirect github.com/emirpasic/gods v1.18.1 // indirect diff --git a/go.sum b/go.sum index 2012f58d..36cd9517 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/caarlos0/testfs v0.4.4 h1:3PHvzHi5Lt+g332CiShwS8ogTgS3HjrmzZxCm6JCDr8= github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= @@ -75,6 +79,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c h1:mxWGS0YyquJ/ikZOjSrRjjFIbUqIP9ojyYQ+QZTU3Rg= @@ -228,6 +234,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM= +github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 00000000..13793adc --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,10 @@ +package store + +import "context" + +type Impl interface { + GetInt(ctx context.Context, segments []string) (int, error) + MultiGetInt(ctx context.Context, segments [][]string) ([]int, error) + + Increment(ctx context.Context, segments []string) error +} diff --git a/internal/store/valkey/valkey.go b/internal/store/valkey/valkey.go new file mode 100644 index 00000000..b58f94d1 --- /dev/null +++ b/internal/store/valkey/valkey.go @@ -0,0 +1,91 @@ +package valkey + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/TecharoHQ/anubis/internal/store" + valkey "github.com/redis/go-redis/v9" +) + +var ( + _ store.Impl = &Store{} +) + +type Store struct { + rdb *valkey.Client +} + +func New(rdb *valkey.Client) *Store { + return &Store{rdb: rdb} +} + +func (s *Store) Increment(ctx context.Context, segments []string) error { + key := fmt.Sprintf("anubis:%s", strings.Join(segments, ":")) + if err := s.rdb.Incr(ctx, key).Err(); err != nil { + return err + } + + return nil +} + +func (s *Store) GetInt(ctx context.Context, segments []string) (int, error) { + key := fmt.Sprintf("anubis:%s", strings.Join(segments, ":")) + numStr, err := s.rdb.Get(ctx, key).Result() + if err != nil { + return 0, err + } + + num, err := strconv.Atoi(numStr) + if err != nil { + return 0, err + } + + return num, nil +} + +func (s *Store) MultiGetInt(ctx context.Context, segments [][]string) ([]int, error) { + var keys []string + for _, segment := range segments { + key := fmt.Sprintf("anubis:%s", strings.Join(segment, ":")) + keys = append(keys, key) + } + + values, err := s.rdb.MGet(ctx, keys...).Result() + if err != nil { + return nil, err + } + + var errs []error + + result := make([]int, len(values)) + for i, val := range values { + if val == nil { + result[i] = 0 + errs = append(errs, fmt.Errorf("can't get key %s: value is null", keys[i])) + continue + } + + switch v := val.(type) { + case string: + num, err := strconv.Atoi(v) + if err != nil { + errs = append(errs, fmt.Errorf("can't parse key %s: %w", keys[i], err)) + continue + } + + result[i] = num + default: + errs = append(errs, fmt.Errorf("can't parse key %s: wanted type string but got type %T", keys[i], val)) + } + } + + if len(errs) != 0 { + return nil, fmt.Errorf("can't read from valkey: %w", errors.Join(errs...)) + } + + return result, nil +} diff --git a/lib/anubis.go b/lib/anubis.go index 499747ae..fec01df6 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -24,6 +24,7 @@ import ( "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/dnsbl" "github.com/TecharoHQ/anubis/internal/ogtags" + "github.com/TecharoHQ/anubis/internal/store" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/policy/config" @@ -70,6 +71,7 @@ type Server struct { priv ed25519.PrivateKey pub ed25519.PublicKey opts Options + store store.Impl } func (s *Server) challengeFor(r *http.Request, difficulty int) string { diff --git a/lib/config.go b/lib/config.go index f1f8d4e5..9b6b44f9 100644 --- a/lib/config.go +++ b/lib/config.go @@ -1,6 +1,7 @@ package lib import ( + "context" "crypto/ed25519" "crypto/rand" "errors" @@ -18,10 +19,12 @@ import ( "github.com/TecharoHQ/anubis/internal" "github.com/TecharoHQ/anubis/internal/dnsbl" "github.com/TecharoHQ/anubis/internal/ogtags" + "github.com/TecharoHQ/anubis/internal/store/valkey" "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/web" "github.com/TecharoHQ/anubis/xess" + "github.com/redis/go-redis/v9" ) type Options struct { @@ -40,6 +43,7 @@ type Options struct { OGPassthrough bool CookiePartitioned bool ServeRobotsTXT bool + ValkeyURL string } func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) { @@ -111,6 +115,23 @@ func New(opts Options) (*Server, error) { cookieName: cookieName, } + if opts.ValkeyURL != "" { + vkOpts, err := redis.ParseURL(opts.ValkeyURL) + if err != nil { + return nil, fmt.Errorf("can't parse valkey URL: %q: %w", opts.ValkeyURL, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + cli := redis.NewClient(vkOpts) + if _, err := cli.Ping(ctx).Result(); err != nil { + return nil, fmt.Errorf("can't ping valkey: %w", err) + } + + result.store = valkey.New(cli) + } + mux := http.NewServeMux() xess.Mount(mux)