diff --git a/internal/model/types.go b/internal/model/types.go
index 6005f85..102dc97 100644
--- a/internal/model/types.go
+++ b/internal/model/types.go
@@ -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"`
}
diff --git a/internal/service/service.go b/internal/service/service.go
index 4e3c814..0281878 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -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
diff --git a/internal/store/store.go b/internal/store/store.go
index b8e2c01..3036ae6 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -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
diff --git a/internal/web/handler.go b/internal/web/handler.go
index b9bc8bf..88c5a25 100644
--- a/internal/web/handler.go
+++ b/internal/web/handler.go
@@ -18,7 +18,7 @@ import (
type App interface {
GetOverview(ctx context.Context, since time.Time, limit int, options model.OverviewOptions) (model.Overview, error)
- ListEvents(ctx context.Context, limit int) ([]model.Event, error)
+ ListEvents(ctx context.Context, since time.Time, limit int, options model.EventListOptions) ([]model.Event, error)
ListIPs(ctx context.Context, limit int, state string) ([]model.IPState, error)
ListRecentIPs(ctx context.Context, since time.Time, limit int) ([]model.RecentIPRow, error)
GetIPDetails(ctx context.Context, ip string) (model.IPDetails, error)
@@ -31,6 +31,7 @@ type App interface {
type handler struct {
app App
overviewPage *template.Template
+ queryLogPage *template.Template
ipDetailsPage *template.Template
}
@@ -48,11 +49,13 @@ func NewHandler(app App) http.Handler {
h := &handler{
app: app,
overviewPage: template.Must(template.New("overview").Parse(overviewHTML)),
+ queryLogPage: template.Must(template.New("query-log").Parse(queryLogHTML)),
ipDetailsPage: template.Must(template.New("ip-details").Parse(ipDetailsHTML)),
}
mux := http.NewServeMux()
mux.HandleFunc("/", h.handleOverviewPage)
+ mux.HandleFunc("/queries", h.handleQueryLogPage)
mux.HandleFunc("/healthz", h.handleHealth)
mux.HandleFunc("/ips/", h.handleIPPage)
mux.HandleFunc("/api/overview", h.handleAPIOverview)
@@ -75,6 +78,18 @@ func (h *handler) handleOverviewPage(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, h.overviewPage, pageData{Title: "Caddy OPNsense Blocker"})
}
+func (h *handler) handleQueryLogPage(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/queries" {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Method != http.MethodGet {
+ methodNotAllowed(w)
+ return
+ }
+ renderTemplate(w, h.queryLogPage, pageData{Title: "Query Log"})
+}
+
func (h *handler) handleIPPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
@@ -125,7 +140,17 @@ func (h *handler) handleAPIEvents(w http.ResponseWriter, r *http.Request) {
return
}
limit := queryLimit(r, 100)
- events, err := h.app.ListEvents(r.Context(), limit)
+ hours := queryInt(r, "hours", 24)
+ if hours <= 0 {
+ hours = 24
+ }
+ since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
+ options := model.EventListOptions{
+ ShowKnownBots: queryBool(r, "show_known_bots", true),
+ ShowAllowed: queryBool(r, "show_allowed", true),
+ ReviewOnly: queryBool(r, "review_only", false),
+ }
+ events, err := h.app.ListEvents(r.Context(), since, limit, options)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
@@ -359,55 +384,65 @@ const overviewHTML = `
{{ .Title }}
+
+
+
+
+
Activity
+
Requests per 10-minute bucket
+
+
+
+ Loading activity…
+
+
+
+
+
Methods
+
Last 24 hours
+
+
+ Loading methods…
+
+
+
+ Loading bot distribution…
+
@@ -496,69 +569,33 @@ const overviewHTML = `
-
-
-
-
-
- |
- |
- |
- |
- |
- |
- Actions |
-
-
- | Loading recent IPs… |
-
-
+
+