You've already forked caddy-opnsense-blocker
415 lines
13 KiB
Go
415 lines
13 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"`
|
|
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
|
|
}
|