2
Files
caddy-opnsense-blocker/internal/service/service_test.go

248 lines
7.9 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/config"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/opnsense"
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/store"
)
func TestServiceProcessesMultipleSourcesAndManualActions(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
mainLogPath := filepath.Join(tempDir, "main.log")
giteaLogPath := filepath.Join(tempDir, "gitea.log")
if err := os.WriteFile(mainLogPath, nil, 0o600); err != nil {
t.Fatalf("create main log: %v", err)
}
if err := os.WriteFile(giteaLogPath, nil, 0o600); err != nil {
t.Fatalf("create gitea log: %v", err)
}
backend := newFakeOPNsenseServer(t)
defer backend.Close()
configPath := filepath.Join(tempDir, "config.yaml")
payload := fmt.Sprintf(`storage:
path: %s/blocker.db
opnsense:
enabled: true
base_url: %s
api_key: key
api_secret: secret
ensure_alias: true
alias:
name: blocked-ips
profiles:
main:
auto_block: true
block_unexpected_posts: true
block_php_paths: true
suspicious_path_prefixes:
- /wp-login.php
gitea:
auto_block: false
block_unexpected_posts: true
allowed_post_paths:
- /user/login
suspicious_path_prefixes:
- /install.php
sources:
- name: main
path: %s
profile: main
initial_position: beginning
poll_interval: 20ms
batch_size: 128
- name: gitea
path: %s
profile: gitea
initial_position: beginning
poll_interval: 20ms
batch_size: 128
`, tempDir, backend.URL, mainLogPath, giteaLogPath)
if err := os.WriteFile(configPath, []byte(payload), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
database, err := store.Open(cfg.Storage.Path)
if err != nil {
t.Fatalf("open store: %v", err)
}
defer database.Close()
svc := New(cfg, database, opnsense.NewClient(cfg.OPNsense), nil, log.New(os.Stderr, "", 0))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = svc.Run(ctx) }()
appendLine(t, mainLogPath, caddyJSONLine("203.0.113.10", "198.51.100.10", "example.test", "GET", "/wp-login.php", 404, "curl/8.0", time.Now().UTC()))
appendLine(t, giteaLogPath, caddyJSONLine("203.0.113.11", "198.51.100.11", "git.example.test", "POST", "/user/login", 401, "curl/8.0", time.Now().UTC()))
appendLine(t, giteaLogPath, caddyJSONLine("203.0.113.12", "198.51.100.12", "git.example.test", "GET", "/install.php", 404, "curl/8.0", time.Now().UTC()))
waitFor(t, 3*time.Second, func() bool {
overview, err := database.GetOverview(context.Background(), 10)
return err == nil && overview.TotalEvents == 3
})
blockedState, found, err := database.GetIPState(context.Background(), "203.0.113.10")
if err != nil || !found {
t.Fatalf("load blocked state: found=%v err=%v", found, err)
}
if blockedState.State != model.IPStateBlocked {
t.Fatalf("expected blocked state, got %+v", blockedState)
}
reviewState, found, err := database.GetIPState(context.Background(), "203.0.113.12")
if err != nil || !found {
t.Fatalf("load review state: found=%v err=%v", found, err)
}
if reviewState.State != model.IPStateReview {
t.Fatalf("expected review state, got %+v", reviewState)
}
observedState, found, err := database.GetIPState(context.Background(), "203.0.113.11")
if err != nil || !found {
t.Fatalf("load observed state: found=%v err=%v", found, err)
}
if observedState.State != model.IPStateObserved {
t.Fatalf("expected observed state, got %+v", observedState)
}
if err := svc.ForceAllow(context.Background(), "203.0.113.10", "test", "manual unblock"); err != nil {
t.Fatalf("force allow: %v", err)
}
state, found, err := database.GetIPState(context.Background(), "203.0.113.10")
if err != nil || !found {
t.Fatalf("reload unblocked state: found=%v err=%v", found, err)
}
if state.ManualOverride != model.ManualOverrideForceAllow || state.State != model.IPStateAllowed {
t.Fatalf("unexpected manual allow state: %+v", state)
}
backend.mu.Lock()
defer backend.mu.Unlock()
if _, ok := backend.ips["203.0.113.10"]; ok {
t.Fatalf("expected IP to be removed from backend alias after manual unblock")
}
}
type fakeOPNsenseServer struct {
*httptest.Server
mu sync.Mutex
aliasUUID string
aliasExists bool
ips map[string]struct{}
}
func newFakeOPNsenseServer(t *testing.T) *fakeOPNsenseServer {
t.Helper()
backend := &fakeOPNsenseServer{ips: map[string]struct{}{}}
backend.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok || username != "key" || password != "secret" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
backend.mu.Lock()
defer backend.mu.Unlock()
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/firewall/alias/get_alias_u_u_i_d/blocked-ips":
if backend.aliasExists {
_ = json.NewEncoder(w).Encode(map[string]any{"uuid": backend.aliasUUID})
} else {
_ = json.NewEncoder(w).Encode(map[string]any{"uuid": ""})
}
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias/add_item":
backend.aliasExists = true
backend.aliasUUID = "uuid-1"
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias/set_item/uuid-1":
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias/reconfigure":
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok"})
case r.Method == http.MethodGet && r.URL.Path == "/api/firewall/alias_util/list/blocked-ips":
rows := make([]map[string]string, 0, len(backend.ips))
for ip := range backend.ips {
rows = append(rows, map[string]string{"ip": ip})
}
_ = json.NewEncoder(w).Encode(map[string]any{"rows": rows})
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias_util/add/blocked-ips":
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
backend.ips[payload["address"]] = struct{}{}
_ = json.NewEncoder(w).Encode(map[string]any{"status": "done"})
case r.Method == http.MethodPost && r.URL.Path == "/api/firewall/alias_util/delete/blocked-ips":
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
delete(backend.ips, payload["address"])
_ = json.NewEncoder(w).Encode(map[string]any{"status": "done"})
default:
http.Error(w, "not found", http.StatusNotFound)
}
}))
return backend
}
func appendLine(t *testing.T, path string, line string) {
t.Helper()
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatalf("open log file for append: %v", err)
}
defer file.Close()
if _, err := file.WriteString(line + "\n"); err != nil {
t.Fatalf("append log line: %v", err)
}
}
func caddyJSONLine(clientIP string, remoteIP string, host string, method string, uri string, status int, userAgent string, occurredAt time.Time) string {
return fmt.Sprintf(`{"ts":%q,"status":%d,"request":{"remote_ip":%q,"client_ip":%q,"host":%q,"method":%q,"uri":%q,"headers":{"User-Agent":[%q]}}}`,
occurredAt.UTC().Format(time.RFC3339Nano),
status,
remoteIP,
clientIP,
host,
method,
uri,
userAgent,
)
}
func waitFor(t *testing.T, timeout time.Duration, condition func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if condition() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("condition was not met within %s", timeout)
}