You've already forked caddy-opnsense-blocker
453 lines
17 KiB
Go
453 lines
17 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.dern.ovh/infrastructure/caddy-opnsense-blocker/internal/model"
|
|
)
|
|
|
|
func TestStoreRecordsEventsAndState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "blocker.db")
|
|
db, err := Open(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
ctx := context.Background()
|
|
occurredAt := time.Date(2025, 3, 11, 12, 0, 0, 0, time.UTC)
|
|
event := &model.Event{
|
|
SourceName: "main",
|
|
ProfileName: "main",
|
|
OccurredAt: occurredAt,
|
|
RemoteIP: "198.51.100.10",
|
|
ClientIP: "203.0.113.10",
|
|
Host: "example.test",
|
|
Method: "GET",
|
|
URI: "/wp-login.php",
|
|
Path: "/wp-login.php",
|
|
Status: 404,
|
|
UserAgent: "curl/8.0",
|
|
Decision: model.DecisionActionBlock,
|
|
DecisionReason: "php_path",
|
|
DecisionReasons: []string{"php_path"},
|
|
Enforced: true,
|
|
RawJSON: `{"status":404}`,
|
|
}
|
|
if err := db.RecordEvent(ctx, event); err != nil {
|
|
t.Fatalf("record event: %v", err)
|
|
}
|
|
if event.ID == 0 {
|
|
t.Fatalf("expected inserted event ID")
|
|
}
|
|
|
|
state, found, err := db.GetIPState(ctx, "203.0.113.10")
|
|
if err != nil {
|
|
t.Fatalf("get ip state: %v", err)
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected IP state to exist")
|
|
}
|
|
if state.State != model.IPStateBlocked {
|
|
t.Fatalf("unexpected ip state: %+v", state)
|
|
}
|
|
if state.TotalEvents != 1 {
|
|
t.Fatalf("unexpected total events: %d", state.TotalEvents)
|
|
}
|
|
|
|
if _, err := db.SetManualOverride(ctx, "203.0.113.10", model.ManualOverrideForceAllow, model.IPStateAllowed, "manual allow"); err != nil {
|
|
t.Fatalf("set manual override: %v", err)
|
|
}
|
|
state, found, err = db.GetIPState(ctx, "203.0.113.10")
|
|
if err != nil || !found {
|
|
t.Fatalf("get overridden ip state: found=%v err=%v", found, err)
|
|
}
|
|
if state.ManualOverride != model.ManualOverrideForceAllow {
|
|
t.Fatalf("unexpected override after set: %+v", state)
|
|
}
|
|
|
|
if _, err := db.ClearManualOverride(ctx, "203.0.113.10", "reset"); err != nil {
|
|
t.Fatalf("clear manual override: %v", err)
|
|
}
|
|
state, found, err = db.GetIPState(ctx, "203.0.113.10")
|
|
if err != nil || !found {
|
|
t.Fatalf("get reset ip state: found=%v err=%v", found, err)
|
|
}
|
|
if state.ManualOverride != model.ManualOverrideNone {
|
|
t.Fatalf("expected cleared override, got %+v", state)
|
|
}
|
|
|
|
if err := db.AddDecision(ctx, &model.DecisionRecord{EventID: event.ID, IP: event.ClientIP, SourceName: event.SourceName, Kind: "automatic", Action: model.DecisionActionBlock, Reason: "php_path", Actor: "engine", Enforced: true}); err != nil {
|
|
t.Fatalf("add decision: %v", err)
|
|
}
|
|
if err := db.AddBackendAction(ctx, &model.OPNsenseAction{IP: event.ClientIP, Action: "block", Result: "added", Message: "php_path"}); err != nil {
|
|
t.Fatalf("add backend action: %v", err)
|
|
}
|
|
if err := db.SaveSourceOffset(ctx, model.SourceOffset{SourceName: "main", Path: "/tmp/main.log", Inode: "1:2", Offset: 42, UpdatedAt: occurredAt}); err != nil {
|
|
t.Fatalf("save source offset: %v", err)
|
|
}
|
|
offset, found, err := db.GetSourceOffset(ctx, "main")
|
|
if err != nil {
|
|
t.Fatalf("get source offset: %v", err)
|
|
}
|
|
if !found || offset.Offset != 42 {
|
|
t.Fatalf("unexpected source offset: found=%v offset=%+v", found, offset)
|
|
}
|
|
|
|
overview, err := db.GetOverview(ctx, occurredAt.Add(-time.Hour), 10, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
|
|
if err != nil {
|
|
t.Fatalf("get overview: %v", err)
|
|
}
|
|
if overview.TotalEvents != 1 || overview.TotalIPs != 1 {
|
|
t.Fatalf("unexpected overview counters: %+v", overview)
|
|
}
|
|
if len(overview.TopIPsByEvents) != 1 || overview.TopIPsByEvents[0].IP != event.ClientIP {
|
|
t.Fatalf("unexpected top ips by events: %+v", overview.TopIPsByEvents)
|
|
}
|
|
if len(overview.TopBotIPsByEvents) != 0 {
|
|
t.Fatalf("expected no bot top ips by events before investigation, got %+v", overview.TopBotIPsByEvents)
|
|
}
|
|
if len(overview.TopNonBotIPsByEvents) != 1 || overview.TopNonBotIPsByEvents[0].IP != event.ClientIP {
|
|
t.Fatalf("unexpected top non-bot ips by events: %+v", overview.TopNonBotIPsByEvents)
|
|
}
|
|
if len(overview.TopSources) != 1 || overview.TopSources[0].SourceName != event.SourceName {
|
|
t.Fatalf("unexpected top sources: %+v", overview.TopSources)
|
|
}
|
|
if len(overview.TopURLs) != 1 || overview.TopURLs[0].URI != event.URI {
|
|
t.Fatalf("unexpected top urls: %+v", overview.TopURLs)
|
|
}
|
|
recentIPs, err := db.ListRecentIPRows(ctx, occurredAt.Add(-time.Hour), 10)
|
|
if err != nil {
|
|
t.Fatalf("list recent ip rows: %v", err)
|
|
}
|
|
if len(recentIPs) != 1 {
|
|
t.Fatalf("unexpected recent ip rows count: %d", len(recentIPs))
|
|
}
|
|
if recentIPs[0].IP != event.ClientIP || recentIPs[0].SourceName != event.SourceName || recentIPs[0].Events != 1 {
|
|
t.Fatalf("unexpected recent ip row: %+v", recentIPs[0])
|
|
}
|
|
details, err := db.GetIPDetails(ctx, event.ClientIP, 10, 10, 10)
|
|
if err != nil {
|
|
t.Fatalf("get ip details: %v", err)
|
|
}
|
|
if len(details.RecentEvents) != 1 || len(details.Decisions) != 1 || len(details.BackendActions) != 1 {
|
|
t.Fatalf("unexpected ip details: %+v", details)
|
|
}
|
|
|
|
investigation := model.IPInvestigation{
|
|
IP: event.ClientIP,
|
|
UpdatedAt: occurredAt,
|
|
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Icon: "🤖", Method: "published_ranges", Verified: true},
|
|
}
|
|
if err := db.SaveInvestigation(ctx, investigation); err != nil {
|
|
t.Fatalf("save investigation: %v", err)
|
|
}
|
|
loadedInvestigation, found, err := db.GetInvestigation(ctx, event.ClientIP)
|
|
if err != nil {
|
|
t.Fatalf("get investigation: %v", err)
|
|
}
|
|
if !found || loadedInvestigation.Bot == nil || loadedInvestigation.Bot.Name != "Googlebot" {
|
|
t.Fatalf("unexpected investigation payload: found=%v investigation=%+v", found, loadedInvestigation)
|
|
}
|
|
investigations, err := db.GetInvestigationsForIPs(ctx, []string{event.ClientIP, "198.51.100.99"})
|
|
if err != nil {
|
|
t.Fatalf("get investigations for ips: %v", err)
|
|
}
|
|
if len(investigations) != 1 || investigations[event.ClientIP].Bot == nil {
|
|
t.Fatalf("unexpected investigations map: %+v", investigations)
|
|
}
|
|
userAgents, err := db.ListRecentUserAgentsForIP(ctx, event.ClientIP, 10)
|
|
if err != nil {
|
|
t.Fatalf("list recent user agents: %v", err)
|
|
}
|
|
if len(userAgents) != 1 || userAgents[0] != event.UserAgent {
|
|
t.Fatalf("unexpected user agents: %#v", userAgents)
|
|
}
|
|
missingInvestigationIPs, err := db.ListIPsWithoutInvestigation(ctx, time.Time{}, 10)
|
|
if err != nil {
|
|
t.Fatalf("list ips without investigation: %v", err)
|
|
}
|
|
if len(missingInvestigationIPs) != 0 {
|
|
t.Fatalf("expected no IPs without investigation, got %#v", missingInvestigationIPs)
|
|
}
|
|
}
|
|
|
|
func TestStoreOverviewLeaderboardsUseTrafficFromRawJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "blocker.db")
|
|
db, err := Open(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
ctx := context.Background()
|
|
baseTime := time.Date(2025, 3, 12, 15, 0, 0, 0, time.UTC)
|
|
events := []*model.Event{
|
|
{
|
|
SourceName: "public-web",
|
|
ProfileName: "public-web",
|
|
OccurredAt: baseTime,
|
|
RemoteIP: "198.51.100.10",
|
|
ClientIP: "203.0.113.10",
|
|
Host: "example.test",
|
|
Method: "GET",
|
|
URI: "/wp-login.php",
|
|
Path: "/wp-login.php",
|
|
Status: 404,
|
|
UserAgent: "curl/8.0",
|
|
Decision: model.DecisionActionBlock,
|
|
DecisionReason: "php_path",
|
|
DecisionReasons: []string{"php_path"},
|
|
RawJSON: `{"status":404,"size":2048}`,
|
|
},
|
|
{
|
|
SourceName: "public-web",
|
|
ProfileName: "public-web",
|
|
OccurredAt: baseTime.Add(10 * time.Second),
|
|
RemoteIP: "198.51.100.11",
|
|
ClientIP: "203.0.113.10",
|
|
Host: "example.test",
|
|
Method: "GET",
|
|
URI: "/wp-login.php",
|
|
Path: "/wp-login.php",
|
|
Status: 404,
|
|
UserAgent: "curl/8.0",
|
|
Decision: model.DecisionActionBlock,
|
|
DecisionReason: "php_path",
|
|
DecisionReasons: []string{"php_path"},
|
|
RawJSON: `{"status":404,"size":1024}`,
|
|
},
|
|
{
|
|
SourceName: "gitea",
|
|
ProfileName: "gitea",
|
|
OccurredAt: baseTime.Add(20 * time.Second),
|
|
RemoteIP: "198.51.100.12",
|
|
ClientIP: "203.0.113.20",
|
|
Host: "git.example.test",
|
|
Method: "GET",
|
|
URI: "/install.php",
|
|
Path: "/install.php",
|
|
Status: 404,
|
|
UserAgent: "curl/8.0",
|
|
Decision: model.DecisionActionReview,
|
|
DecisionReason: "suspicious_path_prefix:/install.php",
|
|
DecisionReasons: []string{"suspicious_path_prefix:/install.php"},
|
|
RawJSON: `{"status":404,"size":4096}`,
|
|
},
|
|
}
|
|
for _, event := range events {
|
|
if err := db.RecordEvent(ctx, event); err != nil {
|
|
t.Fatalf("record event %+v: %v", event, err)
|
|
}
|
|
}
|
|
|
|
overview, err := db.GetOverview(ctx, baseTime.Add(-time.Minute), 10, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
|
|
if err != nil {
|
|
t.Fatalf("get overview: %v", err)
|
|
}
|
|
if len(overview.TopIPsByEvents) < 2 {
|
|
t.Fatalf("expected at least 2 top IP rows by events, got %+v", overview.TopIPsByEvents)
|
|
}
|
|
if overview.TopIPsByEvents[0].IP != "203.0.113.10" || overview.TopIPsByEvents[0].Events != 2 || overview.TopIPsByEvents[0].TrafficBytes != 3072 {
|
|
t.Fatalf("unexpected top IP by events row: %+v", overview.TopIPsByEvents[0])
|
|
}
|
|
if len(overview.TopNonBotIPsByEvents) < 2 || overview.TopNonBotIPsByEvents[0].IP != "203.0.113.10" || overview.TopNonBotIPsByEvents[0].Events != 2 {
|
|
t.Fatalf("unexpected top non-bot IP by events rows: %+v", overview.TopNonBotIPsByEvents)
|
|
}
|
|
if len(overview.TopIPsByTraffic) < 2 {
|
|
t.Fatalf("expected at least 2 top IP rows by traffic, got %+v", overview.TopIPsByTraffic)
|
|
}
|
|
if overview.TopIPsByTraffic[0].IP != "203.0.113.20" || overview.TopIPsByTraffic[0].TrafficBytes != 4096 {
|
|
t.Fatalf("unexpected top IP by traffic row: %+v", overview.TopIPsByTraffic[0])
|
|
}
|
|
if len(overview.TopSources) < 2 || overview.TopSources[0].SourceName != "public-web" || overview.TopSources[0].Events != 2 {
|
|
t.Fatalf("unexpected top source rows: %+v", overview.TopSources)
|
|
}
|
|
if len(overview.TopURLs) == 0 || overview.TopURLs[0].URI != "/wp-login.php" || overview.TopURLs[0].Events != 2 {
|
|
t.Fatalf("unexpected top url rows: %+v", overview.TopURLs)
|
|
}
|
|
|
|
if err := db.SaveInvestigation(ctx, model.IPInvestigation{
|
|
IP: "203.0.113.10",
|
|
UpdatedAt: baseTime,
|
|
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true},
|
|
}); err != nil {
|
|
t.Fatalf("save top bot investigation: %v", err)
|
|
}
|
|
refreshedOverview, err := db.GetOverview(ctx, baseTime.Add(-time.Minute), 10, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
|
|
if err != nil {
|
|
t.Fatalf("get refreshed overview: %v", err)
|
|
}
|
|
if len(refreshedOverview.TopBotIPsByEvents) == 0 || refreshedOverview.TopBotIPsByEvents[0].IP != "203.0.113.10" {
|
|
t.Fatalf("unexpected top bot IPs by events after investigation: %+v", refreshedOverview.TopBotIPsByEvents)
|
|
}
|
|
if len(refreshedOverview.TopNonBotIPsByEvents) == 0 || refreshedOverview.TopNonBotIPsByEvents[0].IP != "203.0.113.20" {
|
|
t.Fatalf("unexpected top non-bot IPs by events after investigation: %+v", refreshedOverview.TopNonBotIPsByEvents)
|
|
}
|
|
if len(refreshedOverview.TopBotIPsByTraffic) == 0 || refreshedOverview.TopBotIPsByTraffic[0].IP != "203.0.113.10" {
|
|
t.Fatalf("unexpected top bot IPs by traffic after investigation: %+v", refreshedOverview.TopBotIPsByTraffic)
|
|
}
|
|
if len(refreshedOverview.TopNonBotIPsByTraffic) == 0 || refreshedOverview.TopNonBotIPsByTraffic[0].IP != "203.0.113.20" {
|
|
t.Fatalf("unexpected top non-bot IPs by traffic after investigation: %+v", refreshedOverview.TopNonBotIPsByTraffic)
|
|
}
|
|
if _, err := db.SetManualOverride(ctx, "203.0.113.20", model.ManualOverrideForceAllow, model.IPStateAllowed, "manual allow"); err != nil {
|
|
t.Fatalf("set manual override for filter test: %v", err)
|
|
}
|
|
|
|
filtered, err := db.GetOverview(ctx, baseTime.Add(-time.Minute), 10, model.OverviewOptions{ShowKnownBots: false, ShowAllowed: false})
|
|
if err != nil {
|
|
t.Fatalf("get filtered overview: %v", err)
|
|
}
|
|
if len(filtered.TopIPsByEvents) != 0 {
|
|
t.Fatalf("expected filtered top IPs by events to be empty, got %+v", filtered.TopIPsByEvents)
|
|
}
|
|
if len(filtered.TopBotIPsByEvents) != 0 {
|
|
t.Fatalf("expected filtered top bot IPs by events to be empty, got %+v", filtered.TopBotIPsByEvents)
|
|
}
|
|
if len(filtered.TopNonBotIPsByEvents) != 0 {
|
|
t.Fatalf("expected filtered top non-bot IPs by events to be empty, got %+v", filtered.TopNonBotIPsByEvents)
|
|
}
|
|
if len(filtered.TopIPsByTraffic) != 0 {
|
|
t.Fatalf("expected filtered top IPs by traffic to be empty, got %+v", filtered.TopIPsByTraffic)
|
|
}
|
|
if len(filtered.TopBotIPsByTraffic) != 0 {
|
|
t.Fatalf("expected filtered top bot IPs by traffic to be empty, got %+v", filtered.TopBotIPsByTraffic)
|
|
}
|
|
if len(filtered.TopNonBotIPsByTraffic) != 0 {
|
|
t.Fatalf("expected filtered top non-bot IPs by traffic to be empty, got %+v", filtered.TopNonBotIPsByTraffic)
|
|
}
|
|
if len(filtered.TopSources) != 0 {
|
|
t.Fatalf("expected filtered top sources to be empty, got %+v", filtered.TopSources)
|
|
}
|
|
if len(filtered.TopURLs) != 0 {
|
|
t.Fatalf("expected filtered top urls to be empty, got %+v", filtered.TopURLs)
|
|
}
|
|
}
|
|
|
|
func TestStoreListEventsSupportsFiltersAndSorting(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "blocker.db")
|
|
db, err := Open(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
ctx := context.Background()
|
|
baseTime := time.Date(2025, 3, 12, 18, 0, 0, 0, time.UTC)
|
|
events := []*model.Event{
|
|
{
|
|
SourceName: "main",
|
|
ProfileName: "main",
|
|
OccurredAt: baseTime,
|
|
RemoteIP: "198.51.100.10",
|
|
ClientIP: "203.0.113.10",
|
|
Host: "example.test",
|
|
Method: "GET",
|
|
URI: "/wp-login.php",
|
|
Path: "/wp-login.php",
|
|
Status: 404,
|
|
UserAgent: "curl/8.0",
|
|
Decision: model.DecisionActionReview,
|
|
DecisionReason: "php_path",
|
|
DecisionReasons: []string{"php_path"},
|
|
RawJSON: `{"status":404}`,
|
|
},
|
|
{
|
|
SourceName: "main",
|
|
ProfileName: "main",
|
|
OccurredAt: baseTime.Add(10 * time.Second),
|
|
RemoteIP: "198.51.100.11",
|
|
ClientIP: "203.0.113.11",
|
|
Host: "example.test",
|
|
Method: "GET",
|
|
URI: "/xmlrpc.php",
|
|
Path: "/xmlrpc.php",
|
|
Status: 401,
|
|
UserAgent: "curl/8.0",
|
|
Decision: model.DecisionActionReview,
|
|
DecisionReason: "php_path",
|
|
DecisionReasons: []string{"php_path"},
|
|
RawJSON: `{"status":401}`,
|
|
},
|
|
{
|
|
SourceName: "main",
|
|
ProfileName: "main",
|
|
OccurredAt: baseTime.Add(20 * time.Second),
|
|
RemoteIP: "198.51.100.12",
|
|
ClientIP: "203.0.113.20",
|
|
Host: "example.test",
|
|
Method: "POST",
|
|
URI: "/xmlrpc.php",
|
|
Path: "/xmlrpc.php",
|
|
Status: 403,
|
|
UserAgent: "curl/8.0",
|
|
Decision: model.DecisionActionReview,
|
|
DecisionReason: "unexpected_post",
|
|
DecisionReasons: []string{"unexpected_post"},
|
|
RawJSON: `{"status":403}`,
|
|
},
|
|
{
|
|
SourceName: "gitea",
|
|
ProfileName: "gitea",
|
|
OccurredAt: baseTime.Add(30 * time.Second),
|
|
RemoteIP: "198.51.100.13",
|
|
ClientIP: "203.0.113.30",
|
|
Host: "git.example.test",
|
|
Method: "GET",
|
|
URI: "/install.php",
|
|
Path: "/install.php",
|
|
Status: 404,
|
|
UserAgent: "curl/8.0",
|
|
Decision: model.DecisionActionReview,
|
|
DecisionReason: "suspicious_path_prefix:/install.php",
|
|
DecisionReasons: []string{"suspicious_path_prefix:/install.php"},
|
|
RawJSON: `{"status":404}`,
|
|
},
|
|
}
|
|
for _, event := range events {
|
|
if err := db.RecordEvent(ctx, event); err != nil {
|
|
t.Fatalf("record event %+v: %v", event, err)
|
|
}
|
|
}
|
|
for _, ip := range []string{"203.0.113.10", "203.0.113.11"} {
|
|
if err := db.SaveInvestigation(ctx, model.IPInvestigation{
|
|
IP: ip,
|
|
UpdatedAt: baseTime,
|
|
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true},
|
|
}); err != nil {
|
|
t.Fatalf("save investigation for %s: %v", ip, err)
|
|
}
|
|
}
|
|
|
|
items, err := db.ListEvents(ctx, baseTime.Add(-time.Minute), 10, model.EventListOptions{
|
|
Source: "main",
|
|
Method: "GET",
|
|
StatusFilter: "4xx",
|
|
State: string(model.IPStateReview),
|
|
BotFilter: "known",
|
|
SortBy: "status",
|
|
SortDesc: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("list events with filters: %v", err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Fatalf("expected 2 filtered items, got %+v", items)
|
|
}
|
|
if items[0].ClientIP != "203.0.113.11" || items[0].Status != 401 {
|
|
t.Fatalf("unexpected first filtered row: %+v", items[0])
|
|
}
|
|
if items[1].ClientIP != "203.0.113.10" || items[1].Status != 404 {
|
|
t.Fatalf("unexpected second filtered row: %+v", items[1])
|
|
}
|
|
}
|