You've already forked caddy-opnsense-blocker
Build a Pi-hole-style dashboard and query log
This commit is contained in:
@@ -54,26 +54,28 @@ func (d Decision) PrimaryReason() string {
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceName string `json:"source_name"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RemoteIP string `json:"remote_ip"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Host string `json:"host"`
|
||||
Method string `json:"method"`
|
||||
URI string `json:"uri"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Decision DecisionAction `json:"decision"`
|
||||
DecisionReason string `json:"decision_reason"`
|
||||
DecisionReasons []string `json:"decision_reasons,omitempty"`
|
||||
Enforced bool `json:"enforced"`
|
||||
RawJSON string `json:"raw_json"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CurrentState IPStateStatus `json:"current_state"`
|
||||
ManualOverride ManualOverride `json:"manual_override"`
|
||||
ID int64 `json:"id"`
|
||||
SourceName string `json:"source_name"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RemoteIP string `json:"remote_ip"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Host string `json:"host"`
|
||||
Method string `json:"method"`
|
||||
URI string `json:"uri"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Decision DecisionAction `json:"decision"`
|
||||
DecisionReason string `json:"decision_reason"`
|
||||
DecisionReasons []string `json:"decision_reasons,omitempty"`
|
||||
Enforced bool `json:"enforced"`
|
||||
RawJSON string `json:"raw_json"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CurrentState IPStateStatus `json:"current_state"`
|
||||
ManualOverride ManualOverride `json:"manual_override"`
|
||||
Bot *BotMatch `json:"bot,omitempty"`
|
||||
Actions ActionAvailability `json:"actions"`
|
||||
}
|
||||
|
||||
type IPState struct {
|
||||
@@ -179,11 +181,11 @@ type RecentIPRow struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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 {
|
||||
@@ -201,11 +203,39 @@ type TopURLRow struct {
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type ActivitySourceCount struct {
|
||||
SourceName string `json:"source_name"`
|
||||
Events int64 `json:"events"`
|
||||
}
|
||||
|
||||
type ActivityBucket struct {
|
||||
BucketStart time.Time `json:"bucket_start"`
|
||||
TotalEvents int64 `json:"total_events"`
|
||||
Sources []ActivitySourceCount `json:"sources,omitempty"`
|
||||
}
|
||||
|
||||
type MethodBreakdownRow struct {
|
||||
Method string `json:"method"`
|
||||
Events int64 `json:"events"`
|
||||
}
|
||||
|
||||
type BotBreakdownRow struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Events int64 `json:"events"`
|
||||
}
|
||||
|
||||
type OverviewOptions struct {
|
||||
ShowKnownBots bool
|
||||
ShowAllowed bool
|
||||
}
|
||||
|
||||
type EventListOptions struct {
|
||||
ShowKnownBots bool
|
||||
ShowAllowed bool
|
||||
ReviewOnly bool
|
||||
}
|
||||
|
||||
type SourceOffset struct {
|
||||
SourceName string
|
||||
Path string
|
||||
@@ -225,17 +255,20 @@ type IPDetails struct {
|
||||
}
|
||||
|
||||
type Overview struct {
|
||||
TotalEvents int64 `json:"total_events"`
|
||||
TotalIPs int64 `json:"total_ips"`
|
||||
BlockedIPs int64 `json:"blocked_ips"`
|
||||
ReviewIPs int64 `json:"review_ips"`
|
||||
AllowedIPs int64 `json:"allowed_ips"`
|
||||
ObservedIPs int64 `json:"observed_ips"`
|
||||
ActivitySince time.Time `json:"activity_since,omitempty"`
|
||||
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"`
|
||||
TotalEvents int64 `json:"total_events"`
|
||||
TotalIPs int64 `json:"total_ips"`
|
||||
BlockedIPs int64 `json:"blocked_ips"`
|
||||
ReviewIPs int64 `json:"review_ips"`
|
||||
AllowedIPs int64 `json:"allowed_ips"`
|
||||
ObservedIPs int64 `json:"observed_ips"`
|
||||
ActivitySince time.Time `json:"activity_since,omitempty"`
|
||||
ActivityBuckets []ActivityBucket `json:"activity_buckets"`
|
||||
Methods []MethodBreakdownRow `json:"methods"`
|
||||
Bots []BotBreakdownRow `json:"bots"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -102,8 +102,15 @@ func (s *Service) GetOverview(ctx context.Context, since time.Time, limit int, o
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||
return s.store.ListRecentEvents(ctx, limit)
|
||||
func (s *Service) ListEvents(ctx context.Context, since time.Time, limit int, options model.EventListOptions) ([]model.Event, error) {
|
||||
items, err := s.store.ListEvents(ctx, since, limit, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.decorateEvents(ctx, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error) {
|
||||
@@ -673,6 +680,47 @@ func recentRowIPs(items []model.RecentIPRow) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func eventIPs(items []model.Event) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, item.ClientIP)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) decorateEvents(ctx context.Context, items []model.Event) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
investigations, err := s.store.GetInvestigationsForIPs(ctx, eventIPs(items))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actionsByIP := make(map[string]model.ActionAvailability, len(items))
|
||||
for index := range items {
|
||||
ip := items[index].ClientIP
|
||||
if investigation, ok := investigations[ip]; ok {
|
||||
items[index].Bot = investigation.Bot
|
||||
} else {
|
||||
s.enqueueInvestigation(ip)
|
||||
}
|
||||
if _, ok := actionsByIP[ip]; !ok {
|
||||
state := model.IPState{
|
||||
IP: ip,
|
||||
State: items[index].CurrentState,
|
||||
ManualOverride: items[index].ManualOverride,
|
||||
}
|
||||
if state.State == "" {
|
||||
state.State = model.IPStateObserved
|
||||
}
|
||||
backend := s.resolveOPNsenseStatus(ctx, state)
|
||||
actionsByIP[ip] = actionAvailability(state, backend)
|
||||
}
|
||||
items[index].Actions = actionsByIP[ip]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) decorateOverviewTopIPs(ctx context.Context, overview *model.Overview) error {
|
||||
if overview == nil {
|
||||
return nil
|
||||
|
||||
@@ -225,19 +225,38 @@ func (s *Store) RecordEvent(ctx context.Context, event *model.Event) error {
|
||||
|
||||
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 knownBotExistsClause(ipExpression string) string {
|
||||
return `EXISTS (
|
||||
SELECT 1
|
||||
FROM ip_investigations i
|
||||
WHERE i.ip = ` + ipExpression + `
|
||||
AND json_valid(i.payload_json)
|
||||
AND json_type(i.payload_json, '$.bot') IS NOT NULL
|
||||
AND COALESCE(json_extract(i.payload_json, '$.bot.verified'), 0) = 1
|
||||
)`
|
||||
}
|
||||
|
||||
func overviewFilterQueryParts(options model.OverviewOptions) (joins []string, clauses []string) {
|
||||
if !options.ShowAllowed {
|
||||
joins = append(joins, `LEFT JOIN ip_state s ON s.ip = e.client_ip`)
|
||||
clauses = append(clauses, `COALESCE(s.state, '') <> '`+string(model.IPStateAllowed)+`'`)
|
||||
}
|
||||
if !options.ShowKnownBots {
|
||||
clauses = append(clauses, `NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ip_investigations i
|
||||
WHERE i.ip = e.client_ip
|
||||
AND json_valid(i.payload_json)
|
||||
AND json_type(i.payload_json, '$.bot') IS NOT NULL
|
||||
)`)
|
||||
clauses = append(clauses, `NOT `+knownBotExistsClause(`e.client_ip`))
|
||||
}
|
||||
return joins, clauses
|
||||
}
|
||||
|
||||
func eventFilterQueryParts(options model.EventListOptions) (joins []string, clauses []string) {
|
||||
joins = append(joins, `LEFT JOIN ip_state s ON s.ip = e.client_ip`)
|
||||
if !options.ShowAllowed {
|
||||
clauses = append(clauses, `COALESCE(s.state, '') <> '`+string(model.IPStateAllowed)+`'`)
|
||||
}
|
||||
if options.ReviewOnly {
|
||||
clauses = append(clauses, `COALESCE(s.state, '') = '`+string(model.IPStateReview)+`'`)
|
||||
}
|
||||
if !options.ShowKnownBots {
|
||||
clauses = append(clauses, `NOT `+knownBotExistsClause(`e.client_ip`))
|
||||
}
|
||||
return joins, clauses
|
||||
}
|
||||
@@ -440,8 +459,23 @@ func (s *Store) GetOverview(ctx context.Context, since time.Time, limit int, opt
|
||||
if err != nil {
|
||||
return model.Overview{}, err
|
||||
}
|
||||
activityBuckets, err := s.listActivityBuckets(ctx, since, options)
|
||||
if err != nil {
|
||||
return model.Overview{}, err
|
||||
}
|
||||
methods, err := s.listMethodBreakdown(ctx, since, options)
|
||||
if err != nil {
|
||||
return model.Overview{}, err
|
||||
}
|
||||
bots, err := s.listBotBreakdown(ctx, since, options)
|
||||
if err != nil {
|
||||
return model.Overview{}, err
|
||||
}
|
||||
overview.RecentIPs = recentIPs
|
||||
overview.RecentEvents = recentEvents
|
||||
overview.ActivityBuckets = activityBuckets
|
||||
overview.Methods = methods
|
||||
overview.Bots = bots
|
||||
overview.TopIPsByEvents = topIPsByEvents
|
||||
overview.TopIPsByTraffic = topIPsByTraffic
|
||||
overview.TopSources = topSources
|
||||
@@ -610,6 +644,200 @@ func (s *Store) listTopURLRows(ctx context.Context, since time.Time, limit int,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) listActivityBuckets(ctx context.Context, since time.Time, options model.OverviewOptions) ([]model.ActivityBucket, error) {
|
||||
if since.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
joins, clauses := overviewFilterQueryParts(options)
|
||||
query := `
|
||||
SELECT (CAST(strftime('%s', e.occurred_at) AS INTEGER) / 600) * 600 AS bucket_unix,
|
||||
e.source_name,
|
||||
COUNT(*) AS event_count
|
||||
FROM events e`
|
||||
if len(joins) > 0 {
|
||||
query += ` ` + strings.Join(joins, ` `)
|
||||
}
|
||||
args := []any{formatTime(since)}
|
||||
clauses = append([]string{`e.occurred_at >= ?`}, clauses...)
|
||||
if len(clauses) > 0 {
|
||||
query += ` WHERE ` + strings.Join(clauses, ` AND `)
|
||||
}
|
||||
query += ` GROUP BY bucket_unix, e.source_name ORDER BY bucket_unix ASC, event_count DESC, e.source_name ASC`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list activity buckets: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type bucketKey int64
|
||||
bucketMap := map[bucketKey]*model.ActivityBucket{}
|
||||
for rows.Next() {
|
||||
var bucketUnix int64
|
||||
var sourceName string
|
||||
var events int64
|
||||
if err := rows.Scan(&bucketUnix, &sourceName, &events); err != nil {
|
||||
return nil, fmt.Errorf("scan activity bucket: %w", err)
|
||||
}
|
||||
key := bucketKey(bucketUnix)
|
||||
bucket, ok := bucketMap[key]
|
||||
if !ok {
|
||||
bucket = &model.ActivityBucket{BucketStart: time.Unix(bucketUnix, 0).UTC()}
|
||||
bucketMap[key] = bucket
|
||||
}
|
||||
bucket.TotalEvents += events
|
||||
bucket.Sources = append(bucket.Sources, model.ActivitySourceCount{SourceName: sourceName, Events: events})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate activity buckets: %w", err)
|
||||
}
|
||||
|
||||
start := since.UTC().Truncate(10 * time.Minute)
|
||||
end := time.Now().UTC().Truncate(10 * time.Minute)
|
||||
buckets := make([]model.ActivityBucket, 0, int(end.Sub(start)/(10*time.Minute))+1)
|
||||
for current := start; !current.After(end); current = current.Add(10 * time.Minute) {
|
||||
bucket, ok := bucketMap[bucketKey(current.Unix())]
|
||||
if !ok {
|
||||
buckets = append(buckets, model.ActivityBucket{BucketStart: current})
|
||||
continue
|
||||
}
|
||||
sort.Slice(bucket.Sources, func(left int, right int) bool {
|
||||
if bucket.Sources[left].Events != bucket.Sources[right].Events {
|
||||
return bucket.Sources[left].Events > bucket.Sources[right].Events
|
||||
}
|
||||
return bucket.Sources[left].SourceName < bucket.Sources[right].SourceName
|
||||
})
|
||||
buckets = append(buckets, *bucket)
|
||||
}
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
func (s *Store) listMethodBreakdown(ctx context.Context, since time.Time, options model.OverviewOptions) ([]model.MethodBreakdownRow, error) {
|
||||
joins, clauses := overviewFilterQueryParts(options)
|
||||
query := `
|
||||
SELECT COALESCE(NULLIF(UPPER(TRIM(e.method)), ''), 'OTHER') AS method,
|
||||
COUNT(*) AS event_count
|
||||
FROM events e`
|
||||
if len(joins) > 0 {
|
||||
query += ` ` + strings.Join(joins, ` `)
|
||||
}
|
||||
args := make([]any, 0, 2)
|
||||
if !since.IsZero() {
|
||||
clauses = append([]string{`e.occurred_at >= ?`}, clauses...)
|
||||
args = append(args, formatTime(since))
|
||||
}
|
||||
if len(clauses) > 0 {
|
||||
query += ` WHERE ` + strings.Join(clauses, ` AND `)
|
||||
}
|
||||
query += ` GROUP BY method ORDER BY event_count DESC, method ASC`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list method breakdown: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]model.MethodBreakdownRow, 0, 8)
|
||||
for rows.Next() {
|
||||
var item model.MethodBreakdownRow
|
||||
if err := rows.Scan(&item.Method, &item.Events); err != nil {
|
||||
return nil, fmt.Errorf("scan method breakdown row: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate method breakdown rows: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) listBotBreakdown(ctx context.Context, since time.Time, options model.OverviewOptions) ([]model.BotBreakdownRow, error) {
|
||||
joins, clauses := overviewFilterQueryParts(options)
|
||||
joins = append(joins, `LEFT JOIN ip_investigations i ON i.ip = e.client_ip`)
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN json_valid(i.payload_json)
|
||||
AND json_type(i.payload_json, '$.bot') IS NOT NULL
|
||||
AND COALESCE(json_extract(i.payload_json, '$.bot.verified'), 0) = 1
|
||||
THEN 1 ELSE 0 END), 0) AS known_bots,
|
||||
COALESCE(SUM(CASE WHEN json_valid(i.payload_json)
|
||||
AND json_type(i.payload_json, '$.bot') IS NOT NULL
|
||||
AND COALESCE(json_extract(i.payload_json, '$.bot.verified'), 0) <> 1
|
||||
THEN 1 ELSE 0 END), 0) AS possible_bots,
|
||||
COALESCE(SUM(CASE WHEN NOT json_valid(i.payload_json)
|
||||
OR json_type(i.payload_json, '$.bot') IS NULL
|
||||
THEN 1 ELSE 0 END), 0) AS other_traffic
|
||||
FROM events e`
|
||||
if len(joins) > 0 {
|
||||
query += ` ` + strings.Join(joins, ` `)
|
||||
}
|
||||
args := make([]any, 0, 2)
|
||||
if !since.IsZero() {
|
||||
clauses = append([]string{`e.occurred_at >= ?`}, clauses...)
|
||||
args = append(args, formatTime(since))
|
||||
}
|
||||
if len(clauses) > 0 {
|
||||
query += ` WHERE ` + strings.Join(clauses, ` AND `)
|
||||
}
|
||||
|
||||
var knownBots int64
|
||||
var possibleBots int64
|
||||
var otherTraffic int64
|
||||
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&knownBots, &possibleBots, &otherTraffic); err != nil {
|
||||
return nil, fmt.Errorf("list bot breakdown: %w", err)
|
||||
}
|
||||
return []model.BotBreakdownRow{
|
||||
{Key: "known", Label: "Known bots", Events: knownBots},
|
||||
{Key: "possible", Label: "Possible bots", Events: possibleBots},
|
||||
{Key: "other", Label: "Other traffic", Events: otherTraffic},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListEvents(ctx context.Context, since time.Time, limit int, options model.EventListOptions) ([]model.Event, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
joins, clauses := eventFilterQueryParts(options)
|
||||
query := `
|
||||
SELECT e.id, e.source_name, e.profile_name, e.occurred_at, e.remote_ip, e.client_ip, e.host,
|
||||
e.method, e.uri, e.path, e.status, e.user_agent, e.decision, e.decision_reason,
|
||||
e.decision_reasons_json, e.enforced, e.raw_json, e.created_at,
|
||||
COALESCE(s.state, ''), COALESCE(s.manual_override, '')
|
||||
FROM events e`
|
||||
if len(joins) > 0 {
|
||||
query += ` ` + strings.Join(joins, ` `)
|
||||
}
|
||||
args := make([]any, 0, 2)
|
||||
if !since.IsZero() {
|
||||
clauses = append([]string{`e.occurred_at >= ?`}, clauses...)
|
||||
args = append(args, formatTime(since))
|
||||
}
|
||||
if len(clauses) > 0 {
|
||||
query += ` WHERE ` + strings.Join(clauses, ` AND `)
|
||||
}
|
||||
query += ` ORDER BY e.occurred_at DESC, e.id DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]model.Event, 0, limit)
|
||||
for rows.Next() {
|
||||
item, err := scanEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate events: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListRecentEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,16 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
||||
t.Fatalf("overview filter options were not forwarded correctly: %+v", app.lastOverviewOptions)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/events?hours=24&limit=250&show_known_bots=false&show_allowed=false&review_only=true", nil)
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected filtered events status: %d", recorder.Code)
|
||||
}
|
||||
if app.lastEventOptions.ShowKnownBots || app.lastEventOptions.ShowAllowed || !app.lastEventOptions.ReviewOnly {
|
||||
t.Fatalf("event filter options were not forwarded correctly: %+v", app.lastEventOptions)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/ips/203.0.113.10/block", strings.NewReader(`{"reason":"test reason","actor":"tester"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
@@ -84,12 +94,21 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
||||
if !strings.Contains(recorder.Body.String(), "Local-only review and enforcement console") {
|
||||
t.Fatalf("overview page did not render expected content")
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "Recent events") {
|
||||
t.Fatalf("overview page should no longer render recent events block")
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Show known bots") {
|
||||
t.Fatalf("overview page should expose the known bots toggle")
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Query Log") {
|
||||
t.Fatalf("overview page should link to the query log")
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Activity") {
|
||||
t.Fatalf("overview page should expose the activity chart")
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Methods") {
|
||||
t.Fatalf("overview page should expose the methods chart")
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Bots") {
|
||||
t.Fatalf("overview page should expose the bots chart")
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Top IPs by events") {
|
||||
t.Fatalf("overview page should expose the top IPs by events block")
|
||||
}
|
||||
@@ -105,7 +124,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
||||
if !strings.Contains(recorder.Body.String(), "Loading…") {
|
||||
t.Fatalf("overview page should render stable loading placeholders")
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "These two filters affect both the leaderboards and the Recent IPs list") {
|
||||
if !strings.Contains(recorder.Body.String(), "These filters affect all dashboard charts and top lists") {
|
||||
t.Fatalf("overview page should explain the scope of the shared filters")
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "position: sticky") {
|
||||
@@ -114,13 +133,30 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
||||
if !strings.Contains(recorder.Body.String(), "Show allowed") {
|
||||
t.Fatalf("overview page should expose the allowed toggle")
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "Review only") {
|
||||
t.Fatalf("overview page should expose the review-only toggle")
|
||||
if strings.Contains(recorder.Body.String(), "Review only") {
|
||||
t.Fatalf("overview page should not expose the review-only toggle anymore")
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "localStorage") {
|
||||
t.Fatalf("overview page should persist preferences in localStorage")
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/queries", nil)
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected query log page status: %d", recorder.Code)
|
||||
}
|
||||
queryLogBody := recorder.Body.String()
|
||||
if !strings.Contains(queryLogBody, "Review only") {
|
||||
t.Fatalf("query log page should expose the review-only toggle")
|
||||
}
|
||||
if !strings.Contains(queryLogBody, "These filters affect the full Query Log") {
|
||||
t.Fatalf("query log page should explain its filters")
|
||||
}
|
||||
if !strings.Contains(queryLogBody, "Request") {
|
||||
t.Fatalf("query log page should render the request table")
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/ips/203.0.113.10", nil)
|
||||
handler.ServeHTTP(recorder, request)
|
||||
@@ -154,6 +190,7 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
||||
type stubApp struct {
|
||||
lastAction string
|
||||
lastOverviewOptions model.OverviewOptions
|
||||
lastEventOptions model.EventListOptions
|
||||
}
|
||||
|
||||
func (s *stubApp) GetOverview(_ context.Context, _ time.Time, _ int, options model.OverviewOptions) (model.Overview, error) {
|
||||
@@ -163,17 +200,29 @@ func (s *stubApp) GetOverview(_ context.Context, _ time.Time, _ int, options mod
|
||||
TotalEvents: 1,
|
||||
TotalIPs: 1,
|
||||
BlockedIPs: 1,
|
||||
ActivityBuckets: []model.ActivityBucket{{
|
||||
BucketStart: now.Add(-10 * time.Minute),
|
||||
TotalEvents: 1,
|
||||
Sources: []model.ActivitySourceCount{{
|
||||
SourceName: "main",
|
||||
Events: 1,
|
||||
}},
|
||||
}},
|
||||
Methods: []model.MethodBreakdownRow{{Method: "GET", Events: 1}},
|
||||
Bots: []model.BotBreakdownRow{{Key: "non_bot", Label: "Non-bot", Events: 1}},
|
||||
TopIPsByEvents: []model.TopIPRow{{
|
||||
IP: "203.0.113.10",
|
||||
Events: 3,
|
||||
TrafficBytes: 4096,
|
||||
LastSeenAt: now,
|
||||
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true},
|
||||
}},
|
||||
TopIPsByTraffic: []model.TopIPRow{{
|
||||
IP: "203.0.113.10",
|
||||
Events: 3,
|
||||
TrafficBytes: 4096,
|
||||
LastSeenAt: now,
|
||||
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true},
|
||||
}},
|
||||
TopSources: []model.TopSourceRow{{
|
||||
SourceName: "main",
|
||||
@@ -196,17 +245,25 @@ func (s *stubApp) GetOverview(_ context.Context, _ time.Time, _ int, options mod
|
||||
LastSeenAt: now,
|
||||
}},
|
||||
RecentEvents: []model.Event{{
|
||||
ID: 1,
|
||||
SourceName: "main",
|
||||
ClientIP: "203.0.113.10",
|
||||
OccurredAt: now,
|
||||
Decision: model.DecisionActionBlock,
|
||||
CurrentState: model.IPStateBlocked,
|
||||
ID: 1,
|
||||
SourceName: "main",
|
||||
ClientIP: "203.0.113.10",
|
||||
OccurredAt: now,
|
||||
Method: http.MethodGet,
|
||||
URI: "/wp-login.php",
|
||||
Host: "example.test",
|
||||
Status: http.StatusNotFound,
|
||||
Decision: model.DecisionActionBlock,
|
||||
CurrentState: model.IPStateBlocked,
|
||||
DecisionReason: "php_path",
|
||||
Actions: model.ActionAvailability{CanUnblock: true},
|
||||
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Verified: true},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubApp) ListEvents(ctx context.Context, limit int) ([]model.Event, error) {
|
||||
func (s *stubApp) ListEvents(ctx context.Context, _ time.Time, limit int, options model.EventListOptions) ([]model.Event, error) {
|
||||
s.lastEventOptions = options
|
||||
overview, _ := s.GetOverview(ctx, time.Time{}, limit, model.OverviewOptions{ShowKnownBots: true, ShowAllowed: true})
|
||||
return overview.RecentEvents, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user