package collector import ( "encoding/json" "log" "net/http" "net/netip" "strings" "time" "git.tornberg.me/go-otel/internal/features" "git.tornberg.me/go-otel/internal/storage" "git.tornberg.me/go-otel/internal/triage" "git.tornberg.me/go-otel/internal/uaclass" ) // SetSourceIPQuerier enables the source address endpoints. func (c *Collector) SetSourceIPQuerier(q storage.SourceIPQuerier) { c.sourceIPs = q } func (c *Collector) sourceIPsEnabled(w http.ResponseWriter) bool { if c.sourceIPs == nil { statsNotEnabled(w) return false } return true } // HandleSourceIPs lists every unique source address ever recorded. // Query: q (substring), hours (only addresses seen within), limit. func (c *Collector) HandleSourceIPs(w http.ResponseWriter, r *http.Request) { if !c.sourceIPsEnabled(w) { return } var since time.Time if _, hours := parseStatsCommon(r); r.URL.Query().Get("hours") != "" { since = time.Now().Add(-time.Duration(hours) * time.Hour) } ips, err := c.sourceIPs.SourceIPs(r.Context(), strings.TrimSpace(r.URL.Query().Get("q")), since, parseLimit(r, 200)) if err != nil { log.Printf("failed to list source ips: %v", err) http.Error(w, "failed to list source ips", http.StatusInternalServerError) return } // Judge scanning behaviour over the last week for the whole page in one // query. A failure only drops the hints; the list itself is still useful. addrs := make([]string, len(ips)) for i := range ips { addrs[i] = ips[i].IP } if routes, err := c.sourceIPs.SourceIPRoutes(r.Context(), addrs, time.Now().Add(-7*24*time.Hour)); err != nil { log.Printf("failed to assess source ips: %v", err) } else { for i := range ips { ips[i].BadActor, ips[i].ProbePaths = assessSourceIP(ips[i].IP, routes[ips[i].IP]) } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"ips": ips}) } // HandleSourceIPActivity lists the recent requests made from one address. // Query: hours (default 168), limit. func (c *Collector) HandleSourceIPActivity(w http.ResponseWriter, r *http.Request) { if !c.sourceIPsEnabled(w) { return } addr, err := netip.ParseAddr(r.PathValue("ip")) if err != nil { http.Error(w, "invalid ip", http.StatusBadRequest) return } _, hours := parseStatsCommon(r) if r.URL.Query().Get("hours") == "" { hours = 24 * 7 } events, err := c.sourceIPs.SourceIPActivity(r.Context(), addr.String(), time.Now().Add(-time.Duration(hours)*time.Hour), parseLimit(r, 200)) if err != nil { log.Printf("failed to load source ip activity: %v", err) http.Error(w, "failed to load source ip activity", http.StatusInternalServerError) return } reason, probes := assessSourceIP(addr.String(), events) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "ip": addr.String(), "hours": hours, "events": events, // badActor is the first matching features.MarkBadActors rule over the // returned window; probePaths is how many distinct probe paths it hit. "badActor": reason, "probePaths": probes, }) } // assessSourceIP applies the same bad-actor rules the detector uses to one // address's requests, so the hint in the UI never disagrees with triage. func assessSourceIP(ip string, events []storage.SourceIPEvent) (reason string, probePaths int) { fe := make([]features.Event, len(events)) probes := map[string]struct{}{} for i, e := range events { fe[i] = features.Event{Route: e.Route, StatusCode: int(e.StatusCode), ClientIP: ip, UAClass: uaclass.Of(e.UserAgent)} if triage.IsProbePath(e.Route) { probes[e.Route] = struct{}{} } } features.MarkBadActors(fe, features.BadActorConfig{}) for _, e := range fe { if e.BadActor != "" { return e.BadActor, len(probes) } } return "", len(probes) }