2
Files
caddy-opnsense-blocker/internal/config/config.go

439 lines
14 KiB
Go

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"`
Investigation InvestigationConfig `yaml:"investigation"`
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 InvestigationConfig struct {
Enabled bool `yaml:"enabled"`
RefreshAfter Duration `yaml:"refresh_after"`
Timeout Duration `yaml:"timeout"`
UserAgent string `yaml:"user_agent"`
SpamhausEnabled bool `yaml:"spamhaus_enabled"`
}
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.Investigation.Enabled {
c.Investigation.Enabled = true
}
if c.Investigation.RefreshAfter.Duration == 0 {
c.Investigation.RefreshAfter.Duration = 24 * time.Hour
}
if c.Investigation.Timeout.Duration == 0 {
c.Investigation.Timeout.Duration = 8 * time.Second
}
if strings.TrimSpace(c.Investigation.UserAgent) == "" {
c.Investigation.UserAgent = "caddy-opnsense-blocker/0.2"
}
if !c.Investigation.SpamhausEnabled {
c.Investigation.SpamhausEnabled = true
}
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
}