2

Build initial caddy-opnsense-blocker daemon

This commit is contained in:
2026-03-12 00:51:06 +01:00
commit 4e87d84237
21 changed files with 4354 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
package engine
import (
"fmt"
"net"
"strings"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
)
type Evaluator struct{}
func NewEvaluator() *Evaluator {
return &Evaluator{}
}
func (e *Evaluator) Evaluate(record model.AccessLogRecord, profile config.ProfileConfig, override model.ManualOverride) model.Decision {
switch override {
case model.ManualOverrideForceAllow:
return model.Decision{Action: model.DecisionActionAllow, Reasons: []string{"manual_override_force_allow"}}
case model.ManualOverrideForceBlock:
return model.Decision{Action: model.DecisionActionBlock, Reasons: []string{"manual_override_force_block"}}
}
if record.Status < profile.MinStatus || record.Status > profile.MaxStatus {
return model.Decision{Action: model.DecisionActionNone}
}
ip := net.ParseIP(record.ClientIP)
if ip == nil {
return model.Decision{Action: model.DecisionActionReview, Reasons: []string{"invalid_client_ip"}}
}
if profile.IsExcluded(ip) {
return model.Decision{Action: model.DecisionActionAllow, Reasons: []string{"excluded_cidr"}}
}
if rule, ok := profile.MatchKnownAgent(ip, record.UserAgent); ok {
if rule.Decision == "allow" {
return model.Decision{Action: model.DecisionActionAllow, Reasons: []string{fmt.Sprintf("known_agent_allow:%s", rule.Name)}}
}
return blockDecision(profile.AutoBlock, []string{fmt.Sprintf("known_agent_deny:%s", rule.Name)})
}
blockReasons := make([]string, 0, 3)
for _, prefix := range profile.SuspiciousPrefixes() {
if strings.HasPrefix(record.Path, prefix) {
blockReasons = append(blockReasons, fmt.Sprintf("suspicious_path_prefix:%s", prefix))
break
}
}
if profile.BlockUnexpectedPosts && strings.EqualFold(record.Method, "POST") && !profile.IsAllowedPostPath(record.Path) {
blockReasons = append(blockReasons, "unexpected_post")
}
if profile.BlockPHPPaths && strings.HasSuffix(record.Path, ".php") {
blockReasons = append(blockReasons, "php_path")
}
return blockDecision(profile.AutoBlock, blockReasons)
}
func blockDecision(autoBlock bool, reasons []string) model.Decision {
if len(reasons) == 0 {
return model.Decision{Action: model.DecisionActionNone}
}
if autoBlock {
return model.Decision{Action: model.DecisionActionBlock, Reasons: reasons}
}
return model.Decision{Action: model.DecisionActionReview, Reasons: reasons}
}

View File

@@ -0,0 +1,153 @@
package engine
import (
"fmt"
"os"
"path/filepath"
"testing"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
)
func TestEvaluatorManualOverridesTakePriority(t *testing.T) {
t.Parallel()
profile := loadProfile(t, `
auto_block: true
block_unexpected_posts: true
block_php_paths: true
suspicious_path_prefixes:
- /wp-admin
`)
evaluator := NewEvaluator()
record := model.AccessLogRecord{ClientIP: "203.0.113.10", Status: 404, Method: "GET", Path: "/wp-admin/install.php", UserAgent: "curl/8.0"}
if decision := evaluator.Evaluate(record, profile, model.ManualOverrideForceAllow); decision.Action != model.DecisionActionAllow {
t.Fatalf("expected manual allow to win, got %+v", decision)
}
if decision := evaluator.Evaluate(record, profile, model.ManualOverrideForceBlock); decision.Action != model.DecisionActionBlock {
t.Fatalf("expected manual block to win, got %+v", decision)
}
}
func TestEvaluatorBlocksSuspiciousRequests(t *testing.T) {
t.Parallel()
profile := loadProfile(t, `
auto_block: true
block_unexpected_posts: true
block_php_paths: true
allowed_post_paths:
- /search
suspicious_path_prefixes:
- /wp-admin
`)
evaluator := NewEvaluator()
record := model.AccessLogRecord{ClientIP: "203.0.113.11", Status: 404, Method: "POST", Path: "/wp-admin/install.php", UserAgent: "curl/8.0"}
decision := evaluator.Evaluate(record, profile, model.ManualOverrideNone)
if decision.Action != model.DecisionActionBlock {
t.Fatalf("expected block decision, got %+v", decision)
}
if len(decision.Reasons) < 2 {
t.Fatalf("expected multiple blocking reasons, got %+v", decision)
}
}
func TestEvaluatorAllowsExcludedCIDRAndKnownAgents(t *testing.T) {
t.Parallel()
profile := loadProfile(t, `
auto_block: true
excluded_cidrs:
- 10.0.0.0/8
known_agents:
- name: friendly-bot
decision: allow
user_agent_prefixes:
- FriendlyBot/
`)
evaluator := NewEvaluator()
excluded := model.AccessLogRecord{ClientIP: "10.0.0.5", Status: 404, Method: "GET", Path: "/wp-login.php", UserAgent: "curl/8.0"}
if decision := evaluator.Evaluate(excluded, profile, model.ManualOverrideNone); decision.Action != model.DecisionActionAllow {
t.Fatalf("expected excluded cidr to be allowed, got %+v", decision)
}
knownAgent := model.AccessLogRecord{ClientIP: "203.0.113.12", Status: 404, Method: "GET", Path: "/wp-login.php", UserAgent: "FriendlyBot/2.0"}
if decision := evaluator.Evaluate(knownAgent, profile, model.ManualOverrideNone); decision.Action != model.DecisionActionAllow {
t.Fatalf("expected known agent to be allowed, got %+v", decision)
}
}
func TestEvaluatorReturnsReviewWhenAutoBlockIsDisabled(t *testing.T) {
t.Parallel()
profile := loadProfile(t, `
auto_block: false
block_unexpected_posts: true
suspicious_path_prefixes:
- /admin
`)
evaluator := NewEvaluator()
record := model.AccessLogRecord{ClientIP: "203.0.113.13", Status: 404, Method: "POST", Path: "/admin", UserAgent: "curl/8.0"}
decision := evaluator.Evaluate(record, profile, model.ManualOverrideNone)
if decision.Action != model.DecisionActionReview {
t.Fatalf("expected review decision, got %+v", decision)
}
}
func loadProfile(t *testing.T, profileSnippet string) config.ProfileConfig {
t.Helper()
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
payload := fmt.Sprintf(`profiles:
main:%s
sources:
- name: main
path: %s/access.json
profile: main
`, indent(profileSnippet, 4), tempDir)
if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil {
t.Fatalf("write config file: %v", err)
}
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
return cfg.Profiles["main"]
}
func indent(value string, spaces int) string {
padding := ""
for range spaces {
padding += " "
}
lines := []byte(value)
_ = lines
var output string
for _, line := range splitLines(value) {
trimmed := line
if trimmed == "" {
output += "\n"
continue
}
output += "\n" + padding + trimmed
}
return output
}
func splitLines(value string) []string {
var lines []string
start := 0
for index, character := range value {
if character == '\n' {
lines = append(lines, value[start:index])
start = index + 1
}
}
lines = append(lines, value[start:])
return lines
}