package config import ( "errors" "fmt" "net" "os" "path/filepath" "sort" "strings" "time" "gopkg.in/yaml.v3" ) type Duration struct { time.Duration } func (d *Duration) UnmarshalYAML(node *yaml.Node) error { if node == nil || node.Value == "" { d.Duration = 0 return nil } parsed, err := time.ParseDuration(node.Value) if err != nil { return fmt.Errorf("invalid duration %q: %w", node.Value, err) } d.Duration = parsed return nil } func (d Duration) MarshalYAML() (any, error) { return d.String(), nil } type Config struct { Server ServerConfig `yaml:"server"` Storage StorageConfig `yaml:"storage"` OPNsense OPNsenseConfig `yaml:"opnsense"` Profiles map[string]ProfileConfig `yaml:"profiles"` Sources []SourceConfig `yaml:"sources"` } type ServerConfig struct { ListenAddress string `yaml:"listen_address"` ReadTimeout Duration `yaml:"read_timeout"` WriteTimeout Duration `yaml:"write_timeout"` ShutdownTimeout Duration `yaml:"shutdown_timeout"` } type StorageConfig struct { Path string `yaml:"path"` } type OPNsenseConfig struct { Enabled bool `yaml:"enabled"` BaseURL string `yaml:"base_url"` APIKey string `yaml:"api_key"` APISecret string `yaml:"api_secret"` APIKeyFile string `yaml:"api_key_file"` APISecretFile string `yaml:"api_secret_file"` Timeout Duration `yaml:"timeout"` InsecureSkipVerify bool `yaml:"insecure_skip_verify"` EnsureAlias bool `yaml:"ensure_alias"` Alias AliasConfig `yaml:"alias"` APIPaths APIPathsConfig `yaml:"api_paths"` } type AliasConfig struct { Name string `yaml:"name"` Type string `yaml:"type"` Description string `yaml:"description"` } type APIPathsConfig struct { AliasGetUUID string `yaml:"alias_get_uuid"` AliasAddItem string `yaml:"alias_add_item"` AliasSetItem string `yaml:"alias_set_item"` AliasReconfig string `yaml:"alias_reconfigure"` AliasUtilList string `yaml:"alias_util_list"` AliasUtilAdd string `yaml:"alias_util_add"` AliasUtilDelete string `yaml:"alias_util_delete"` } type SourceConfig struct { Name string `yaml:"name"` Path string `yaml:"path"` Profile string `yaml:"profile"` InitialPosition string `yaml:"initial_position"` PollInterval Duration `yaml:"poll_interval"` BatchSize int `yaml:"batch_size"` } type KnownAgentRule struct { Name string `yaml:"name"` Decision string `yaml:"decision"` UserAgentPrefixes []string `yaml:"user_agent_prefixes"` CIDRs []string `yaml:"cidrs"` normalizedPrefixes []string networks []*net.IPNet } type ProfileConfig struct { AutoBlock bool `yaml:"auto_block"` MinStatus int `yaml:"min_status"` MaxStatus int `yaml:"max_status"` BlockUnexpectedPosts bool `yaml:"block_unexpected_posts"` BlockPHPPaths bool `yaml:"block_php_paths"` AllowedPostPaths []string `yaml:"allowed_post_paths"` SuspiciousPathPrefixes []string `yaml:"suspicious_path_prefixes"` ExcludedCIDRs []string `yaml:"excluded_cidrs"` KnownAgents []KnownAgentRule `yaml:"known_agents"` normalizedAllowedPostPaths map[string]struct{} normalizedSuspiciousPaths []string excludedNetworks []*net.IPNet } func Load(path string) (*Config, error) { payload, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config: %w", err) } var cfg Config if err := yaml.Unmarshal(payload, &cfg); err != nil { return nil, fmt.Errorf("decode config: %w", err) } if err := cfg.applyDefaults(); err != nil { return nil, err } if err := cfg.validate(path); err != nil { return nil, err } return &cfg, nil } func (c *Config) applyDefaults() error { if c.Server.ListenAddress == "" { c.Server.ListenAddress = "127.0.0.1:9080" } if c.Server.ReadTimeout.Duration == 0 { c.Server.ReadTimeout.Duration = 5 * time.Second } if c.Server.WriteTimeout.Duration == 0 { c.Server.WriteTimeout.Duration = 10 * time.Second } if c.Server.ShutdownTimeout.Duration == 0 { c.Server.ShutdownTimeout.Duration = 15 * time.Second } if c.Storage.Path == "" { c.Storage.Path = "./data/caddy-opnsense-blocker.db" } if c.OPNsense.Timeout.Duration == 0 { c.OPNsense.Timeout.Duration = 8 * time.Second } if c.OPNsense.Alias.Type == "" { c.OPNsense.Alias.Type = "host" } if c.OPNsense.Alias.Description == "" { c.OPNsense.Alias.Description = "Managed by caddy-opnsense-blocker" } if c.OPNsense.APIPaths.AliasGetUUID == "" { c.OPNsense.APIPaths.AliasGetUUID = "/api/firewall/alias/get_alias_u_u_i_d/{alias}" } if c.OPNsense.APIPaths.AliasAddItem == "" { c.OPNsense.APIPaths.AliasAddItem = "/api/firewall/alias/add_item" } if c.OPNsense.APIPaths.AliasSetItem == "" { c.OPNsense.APIPaths.AliasSetItem = "/api/firewall/alias/set_item/{uuid}" } if c.OPNsense.APIPaths.AliasReconfig == "" { c.OPNsense.APIPaths.AliasReconfig = "/api/firewall/alias/reconfigure" } if c.OPNsense.APIPaths.AliasUtilList == "" { c.OPNsense.APIPaths.AliasUtilList = "/api/firewall/alias_util/list/{alias}" } if c.OPNsense.APIPaths.AliasUtilAdd == "" { c.OPNsense.APIPaths.AliasUtilAdd = "/api/firewall/alias_util/add/{alias}" } if c.OPNsense.APIPaths.AliasUtilDelete == "" { c.OPNsense.APIPaths.AliasUtilDelete = "/api/firewall/alias_util/delete/{alias}" } if !c.OPNsense.EnsureAlias { c.OPNsense.EnsureAlias = true } for name, profile := range c.Profiles { if profile.MinStatus == 0 { profile.MinStatus = 400 } if profile.MaxStatus == 0 { profile.MaxStatus = 599 } profile.normalizedAllowedPostPaths = make(map[string]struct{}, len(profile.AllowedPostPaths)) for _, path := range profile.AllowedPostPaths { profile.normalizedAllowedPostPaths[normalizePath(path)] = struct{}{} } profile.normalizedSuspiciousPaths = make([]string, 0, len(profile.SuspiciousPathPrefixes)) for _, prefix := range profile.SuspiciousPathPrefixes { profile.normalizedSuspiciousPaths = append(profile.normalizedSuspiciousPaths, normalizePrefix(prefix)) } sort.Strings(profile.normalizedSuspiciousPaths) for _, cidr := range profile.ExcludedCIDRs { _, network, err := net.ParseCIDR(strings.TrimSpace(cidr)) if err != nil { return fmt.Errorf("profile %q has invalid excluded_cidr %q: %w", name, cidr, err) } profile.excludedNetworks = append(profile.excludedNetworks, network) } for index, rule := range profile.KnownAgents { decision := strings.ToLower(strings.TrimSpace(rule.Decision)) profile.KnownAgents[index].Decision = decision for _, prefix := range rule.UserAgentPrefixes { normalized := strings.ToLower(strings.TrimSpace(prefix)) if normalized != "" { profile.KnownAgents[index].normalizedPrefixes = append(profile.KnownAgents[index].normalizedPrefixes, normalized) } } for _, cidr := range rule.CIDRs { _, network, err := net.ParseCIDR(strings.TrimSpace(cidr)) if err != nil { return fmt.Errorf("profile %q rule %q has invalid cidr %q: %w", name, rule.Name, cidr, err) } profile.KnownAgents[index].networks = append(profile.KnownAgents[index].networks, network) } } c.Profiles[name] = profile } for index := range c.Sources { c.Sources[index].InitialPosition = strings.ToLower(strings.TrimSpace(c.Sources[index].InitialPosition)) if c.Sources[index].InitialPosition == "" { c.Sources[index].InitialPosition = "end" } if c.Sources[index].PollInterval.Duration == 0 { c.Sources[index].PollInterval.Duration = time.Second } if c.Sources[index].BatchSize <= 0 { c.Sources[index].BatchSize = 256 } } return nil } func (c *Config) validate(sourcePath string) error { if len(c.Profiles) == 0 { return errors.New("at least one profile is required") } if len(c.Sources) == 0 { return errors.New("at least one source is required") } if _, _, err := net.SplitHostPort(c.Server.ListenAddress); err != nil { return fmt.Errorf("invalid server.listen_address: %w", err) } if err := os.MkdirAll(filepath.Dir(c.Storage.Path), 0o755); err != nil { return fmt.Errorf("prepare storage directory: %w", err) } seenNames := map[string]struct{}{} seenPaths := map[string]struct{}{} for _, source := range c.Sources { if source.Name == "" { return errors.New("source.name must not be empty") } if source.Path == "" { return fmt.Errorf("source %q must define a path", source.Name) } if _, ok := seenNames[source.Name]; ok { return fmt.Errorf("duplicate source name %q", source.Name) } seenNames[source.Name] = struct{}{} if _, ok := seenPaths[source.Path]; ok { return fmt.Errorf("duplicate source path %q", source.Path) } seenPaths[source.Path] = struct{}{} if _, ok := c.Profiles[source.Profile]; !ok { return fmt.Errorf("source %q references unknown profile %q", source.Name, source.Profile) } if source.InitialPosition != "beginning" && source.InitialPosition != "end" { return fmt.Errorf("source %q has invalid initial_position %q", source.Name, source.InitialPosition) } } for name, profile := range c.Profiles { if profile.MinStatus < 100 || profile.MinStatus > 599 { return fmt.Errorf("profile %q has invalid min_status %d", name, profile.MinStatus) } if profile.MaxStatus < profile.MinStatus || profile.MaxStatus > 599 { return fmt.Errorf("profile %q has invalid max_status %d", name, profile.MaxStatus) } for _, prefix := range profile.normalizedSuspiciousPaths { if prefix == "/" { return fmt.Errorf("profile %q has overly broad suspicious path prefix %q", name, prefix) } } for _, rule := range profile.KnownAgents { if rule.Decision != "allow" && rule.Decision != "deny" { return fmt.Errorf("profile %q known agent %q has invalid decision %q", name, rule.Name, rule.Decision) } if len(rule.normalizedPrefixes) == 0 && len(rule.networks) == 0 { return fmt.Errorf("profile %q known agent %q must define user_agent_prefixes and/or cidrs", name, rule.Name) } } } if c.OPNsense.Enabled { if c.OPNsense.BaseURL == "" { return errors.New("opnsense.base_url is required when opnsense is enabled") } if c.OPNsense.Alias.Name == "" { return errors.New("opnsense.alias.name is required when opnsense is enabled") } if c.OPNsense.APIKey == "" && c.OPNsense.APIKeyFile == "" { return errors.New("opnsense.api_key or opnsense.api_key_file is required when opnsense is enabled") } if c.OPNsense.APISecret == "" && c.OPNsense.APISecretFile == "" { return errors.New("opnsense.api_secret or opnsense.api_secret_file is required when opnsense is enabled") } if c.OPNsense.APIKey == "" { payload, err := os.ReadFile(c.OPNsense.APIKeyFile) if err != nil { return fmt.Errorf("read opnsense.api_key_file: %w", err) } c.OPNsense.APIKey = strings.TrimSpace(string(payload)) } if c.OPNsense.APISecret == "" { payload, err := os.ReadFile(c.OPNsense.APISecretFile) if err != nil { return fmt.Errorf("read opnsense.api_secret_file: %w", err) } c.OPNsense.APISecret = strings.TrimSpace(string(payload)) } } _ = sourcePath return nil } func normalizePath(input string) string { value := strings.TrimSpace(input) if value == "" { return "/" } value = strings.SplitN(value, "?", 2)[0] value = strings.SplitN(value, "#", 2)[0] if !strings.HasPrefix(value, "/") { value = "/" + value } if value != "/" { value = strings.TrimRight(value, "/") } return strings.ToLower(value) } func normalizePrefix(input string) string { return normalizePath(input) } func (p ProfileConfig) IsExcluded(ip net.IP) bool { for _, network := range p.excludedNetworks { if network.Contains(ip) { return true } } return false } func (p ProfileConfig) IsAllowedPostPath(path string) bool { _, ok := p.normalizedAllowedPostPaths[normalizePath(path)] return ok } func (p ProfileConfig) SuspiciousPrefixes() []string { return append([]string(nil), p.normalizedSuspiciousPaths...) } func (p ProfileConfig) MatchKnownAgent(ip net.IP, userAgent string) (KnownAgentRule, bool) { normalizedUA := strings.ToLower(strings.TrimSpace(userAgent)) for _, rule := range p.KnownAgents { uaMatched := len(rule.normalizedPrefixes) == 0 for _, prefix := range rule.normalizedPrefixes { if strings.HasPrefix(normalizedUA, prefix) { uaMatched = true break } } if !uaMatched { continue } cidrMatched := len(rule.networks) == 0 for _, network := range rule.networks { if network.Contains(ip) { cidrMatched = true break } } if cidrMatched { return rule, true } } return KnownAgentRule{}, false }