2

Add on-demand IP investigation and richer IP details

This commit is contained in:
2026-03-12 01:53:44 +01:00
parent 33dd9bac76
commit c5e1c4ff36
13 changed files with 1561 additions and 144 deletions

View File

@@ -94,6 +94,14 @@ CREATE TABLE IF NOT EXISTS source_offsets (
offset INTEGER NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ip_investigations (
ip TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ip_investigations_updated_at ON ip_investigations(updated_at DESC, ip ASC);
`
type Store struct {
@@ -492,6 +500,53 @@ func (s *Store) GetIPDetails(ctx context.Context, ip string, eventLimit, decisio
}, nil
}
func (s *Store) GetInvestigation(ctx context.Context, ip string) (model.IPInvestigation, bool, error) {
row := s.db.QueryRowContext(ctx, `SELECT payload_json, updated_at FROM ip_investigations WHERE ip = ?`, ip)
var payload string
var updatedAt string
if err := row.Scan(&payload, &updatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.IPInvestigation{}, false, nil
}
return model.IPInvestigation{}, false, fmt.Errorf("query ip investigation %q: %w", ip, err)
}
var item model.IPInvestigation
if err := json.Unmarshal([]byte(payload), &item); err != nil {
return model.IPInvestigation{}, false, fmt.Errorf("decode ip investigation %q: %w", ip, err)
}
parsed, err := parseTime(updatedAt)
if err != nil {
return model.IPInvestigation{}, false, fmt.Errorf("parse ip investigation updated_at: %w", err)
}
item.UpdatedAt = parsed
return item, true, nil
}
func (s *Store) SaveInvestigation(ctx context.Context, item model.IPInvestigation) error {
if item.UpdatedAt.IsZero() {
item.UpdatedAt = time.Now().UTC()
}
payload, err := json.Marshal(item)
if err != nil {
return fmt.Errorf("encode ip investigation: %w", err)
}
_, err = s.db.ExecContext(
ctx,
`INSERT INTO ip_investigations (ip, payload_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(ip) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at`,
item.IP,
string(payload),
formatTime(item.UpdatedAt),
)
if err != nil {
return fmt.Errorf("upsert ip investigation %q: %w", item.IP, err)
}
return nil
}
func (s *Store) GetSourceOffset(ctx context.Context, sourceName string) (model.SourceOffset, bool, error) {
row := s.db.QueryRowContext(ctx, `SELECT source_name, path, inode, offset, updated_at FROM source_offsets WHERE source_name = ?`, sourceName)
var offset model.SourceOffset
@@ -536,10 +591,7 @@ func (s *Store) SaveSourceOffset(ctx context.Context, offset model.SourceOffset)
}
func (s *Store) listEventsForIP(ctx context.Context, ip string, limit int) ([]model.Event, error) {
if limit <= 0 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, `
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,
@@ -547,17 +599,19 @@ func (s *Store) listEventsForIP(ctx context.Context, ip string, limit int) ([]mo
FROM events e
LEFT JOIN ip_state s ON s.ip = e.client_ip
WHERE e.client_ip = ?
ORDER BY e.occurred_at DESC, e.id DESC
LIMIT ?`,
ip,
limit,
)
ORDER BY e.occurred_at DESC, e.id DESC`
args := []any{ip}
if limit > 0 {
query += ` LIMIT ?`
args = append(args, limit)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list events for ip %q: %w", ip, err)
}
defer rows.Close()
items := make([]model.Event, 0, limit)
items := make([]model.Event, 0, max(limit, 0))
for rows.Next() {
item, err := scanEvent(rows)
if err != nil {
@@ -651,6 +705,13 @@ func (s *Store) listBackendActionsForIP(ctx context.Context, ip string, limit in
return items, nil
}
func max(left int, right int) int {
if left > right {
return left
}
return right
}
func getIPStateDB(ctx context.Context, db queryer, ip string) (model.IPState, bool, error) {
row := db.QueryRowContext(ctx, `
SELECT ip, first_seen_at, last_seen_at, last_source_name, last_user_agent, latest_status,

View File

@@ -113,4 +113,20 @@ func TestStoreRecordsEventsAndState(t *testing.T) {
if len(details.RecentEvents) != 1 || len(details.Decisions) != 1 || len(details.BackendActions) != 1 {
t.Fatalf("unexpected ip details: %+v", details)
}
investigation := model.IPInvestigation{
IP: event.ClientIP,
UpdatedAt: occurredAt,
Bot: &model.BotMatch{Name: "Googlebot", ProviderID: "google_official", Icon: "🤖", Method: "published_ranges", Verified: true},
}
if err := db.SaveInvestigation(ctx, investigation); err != nil {
t.Fatalf("save investigation: %v", err)
}
loadedInvestigation, found, err := db.GetInvestigation(ctx, event.ClientIP)
if err != nil {
t.Fatalf("get investigation: %v", err)
}
if !found || loadedInvestigation.Bot == nil || loadedInvestigation.Bot.Name != "Googlebot" {
t.Fatalf("unexpected investigation payload: found=%v investigation=%+v", found, loadedInvestigation)
}
}