You've already forked caddy-opnsense-blocker
Add dashboard activity leaderboards
This commit is contained in:
@@ -223,6 +223,8 @@ func (s *Store) RecordEvent(ctx context.Context, event *model.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const responseBytesExpression = `CASE WHEN json_valid(e.raw_json) THEN CAST(COALESCE(json_extract(e.raw_json, '$.size'), 0) AS INTEGER) ELSE 0 END`
|
||||
|
||||
func (s *Store) AddDecision(ctx context.Context, decision *model.DecisionRecord) error {
|
||||
if decision == nil {
|
||||
return errors.New("nil decision record")
|
||||
@@ -370,11 +372,14 @@ func (s *Store) ClearManualOverride(ctx context.Context, ip string, reason strin
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOverview(ctx context.Context, limit int) (model.Overview, error) {
|
||||
func (s *Store) GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var overview model.Overview
|
||||
if !since.IsZero() {
|
||||
overview.ActivitySince = since.UTC()
|
||||
}
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM events`).Scan(&overview.TotalEvents); err != nil {
|
||||
return model.Overview{}, fmt.Errorf("count events: %w", err)
|
||||
}
|
||||
@@ -402,11 +407,171 @@ func (s *Store) GetOverview(ctx context.Context, limit int) (model.Overview, err
|
||||
if err != nil {
|
||||
return model.Overview{}, err
|
||||
}
|
||||
topIPsByEvents, err := s.listTopIPRows(ctx, since, limit, "events")
|
||||
if err != nil {
|
||||
return model.Overview{}, err
|
||||
}
|
||||
topIPsByTraffic, err := s.listTopIPRows(ctx, since, limit, "traffic")
|
||||
if err != nil {
|
||||
return model.Overview{}, err
|
||||
}
|
||||
topSources, err := s.listTopSourceRows(ctx, since, limit)
|
||||
if err != nil {
|
||||
return model.Overview{}, err
|
||||
}
|
||||
topURLs, err := s.listTopURLRows(ctx, since, limit)
|
||||
if err != nil {
|
||||
return model.Overview{}, err
|
||||
}
|
||||
overview.RecentIPs = recentIPs
|
||||
overview.RecentEvents = recentEvents
|
||||
overview.TopIPsByEvents = topIPsByEvents
|
||||
overview.TopIPsByTraffic = topIPsByTraffic
|
||||
overview.TopSources = topSources
|
||||
overview.TopURLs = topURLs
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
func (s *Store) listTopIPRows(ctx context.Context, since time.Time, limit int, orderBy string) ([]model.TopIPRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT e.client_ip,
|
||||
COUNT(*) AS event_count,
|
||||
COALESCE(SUM(%s), 0) AS traffic_bytes,
|
||||
MAX(e.occurred_at) AS last_seen_at
|
||||
FROM events e`, responseBytesExpression)
|
||||
args := make([]any, 0, 2)
|
||||
if !since.IsZero() {
|
||||
query += ` WHERE e.occurred_at >= ?`
|
||||
args = append(args, formatTime(since))
|
||||
}
|
||||
query += ` GROUP BY e.client_ip`
|
||||
switch orderBy {
|
||||
case "traffic":
|
||||
query += ` ORDER BY traffic_bytes DESC, event_count DESC, last_seen_at DESC, e.client_ip ASC`
|
||||
default:
|
||||
query += ` ORDER BY event_count DESC, traffic_bytes DESC, last_seen_at DESC, e.client_ip ASC`
|
||||
}
|
||||
query += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list top ip rows by %s: %w", orderBy, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]model.TopIPRow, 0, limit)
|
||||
for rows.Next() {
|
||||
var item model.TopIPRow
|
||||
var lastSeenAt string
|
||||
if err := rows.Scan(&item.IP, &item.Events, &item.TrafficBytes, &lastSeenAt); err != nil {
|
||||
return nil, fmt.Errorf("scan top ip row: %w", err)
|
||||
}
|
||||
parsed, err := parseTime(lastSeenAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse top ip row last_seen_at: %w", err)
|
||||
}
|
||||
item.LastSeenAt = parsed
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate top ip rows by %s: %w", orderBy, err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) listTopSourceRows(ctx context.Context, since time.Time, limit int) ([]model.TopSourceRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT e.source_name,
|
||||
COUNT(*) AS event_count,
|
||||
COALESCE(SUM(%s), 0) AS traffic_bytes,
|
||||
MAX(e.occurred_at) AS last_seen_at
|
||||
FROM events e`, responseBytesExpression)
|
||||
args := make([]any, 0, 2)
|
||||
if !since.IsZero() {
|
||||
query += ` WHERE e.occurred_at >= ?`
|
||||
args = append(args, formatTime(since))
|
||||
}
|
||||
query += ` GROUP BY e.source_name ORDER BY event_count DESC, traffic_bytes DESC, last_seen_at DESC, e.source_name ASC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list top source rows: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]model.TopSourceRow, 0, limit)
|
||||
for rows.Next() {
|
||||
var item model.TopSourceRow
|
||||
var lastSeenAt string
|
||||
if err := rows.Scan(&item.SourceName, &item.Events, &item.TrafficBytes, &lastSeenAt); err != nil {
|
||||
return nil, fmt.Errorf("scan top source row: %w", err)
|
||||
}
|
||||
parsed, err := parseTime(lastSeenAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse top source row last_seen_at: %w", err)
|
||||
}
|
||||
item.LastSeenAt = parsed
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate top source rows: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) listTopURLRows(ctx context.Context, since time.Time, limit int) ([]model.TopURLRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
SELECT e.host,
|
||||
e.uri,
|
||||
COUNT(*) AS event_count,
|
||||
COALESCE(SUM(%s), 0) AS traffic_bytes,
|
||||
MAX(e.occurred_at) AS last_seen_at
|
||||
FROM events e`, responseBytesExpression)
|
||||
args := make([]any, 0, 2)
|
||||
if !since.IsZero() {
|
||||
query += ` WHERE e.occurred_at >= ?`
|
||||
args = append(args, formatTime(since))
|
||||
}
|
||||
query += ` GROUP BY e.host, e.uri ORDER BY event_count DESC, traffic_bytes DESC, last_seen_at DESC, e.host ASC, e.uri ASC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list top url rows: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]model.TopURLRow, 0, limit)
|
||||
for rows.Next() {
|
||||
var item model.TopURLRow
|
||||
var lastSeenAt string
|
||||
if err := rows.Scan(&item.Host, &item.URI, &item.Events, &item.TrafficBytes, &lastSeenAt); err != nil {
|
||||
return nil, fmt.Errorf("scan top url row: %w", err)
|
||||
}
|
||||
parsed, err := parseTime(lastSeenAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse top url row last_seen_at: %w", err)
|
||||
}
|
||||
item.LastSeenAt = parsed
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate top url rows: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRecentEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
|
||||
@@ -99,13 +99,22 @@ func TestStoreRecordsEventsAndState(t *testing.T) {
|
||||
t.Fatalf("unexpected source offset: found=%v offset=%+v", found, offset)
|
||||
}
|
||||
|
||||
overview, err := db.GetOverview(ctx, 10)
|
||||
overview, err := db.GetOverview(ctx, occurredAt.Add(-time.Hour), 10)
|
||||
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.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)
|
||||
@@ -161,3 +170,98 @@ func TestStoreRecordsEventsAndState(t *testing.T) {
|
||||
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)
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user