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 }