mirror of
https://github.com/TecharoHQ/anubis.git
synced 2025-08-03 09:48:08 -04:00

* feat(decaymap): add Delete method Signed-off-by: Xe Iaso <me@xeiaso.net> * chore(lib/challenge): refactor Validate to take ValidateInput Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(lib): implement store interface Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(lib/store): all metapackage to import all store implementations Signed-off-by: Xe Iaso <me@xeiaso.net> * chore(policy): import all store backends Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(lib): use new challenge creation flow Previously Anubis constructed challenge strings from request metadata. This was a good idea in spirit, but has turned out to be a very bad idea in practice. This new flow reuses the Store facility to dynamically create challenge values with completely random data. This is a fairly big rewrite of how Anubis processes challenges. Right now it defaults to using the in-memory storage backend, but on-disk (boltdb) and valkey-based adaptors will come soon. Signed-off-by: Xe Iaso <me@xeiaso.net> * chore(decaymap): fix documentation typo Signed-off-by: Xe Iaso <me@xeiaso.net> * chore(lib): fix SA4004 Signed-off-by: Xe Iaso <me@xeiaso.net> * test(lib/store): make generic storage interface test adaptor Signed-off-by: Xe Iaso <me@xeiaso.net> * chore: spelling Signed-off-by: Xe Iaso <me@xeiaso.net> * fix(decaymap): invert locking process for Delete Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(lib/store): add bbolt store implementation Signed-off-by: Xe Iaso <me@xeiaso.net> * chore: spelling Signed-off-by: Xe Iaso <me@xeiaso.net> * chore: go mod tidy Signed-off-by: Xe Iaso <me@xeiaso.net> * chore(devcontainer): adapt to docker compose, add valkey service Signed-off-by: Xe Iaso <me@xeiaso.net> * fix(lib): make challenges live for 30 minutes by default Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(lib/store): implement valkey backend Signed-off-by: Xe Iaso <me@xeiaso.net> * test(lib/store/valkey): disable tests if not using docker Signed-off-by: Xe Iaso <me@xeiaso.net> * test(lib/policy/config): ensure valkey stores can be loaded Signed-off-by: Xe Iaso <me@xeiaso.net> * Update metadata check-spelling run (pull_request) for Xe/store-interface Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com> on-behalf-of: @check-spelling <check-spelling-bot@check-spelling.dev> * chore(devcontainer): remove port forwards because vs code handles that for you Signed-off-by: Xe Iaso <me@xeiaso.net> * docs(default-config): add a nudge to the storage backends section of the docs Signed-off-by: Xe Iaso <me@xeiaso.net> * chore(docs): listen on 0.0.0.0 for dev container support Signed-off-by: Xe Iaso <me@xeiaso.net> * docs(policy): document storage backends Signed-off-by: Xe Iaso <me@xeiaso.net> * docs: update CHANGELOG and internal links Signed-off-by: Xe Iaso <me@xeiaso.net> * docs(admin/policies): don't start a sentence with as Signed-off-by: Xe Iaso <me@xeiaso.net> * chore: fixes found in review Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Xe Iaso <me@xeiaso.net> Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
493 lines
12 KiB
Go
493 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/TecharoHQ/anubis/data"
|
|
"k8s.io/apimachinery/pkg/util/yaml"
|
|
)
|
|
|
|
var (
|
|
ErrNoBotRulesDefined = errors.New("config: must define at least one (1) bot rule")
|
|
ErrBotMustHaveName = errors.New("config.Bot: must set name")
|
|
ErrBotMustHaveUserAgentOrPath = errors.New("config.Bot: must set either user_agent_regex, path_regex, headers_regex, or remote_addresses")
|
|
ErrBotMustHaveUserAgentOrPathNotBoth = errors.New("config.Bot: must set either user_agent_regex, path_regex, and not both")
|
|
ErrUnknownAction = errors.New("config.Bot: unknown action")
|
|
ErrInvalidUserAgentRegex = errors.New("config.Bot: invalid user agent regex")
|
|
ErrInvalidPathRegex = errors.New("config.Bot: invalid path regex")
|
|
ErrInvalidHeadersRegex = errors.New("config.Bot: invalid headers regex")
|
|
ErrInvalidCIDR = errors.New("config.Bot: invalid CIDR")
|
|
ErrRegexEndsWithNewline = errors.New("config.Bot: regular expression ends with newline (try >- instead of > in yaml)")
|
|
ErrInvalidImportStatement = errors.New("config.ImportStatement: invalid source file")
|
|
ErrCantSetBotAndImportValuesAtOnce = errors.New("config.BotOrImport: can't set bot rules and import values at the same time")
|
|
ErrMustSetBotOrImportRules = errors.New("config.BotOrImport: rule definition is invalid, you must set either bot rules or an import statement, not both")
|
|
ErrStatusCodeNotValid = errors.New("config.StatusCode: status code not valid, must be between 100 and 599")
|
|
)
|
|
|
|
type Rule string
|
|
|
|
const (
|
|
RuleUnknown Rule = ""
|
|
RuleAllow Rule = "ALLOW"
|
|
RuleDeny Rule = "DENY"
|
|
RuleChallenge Rule = "CHALLENGE"
|
|
RuleWeigh Rule = "WEIGH"
|
|
RuleBenchmark Rule = "DEBUG_BENCHMARK"
|
|
)
|
|
|
|
func (r Rule) Valid() error {
|
|
switch r {
|
|
case RuleAllow, RuleDeny, RuleChallenge, RuleWeigh, RuleBenchmark:
|
|
return nil
|
|
default:
|
|
return ErrUnknownAction
|
|
}
|
|
}
|
|
|
|
const DefaultAlgorithm = "fast"
|
|
|
|
type BotConfig struct {
|
|
UserAgentRegex *string `json:"user_agent_regex,omitempty" yaml:"user_agent_regex,omitempty"`
|
|
PathRegex *string `json:"path_regex,omitempty" yaml:"path_regex,omitempty"`
|
|
HeadersRegex map[string]string `json:"headers_regex,omitempty" yaml:"headers_regex,omitempty"`
|
|
Expression *ExpressionOrList `json:"expression,omitempty" yaml:"expression,omitempty"`
|
|
Challenge *ChallengeRules `json:"challenge,omitempty" yaml:"challenge,omitempty"`
|
|
Weight *Weight `json:"weight,omitempty" yaml:"weight,omitempty"`
|
|
Name string `json:"name" yaml:"name"`
|
|
Action Rule `json:"action" yaml:"action"`
|
|
RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
|
|
|
|
// Thoth features
|
|
GeoIP *GeoIP `json:"geoip,omitempty"`
|
|
ASNs *ASNs `json:"asns,omitempty"`
|
|
}
|
|
|
|
func (b BotConfig) Zero() bool {
|
|
for _, cond := range []bool{
|
|
b.Name != "",
|
|
b.UserAgentRegex != nil,
|
|
b.PathRegex != nil,
|
|
len(b.HeadersRegex) != 0,
|
|
b.Action != "",
|
|
len(b.RemoteAddr) != 0,
|
|
b.Challenge != nil,
|
|
b.GeoIP != nil,
|
|
b.ASNs != nil,
|
|
} {
|
|
if cond {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (b *BotConfig) Valid() error {
|
|
var errs []error
|
|
|
|
if b.Name == "" {
|
|
errs = append(errs, ErrBotMustHaveName)
|
|
}
|
|
|
|
allFieldsEmpty := b.UserAgentRegex == nil &&
|
|
b.PathRegex == nil &&
|
|
len(b.RemoteAddr) == 0 &&
|
|
len(b.HeadersRegex) == 0 &&
|
|
b.ASNs == nil &&
|
|
b.GeoIP == nil
|
|
|
|
if allFieldsEmpty && b.Expression == nil {
|
|
errs = append(errs, ErrBotMustHaveUserAgentOrPath)
|
|
}
|
|
|
|
if b.UserAgentRegex != nil && b.PathRegex != nil {
|
|
errs = append(errs, ErrBotMustHaveUserAgentOrPathNotBoth)
|
|
}
|
|
|
|
if b.UserAgentRegex != nil {
|
|
if strings.HasSuffix(*b.UserAgentRegex, "\n") {
|
|
errs = append(errs, fmt.Errorf("%w: user agent regex: %q", ErrRegexEndsWithNewline, *b.UserAgentRegex))
|
|
}
|
|
|
|
if _, err := regexp.Compile(*b.UserAgentRegex); err != nil {
|
|
errs = append(errs, ErrInvalidUserAgentRegex, err)
|
|
}
|
|
}
|
|
|
|
if b.PathRegex != nil {
|
|
if strings.HasSuffix(*b.PathRegex, "\n") {
|
|
errs = append(errs, fmt.Errorf("%w: path regex: %q", ErrRegexEndsWithNewline, *b.PathRegex))
|
|
}
|
|
|
|
if _, err := regexp.Compile(*b.PathRegex); err != nil {
|
|
errs = append(errs, ErrInvalidPathRegex, err)
|
|
}
|
|
}
|
|
|
|
if len(b.HeadersRegex) > 0 {
|
|
for name, expr := range b.HeadersRegex {
|
|
if name == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.HasSuffix(expr, "\n") {
|
|
errs = append(errs, fmt.Errorf("%w: header %s regex: %q", ErrRegexEndsWithNewline, name, expr))
|
|
}
|
|
|
|
if _, err := regexp.Compile(expr); err != nil {
|
|
errs = append(errs, ErrInvalidHeadersRegex, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(b.RemoteAddr) > 0 {
|
|
for _, cidr := range b.RemoteAddr {
|
|
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
|
errs = append(errs, ErrInvalidCIDR, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if b.Expression != nil {
|
|
if err := b.Expression.Valid(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
switch b.Action {
|
|
case RuleAllow, RuleBenchmark, RuleChallenge, RuleDeny, RuleWeigh:
|
|
// okay
|
|
default:
|
|
errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownAction, b.Action))
|
|
}
|
|
|
|
if b.Action == RuleChallenge && b.Challenge != nil {
|
|
if err := b.Challenge.Valid(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
if b.Action == RuleWeigh && b.Weight == nil {
|
|
b.Weight = &Weight{Adjust: 5}
|
|
}
|
|
|
|
if len(errs) != 0 {
|
|
return fmt.Errorf("config: bot entry for %q is not valid:\n%w", b.Name, errors.Join(errs...))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type ChallengeRules struct {
|
|
Algorithm string `json:"algorithm,omitempty" yaml:"algorithm,omitempty"`
|
|
Difficulty int `json:"difficulty,omitempty" yaml:"difficulty,omitempty"`
|
|
ReportAs int `json:"report_as,omitempty" yaml:"report_as,omitempty"`
|
|
}
|
|
|
|
var (
|
|
ErrChallengeDifficultyTooLow = errors.New("config.ChallengeRules: difficulty is too low (must be >= 1)")
|
|
ErrChallengeDifficultyTooHigh = errors.New("config.ChallengeRules: difficulty is too high (must be <= 64)")
|
|
ErrChallengeMustHaveAlgorithm = errors.New("config.ChallengeRules: must have algorithm name set")
|
|
)
|
|
|
|
func (cr ChallengeRules) Valid() error {
|
|
var errs []error
|
|
|
|
if cr.Algorithm == "" {
|
|
errs = append(errs, ErrChallengeMustHaveAlgorithm)
|
|
}
|
|
|
|
if cr.Difficulty < 1 {
|
|
errs = append(errs, fmt.Errorf("%w, got: %d", ErrChallengeDifficultyTooLow, cr.Difficulty))
|
|
}
|
|
|
|
if cr.Difficulty > 64 {
|
|
errs = append(errs, fmt.Errorf("%w, got: %d", ErrChallengeDifficultyTooHigh, cr.Difficulty))
|
|
}
|
|
|
|
if len(errs) != 0 {
|
|
return fmt.Errorf("config: challenge rules entry is not valid:\n%w", errors.Join(errs...))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type ImportStatement struct {
|
|
Import string `json:"import"`
|
|
Bots []BotConfig
|
|
}
|
|
|
|
func (is *ImportStatement) open() (fs.File, error) {
|
|
if strings.HasPrefix(is.Import, "(data)/") {
|
|
fname := strings.TrimPrefix(is.Import, "(data)/")
|
|
fin, err := data.BotPolicies.Open(fname)
|
|
return fin, err
|
|
}
|
|
|
|
return os.Open(is.Import)
|
|
}
|
|
|
|
func (is *ImportStatement) load() error {
|
|
fin, err := is.open()
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %s: %w", ErrInvalidImportStatement, is.Import, err)
|
|
}
|
|
defer fin.Close()
|
|
|
|
var imported []BotOrImport
|
|
var result []BotConfig
|
|
|
|
if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&imported); err != nil {
|
|
return fmt.Errorf("can't parse %s: %w", is.Import, err)
|
|
}
|
|
|
|
var errs []error
|
|
|
|
for _, b := range imported {
|
|
if err := b.Valid(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
|
|
if b.ImportStatement != nil {
|
|
result = append(result, b.ImportStatement.Bots...)
|
|
}
|
|
|
|
if b.BotConfig != nil {
|
|
result = append(result, *b.BotConfig)
|
|
}
|
|
}
|
|
|
|
if len(errs) != 0 {
|
|
return fmt.Errorf("config %s is not valid:\n%w", is.Import, errors.Join(errs...))
|
|
}
|
|
|
|
is.Bots = result
|
|
|
|
return nil
|
|
}
|
|
|
|
func (is *ImportStatement) Valid() error {
|
|
return is.load()
|
|
}
|
|
|
|
type BotOrImport struct {
|
|
*BotConfig `json:",inline"`
|
|
*ImportStatement `json:",inline"`
|
|
}
|
|
|
|
func (boi *BotOrImport) Valid() error {
|
|
if boi.BotConfig != nil && boi.ImportStatement != nil {
|
|
return ErrCantSetBotAndImportValuesAtOnce
|
|
}
|
|
|
|
if boi.BotConfig != nil {
|
|
return boi.BotConfig.Valid()
|
|
}
|
|
|
|
if boi.ImportStatement != nil {
|
|
return boi.ImportStatement.Valid()
|
|
}
|
|
|
|
return ErrMustSetBotOrImportRules
|
|
}
|
|
|
|
type StatusCodes struct {
|
|
Challenge int `json:"CHALLENGE"`
|
|
Deny int `json:"DENY"`
|
|
}
|
|
|
|
func (sc StatusCodes) Valid() error {
|
|
var errs []error
|
|
|
|
if sc.Challenge == 0 || (sc.Challenge < 100 && sc.Challenge >= 599) {
|
|
errs = append(errs, fmt.Errorf("%w: challenge is %d", ErrStatusCodeNotValid, sc.Challenge))
|
|
}
|
|
|
|
if sc.Deny == 0 || (sc.Deny < 100 && sc.Deny >= 599) {
|
|
errs = append(errs, fmt.Errorf("%w: deny is %d", ErrStatusCodeNotValid, sc.Deny))
|
|
}
|
|
|
|
if len(errs) != 0 {
|
|
return fmt.Errorf("status codes not valid:\n%w", errors.Join(errs...))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type fileConfig struct {
|
|
Bots []BotOrImport `json:"bots"`
|
|
DNSBL bool `json:"dnsbl"`
|
|
OpenGraph openGraphFileConfig `json:"openGraph,omitempty"`
|
|
Impressum *Impressum `json:"impressum,omitempty"`
|
|
StatusCodes StatusCodes `json:"status_codes"`
|
|
Store *Store `json:"store"`
|
|
Thresholds []Threshold `json:"thresholds"`
|
|
}
|
|
|
|
func (c *fileConfig) Valid() error {
|
|
var errs []error
|
|
|
|
if len(c.Bots) == 0 {
|
|
errs = append(errs, ErrNoBotRulesDefined)
|
|
}
|
|
|
|
for i, b := range c.Bots {
|
|
if err := b.Valid(); err != nil {
|
|
errs = append(errs, fmt.Errorf("bot %d: %w", i, err))
|
|
}
|
|
}
|
|
|
|
if c.OpenGraph.Enabled {
|
|
if err := c.OpenGraph.Valid(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
if err := c.StatusCodes.Valid(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
|
|
for i, t := range c.Thresholds {
|
|
if err := t.Valid(); err != nil {
|
|
errs = append(errs, fmt.Errorf("threshold %d: %w", i, err))
|
|
}
|
|
}
|
|
|
|
if c.Store != nil {
|
|
if err := c.Store.Valid(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
if len(errs) != 0 {
|
|
return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func Load(fin io.Reader, fname string) (*Config, error) {
|
|
c := &fileConfig{
|
|
StatusCodes: StatusCodes{
|
|
Challenge: http.StatusOK,
|
|
Deny: http.StatusOK,
|
|
},
|
|
Store: &Store{
|
|
Backend: "memory",
|
|
},
|
|
}
|
|
|
|
if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&c); err != nil {
|
|
return nil, fmt.Errorf("can't parse policy config YAML %s: %w", fname, err)
|
|
}
|
|
|
|
if err := c.Valid(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &Config{
|
|
DNSBL: c.DNSBL,
|
|
OpenGraph: OpenGraph{
|
|
Enabled: c.OpenGraph.Enabled,
|
|
ConsiderHost: c.OpenGraph.ConsiderHost,
|
|
Override: c.OpenGraph.Override,
|
|
},
|
|
StatusCodes: c.StatusCodes,
|
|
Store: c.Store,
|
|
}
|
|
|
|
if c.OpenGraph.TimeToLive != "" {
|
|
// XXX(Xe): already validated in Valid()
|
|
ogTTL, _ := time.ParseDuration(c.OpenGraph.TimeToLive)
|
|
result.OpenGraph.TimeToLive = ogTTL
|
|
}
|
|
|
|
var validationErrs []error
|
|
|
|
for _, boi := range c.Bots {
|
|
if boi.ImportStatement != nil {
|
|
if err := boi.load(); err != nil {
|
|
validationErrs = append(validationErrs, err)
|
|
continue
|
|
}
|
|
|
|
result.Bots = append(result.Bots, boi.ImportStatement.Bots...)
|
|
}
|
|
|
|
if boi.BotConfig != nil {
|
|
if err := boi.BotConfig.Valid(); err != nil {
|
|
validationErrs = append(validationErrs, err)
|
|
continue
|
|
}
|
|
|
|
result.Bots = append(result.Bots, *boi.BotConfig)
|
|
}
|
|
}
|
|
|
|
if c.Impressum != nil {
|
|
if err := c.Impressum.Valid(); err != nil {
|
|
validationErrs = append(validationErrs, err)
|
|
}
|
|
|
|
result.Impressum = c.Impressum
|
|
}
|
|
|
|
if len(c.Thresholds) == 0 {
|
|
c.Thresholds = DefaultThresholds
|
|
}
|
|
|
|
for _, t := range c.Thresholds {
|
|
if err := t.Valid(); err != nil {
|
|
validationErrs = append(validationErrs, err)
|
|
continue
|
|
}
|
|
|
|
result.Thresholds = append(result.Thresholds, t)
|
|
}
|
|
|
|
if len(validationErrs) > 0 {
|
|
return nil, fmt.Errorf("errors validating policy config %s: %w", fname, errors.Join(validationErrs...))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
type Config struct {
|
|
Bots []BotConfig
|
|
Thresholds []Threshold
|
|
DNSBL bool
|
|
Impressum *Impressum
|
|
OpenGraph OpenGraph
|
|
StatusCodes StatusCodes
|
|
Store *Store
|
|
}
|
|
|
|
func (c Config) Valid() error {
|
|
var errs []error
|
|
|
|
if len(c.Bots) == 0 {
|
|
errs = append(errs, ErrNoBotRulesDefined)
|
|
}
|
|
|
|
for _, b := range c.Bots {
|
|
if err := b.Valid(); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
if len(errs) != 0 {
|
|
return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...))
|
|
}
|
|
|
|
return nil
|
|
}
|