You've already forked caddy-opnsense-blocker
Build initial caddy-opnsense-blocker daemon
This commit is contained in:
414
internal/config/config.go
Normal file
414
internal/config/config.go
Normal file
@@ -0,0 +1,414 @@
|
||||
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
|
||||
}
|
||||
106
internal/config/config_test.go
Normal file
106
internal/config/config_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAppliesDefaultsAndReadsSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
keyPath := filepath.Join(tempDir, "api-key")
|
||||
secretPath := filepath.Join(tempDir, "api-secret")
|
||||
if err := os.WriteFile(keyPath, []byte("test-key\n"), 0o600); err != nil {
|
||||
t.Fatalf("write key file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(secretPath, []byte("test-secret\n"), 0o600); err != nil {
|
||||
t.Fatalf("write secret file: %v", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
payload := fmt.Sprintf(`storage:
|
||||
path: %s/data/blocker.db
|
||||
opnsense:
|
||||
enabled: true
|
||||
base_url: https://router.example.test
|
||||
api_key_file: %s
|
||||
api_secret_file: %s
|
||||
ensure_alias: true
|
||||
alias:
|
||||
name: blocked-ips
|
||||
profiles:
|
||||
main:
|
||||
auto_block: true
|
||||
block_unexpected_posts: true
|
||||
block_php_paths: true
|
||||
allowed_post_paths:
|
||||
- /search
|
||||
suspicious_path_prefixes:
|
||||
- /wp-admin
|
||||
excluded_cidrs:
|
||||
- 10.0.0.0/8
|
||||
known_agents:
|
||||
- name: friendly-bot
|
||||
decision: allow
|
||||
user_agent_prefixes:
|
||||
- FriendlyBot/
|
||||
sources:
|
||||
- name: main
|
||||
path: %s/access.json
|
||||
profile: main
|
||||
`, tempDir, keyPath, secretPath, tempDir)
|
||||
if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil {
|
||||
t.Fatalf("write config file: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
if got, want := cfg.Server.ListenAddress, "127.0.0.1:9080"; got != want {
|
||||
t.Fatalf("unexpected listen address: got %q want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.Sources[0].InitialPosition, "end"; got != want {
|
||||
t.Fatalf("unexpected initial position: got %q want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.OPNsense.APIKey, "test-key"; got != want {
|
||||
t.Fatalf("unexpected api key: got %q want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.OPNsense.APISecret, "test-secret"; got != want {
|
||||
t.Fatalf("unexpected api secret: got %q want %q", got, want)
|
||||
}
|
||||
profile := cfg.Profiles["main"]
|
||||
if !profile.IsAllowedPostPath("/search") {
|
||||
t.Fatalf("expected /search to be normalized as an allowed POST path")
|
||||
}
|
||||
if len(profile.SuspiciousPrefixes()) != 1 || profile.SuspiciousPrefixes()[0] != "/wp-admin" {
|
||||
t.Fatalf("unexpected suspicious prefixes: %#v", profile.SuspiciousPrefixes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidInitialPosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
payload := fmt.Sprintf(`profiles:
|
||||
main:
|
||||
auto_block: true
|
||||
sources:
|
||||
- name: main
|
||||
path: %s/access.json
|
||||
profile: main
|
||||
initial_position: sideways
|
||||
`, tempDir)
|
||||
if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil {
|
||||
t.Fatalf("write config file: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Load(configPath); err == nil {
|
||||
t.Fatalf("expected invalid initial_position to be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user