feat: add valkey backed store

Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
Xe Iaso
2025-05-16 06:59:55 -04:00
parent 6e964e6449
commit c28b191b79
15 changed files with 225 additions and 18 deletions

View File

@@ -26,6 +26,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/valkey"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
)
@@ -68,6 +69,7 @@ type Server struct {
pub ed25519.PublicKey
opts Options
cookieName string
store *valkey.Store
}
func (s *Server) challengeFor(r *http.Request, difficulty int) string {
@@ -233,6 +235,10 @@ func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
lg = lg.With("check_result", cr)
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "challenges_issued"})
}
err = encoder.Encode(struct {
Rules *config.ChallengeRules `json:"rules"`
Challenge string `json:"challenge"`
@@ -325,6 +331,9 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
s.ClearCookie(w)
lg.Debug("hash does not match", "got", response, "want", calculated)
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "fail"})
}
failedValidations.Inc()
return
}
@@ -334,6 +343,9 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
s.ClearCookie(w)
lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty)
s.respondWithStatus(w, r, "invalid response", http.StatusForbidden)
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "fail"})
}
failedValidations.Inc()
return
}
@@ -370,6 +382,10 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
Path: cookiePath,
})
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "pass"})
}
challengesValidated.Inc()
lg.Debug("challenge passed, redirecting to app")
http.Redirect(w, r, redir, http.StatusFound)
@@ -399,6 +415,8 @@ func (s *Server) check(r *http.Request) (policy.CheckResult, *policy.Bot, error)
return decaymap.Zilch[policy.CheckResult](), nil, fmt.Errorf("[misconfiguration] %q is not an IP address", host)
}
weight := 0
for _, b := range s.policy.Bots {
match, err := b.Rules.Check(r)
if err != nil {
@@ -406,10 +424,27 @@ func (s *Server) check(r *http.Request) (policy.CheckResult, *policy.Bot, error)
}
if match {
return cr("bot/"+b.Name, b.Action), &b, nil
switch b.Action {
case config.RuleDeny, config.RuleAllow, config.RuleBenchmark:
return cr("bot/"+b.Name, b.Action), &b, nil
case config.RuleChallenge:
weight += 5
case config.RuleWeigh:
weight += b.Weight.Adjust
}
}
}
if weight < 0 {
return cr("weight/okay", config.RuleAllow), &policy.Bot{
Challenge: &config.ChallengeRules{
Difficulty: s.policy.DefaultDifficulty,
ReportAs: s.policy.DefaultDifficulty,
Algorithm: config.AlgorithmFast,
},
}, nil
}
return cr("default/allow", config.RuleAllow), &policy.Bot{
Challenge: &config.ChallengeRules{
Difficulty: s.policy.DefaultDifficulty,

View File

@@ -65,6 +65,10 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
return
}
if s.store != nil {
s.store.Increment(r.Context(), []string{"pass_rate", "User-Agent", r.UserAgent(), "challenges_issued"})
}
handler := internal.NoStoreCache(templ.Handler(
component,
templ.WithStatus(s.opts.Policy.StatusCodes.Challenge),

View File

@@ -12,6 +12,7 @@ type Bot struct {
Challenge *config.ChallengeRules
Name string
Action config.Rule
Weight *config.Weight
}
func (b Bot) Hash() string {

View File

@@ -7,12 +7,15 @@ import (
)
type CheckResult struct {
Name string
Rule config.Rule
Name string
Rule config.Rule
Weight int
}
func (cr CheckResult) LogValue() slog.Value {
return slog.GroupValue(
slog.String("name", cr.Name),
slog.String("rule", string(cr.Rule)))
slog.String("rule", string(cr.Rule)),
slog.Int("weight", cr.Weight),
)
}

View File

@@ -0,0 +1,47 @@
package policy
import (
"fmt"
"net/http"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/internal/store/valkey"
)
type PassRateChecker struct {
store *valkey.Store
header string
rate float64
}
func NewPassRateChecker(store *valkey.Store, rate float64) Checker {
return &PassRateChecker{
store: store,
rate: rate,
header: "User-Agent",
}
}
func (prc *PassRateChecker) Hash() string {
return internal.SHA256sum(fmt.Sprintf("pass rate checker::%s", prc.header))
}
func (prc *PassRateChecker) Check(r *http.Request) (bool, error) {
data, err := prc.store.MultiGetInt(r.Context(), [][]string{
{"pass_rate", prc.header, r.Header.Get(prc.header), "pass"},
{"pass_rate", prc.header, r.Header.Get(prc.header), "challenges_issued"},
{"pass_rate", prc.header, r.Header.Get(prc.header), "fail"},
})
if err != nil {
return false, err
}
passCount, challengeCount, failCount := data[0], data[1], data[2]
passRate := float64(passCount-failCount) / float64(challengeCount)
if passRate >= prc.rate {
return true, nil
}
return false, nil
}

View File

@@ -116,6 +116,10 @@ func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedCon
}
}
if b.Weight != nil {
parsedBot.Weight = b.Weight
}
parsedBot.Rules = cl
result.Bots = append(result.Bots, parsedBot)