You've already forked caddy-opnsense-blocker
Add dashboard activity leaderboards
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
- Real-time ingestion of multiple Caddy JSON access log files.
|
- Real-time ingestion of multiple Caddy JSON access log files.
|
||||||
- One heuristic profile per log source, so different applications can have different rules while sharing the same OPNsense destination alias.
|
- One heuristic profile per log source, so different applications can have different rules while sharing the same OPNsense destination alias.
|
||||||
- Persistent SQLite state for events, IP states, investigations, decisions, backend actions, and source offsets.
|
- Persistent SQLite state for events, IP states, investigations, decisions, backend actions, and source offsets.
|
||||||
- Lightweight web UI with overview cards, a sortable “Recent IPs” table, IP detail pages, decision history, and full request history per address.
|
- Lightweight web UI with overview cards, top activity leaderboards, a sortable “Recent IPs” table, IP detail pages, decision history, and full request history per address.
|
||||||
- Background investigation workers that fill in missing cached intelligence without slowing down page loads.
|
- Background investigation workers that fill in missing cached intelligence without slowing down page loads.
|
||||||
- Manual `Block`, `Unblock`, `Clear override`, and `Refresh investigation` actions from the UI or the HTTP API.
|
- Manual `Block`, `Unblock`, `Clear override`, and `Refresh investigation` actions from the UI or the HTTP API.
|
||||||
- Optional OPNsense integration; the daemon also works in review-only mode.
|
- Optional OPNsense integration; the daemon also works in review-only mode.
|
||||||
@@ -116,7 +116,7 @@ Detailed NixOS installation examples are in [`docs/install.md`](docs/install.md)
|
|||||||
The UI is backed by a small JSON API. The main endpoints are:
|
The UI is backed by a small JSON API. The main endpoints are:
|
||||||
|
|
||||||
- `GET /healthz`
|
- `GET /healthz`
|
||||||
- `GET /api/overview`
|
- `GET /api/overview?hours=24`
|
||||||
- `GET /api/events`
|
- `GET /api/events`
|
||||||
- `GET /api/ips`
|
- `GET /api/ips`
|
||||||
- `GET /api/recent-ips?hours=24`
|
- `GET /api/recent-ips?hours=24`
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ Query parameters:
|
|||||||
- optional
|
- optional
|
||||||
- default: `50`
|
- default: `50`
|
||||||
- maximum: `1000`
|
- maximum: `1000`
|
||||||
|
- `hours`
|
||||||
|
- optional
|
||||||
|
- default: `24`
|
||||||
|
- used for the top activity leaderboards returned in the same payload
|
||||||
|
|
||||||
Main response fields:
|
Main response fields:
|
||||||
|
|
||||||
@@ -42,6 +46,11 @@ Main response fields:
|
|||||||
- `review_ips`
|
- `review_ips`
|
||||||
- `allowed_ips`
|
- `allowed_ips`
|
||||||
- `observed_ips`
|
- `observed_ips`
|
||||||
|
- `activity_since`
|
||||||
|
- `top_ips_by_events`
|
||||||
|
- `top_ips_by_traffic`
|
||||||
|
- `top_sources`
|
||||||
|
- `top_urls`
|
||||||
- `recent_ips`
|
- `recent_ips`
|
||||||
- `recent_events`
|
- `recent_events`
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,29 @@ type RecentIPRow struct {
|
|||||||
Actions ActionAvailability `json:"actions"`
|
Actions ActionAvailability `json:"actions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TopIPRow struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Events int64 `json:"events"`
|
||||||
|
TrafficBytes int64 `json:"traffic_bytes"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
Bot *BotMatch `json:"bot,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TopSourceRow struct {
|
||||||
|
SourceName string `json:"source_name"`
|
||||||
|
Events int64 `json:"events"`
|
||||||
|
TrafficBytes int64 `json:"traffic_bytes"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TopURLRow struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Events int64 `json:"events"`
|
||||||
|
TrafficBytes int64 `json:"traffic_bytes"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type SourceOffset struct {
|
type SourceOffset struct {
|
||||||
SourceName string
|
SourceName string
|
||||||
Path string
|
Path string
|
||||||
@@ -197,12 +220,17 @@ type IPDetails struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Overview struct {
|
type Overview struct {
|
||||||
TotalEvents int64 `json:"total_events"`
|
TotalEvents int64 `json:"total_events"`
|
||||||
TotalIPs int64 `json:"total_ips"`
|
TotalIPs int64 `json:"total_ips"`
|
||||||
BlockedIPs int64 `json:"blocked_ips"`
|
BlockedIPs int64 `json:"blocked_ips"`
|
||||||
ReviewIPs int64 `json:"review_ips"`
|
ReviewIPs int64 `json:"review_ips"`
|
||||||
AllowedIPs int64 `json:"allowed_ips"`
|
AllowedIPs int64 `json:"allowed_ips"`
|
||||||
ObservedIPs int64 `json:"observed_ips"`
|
ObservedIPs int64 `json:"observed_ips"`
|
||||||
RecentIPs []IPState `json:"recent_ips"`
|
ActivitySince time.Time `json:"activity_since,omitempty"`
|
||||||
RecentEvents []Event `json:"recent_events"`
|
TopIPsByEvents []TopIPRow `json:"top_ips_by_events"`
|
||||||
|
TopIPsByTraffic []TopIPRow `json:"top_ips_by_traffic"`
|
||||||
|
TopSources []TopSourceRow `json:"top_sources"`
|
||||||
|
TopURLs []TopURLRow `json:"top_urls"`
|
||||||
|
RecentIPs []IPState `json:"recent_ips"`
|
||||||
|
RecentEvents []Event `json:"recent_events"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,8 +91,15 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetOverview(ctx context.Context, limit int) (model.Overview, error) {
|
func (s *Service) GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error) {
|
||||||
return s.store.GetOverview(ctx, limit)
|
overview, err := s.store.GetOverview(ctx, since, limit)
|
||||||
|
if err != nil {
|
||||||
|
return model.Overview{}, err
|
||||||
|
}
|
||||||
|
if err := s.decorateOverviewTopIPs(ctx, &overview); err != nil {
|
||||||
|
return model.Overview{}, err
|
||||||
|
}
|
||||||
|
return overview, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
func (s *Service) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||||
@@ -665,3 +672,37 @@ func recentRowIPs(items []model.RecentIPRow) []string {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) decorateOverviewTopIPs(ctx context.Context, overview *model.Overview) error {
|
||||||
|
if overview == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ips := append(topIPRowIPs(overview.TopIPsByEvents), topIPRowIPs(overview.TopIPsByTraffic)...)
|
||||||
|
investigations, err := s.store.GetInvestigationsForIPs(ctx, ips)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for index := range overview.TopIPsByEvents {
|
||||||
|
if investigation, ok := investigations[overview.TopIPsByEvents[index].IP]; ok {
|
||||||
|
overview.TopIPsByEvents[index].Bot = investigation.Bot
|
||||||
|
} else {
|
||||||
|
s.enqueueInvestigation(overview.TopIPsByEvents[index].IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := range overview.TopIPsByTraffic {
|
||||||
|
if investigation, ok := investigations[overview.TopIPsByTraffic[index].IP]; ok {
|
||||||
|
overview.TopIPsByTraffic[index].Bot = investigation.Bot
|
||||||
|
} else {
|
||||||
|
s.enqueueInvestigation(overview.TopIPsByTraffic[index].IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func topIPRowIPs(items []model.TopIPRow) []string {
|
||||||
|
result := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
result = append(result, item.IP)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ sources:
|
|||||||
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.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()))
|
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 {
|
waitFor(t, 3*time.Second, func() bool {
|
||||||
overview, err := database.GetOverview(context.Background(), 10)
|
overview, err := database.GetOverview(context.Background(), time.Now().UTC().Add(-time.Hour), 10)
|
||||||
return err == nil && overview.TotalEvents == 3
|
return err == nil && overview.TotalEvents == 3
|
||||||
})
|
})
|
||||||
|
|
||||||
blockedState, found, err := database.GetIPState(context.Background(), "203.0.113.10")
|
blockedState, found, err := database.GetIPState(context.Background(), "203.0.113.10")
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ func (s *Store) RecordEvent(ctx context.Context, event *model.Event) error {
|
|||||||
return nil
|
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 {
|
func (s *Store) AddDecision(ctx context.Context, decision *model.DecisionRecord) error {
|
||||||
if decision == nil {
|
if decision == nil {
|
||||||
return errors.New("nil decision record")
|
return errors.New("nil decision record")
|
||||||
@@ -370,11 +372,14 @@ func (s *Store) ClearManualOverride(ctx context.Context, ip string, reason strin
|
|||||||
return current, nil
|
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 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
var overview model.Overview
|
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 {
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM events`).Scan(&overview.TotalEvents); err != nil {
|
||||||
return model.Overview{}, fmt.Errorf("count events: %w", err)
|
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 {
|
if err != nil {
|
||||||
return model.Overview{}, err
|
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.RecentIPs = recentIPs
|
||||||
overview.RecentEvents = recentEvents
|
overview.RecentEvents = recentEvents
|
||||||
|
overview.TopIPsByEvents = topIPsByEvents
|
||||||
|
overview.TopIPsByTraffic = topIPsByTraffic
|
||||||
|
overview.TopSources = topSources
|
||||||
|
overview.TopURLs = topURLs
|
||||||
return overview, nil
|
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) {
|
func (s *Store) ListRecentEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
|
|||||||
@@ -99,13 +99,22 @@ func TestStoreRecordsEventsAndState(t *testing.T) {
|
|||||||
t.Fatalf("unexpected source offset: found=%v offset=%+v", found, offset)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("get overview: %v", err)
|
t.Fatalf("get overview: %v", err)
|
||||||
}
|
}
|
||||||
if overview.TotalEvents != 1 || overview.TotalIPs != 1 {
|
if overview.TotalEvents != 1 || overview.TotalIPs != 1 {
|
||||||
t.Fatalf("unexpected overview counters: %+v", overview)
|
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)
|
recentIPs, err := db.ListRecentIPRows(ctx, occurredAt.Add(-time.Hour), 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list recent ip rows: %v", err)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App interface {
|
type App interface {
|
||||||
GetOverview(ctx context.Context, limit int) (model.Overview, error)
|
GetOverview(ctx context.Context, since time.Time, limit int) (model.Overview, error)
|
||||||
ListEvents(ctx context.Context, limit int) ([]model.Event, error)
|
ListEvents(ctx context.Context, limit int) ([]model.Event, error)
|
||||||
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error)
|
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error)
|
||||||
ListRecentIPs(ctx context.Context, since time.Time, limit int) ([]model.RecentIPRow, error)
|
ListRecentIPs(ctx context.Context, since time.Time, limit int) ([]model.RecentIPRow, error)
|
||||||
@@ -102,7 +102,12 @@ func (h *handler) handleAPIOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit := queryLimit(r, 50)
|
limit := queryLimit(r, 50)
|
||||||
overview, err := h.app.GetOverview(r.Context(), limit)
|
hours := queryInt(r, "hours", 24)
|
||||||
|
if hours <= 0 {
|
||||||
|
hours = 24
|
||||||
|
}
|
||||||
|
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||||
|
overview, err := h.app.GetOverview(r.Context(), since, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err)
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
@@ -355,6 +360,15 @@ const overviewHTML = `<!doctype html>
|
|||||||
.muted { color: #94a3b8; }
|
.muted { color: #94a3b8; }
|
||||||
.mono { font-family: ui-monospace, monospace; }
|
.mono { font-family: ui-monospace, monospace; }
|
||||||
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
|
.panel { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; overflow: auto; }
|
||||||
|
.leaders { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; }
|
||||||
|
.leader-card { background: #111827; border: 1px solid #334155; border-radius: .75rem; padding: 1rem; }
|
||||||
|
.leader-card h2 { margin-bottom: .35rem; font-size: 1rem; }
|
||||||
|
.leader-list { list-style: none; margin: .75rem 0 0 0; padding: 0; display: grid; gap: .65rem; }
|
||||||
|
.leader-item { display: grid; gap: .2rem; }
|
||||||
|
.leader-main { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||||
|
.leader-main .mono { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.leader-value { font-weight: 600; white-space: nowrap; }
|
||||||
|
.leader-sub { font-size: .87rem; color: #94a3b8; }
|
||||||
.toolbar { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: .75rem; }
|
.toolbar { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: .75rem; }
|
||||||
.toolbar .meta { font-size: .95rem; color: #94a3b8; }
|
.toolbar .meta { font-size: .95rem; color: #94a3b8; }
|
||||||
.toolbar-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; justify-content: flex-end; }
|
.toolbar-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
@@ -393,6 +407,7 @@ const overviewHTML = `<!doctype html>
|
|||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section class="stats" id="stats"></section>
|
<section class="stats" id="stats"></section>
|
||||||
|
<section class="leaders" id="leaderboards"></section>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<h2>Recent IPs</h2>
|
<h2>Recent IPs</h2>
|
||||||
@@ -460,6 +475,22 @@ const overviewHTML = `<!doctype html>
|
|||||||
].join('')).join('');
|
].join('')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
const bytes = Number(value || 0);
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
|
||||||
|
let current = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (current >= 1000 && unitIndex < units.length - 1) {
|
||||||
|
current /= 1000;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
const precision = current >= 100 || unitIndex === 0 ? 0 : 1;
|
||||||
|
return current.toFixed(precision) + ' ' + units[unitIndex];
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
return String(value ?? '').replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]));
|
||||||
}
|
}
|
||||||
@@ -606,6 +637,95 @@ const overviewHTML = `<!doctype html>
|
|||||||
return '<span class="bot-chip ' + escapeHtml(visual.className) + ' ' + statusClass + '" title="' + escapeHtml(title) + '">' + escapeHtml(visual.short) + '</span>';
|
return '<span class="bot-chip ' + escapeHtml(visual.className) + ' ' + statusClass + '" title="' + escapeHtml(title) + '">' + escapeHtml(visual.short) + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTopIPs(items, primaryMetric) {
|
||||||
|
const filteredItems = (Array.isArray(items) ? items : []).filter(item => showKnownBots || !item.bot);
|
||||||
|
if (filteredItems.length === 0) {
|
||||||
|
return '<div class="muted">No matching IP activity in the selected window.</div>';
|
||||||
|
}
|
||||||
|
return '<ol class="leader-list">' + filteredItems.map(item => {
|
||||||
|
const primaryValue = primaryMetric === 'traffic'
|
||||||
|
? formatBytes(item.traffic_bytes)
|
||||||
|
: String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's');
|
||||||
|
const secondaryValue = primaryMetric === 'traffic'
|
||||||
|
? String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')
|
||||||
|
: formatBytes(item.traffic_bytes);
|
||||||
|
return [
|
||||||
|
'<li class="leader-item">',
|
||||||
|
' <div class="leader-main">',
|
||||||
|
' <div class="ip-cell mono">' + renderBotChip(item.bot) + '<a href="/ips/' + encodeURIComponent(item.ip) + '">' + escapeHtml(item.ip) + '</a></div>',
|
||||||
|
' <span class="leader-value">' + escapeHtml(primaryValue) + '</span>',
|
||||||
|
' </div>',
|
||||||
|
' <div class="leader-sub">' + escapeHtml(secondaryValue) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
|
||||||
|
'</li>'
|
||||||
|
].join('');
|
||||||
|
}).join('') + '</ol>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTopSources(items) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return '<div class="muted">No source activity in the selected window.</div>';
|
||||||
|
}
|
||||||
|
return '<ol class="leader-list">' + items.map(item => [
|
||||||
|
'<li class="leader-item">',
|
||||||
|
' <div class="leader-main">',
|
||||||
|
' <span class="mono">' + escapeHtml(item.source_name || '—') + '</span>',
|
||||||
|
' <span class="leader-value">' + escapeHtml(String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')) + '</span>',
|
||||||
|
' </div>',
|
||||||
|
' <div class="leader-sub">' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
|
||||||
|
'</li>'
|
||||||
|
].join('')).join('') + '</ol>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTopURLs(items) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return '<div class="muted">No URL activity in the selected window.</div>';
|
||||||
|
}
|
||||||
|
return '<ol class="leader-list">' + items.map(item => {
|
||||||
|
const label = ((item.host || '') ? (item.host + item.uri) : (item.uri || '—'));
|
||||||
|
return [
|
||||||
|
'<li class="leader-item">',
|
||||||
|
' <div class="leader-main">',
|
||||||
|
' <span class="mono">' + escapeHtml(label) + '</span>',
|
||||||
|
' <span class="leader-value">' + escapeHtml(String(item.events || 0) + ' event' + (Number(item.events || 0) === 1 ? '' : 's')) + '</span>',
|
||||||
|
' </div>',
|
||||||
|
' <div class="leader-sub">' + escapeHtml(formatBytes(item.traffic_bytes)) + ' · last seen ' + escapeHtml(formatDate(item.last_seen_at)) + '</div>',
|
||||||
|
'</li>'
|
||||||
|
].join('');
|
||||||
|
}).join('') + '</ol>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLeaderboards(data) {
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: 'Top IPs by events',
|
||||||
|
subtitle: 'Last 24 hours',
|
||||||
|
body: renderTopIPs(data.top_ips_by_events, 'events'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Top IPs by traffic',
|
||||||
|
subtitle: 'Last 24 hours',
|
||||||
|
body: renderTopIPs(data.top_ips_by_traffic, 'traffic'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Top sources by events',
|
||||||
|
subtitle: 'Last 24 hours',
|
||||||
|
body: renderTopSources(data.top_sources),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Top URLs by events',
|
||||||
|
subtitle: 'Last 24 hours',
|
||||||
|
body: renderTopURLs(data.top_urls),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
document.getElementById('leaderboards').innerHTML = cards.map(card => [
|
||||||
|
'<section class="leader-card">',
|
||||||
|
' <h2>' + escapeHtml(card.title) + '</h2>',
|
||||||
|
' <div class="muted">' + escapeHtml(card.subtitle) + '</div>',
|
||||||
|
card.body,
|
||||||
|
'</section>'
|
||||||
|
].join('')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
function updateSortButtons() {
|
function updateSortButtons() {
|
||||||
const botsToggle = document.getElementById('show-bots-toggle');
|
const botsToggle = document.getElementById('show-bots-toggle');
|
||||||
if (botsToggle) {
|
if (botsToggle) {
|
||||||
@@ -717,6 +837,8 @@ const overviewHTML = `<!doctype html>
|
|||||||
showKnownBots = !toggle || toggle.checked;
|
showKnownBots = !toggle || toggle.checked;
|
||||||
saveShowKnownBotsPreference(showKnownBots);
|
saveShowKnownBotsPreference(showKnownBots);
|
||||||
render();
|
render();
|
||||||
|
const overviewStats = window.__overviewPayload || {};
|
||||||
|
renderLeaderboards(overviewStats);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAllowed() {
|
function toggleAllowed() {
|
||||||
@@ -753,13 +875,15 @@ const overviewHTML = `<!doctype html>
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const [overviewResponse, recentResponse] = await Promise.all([
|
const [overviewResponse, recentResponse] = await Promise.all([
|
||||||
fetch('/api/overview?limit=50'),
|
fetch('/api/overview?hours=' + recentHours + '&limit=10'),
|
||||||
fetch('/api/recent-ips?hours=' + recentHours + '&limit=250')
|
fetch('/api/recent-ips?hours=' + recentHours + '&limit=250')
|
||||||
]);
|
]);
|
||||||
const overviewPayload = await overviewResponse.json().catch(() => ({}));
|
const overviewPayload = await overviewResponse.json().catch(() => ({}));
|
||||||
const recentPayload = await recentResponse.json().catch(() => []);
|
const recentPayload = await recentResponse.json().catch(() => []);
|
||||||
if (overviewResponse.ok) {
|
if (overviewResponse.ok) {
|
||||||
|
window.__overviewPayload = overviewPayload || {};
|
||||||
renderStats(overviewPayload || {});
|
renderStats(overviewPayload || {});
|
||||||
|
renderLeaderboards(overviewPayload || {});
|
||||||
}
|
}
|
||||||
if (!recentResponse.ok) {
|
if (!recentResponse.ok) {
|
||||||
const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText);
|
const message = Array.isArray(recentPayload) ? recentResponse.statusText : (recentPayload.error || recentResponse.statusText);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
recorder = httptest.NewRecorder()
|
recorder = httptest.NewRecorder()
|
||||||
request = httptest.NewRequest(http.MethodGet, "/api/overview?limit=10", nil)
|
request = httptest.NewRequest(http.MethodGet, "/api/overview?hours=24&limit=10", nil)
|
||||||
handler.ServeHTTP(recorder, request)
|
handler.ServeHTTP(recorder, request)
|
||||||
if recorder.Code != http.StatusOK {
|
if recorder.Code != http.StatusOK {
|
||||||
t.Fatalf("unexpected overview status: %d", recorder.Code)
|
t.Fatalf("unexpected overview status: %d", recorder.Code)
|
||||||
@@ -42,7 +42,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
|||||||
if err := json.Unmarshal(recorder.Body.Bytes(), &overview); err != nil {
|
if err := json.Unmarshal(recorder.Body.Bytes(), &overview); err != nil {
|
||||||
t.Fatalf("decode overview payload: %v", err)
|
t.Fatalf("decode overview payload: %v", err)
|
||||||
}
|
}
|
||||||
if overview.TotalEvents != 1 || len(overview.RecentIPs) != 1 {
|
if overview.TotalEvents != 1 || len(overview.RecentIPs) != 1 || len(overview.TopIPsByEvents) != 1 {
|
||||||
t.Fatalf("unexpected overview payload: %+v", overview)
|
t.Fatalf("unexpected overview payload: %+v", overview)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +80,18 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
|||||||
if !strings.Contains(recorder.Body.String(), "Show known bots") {
|
if !strings.Contains(recorder.Body.String(), "Show known bots") {
|
||||||
t.Fatalf("overview page should expose the known bots toggle")
|
t.Fatalf("overview page should expose the known bots toggle")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "Top IPs by events") {
|
||||||
|
t.Fatalf("overview page should expose the top IPs by events block")
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "Top IPs by traffic") {
|
||||||
|
t.Fatalf("overview page should expose the top IPs by traffic block")
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "Top sources by events") {
|
||||||
|
t.Fatalf("overview page should expose the top sources block")
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "Top URLs by events") {
|
||||||
|
t.Fatalf("overview page should expose the top URLs block")
|
||||||
|
}
|
||||||
if !strings.Contains(recorder.Body.String(), "Show allowed") {
|
if !strings.Contains(recorder.Body.String(), "Show allowed") {
|
||||||
t.Fatalf("overview page should expose the allowed toggle")
|
t.Fatalf("overview page should expose the allowed toggle")
|
||||||
}
|
}
|
||||||
@@ -121,12 +133,37 @@ type stubApp struct {
|
|||||||
lastAction string
|
lastAction string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubApp) GetOverview(context.Context, int) (model.Overview, error) {
|
func (s *stubApp) GetOverview(context.Context, time.Time, int) (model.Overview, error) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
return model.Overview{
|
return model.Overview{
|
||||||
TotalEvents: 1,
|
TotalEvents: 1,
|
||||||
TotalIPs: 1,
|
TotalIPs: 1,
|
||||||
BlockedIPs: 1,
|
BlockedIPs: 1,
|
||||||
|
TopIPsByEvents: []model.TopIPRow{{
|
||||||
|
IP: "203.0.113.10",
|
||||||
|
Events: 3,
|
||||||
|
TrafficBytes: 4096,
|
||||||
|
LastSeenAt: now,
|
||||||
|
}},
|
||||||
|
TopIPsByTraffic: []model.TopIPRow{{
|
||||||
|
IP: "203.0.113.10",
|
||||||
|
Events: 3,
|
||||||
|
TrafficBytes: 4096,
|
||||||
|
LastSeenAt: now,
|
||||||
|
}},
|
||||||
|
TopSources: []model.TopSourceRow{{
|
||||||
|
SourceName: "main",
|
||||||
|
Events: 3,
|
||||||
|
TrafficBytes: 4096,
|
||||||
|
LastSeenAt: now,
|
||||||
|
}},
|
||||||
|
TopURLs: []model.TopURLRow{{
|
||||||
|
Host: "example.test",
|
||||||
|
URI: "/wp-login.php",
|
||||||
|
Events: 3,
|
||||||
|
TrafficBytes: 4096,
|
||||||
|
LastSeenAt: now,
|
||||||
|
}},
|
||||||
RecentIPs: []model.IPState{{
|
RecentIPs: []model.IPState{{
|
||||||
IP: "203.0.113.10",
|
IP: "203.0.113.10",
|
||||||
State: model.IPStateBlocked,
|
State: model.IPStateBlocked,
|
||||||
@@ -146,12 +183,12 @@ func (s *stubApp) GetOverview(context.Context, int) (model.Overview, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||||
overview, _ := s.GetOverview(ctx, limit)
|
overview, _ := s.GetOverview(ctx, time.Time{}, limit)
|
||||||
return overview.RecentEvents, nil
|
return overview.RecentEvents, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubApp) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) {
|
func (s *stubApp) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) {
|
||||||
overview, _ := s.GetOverview(ctx, limit)
|
overview, _ := s.GetOverview(ctx, time.Time{}, limit)
|
||||||
return overview.RecentIPs, nil
|
return overview.RecentIPs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user