diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 53ee49d..1791daf 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -80,6 +80,17 @@ func formatStorageTime(t time.Time) string { return t.UTC().Format(storageTimeFormat) } +// All rows are canonical RFC3339 after migrateLegacyTimestamps, so two layouts suffice +func parseStorageTime(s string) time.Time { + if t, err := time.Parse(storageTimeFormat, s); err == nil { + return t.UTC() + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t.UTC() + } + return time.Time{} +} + // All rows are RFC3339 (legacy formats are rewritten by migrateLegacyTimestamps), // so a plain string comparison is correct and keeps the occurred_at indexes usable. func addOccurredAtSinceFilter(query *string, args *[]any, since time.Time) { @@ -116,6 +127,50 @@ func addSearchFilter(query *string, args *[]any, search string) { } } +type BanEventFilter struct { + ServerID string + Jail string + Country string + Search string + Since time.Time + Until time.Time + BansOnly bool +} + +// Returns a condition fragment (starting with " AND ..." or empty) to append after "WHERE 1=1", plus the positional args +func (f BanEventFilter) buildWhere() (string, []any) { + conditions := "" + args := []any{} + + if f.ServerID != "" { + conditions += " AND server_id = ?" + args = append(args, f.ServerID) + } + if f.Jail != "" { + conditions += " AND jail = ?" + args = append(args, f.Jail) + } + if f.BansOnly { + conditions += " AND (event_type = 'ban' OR event_type IS NULL)" + } + addOccurredAtSinceFilter(&conditions, &args, f.Since) + if !f.Until.IsZero() { + // Same string-comparison reasoning as addOccurredAtSinceFilter. + conditions += " AND occurred_at < ?" + args = append(args, formatStorageTime(f.Until)) + } + addSearchFilter(&conditions, &args, strings.TrimSpace(f.Search)) + if f.Country != "" && f.Country != "all" { + if f.Country == "__unknown__" { + conditions += " AND (country IS NULL OR country = '')" + } else { + conditions += " AND LOWER(COALESCE(country,'')) = ?" + args = append(args, strings.ToLower(f.Country)) + } + } + return conditions, args +} + // ========================================================================= // Types // ========================================================================= @@ -777,10 +832,10 @@ const ( MaxBanEventsOffset = 1000 ) -// Returns ban events with optional search and country filter, ordered by occurred_at DESC. -// search is applied as LIKE %search% on ip, jail, server_name, hostname, country. +// Returns ban events matching the filter, ordered by occurred_at DESC. +// Search is applied via FTS (or LIKE fallback) on ip, jail, server_name, hostname, country. // limit is capped at MaxBanEventsLimit; offset is capped at MaxBanEventsOffset. -func ListBanEventsFiltered(ctx context.Context, serverID string, limit, offset int, since time.Time, search, country string) ([]BanEventRecord, error) { +func ListBanEventsFiltered(ctx context.Context, f BanEventFilter, limit, offset int) ([]BanEventRecord, error) { if db == nil { return nil, errors.New("storage not initialised") } @@ -792,7 +847,7 @@ func ListBanEventsFiltered(ctx context.Context, serverID string, limit, offset i } from := "FROM ban_events" - search = strings.TrimSpace(search) + search := strings.TrimSpace(f.Search) if search != "" && ftsAvailable { var matches int64 if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ban_events_fts WHERE ban_events_fts MATCH ?`, buildFTSMatch(search)).Scan(&matches); err == nil && matches > broadSearchThreshold { @@ -807,22 +862,8 @@ SELECT id, server_id, server_name, jail, ip, country, hostname, failures, event_type, occurred_at, created_at ` + from + ` WHERE 1=1` - args := []any{} - - if serverID != "" { - baseQuery += " AND server_id = ?" - args = append(args, serverID) - } - addOccurredAtSinceFilter(&baseQuery, &args, since) - addSearchFilter(&baseQuery, &args, search) - if country != "" && country != "all" { - if country == "__unknown__" { - baseQuery += " AND (country IS NULL OR country = '')" - } else { - baseQuery += " AND LOWER(COALESCE(country,'')) = ?" - args = append(args, strings.ToLower(country)) - } - } + conditions, args := f.buildWhere() + baseQuery += conditions baseQuery += " ORDER BY occurred_at DESC LIMIT ? OFFSET ?" args = append(args, limit, offset) @@ -919,14 +960,15 @@ const MaxSearchCount = 5000 const broadSearchThreshold = 10000 // Returns the total count of ban events matching the same filters as ListBanEventsFiltered. -func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Time, search, country string) (int64, error) { +func CountBanEventsFiltered(ctx context.Context, f BanEventFilter) (int64, error) { if db == nil { return 0, errors.New("storage not initialised") } // A search without further filters is answered index-only by FTS. - search = strings.TrimSpace(search) - if search != "" && ftsAvailable && serverID == "" && since.IsZero() && (country == "" || country == "all") { + search := strings.TrimSpace(f.Search) + if search != "" && ftsAvailable && f.ServerID == "" && f.Jail == "" && !f.BansOnly && + f.Since.IsZero() && f.Until.IsZero() && (f.Country == "" || f.Country == "all") { var total int64 if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ban_events_fts WHERE ban_events_fts MATCH ?`, buildFTSMatch(search)).Scan(&total); err != nil { return 0, err @@ -937,24 +979,7 @@ func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Tim return total, nil } - conditions := "" - args := []any{} - - if serverID != "" { - conditions += " AND server_id = ?" - args = append(args, serverID) - } - addOccurredAtSinceFilter(&conditions, &args, since) - search = strings.TrimSpace(search) - addSearchFilter(&conditions, &args, search) - if country != "" && country != "all" { - if country == "__unknown__" { - conditions += " AND (country IS NULL OR country = '')" - } else { - conditions += " AND LOWER(COALESCE(country,'')) = ?" - args = append(args, strings.ToLower(country)) - } - } + conditions, args := f.buildWhere() query := `SELECT COUNT(*) FROM ban_events WHERE 1=1` + conditions if search != "" { @@ -1136,6 +1161,231 @@ WHERE 1=1` return result, rows.Err() } +// Returns overall / today / week ban-event counts in a single query +func CountBanEventTotals(ctx context.Context, serverID string, now time.Time) (overall, today, week int64, err error) { + if db == nil { + return 0, 0, 0, errors.New("storage not initialised") + } + + query := ` +SELECT COUNT(*), + COALESCE(SUM(occurred_at >= ?), 0), + COALESCE(SUM(occurred_at >= ?), 0) +FROM ban_events +WHERE 1=1` + args := []any{ + formatStorageTime(now.Add(-24 * time.Hour)), + formatStorageTime(now.Add(-7 * 24 * time.Hour)), + } + if serverID != "" { + query += " AND server_id = ?" + args = append(args, serverID) + } + + err = db.QueryRowContext(ctx, query, args...).Scan(&overall, &today, &week) + return overall, today, week, err +} + +// ========================================================================= +// Timeline & IP Aggregation +// ========================================================================= + +type TimelineBucket struct { + Start time.Time `json:"start"` + Bans int64 `json:"bans"` + Unbans int64 `json:"unbans"` +} + +const maxTimelineBuckets = 1000 + +// Returns ban / unban counts bucketed into bucketSeconds-wide intervals aligned to epoch multiples, zero-filled across [f.Since, f.Until) +func BanEventTimeline(ctx context.Context, f BanEventFilter, bucketSeconds int64) ([]TimelineBucket, error) { + if db == nil { + return nil, errors.New("storage not initialised") + } + if bucketSeconds <= 0 { + return nil, errors.New("bucket size must be positive") + } + if f.Since.IsZero() || f.Until.IsZero() || !f.Until.After(f.Since) { + return nil, errors.New("a valid since/until range is required") + } + startBucket := (f.Since.Unix() / bucketSeconds) * bucketSeconds + endEpoch := f.Until.Unix() + if (endEpoch-startBucket)/bucketSeconds+1 > maxTimelineBuckets { + return nil, errors.New("time range yields too many buckets") + } + + conditions, args := f.buildWhere() + query := ` +SELECT (CAST(strftime('%s', occurred_at) AS INTEGER) / ?) * ? AS bucket, + SUM(CASE WHEN event_type = 'unban' THEN 0 ELSE 1 END) AS bans, + SUM(CASE WHEN event_type = 'unban' THEN 1 ELSE 0 END) AS unbans +FROM ban_events +WHERE 1=1` + conditions + ` +GROUP BY bucket +ORDER BY bucket` + queryArgs := append([]any{bucketSeconds, bucketSeconds}, args...) + + rows, err := db.QueryContext(ctx, query, queryArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + + type bucketCounts struct{ bans, unbans int64 } + counts := make(map[int64]bucketCounts) + for rows.Next() { + var bucket, bans, unbans int64 + if err := rows.Scan(&bucket, &bans, &unbans); err != nil { + return nil, err + } + counts[bucket] = bucketCounts{bans: bans, unbans: unbans} + } + if err := rows.Err(); err != nil { + return nil, err + } + + buckets := make([]TimelineBucket, 0, (endEpoch-startBucket)/bucketSeconds+1) + for ts := startBucket; ts < endEpoch; ts += bucketSeconds { + c := counts[ts] + buckets = append(buckets, TimelineBucket{ + Start: time.Unix(ts, 0).UTC(), + Bans: c.bans, + Unbans: c.unbans, + }) + } + return buckets, nil +} + +// Aggregates ban events per IP within a time range. +type BanEventIPStat struct { + IP string `json:"ip"` + Country string `json:"country"` + Count int64 `json:"count"` + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` + Jails string `json:"jails"` +} + +// Returns per-IP ban aggregates ordered by count desc, plus the total number of +// distinct IPs matching the filter so callers can detect truncation. +func ListBanEventIPs(ctx context.Context, f BanEventFilter, limit int) ([]BanEventIPStat, int64, error) { + if db == nil { + return nil, 0, errors.New("storage not initialised") + } + if limit <= 0 || limit > 10000 { + limit = 2000 + } + f.BansOnly = true + + conditions, args := f.buildWhere() + query := ` +SELECT ip, + MAX(COALESCE(country, '')) AS country, + COUNT(*) AS cnt, + MIN(occurred_at) AS first_seen, + MAX(occurred_at) AS last_seen, + GROUP_CONCAT(DISTINCT jail) AS jails +FROM ban_events +WHERE ip != ''` + conditions + ` +GROUP BY ip +ORDER BY cnt DESC, ip +LIMIT ?` + queryArgs := append(append([]any{}, args...), limit) + + rows, err := db.QueryContext(ctx, query, queryArgs...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var results []BanEventIPStat + for rows.Next() { + var stat BanEventIPStat + var firstSeen, lastSeen, jails sql.NullString + if err := rows.Scan(&stat.IP, &stat.Country, &stat.Count, &firstSeen, &lastSeen, &jails); err != nil { + return nil, 0, err + } + if firstSeen.Valid { + stat.FirstSeen = parseStorageTime(firstSeen.String) + } + if lastSeen.Valid { + stat.LastSeen = parseStorageTime(lastSeen.String) + } + stat.Jails = stringFromNull(jails) + results = append(results, stat) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + var total int64 + countQuery := `SELECT COUNT(DISTINCT ip) FROM ban_events WHERE ip != ''` + conditions + if err := db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + return results, total, nil +} + +type IPActivityPeriod struct { + Day string `json:"day"` + Overlap int64 `json:"overlap"` + Events int64 `json:"events"` +} + +func ListBanEventIPActivity(ctx context.Context, f BanEventFilter, minOverlap int) ([]IPActivityPeriod, error) { + if db == nil { + return nil, errors.New("storage not initialised") + } + if f.Since.IsZero() || f.Until.IsZero() || !f.Until.After(f.Since) { + return nil, errors.New("a valid since/until range is required") + } + if minOverlap < 1 { + minOverlap = 1 + } + f.BansOnly = true + + innerConditions, innerArgs := f.buildWhere() + query := ` +SELECT strftime('%Y-%m-%d', occurred_at) AS day, + COUNT(DISTINCT ip) AS overlap, + COUNT(*) AS events +FROM ban_events +WHERE ip != '' + AND (event_type = 'ban' OR event_type IS NULL) + AND ip IN (SELECT DISTINCT ip FROM ban_events WHERE ip != ''` + innerConditions + `) + AND (occurred_at < ? OR occurred_at >= ?)` + args := append(append([]any{}, innerArgs...), formatStorageTime(f.Since), formatStorageTime(f.Until)) + + if f.ServerID != "" { + query += " AND server_id = ?" + args = append(args, f.ServerID) + } + + query += ` +GROUP BY day +HAVING overlap >= ? +ORDER BY overlap DESC, day DESC +LIMIT 20` + args = append(args, minOverlap) + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []IPActivityPeriod + for rows.Next() { + var period IPActivityPeriod + if err := rows.Scan(&period.Day, &period.Overlap, &period.Events); err != nil { + return nil, err + } + results = append(results, period) + } + return results, rows.Err() +} + // Deletes all ban event records. func ClearBanEvents(ctx context.Context) (int64, error) { if db == nil { @@ -1268,7 +1518,7 @@ func ensureSchema(ctx context.Context) error { return errors.New("storage not initialised") } - const createTable = ` + const createTables = ` CREATE TABLE IF NOT EXISTS app_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), -- Basic app settings @@ -1348,14 +1598,6 @@ CREATE TABLE IF NOT EXISTS ban_events ( created_at DATETIME NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id); -CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at); -CREATE INDEX IF NOT EXISTS idx_ban_events_ip ON ban_events(ip); -CREATE INDEX IF NOT EXISTS idx_ban_events_server_jail_occurred_at ON ban_events(server_id, jail, occurred_at); -CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at_ip ON ban_events(occurred_at, ip, country, event_type, server_id); -CREATE INDEX IF NOT EXISTS idx_ban_events_server_occurred_at ON ban_events(server_id, occurred_at); -CREATE INDEX IF NOT EXISTS idx_ban_events_country ON ban_events(country); - CREATE TABLE IF NOT EXISTS permanent_blocks ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT NOT NULL, @@ -1368,85 +1610,57 @@ CREATE TABLE IF NOT EXISTS permanent_blocks ( updated_at TEXT NOT NULL, UNIQUE(ip, integration) ); +` + + const createIndexes = ` +CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id); +CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at); +CREATE INDEX IF NOT EXISTS idx_ban_events_ip ON ban_events(ip); +CREATE INDEX IF NOT EXISTS idx_ban_events_server_jail_occurred_at ON ban_events(server_id, jail, occurred_at); +CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at_ip ON ban_events(occurred_at, ip, country, event_type, server_id); +CREATE INDEX IF NOT EXISTS idx_ban_events_server_occurred_at ON ban_events(server_id, occurred_at); +CREATE INDEX IF NOT EXISTS idx_ban_events_country ON ban_events(country); CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status); CREATE INDEX IF NOT EXISTS idx_perm_blocks_updated_at ON permanent_blocks(updated_at); ` - if _, err := db.ExecContext(ctx, createTable); err != nil { + // Columns added after a table first shipped. CREATE TABLE IF NOT EXISTS is a no-op on existing databases, so every later column needs an entry here + alterColumns := []string{ + `ALTER TABLE app_settings ADD COLUMN console_output INTEGER DEFAULT 0`, + `ALTER TABLE app_settings ADD COLUMN smtp_insecure_skip_verify INTEGER DEFAULT 0`, + `ALTER TABLE app_settings ADD COLUMN smtp_auth_method TEXT DEFAULT 'auto'`, + `ALTER TABLE app_settings ADD COLUMN chain TEXT DEFAULT 'INPUT'`, + `ALTER TABLE app_settings ADD COLUMN bantime_rndtime TEXT DEFAULT ''`, + `ALTER TABLE app_settings ADD COLUMN bantime_maxtime TEXT DEFAULT ''`, + `ALTER TABLE app_settings ADD COLUMN bantime_factor TEXT DEFAULT ''`, + `ALTER TABLE app_settings ADD COLUMN bantime_overalljails INTEGER DEFAULT 0`, + `ALTER TABLE app_settings ADD COLUMN alert_provider TEXT DEFAULT 'email'`, + `ALTER TABLE app_settings ADD COLUMN webhook TEXT DEFAULT '{}'`, + `ALTER TABLE app_settings ADD COLUMN elasticsearch TEXT DEFAULT '{}'`, + `ALTER TABLE app_settings ADD COLUMN threat_intel TEXT DEFAULT '{}'`, + `ALTER TABLE servers ADD COLUMN config_path TEXT`, + `ALTER TABLE servers ADD COLUMN reverse_tunnel INTEGER DEFAULT 0`, + `ALTER TABLE app_settings ADD COLUMN event_retention_days INTEGER DEFAULT 180`, + `ALTER TABLE ban_events ADD COLUMN event_type TEXT NOT NULL DEFAULT 'ban'`, + } + + if _, err := db.ExecContext(ctx, createTables); err != nil { return err } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN console_output INTEGER DEFAULT 0`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + for _, ddl := range alterColumns { + if err := addColumnIfMissing(ctx, ddl); err != nil { return err } } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN smtp_insecure_skip_verify INTEGER DEFAULT 0`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN smtp_auth_method TEXT DEFAULT 'auto'`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN chain TEXT DEFAULT 'INPUT'`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN bantime_rndtime TEXT DEFAULT ''`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN bantime_maxtime TEXT DEFAULT ''`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN bantime_factor TEXT DEFAULT ''`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN bantime_overalljails INTEGER DEFAULT 0`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN alert_provider TEXT DEFAULT 'email'`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN webhook TEXT DEFAULT '{}'`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN elasticsearch TEXT DEFAULT '{}'`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN threat_intel TEXT DEFAULT '{}'`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE servers ADD COLUMN config_path TEXT`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } - } - if _, err := db.ExecContext(ctx, `ALTER TABLE servers ADD COLUMN reverse_tunnel INTEGER DEFAULT 0`); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { - return err - } + if _, err := db.ExecContext(ctx, createIndexes); err != nil { + return err } - if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN event_retention_days INTEGER DEFAULT 180`); err != nil { + return nil +} + +func addColumnIfMissing(ctx context.Context, ddl string) error { + if _, err := db.ExecContext(ctx, ddl); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { return err } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 5cc58b7..336f469 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -18,6 +18,7 @@ package storage import ( "context" + "database/sql" "os" "path/filepath" "sync" @@ -65,6 +66,75 @@ func initTestStorage(t *testing.T) { }) } +func TestEnsureSchemaMigratesLegacyBanEvents(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "fail2ban-ui-legacy.db") + legacy, err := sql.Open("sqlite", "file:"+dbPath) + if err != nil { + t.Fatalf("open legacy db: %v", err) + } + const legacySchema = ` +CREATE TABLE ban_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id TEXT NOT NULL, + server_name TEXT NOT NULL, + jail TEXT NOT NULL, + ip TEXT NOT NULL, + country TEXT, + hostname TEXT, + failures TEXT, + whois TEXT, + logs TEXT, + occurred_at DATETIME NOT NULL, + created_at DATETIME NOT NULL +);` + if _, err := legacy.Exec(legacySchema); err != nil { + t.Fatalf("create legacy schema: %v", err) + } + if _, err := legacy.Exec( + `INSERT INTO ban_events (server_id, server_name, jail, ip, occurred_at, created_at) + VALUES ('local', 'Local', 'sshd', '203.0.113.7', '2026-01-02 03:04:05', '2026-01-02 03:04:05')`, + ); err != nil { + t.Fatalf("insert legacy row: %v", err) + } + if err := legacy.Close(); err != nil { + t.Fatalf("close legacy db: %v", err) + } + + if db != nil { + _ = db.Close() + } + db = nil + initOnce = sync.Once{} + initErr = nil + t.Cleanup(func() { + if db != nil { + _ = db.Close() + } + db = nil + initOnce = sync.Once{} + initErr = nil + }) + + if err := Init(dbPath); err != nil { + t.Fatalf("Init on legacy database: %v", err) + } + + var eventType string + if err := db.QueryRow(`SELECT event_type FROM ban_events WHERE ip = '203.0.113.7'`).Scan(&eventType); err != nil { + t.Fatalf("read migrated event_type: %v", err) + } + if eventType != "ban" { + t.Fatalf("migrated event_type = %q, want %q", eventType, "ban") + } + + var indexName string + if err := db.QueryRow( + `SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_ban_events_occurred_at_ip'`, + ).Scan(&indexName); err != nil { + t.Fatalf("index idx_ban_events_occurred_at_ip missing after migration: %v", err) + } +} + func TestReplaceServersRoundTrip(t *testing.T) { initTestStorage(t) @@ -125,7 +195,7 @@ func TestBanEventsFTSSearchAndEnrichment(t *testing.T) { searchTotal := func(term string) int64 { t.Helper() - total, err := CountBanEventsFiltered(ctx, "", time.Time{}, term, "") + total, err := CountBanEventsFiltered(ctx, BanEventFilter{Search: term}) if err != nil { t.Fatalf("CountBanEventsFiltered(%q): %v", term, err) } @@ -145,7 +215,7 @@ func TestBanEventsFTSSearchAndEnrichment(t *testing.T) { } } - events, err := ListBanEventsFiltered(ctx, "", 10, 0, time.Time{}, "nextcloud", "") + events, err := ListBanEventsFiltered(ctx, BanEventFilter{Search: "nextcloud"}, 10, 0) if err != nil || len(events) != 1 || events[0].IP != "203.0.113.45" { t.Fatalf("ListBanEventsFiltered(nextcloud) = %+v, err=%v", events, err) } @@ -219,7 +289,7 @@ func TestBanEventsFTSBackfillsExistingRows(t *testing.T) { t.Fatal("FTS not available after ensureBanEventsFTS") } - total, err := CountBanEventsFiltered(ctx, "", time.Time{}, "legacy", "") + total, err := CountBanEventsFiltered(ctx, BanEventFilter{Search: "legacy"}) if err != nil || total != 1 { t.Fatalf("search for pre-existing row: total=%d err=%v (backfill missing)", total, err) } @@ -326,7 +396,7 @@ func TestListBanEventsFilteredOmitsHeavyFields(t *testing.T) { } } - events, err := ListBanEventsFiltered(ctx, "srv-1", 50, 0, time.Time{}, "", "") + events, err := ListBanEventsFiltered(ctx, BanEventFilter{ServerID: "srv-1"}, 50, 0) if err != nil { t.Fatalf("ListBanEventsFiltered: %v", err) } @@ -422,3 +492,192 @@ VALUES (?, ?, ?, ?, ?, ?, ?)`, t.Fatalf("recent ban count=%d want 0 for later since", got["sshd"]) } } + +func TestBanEventFilterUntilIsExclusive(t *testing.T) { + initTestStorage(t) + + ctx := context.Background() + cutoff := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + for i, occurredAt := range []time.Time{ + cutoff.Add(-time.Minute), + cutoff, + cutoff.Add(time.Minute), + } { + if _, err := RecordBanEvent(ctx, BanEventRecord{ + ServerID: "srv-1", ServerName: "server 1", Jail: "sshd", + IP: "192.0.2.10", EventType: "ban", OccurredAt: occurredAt, + }); err != nil { + t.Fatalf("RecordBanEvent %d: %v", i, err) + } + } + + filter := BanEventFilter{Since: cutoff.Add(-time.Hour), Until: cutoff} + total, err := CountBanEventsFiltered(ctx, filter) + if err != nil { + t.Fatalf("CountBanEventsFiltered: %v", err) + } + if total != 1 { + t.Fatalf("count with until=%v is %d, want 1 (until must be exclusive)", cutoff, total) + } + events, err := ListBanEventsFiltered(ctx, filter, 10, 0) + if err != nil || len(events) != 1 { + t.Fatalf("ListBanEventsFiltered = %d events, err=%v, want 1", len(events), err) + } +} + +func TestBanEventTimelineBucketsAndZeroFill(t *testing.T) { + initTestStorage(t) + + ctx := context.Background() + base := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC) + events := []BanEventRecord{ + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.1", EventType: "ban", OccurredAt: base.Add(5 * time.Minute)}, + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.2", EventType: "ban", OccurredAt: base.Add(30 * time.Minute)}, + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.1", EventType: "unban", OccurredAt: base.Add(40 * time.Minute)}, + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.3", EventType: "ban", OccurredAt: base.Add(2*time.Hour + 10*time.Minute)}, + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.4", EventType: "ban", OccurredAt: base.Add(4 * time.Hour)}, + } + for i, event := range events { + if _, err := RecordBanEvent(ctx, event); err != nil { + t.Fatalf("RecordBanEvent %d: %v", i, err) + } + } + + filter := BanEventFilter{Since: base, Until: base.Add(3 * time.Hour)} + buckets, err := BanEventTimeline(ctx, filter, 3600) + if err != nil { + t.Fatalf("BanEventTimeline: %v", err) + } + if len(buckets) != 3 { + t.Fatalf("got %d buckets, want 3 (zero-filled)", len(buckets)) + } + if !buckets[0].Start.Equal(base) { + t.Fatalf("bucket[0].Start=%v want %v", buckets[0].Start, base) + } + if buckets[0].Bans != 2 || buckets[0].Unbans != 1 { + t.Fatalf("bucket[0] = %+v, want bans=2 unbans=1", buckets[0]) + } + if buckets[1].Bans != 0 || buckets[1].Unbans != 0 { + t.Fatalf("bucket[1] = %+v, want zero-filled empty bucket", buckets[1]) + } + if buckets[2].Bans != 1 || buckets[2].Unbans != 0 { + t.Fatalf("bucket[2] = %+v, want bans=1", buckets[2]) + } +} + +func TestListBanEventIPsAggregatesAndTruncates(t *testing.T) { + initTestStorage(t) + + ctx := context.Background() + base := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC) + events := []BanEventRecord{ + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.1", Country: "DE", EventType: "ban", OccurredAt: base}, + {ServerID: "srv-1", ServerName: "s1", Jail: "nginx", IP: "192.0.2.1", Country: "DE", EventType: "ban", OccurredAt: base.Add(10 * time.Minute)}, + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.1", EventType: "unban", OccurredAt: base.Add(20 * time.Minute)}, // not counted + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "198.51.100.7", Country: "BR", EventType: "ban", OccurredAt: base.Add(5 * time.Minute)}, + } + for i, event := range events { + if _, err := RecordBanEvent(ctx, event); err != nil { + t.Fatalf("RecordBanEvent %d: %v", i, err) + } + } + + filter := BanEventFilter{Since: base.Add(-time.Hour), Until: base.Add(time.Hour)} + stats, total, err := ListBanEventIPs(ctx, filter, 10) + if err != nil { + t.Fatalf("ListBanEventIPs: %v", err) + } + if total != 2 || len(stats) != 2 { + t.Fatalf("total=%d len=%d, want 2/2", total, len(stats)) + } + top := stats[0] + if top.IP != "192.0.2.1" || top.Count != 2 || top.Country != "DE" { + t.Fatalf("top stat = %+v, want 192.0.2.1 count=2 country=DE (unban must not count)", top) + } + if !top.FirstSeen.Equal(base) || !top.LastSeen.Equal(base.Add(10*time.Minute)) { + t.Fatalf("first/last seen = %v / %v", top.FirstSeen, top.LastSeen) + } + if top.Jails != "sshd,nginx" && top.Jails != "nginx,sshd" { + t.Fatalf("jails = %q, want both sshd and nginx", top.Jails) + } + + // limit=1 truncates but reports the full distinct count + stats, total, err = ListBanEventIPs(ctx, filter, 1) + if err != nil || len(stats) != 1 || total != 2 { + t.Fatalf("truncated: len=%d total=%d err=%v, want 1/2", len(stats), total, err) + } +} + +func TestListBanEventIPActivityFindsOverlappingDays(t *testing.T) { + initTestStorage(t) + + ctx := context.Background() + incident := time.Date(2026, 6, 10, 8, 0, 0, 0, time.UTC) + earlier := time.Date(2026, 4, 15, 20, 0, 0, 0, time.UTC) + events := []BanEventRecord{ + // current incident: 3 IPs + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.1", EventType: "ban", OccurredAt: incident}, + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.2", EventType: "ban", OccurredAt: incident.Add(time.Minute)}, + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.3", EventType: "ban", OccurredAt: incident.Add(2 * time.Minute)}, + // two months earlier: 2 of the same IPs plus an unrelated one + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.1", EventType: "ban", OccurredAt: earlier}, + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.2", EventType: "ban", OccurredAt: earlier.Add(time.Minute)}, + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "203.0.113.9", EventType: "ban", OccurredAt: earlier.Add(2 * time.Minute)}, + } + for i, event := range events { + if _, err := RecordBanEvent(ctx, event); err != nil { + t.Fatalf("RecordBanEvent %d: %v", i, err) + } + } + + filter := BanEventFilter{Since: incident.Add(-time.Hour), Until: incident.Add(time.Hour)} + periods, err := ListBanEventIPActivity(ctx, filter, 2) + if err != nil { + t.Fatalf("ListBanEventIPActivity: %v", err) + } + if len(periods) != 1 { + t.Fatalf("got %d periods, want 1: %+v", len(periods), periods) + } + if periods[0].Day != "2026-04-15" || periods[0].Overlap != 2 || periods[0].Events != 2 { + t.Fatalf("period = %+v, want day=2026-04-15 overlap=2 events=2", periods[0]) + } + + periods, err = ListBanEventIPActivity(ctx, filter, 3) + if err != nil || len(periods) != 0 { + t.Fatalf("minOverlap=3: got %d periods err=%v, want 0", len(periods), err) + } +} + +func TestCountBanEventTotals(t *testing.T) { + initTestStorage(t) + + ctx := context.Background() + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + events := []BanEventRecord{ + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.1", EventType: "ban", OccurredAt: now.Add(-time.Hour)}, // today+week + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.2", EventType: "ban", OccurredAt: now.Add(-3 * 24 * time.Hour)}, // week + {ServerID: "srv-1", ServerName: "s1", Jail: "sshd", IP: "192.0.2.3", EventType: "ban", OccurredAt: now.Add(-30 * 24 * time.Hour)}, // overall only + {ServerID: "srv-2", ServerName: "s2", Jail: "sshd", IP: "192.0.2.4", EventType: "ban", OccurredAt: now.Add(-time.Hour)}, + } + for i, event := range events { + if _, err := RecordBanEvent(ctx, event); err != nil { + t.Fatalf("RecordBanEvent %d: %v", i, err) + } + } + + overall, today, week, err := CountBanEventTotals(ctx, "", now) + if err != nil { + t.Fatalf("CountBanEventTotals: %v", err) + } + if overall != 4 || today != 2 || week != 3 { + t.Fatalf("totals = %d/%d/%d, want 4/2/3", overall, today, week) + } + + overall, today, week, err = CountBanEventTotals(ctx, "srv-1", now) + if err != nil { + t.Fatalf("CountBanEventTotals(srv-1): %v", err) + } + if overall != 3 || today != 1 || week != 2 { + t.Fatalf("srv-1 totals = %d/%d/%d, want 3/1/2", overall, today, week) + } +} diff --git a/pkg/web/ban_insights_test.go b/pkg/web/ban_insights_test.go new file mode 100644 index 0000000..0fe4cf5 --- /dev/null +++ b/pkg/web/ban_insights_test.go @@ -0,0 +1,188 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func newTestContext(method, target, body string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + var reader *strings.Reader + if body != "" { + reader = strings.NewReader(body) + } else { + reader = strings.NewReader("") + } + req := httptest.NewRequest(method, target, reader) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + c.Request = req + return c, w +} + +func TestParseEventRange(t *testing.T) { + t.Run("defaults to last 8h", func(t *testing.T) { + c, _ := newTestContext(http.MethodGet, "/api/events/bans/timeline", "") + since, until, ok := parseEventRange(c) + if !ok { + t.Fatal("expected ok") + } + if got := until.Sub(since); got != 8*time.Hour { + t.Fatalf("default range = %v, want 8h", got) + } + }) + + t.Run("malformed since is 400", func(t *testing.T) { + c, w := newTestContext(http.MethodGet, "/api/events/bans/timeline?since=yesterday", "") + if _, _, ok := parseEventRange(c); ok { + t.Fatal("expected not ok") + } + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + }) + + t.Run("malformed until is 400", func(t *testing.T) { + c, w := newTestContext(http.MethodGet, "/api/events/bans/timeline?until=2026-13-99", "") + if _, _, ok := parseEventRange(c); ok { + t.Fatal("expected not ok") + } + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + }) + + t.Run("until before since is 400", func(t *testing.T) { + c, w := newTestContext(http.MethodGet, + "/api/events/bans/timeline?since=2026-06-02T00:00:00Z&until=2026-06-01T00:00:00Z", "") + if _, _, ok := parseEventRange(c); ok { + t.Fatal("expected not ok") + } + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + }) + + t.Run("valid explicit range", func(t *testing.T) { + c, _ := newTestContext(http.MethodGet, + "/api/events/bans/timeline?since=2026-06-01T00:00:00Z&until=2026-06-02T00:00:00Z", "") + since, until, ok := parseEventRange(c) + if !ok { + t.Fatal("expected ok") + } + if !since.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) || !until.Equal(time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("range = %v..%v", since, until) + } + }) +} + +func TestChooseBucketSeconds(t *testing.T) { + base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + tests := []struct { + name string + duration time.Duration + override int64 + want int64 + }{ + {"8h picks 5m buckets", 8 * time.Hour, 0, 300}, + {"48h picks 30m buckets", 48 * time.Hour, 0, 1800}, + {"30d picks 6h buckets", 30 * 24 * time.Hour, 0, 21600}, + {"180d picks 1d buckets", 180 * 24 * time.Hour, 0, 86400}, + {"override honored when sane", 8 * time.Hour, 600, 600}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := chooseBucketSeconds(base, base.Add(tt.duration), tt.override); got != tt.want { + t.Fatalf("chooseBucketSeconds(%v) = %d, want %d", tt.duration, got, tt.want) + } + }) + } + + t.Run("override clamped to bucket cap", func(t *testing.T) { + got := chooseBucketSeconds(base, base.Add(30*24*time.Hour), 60) + rangeSeconds := int64(30 * 24 * 3600) + if rangeSeconds/got > timelineMaxBuckets { + t.Fatalf("bucket %d yields %d buckets, cap is %d", got, rangeSeconds/got, timelineMaxBuckets) + } + }) + + t.Run("never exceeds cap for huge ranges", func(t *testing.T) { + got := chooseBucketSeconds(base, base.Add(5*365*24*time.Hour), 0) + rangeSeconds := int64(5 * 365 * 24 * 3600) + if rangeSeconds/got > timelineMaxBuckets { + t.Fatalf("bucket %d yields %d buckets, cap is %d", got, rangeSeconds/got, timelineMaxBuckets) + } + }) +} + +func TestBulkPermanentBlockHandlerValidation(t *testing.T) { + t.Run("invalid payload is 400", func(t *testing.T) { + c, w := newTestContext(http.MethodPost, "/api/advanced-actions/blocks", `not json`) + BulkPermanentBlockHandler(c) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + }) + + t.Run("empty ip list is 400", func(t *testing.T) { + c, w := newTestContext(http.MethodPost, "/api/advanced-actions/blocks", `{"ips":[]}`) + BulkPermanentBlockHandler(c) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + }) + + t.Run("over the cap is 400", func(t *testing.T) { + ips := make([]string, bulkBlockMaxIPs+1) + for i := range ips { + ips[i] = fmt.Sprintf("203.0.113.%d", i%250) + } + body, _ := json.Marshal(map[string]any{"ips": ips}) + c, w := newTestContext(http.MethodPost, "/api/advanced-actions/blocks", string(body)) + BulkPermanentBlockHandler(c) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + if !strings.Contains(w.Body.String(), "too many IPs") { + t.Fatalf("body = %s, want cap error", w.Body.String()) + } + }) + + t.Run("no integration configured is 400", func(t *testing.T) { + // Default settings ship without an advanced-actions integration. + c, w := newTestContext(http.MethodPost, "/api/advanced-actions/blocks", `{"ips":["203.0.113.7"]}`) + BulkPermanentBlockHandler(c) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } + if !strings.Contains(w.Body.String(), "no integration configured") { + t.Fatalf("body = %s, want integration error", w.Body.String()) + } + }) +} diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index afa925a..3fff632 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -624,14 +624,26 @@ func ListBanEventsHandler(c *gin.Context) { } } - var since time.Time + var since, until time.Time if sinceStr := c.Query("since"); sinceStr != "" { if parsed, err := time.Parse(time.RFC3339, sinceStr); err == nil { since = parsed } } - search := strings.TrimSpace(c.Query("search")) - country := strings.TrimSpace(c.Query("country")) + if untilStr := c.Query("until"); untilStr != "" { + if parsed, err := time.Parse(time.RFC3339, untilStr); err == nil { + until = parsed + } + } + + filter := storage.BanEventFilter{ + ServerID: serverID, + Jail: strings.TrimSpace(c.Query("jail")), + Country: strings.TrimSpace(c.Query("country")), + Search: strings.TrimSpace(c.Query("search")), + Since: since, + Until: until, + } ctx := c.Request.Context() @@ -645,13 +657,13 @@ func ListBanEventsHandler(c *gin.Context) { wg.Add(1) go func() { defer wg.Done() - events, listErr = storage.ListBanEventsFiltered(ctx, serverID, limit, offset, since, search, country) + events, listErr = storage.ListBanEventsFiltered(ctx, filter, limit, offset) }() if offset == 0 { wg.Add(1) go func() { defer wg.Done() - total, countErr = storage.CountBanEventsFiltered(ctx, serverID, since, search, country) + total, countErr = storage.CountBanEventsFiltered(ctx, filter) }() } wg.Wait() @@ -743,7 +755,7 @@ func BanInsightsHandler(c *gin.Context) { ctx := c.Request.Context() now := time.Now().UTC() - // The five queries are independent; run them concurrently. + // The three queries are independent; run them concurrently. var ( countriesMap map[string]int64 recurring []storage.RecurringIPStat @@ -751,12 +763,10 @@ func BanInsightsHandler(c *gin.Context) { totalWeek int64 countriesErr error recurringErr error - overallErr error - todayErr error - weekErr error + totalsErr error wg sync.WaitGroup ) - wg.Add(5) + wg.Add(3) go func() { defer wg.Done() countriesMap, countriesErr = storage.CountBanEventsByCountry(ctx, since, serverID) @@ -767,15 +777,7 @@ func BanInsightsHandler(c *gin.Context) { }() go func() { defer wg.Done() - totalOverall, overallErr = storage.CountBanEvents(ctx, time.Time{}, serverID) - }() - go func() { - defer wg.Done() - totalToday, todayErr = storage.CountBanEvents(ctx, now.Add(-24*time.Hour), serverID) - }() - go func() { - defer wg.Done() - totalWeek, weekErr = storage.CountBanEvents(ctx, now.Add(-7*24*time.Hour), serverID) + totalOverall, totalToday, totalWeek, totalsErr = storage.CountBanEventTotals(ctx, serverID, now) }() wg.Wait() @@ -799,11 +801,9 @@ func BanInsightsHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": errorMsg}) return } - for _, err := range []error{overallErr, todayErr, weekErr} { - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } + if totalsErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": totalsErr.Error()}) + return } type countryStat struct { @@ -837,6 +837,162 @@ func BanInsightsHandler(c *gin.Context) { }) } +// Bucket ladder for the timeline endpoint +var timelineBucketLadder = []int64{60, 300, 900, 1800, 3600, 10800, 21600, 43200, 86400} + +const ( + timelineTargetBuckets = 180 + timelineMaxBuckets = 500 +) + +func parseEventRange(c *gin.Context) (since, until time.Time, ok bool) { + until = time.Now().UTC() + if untilStr := c.Query("until"); untilStr != "" { + parsed, err := time.Parse(time.RFC3339, untilStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid 'until' timestamp, expected RFC3339"}) + return since, until, false + } + until = parsed.UTC() + } + since = until.Add(-8 * time.Hour) + if sinceStr := c.Query("since"); sinceStr != "" { + parsed, err := time.Parse(time.RFC3339, sinceStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid 'since' timestamp, expected RFC3339"}) + return since, until, false + } + since = parsed.UTC() + } + if !until.After(since) { + c.JSON(http.StatusBadRequest, gin.H{"error": "'until' must be after 'since'"}) + return since, until, false + } + return since, until, true +} + +func eventFilterFromQuery(c *gin.Context, since, until time.Time) storage.BanEventFilter { + return storage.BanEventFilter{ + ServerID: c.Query("serverId"), + Jail: strings.TrimSpace(c.Query("jail")), + Country: strings.TrimSpace(c.Query("country")), + Search: strings.TrimSpace(c.Query("search")), + Since: since, + Until: until, + } +} + +func chooseBucketSeconds(since, until time.Time, override int64) int64 { + rangeSeconds := until.Unix() - since.Unix() + if rangeSeconds < 1 { + rangeSeconds = 1 + } + minAllowed := rangeSeconds/timelineMaxBuckets + 1 + if override > 0 { + if override < minAllowed { + return minAllowed + } + return override + } + for _, b := range timelineBucketLadder { + if rangeSeconds/b <= timelineTargetBuckets { + return b + } + } + bucket := timelineBucketLadder[len(timelineBucketLadder)-1] + for rangeSeconds/bucket > timelineMaxBuckets { + bucket *= 2 + } + return bucket +} + +// Returns time-bucketed ban/unban counts for the insights timeline. +func BanTimelineHandler(c *gin.Context) { + since, until, ok := parseEventRange(c) + if !ok { + return + } + var override int64 + if bucketStr := c.Query("bucket"); bucketStr != "" { + if parsed, err := strconv.ParseInt(bucketStr, 10, 64); err == nil && parsed > 0 { + override = parsed + } + } + bucketSeconds := chooseBucketSeconds(since, until, override) + + buckets, err := storage.BanEventTimeline(c.Request.Context(), eventFilterFromQuery(c, since, until), bucketSeconds) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var totalBans, totalUnbans int64 + for _, b := range buckets { + totalBans += b.Bans + totalUnbans += b.Unbans + } + c.JSON(http.StatusOK, gin.H{ + "since": since, + "until": until, + "bucketSeconds": bucketSeconds, + "buckets": buckets, + "totals": gin.H{"bans": totalBans, "unbans": totalUnbans}, + }) +} + +// Returns per-IP ban aggregates for a time range +func ListBanEventIPsHandler(c *gin.Context) { + since, until, ok := parseEventRange(c) + if !ok { + return + } + limit := 2000 + if limitStr := c.Query("limit"); limitStr != "" { + if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 10000 { + limit = parsed + } + } + + ips, total, err := storage.ListBanEventIPs(c.Request.Context(), eventFilterFromQuery(c, since, until), limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if ips == nil { + ips = []storage.BanEventIPStat{} + } + c.JSON(http.StatusOK, gin.H{ + "ips": ips, + "total": total, + "truncated": total > int64(len(ips)), + "since": since, + "until": until, + }) +} + +func BanEventIPActivityHandler(c *gin.Context) { + since, until, ok := parseEventRange(c) + if !ok { + return + } + minOverlap := 3 + if minOverlapStr := c.Query("minOverlap"); minOverlapStr != "" { + if parsed, err := strconv.Atoi(minOverlapStr); err == nil && parsed >= 1 { + minOverlap = parsed + } + } + + periods, err := storage.ListBanEventIPActivity(c.Request.Context(), eventFilterFromQuery(c, since, until), minOverlap) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if periods == nil { + periods = []storage.IPActivityPeriod{} + } + c.JSON(http.StatusOK, gin.H{"periods": periods}) +} + // Deletes all stored ban event records. func ClearBanEventsHandler(c *gin.Context) { deleted, err := storage.ClearBanEvents(c.Request.Context()) @@ -2502,6 +2658,136 @@ func AdvancedActionsTestHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Action %s completed for %s", action, req.IP)}) } +const bulkBlockMaxIPs = 500 +const bulkBlockAbortThreshold = 5 + +// Permanently blocks a list of IPs via the configured advanced-actions integration +func BulkPermanentBlockHandler(c *gin.Context) { + var req struct { + IPs []string `json:"ips"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if len(req.IPs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ips is required"}) + return + } + if len(req.IPs) > bulkBlockMaxIPs { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("too many IPs (max %d per request)", bulkBlockMaxIPs)}) + return + } + + settings := config.GetSettings() + if settings.AdvancedActions.Integration == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no integration configured. Please configure an integration (MikroTik, pfSense, or OPNsense) in Advanced Actions settings first"}) + return + } + integration, ok := integrations.Get(settings.AdvancedActions.Integration) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("integration %s not found or not registered", settings.AdvancedActions.Integration)}) + return + } + if err := integration.Validate(settings.AdvancedActions); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("integration configuration is invalid: %v", err)}) + return + } + + actingUser := "" + if sessionValue, exists := c.Get("session"); exists { + if session, ok := sessionValue.(*auth.Session); ok && session != nil { + actingUser = session.Username + if actingUser == "" { + actingUser = session.Email + } + } + } + + // Dedupe preserving order. + seen := make(map[string]bool, len(req.IPs)) + ips := make([]string, 0, len(req.IPs)) + for _, raw := range req.IPs { + ip := strings.TrimSpace(raw) + if ip == "" || seen[ip] { + continue + } + seen[ip] = true + ips = append(ips, ip) + } + + type bulkBlockResult struct { + IP string `json:"ip"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + } + results := make([]bulkBlockResult, 0, len(ips)) + summary := map[string]int{ + "requested": len(ips), "blocked": 0, "alreadyBlocked": 0, + "skipped": 0, "invalid": 0, "failed": 0, "aborted": 0, + } + + ctx := c.Request.Context() + consecutiveErrors := 0 + lastErrorMsg := "" + aborted := false + + for _, ip := range ips { + if aborted { + results = append(results, bulkBlockResult{IP: ip, Status: "aborted"}) + summary["aborted"]++ + continue + } + parsed := net.ParseIP(ip) + if parsed == nil { + results = append(results, bulkBlockResult{IP: ip, Status: "invalid", Message: "not a valid IP address"}) + summary["invalid"]++ + continue + } + if parsed.IsLoopback() || parsed.IsLinkLocalUnicast() || parsed.IsLinkLocalMulticast() || + parsed.IsMulticast() || parsed.IsUnspecified() || parsed.IsPrivate() { + results = append(results, bulkBlockResult{IP: ip, Status: "skipped_private", Message: "private/reserved address"}) + summary["skipped"]++ + continue + } + if active, err := storage.IsPermanentBlockActive(ctx, ip, settings.AdvancedActions.Integration); err == nil && active { + results = append(results, bulkBlockResult{IP: ip, Status: "already_blocked"}) + summary["alreadyBlocked"]++ + continue + } + + err := runAdvancedIntegrationAction(ctx, "block", ip, settings, config.Fail2banServer{}, map[string]any{ + "reason": "bulk_insights", + "user": actingUser, + "batchSize": len(ips), + }, false) + if err != nil { + results = append(results, bulkBlockResult{IP: ip, Status: "error", Message: err.Error()}) + summary["failed"]++ + if err.Error() == lastErrorMsg { + consecutiveErrors++ + } else { + consecutiveErrors = 1 + lastErrorMsg = err.Error() + } + if consecutiveErrors >= bulkBlockAbortThreshold { + aborted = true + } + continue + } + consecutiveErrors = 0 + lastErrorMsg = "" + results = append(results, bulkBlockResult{IP: ip, Status: "blocked"}) + summary["blocked"]++ + } + + log.Printf("Bulk permanent block: user=%s requested=%d blocked=%d alreadyBlocked=%d skipped=%d invalid=%d failed=%d aborted=%d", + actingUser, summary["requested"], summary["blocked"], summary["alreadyBlocked"], + summary["skipped"], summary["invalid"], summary["failed"], summary["aborted"]) + + c.JSON(http.StatusOK, gin.H{"results": results, "summary": summary}) +} + func getJailNames(jails map[string]bool) []string { names := make([]string, 0, len(jails)) for name := range jails { diff --git a/pkg/web/locales/ca.json b/pkg/web/locales/ca.json index 06ad96d..95801d8 100644 --- a/pkg/web/locales/ca.json +++ b/pkg/web/locales/ca.json @@ -701,5 +701,57 @@ "filters.toast.test_error": "Error en provar el filtre", "filters.confirm.delete": "Segur que voleu suprimir el filtre \"{name}\"? Aquesta acció no es pot desfer.", "globe.bans_singular": "bloqueig", - "globe.bans_plural": "bloqueigs" + "globe.bans_plural": "bloqueigs", + "logs.timeline.back": "Torna a l'interval anterior", + "logs.timeline.block_aborted": "cancel·lades", + "logs.timeline.block_all": "Bloqueja-les totes mitjançant el tallafoc", + "logs.timeline.block_already": "ja bloquejades", + "logs.timeline.block_capped": "Llista limitada a 500 IPs per sol·licitud.", + "logs.timeline.block_confirm_body": "Això bloquejarà permanentment {count} adreces IP mitjançant la integració de tallafoc configurada. Revisa la llista de sota.", + "logs.timeline.block_confirm_button": "Bloqueja {count} IPs", + "logs.timeline.block_confirm_title": "Confirma el bloqueig permanent", + "logs.timeline.block_error": "Error en enviar la sol·licitud de bloqueig", + "logs.timeline.block_failed": "fallides", + "logs.timeline.block_skipped": "omeses", + "logs.timeline.block_success": "{count} IPs enviades per bloquejar.", + "logs.timeline.compare_clear": "Treu", + "logs.timeline.compare_incident_a": "Incident A", + "logs.timeline.compare_incident_b": "Incident B", + "logs.timeline.compare_overlap": "IPs presents en tots dos incidents", + "logs.timeline.compare_overlap_empty": "No hi ha IPs comunes entre els dos incidents.", + "logs.timeline.compare_pin_hint": "Fixa dos incidents per comparar-ne les IPs.", + "logs.timeline.compare_title": "Comparació d'incidents", + "logs.timeline.compare_unpinned": "sense fixar", + "logs.timeline.custom_apply": "Aplica", + "logs.timeline.custom_invalid": "Introdueix un interval vàlid (d'1 hora a 12 mesos).", + "logs.timeline.custom_unit_days": "Dies", + "logs.timeline.custom_unit_hours": "Hores", + "logs.timeline.custom_unit_months": "Mesos", + "logs.timeline.custom_unit_weeks": "Setmanes", + "logs.timeline.empty": "No hi ha esdeveniments en aquest interval de temps.", + "logs.timeline.events_failures": "intents fallits", + "logs.timeline.events_no_logs": "No hi ha línies de registre desades per a aquest esdeveniment.", + "logs.timeline.events_reason": "Motiu del bloqueig", + "logs.timeline.events_title": "Esdeveniments a l'interval seleccionat", + "logs.timeline.events_total_suffix": "esdeveniments", + "logs.timeline.export_csv": "Exporta CSV", + "logs.timeline.export_empty": "No hi ha IPs per exportar per a aquesta selecció.", + "logs.timeline.export_error": "Error en exportar la llista d'IPs", + "logs.timeline.export_json": "Exporta JSON", + "logs.timeline.hint": "Arrossega sobre el gràfic per ampliar un interval de temps.", + "logs.timeline.live_new_events": "{count} esdeveniments nous - actualitza", + "logs.timeline.loading_error": "Error en carregar les dades de la línia de temps", + "logs.timeline.preset_14d": "14d", + "logs.timeline.preset_24h": "24h", + "logs.timeline.preset_30d": "30d", + "logs.timeline.preset_48h": "48h", + "logs.timeline.preset_7d": "7d", + "logs.timeline.preset_8h": "8h", + "logs.timeline.selection_pin_a": "Fixa com a incident A", + "logs.timeline.selection_pin_b": "Fixa com a incident B", + "logs.timeline.series_bans": "Bans", + "logs.timeline.series_unbans": "Desbans", + "logs.timeline.suggestions_empty": "No s'han trobat períodes semblants.", + "logs.timeline.suggestions_title": "Períodes passats semblants", + "logs.timeline.title": "Activitat de bans i desbans" } diff --git a/pkg/web/locales/de.json b/pkg/web/locales/de.json index 0db2e07..3ce5c00 100644 --- a/pkg/web/locales/de.json +++ b/pkg/web/locales/de.json @@ -701,5 +701,57 @@ "filters.toast.test_error": "Fehler beim Testen des Filters", "filters.confirm.delete": "Soll der Filter \"{name}\" wirklich gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.", "globe.bans_singular": "Sperre", - "globe.bans_plural": "Sperren" + "globe.bans_plural": "Sperren", + "logs.timeline.back": "Zurück zum vorherigen Zeitraum", + "logs.timeline.block_aborted": "abgebrochen", + "logs.timeline.block_all": "Alle per Firewall blockieren", + "logs.timeline.block_already": "bereits blockiert", + "logs.timeline.block_capped": "Liste auf 500 IPs pro Anfrage begrenzt.", + "logs.timeline.block_confirm_body": "Dies blockiert {count} IP-Adressen dauerhaft über die konfigurierte Firewall-Integration. Prüfen Sie die Liste unten.", + "logs.timeline.block_confirm_button": "{count} IPs blockieren", + "logs.timeline.block_confirm_title": "Permanente Sperrung bestätigen", + "logs.timeline.block_error": "Fehler beim Senden der Blockierungsanfrage", + "logs.timeline.block_failed": "fehlgeschlagen", + "logs.timeline.block_skipped": "übersprungen", + "logs.timeline.block_success": "{count} IPs zur Blockierung übermittelt.", + "logs.timeline.compare_clear": "Entfernen", + "logs.timeline.compare_incident_a": "Vorfall A", + "logs.timeline.compare_incident_b": "Vorfall B", + "logs.timeline.compare_overlap": "IPs in beiden Vorfällen", + "logs.timeline.compare_overlap_empty": "Keine überlappenden IPs zwischen den beiden Vorfällen.", + "logs.timeline.compare_pin_hint": "Heften Sie zwei Vorfälle an, um deren IPs zu vergleichen.", + "logs.timeline.compare_title": "Vorfall-Vergleich", + "logs.timeline.compare_unpinned": "nicht angeheftet", + "logs.timeline.custom_apply": "Anwenden", + "logs.timeline.custom_invalid": "Geben Sie einen gültigen Zeitraum ein (1 Stunde bis 12 Monate).", + "logs.timeline.custom_unit_days": "Tage", + "logs.timeline.custom_unit_hours": "Stunden", + "logs.timeline.custom_unit_months": "Monate", + "logs.timeline.custom_unit_weeks": "Wochen", + "logs.timeline.empty": "Keine Ereignisse in diesem Zeitraum.", + "logs.timeline.events_failures": "Fehlversuche", + "logs.timeline.events_no_logs": "Keine passenden Logzeilen für dieses Ereignis gespeichert.", + "logs.timeline.events_reason": "Sperrgrund", + "logs.timeline.events_title": "Ereignisse im gewählten Zeitraum", + "logs.timeline.events_total_suffix": "Ereignisse", + "logs.timeline.export_csv": "CSV exportieren", + "logs.timeline.export_empty": "Keine IPs für diese Auswahl zu exportieren.", + "logs.timeline.export_error": "Fehler beim Exportieren der IP-Liste", + "logs.timeline.export_json": "JSON exportieren", + "logs.timeline.hint": "Ziehen Sie auf dem Diagramm, um in einen Zeitraum zu zoomen.", + "logs.timeline.live_new_events": "{count} neue Ereignisse - aktualisieren", + "logs.timeline.loading_error": "Fehler beim Laden der Timeline-Daten", + "logs.timeline.preset_14d": "14d", + "logs.timeline.preset_24h": "24h", + "logs.timeline.preset_30d": "30d", + "logs.timeline.preset_48h": "48h", + "logs.timeline.preset_7d": "7d", + "logs.timeline.preset_8h": "8h", + "logs.timeline.selection_pin_a": "Als Vorfall A anheften", + "logs.timeline.selection_pin_b": "Als Vorfall B anheften", + "logs.timeline.series_bans": "Sperrungen", + "logs.timeline.series_unbans": "Entsperrungen", + "logs.timeline.suggestions_empty": "Keine ähnlichen Zeiträume gefunden.", + "logs.timeline.suggestions_title": "Ähnliche frühere Zeiträume", + "logs.timeline.title": "Ban- & Unban-Aktivität" } diff --git a/pkg/web/locales/de_ch.json b/pkg/web/locales/de_ch.json index 03a323e..702cde0 100644 --- a/pkg/web/locales/de_ch.json +++ b/pkg/web/locales/de_ch.json @@ -701,5 +701,57 @@ "filters.toast.test_error": "Fähler bim Teste vom Filter", "filters.confirm.delete": "Söu dr Filter \"{name}\" würklich glöscht wärde? Die Aktion cha nid rückgängig gmacht wärde.", "globe.bans_singular": "Sperri", - "globe.bans_plural": "Sperre" + "globe.bans_plural": "Sperre", + "logs.timeline.back": "Zurück zum vorherigen Zeitraum", + "logs.timeline.block_aborted": "abgebrochen", + "logs.timeline.block_all": "Alle per Firewall blockieren", + "logs.timeline.block_already": "bereits blockiert", + "logs.timeline.block_capped": "Liste auf 500 IPs pro Anfrage begrenzt.", + "logs.timeline.block_confirm_body": "Dies blockiert {count} IP-Adressen dauerhaft über die konfigurierte Firewall-Integration. Prüfen Sie die Liste unten.", + "logs.timeline.block_confirm_button": "{count} IPs blockieren", + "logs.timeline.block_confirm_title": "Permanente Sperrung bestätigen", + "logs.timeline.block_error": "Fehler beim Senden der Blockierungsanfrage", + "logs.timeline.block_failed": "fehlgeschlagen", + "logs.timeline.block_skipped": "übersprungen", + "logs.timeline.block_success": "{count} IPs zur Blockierung übermittelt.", + "logs.timeline.compare_clear": "Entfernen", + "logs.timeline.compare_incident_a": "Vorfall A", + "logs.timeline.compare_incident_b": "Vorfall B", + "logs.timeline.compare_overlap": "IPs in beiden Vorfällen", + "logs.timeline.compare_overlap_empty": "Keine überlappenden IPs zwischen den beiden Vorfällen.", + "logs.timeline.compare_pin_hint": "Heften Sie zwei Vorfälle an, um deren IPs zu vergleichen.", + "logs.timeline.compare_title": "Vorfall-Vergleich", + "logs.timeline.compare_unpinned": "nicht angeheftet", + "logs.timeline.custom_apply": "Anwenden", + "logs.timeline.custom_invalid": "Geben Sie einen gültigen Zeitraum ein (1 Stunde bis 12 Monate).", + "logs.timeline.custom_unit_days": "Tage", + "logs.timeline.custom_unit_hours": "Stunden", + "logs.timeline.custom_unit_months": "Monate", + "logs.timeline.custom_unit_weeks": "Wochen", + "logs.timeline.empty": "Keine Ereignisse in diesem Zeitraum.", + "logs.timeline.events_failures": "Fehlversuche", + "logs.timeline.events_no_logs": "Keine passenden Logzeilen für dieses Ereignis gespeichert.", + "logs.timeline.events_reason": "Sperrgrund", + "logs.timeline.events_title": "Ereignisse im gewählten Zeitraum", + "logs.timeline.events_total_suffix": "Ereignisse", + "logs.timeline.export_csv": "CSV exportieren", + "logs.timeline.export_empty": "Keine IPs für diese Auswahl zu exportieren.", + "logs.timeline.export_error": "Fehler beim Exportieren der IP-Liste", + "logs.timeline.export_json": "JSON exportieren", + "logs.timeline.hint": "Ziehen Sie auf dem Diagramm, um in einen Zeitraum zu zoomen.", + "logs.timeline.live_new_events": "{count} neue Ereignisse - aktualisieren", + "logs.timeline.loading_error": "Fehler beim Laden der Timeline-Daten", + "logs.timeline.preset_14d": "14d", + "logs.timeline.preset_24h": "24h", + "logs.timeline.preset_30d": "30d", + "logs.timeline.preset_48h": "48h", + "logs.timeline.preset_7d": "7d", + "logs.timeline.preset_8h": "8h", + "logs.timeline.selection_pin_a": "Als Vorfall A anheften", + "logs.timeline.selection_pin_b": "Als Vorfall B anheften", + "logs.timeline.series_bans": "Sperrungen", + "logs.timeline.series_unbans": "Entsperrungen", + "logs.timeline.suggestions_empty": "Keine ähnlichen Zeiträume gefunden.", + "logs.timeline.suggestions_title": "Ähnliche frühere Zeiträume", + "logs.timeline.title": "Ban- & Unban-Aktivität" } diff --git a/pkg/web/locales/en.json b/pkg/web/locales/en.json index 7690006..36a7b38 100644 --- a/pkg/web/locales/en.json +++ b/pkg/web/locales/en.json @@ -701,5 +701,57 @@ "filters.toast.test_error": "Error testing filter", "filters.confirm.delete": "Are you sure you want to delete the filter \"{name}\"? This action cannot be undone.", "globe.bans_singular": "ban", - "globe.bans_plural": "bans" + "globe.bans_plural": "bans", + "logs.timeline.back": "Back to previous range", + "logs.timeline.block_aborted": "aborted", + "logs.timeline.block_all": "Block all via firewall", + "logs.timeline.block_already": "already blocked", + "logs.timeline.block_capped": "List capped at 500 IPs per request.", + "logs.timeline.block_confirm_body": "This will permanently block {count} IP addresses on the configured firewall integration. Review the list below.", + "logs.timeline.block_confirm_button": "Block {count} IPs", + "logs.timeline.block_confirm_title": "Confirm permanent block", + "logs.timeline.block_error": "Error submitting block request", + "logs.timeline.block_failed": "failed", + "logs.timeline.block_skipped": "skipped", + "logs.timeline.block_success": "{count} IPs submitted for blocking.", + "logs.timeline.compare_clear": "Clear", + "logs.timeline.compare_incident_a": "Incident A", + "logs.timeline.compare_incident_b": "Incident B", + "logs.timeline.compare_overlap": "IPs present in both incidents", + "logs.timeline.compare_overlap_empty": "No overlapping IPs between the two incidents.", + "logs.timeline.compare_pin_hint": "Pin two incidents to compare their IPs.", + "logs.timeline.compare_title": "Incident compare", + "logs.timeline.compare_unpinned": "not pinned", + "logs.timeline.custom_apply": "Apply", + "logs.timeline.custom_invalid": "Enter a valid range (1 hour to 12 months).", + "logs.timeline.custom_unit_days": "Days", + "logs.timeline.custom_unit_hours": "Hours", + "logs.timeline.custom_unit_months": "Months", + "logs.timeline.custom_unit_weeks": "Weeks", + "logs.timeline.empty": "No events in this time range.", + "logs.timeline.events_failures": "failures", + "logs.timeline.events_no_logs": "No matched log lines stored for this event.", + "logs.timeline.events_reason": "Why blocked", + "logs.timeline.events_title": "Events in selected range", + "logs.timeline.events_total_suffix": "events", + "logs.timeline.export_csv": "Export CSV", + "logs.timeline.export_empty": "No IPs to export for this selection.", + "logs.timeline.export_error": "Error exporting IP list", + "logs.timeline.export_json": "Export JSON", + "logs.timeline.hint": "Drag on the chart to zoom into a time range.", + "logs.timeline.live_new_events": "{count} new events - refresh", + "logs.timeline.loading_error": "Error loading timeline data", + "logs.timeline.preset_14d": "14d", + "logs.timeline.preset_24h": "24h", + "logs.timeline.preset_30d": "30d", + "logs.timeline.preset_48h": "48h", + "logs.timeline.preset_7d": "7d", + "logs.timeline.preset_8h": "8h", + "logs.timeline.selection_pin_a": "Pin as Incident A", + "logs.timeline.selection_pin_b": "Pin as Incident B", + "logs.timeline.series_bans": "Bans", + "logs.timeline.series_unbans": "Unbans", + "logs.timeline.suggestions_empty": "No similar periods found.", + "logs.timeline.suggestions_title": "Similar past periods", + "logs.timeline.title": "Ban & unban activity" } diff --git a/pkg/web/locales/es.json b/pkg/web/locales/es.json index 8b1bf94..53d166a 100644 --- a/pkg/web/locales/es.json +++ b/pkg/web/locales/es.json @@ -701,5 +701,57 @@ "filters.toast.test_error": "Error al probar el filtro", "filters.confirm.delete": "¿Seguro que desea eliminar el filtro \"{name}\"? Esta acción no se puede deshacer.", "globe.bans_singular": "bloqueo", - "globe.bans_plural": "bloqueos" + "globe.bans_plural": "bloqueos", + "logs.timeline.back": "Volver al intervalo anterior", + "logs.timeline.block_aborted": "canceladas", + "logs.timeline.block_all": "Bloquear todas mediante el cortafuegos", + "logs.timeline.block_already": "ya bloqueadas", + "logs.timeline.block_capped": "Lista limitada a 500 IPs por solicitud.", + "logs.timeline.block_confirm_body": "Esto bloqueará permanentemente {count} direcciones IP mediante la integración de cortafuegos configurada. Revisa la lista a continuación.", + "logs.timeline.block_confirm_button": "Bloquear {count} IPs", + "logs.timeline.block_confirm_title": "Confirmar bloqueo permanente", + "logs.timeline.block_error": "Error al enviar la solicitud de bloqueo", + "logs.timeline.block_failed": "fallidas", + "logs.timeline.block_skipped": "omitidas", + "logs.timeline.block_success": "{count} IPs enviadas para su bloqueo.", + "logs.timeline.compare_clear": "Quitar", + "logs.timeline.compare_incident_a": "Incidente A", + "logs.timeline.compare_incident_b": "Incidente B", + "logs.timeline.compare_overlap": "IPs presentes en ambos incidentes", + "logs.timeline.compare_overlap_empty": "No hay IPs comunes entre los dos incidentes.", + "logs.timeline.compare_pin_hint": "Fija dos incidentes para comparar sus IPs.", + "logs.timeline.compare_title": "Comparación de incidentes", + "logs.timeline.compare_unpinned": "sin fijar", + "logs.timeline.custom_apply": "Aplicar", + "logs.timeline.custom_invalid": "Introduce un intervalo válido (de 1 hora a 12 meses).", + "logs.timeline.custom_unit_days": "Días", + "logs.timeline.custom_unit_hours": "Horas", + "logs.timeline.custom_unit_months": "Meses", + "logs.timeline.custom_unit_weeks": "Semanas", + "logs.timeline.empty": "No hay eventos en este intervalo de tiempo.", + "logs.timeline.events_failures": "intentos fallidos", + "logs.timeline.events_no_logs": "No hay líneas de registro almacenadas para este evento.", + "logs.timeline.events_reason": "Motivo del bloqueo", + "logs.timeline.events_title": "Eventos en el intervalo seleccionado", + "logs.timeline.events_total_suffix": "eventos", + "logs.timeline.export_csv": "Exportar CSV", + "logs.timeline.export_empty": "No hay IPs que exportar para esta selección.", + "logs.timeline.export_error": "Error al exportar la lista de IPs", + "logs.timeline.export_json": "Exportar JSON", + "logs.timeline.hint": "Arrastra sobre el gráfico para ampliar un intervalo de tiempo.", + "logs.timeline.live_new_events": "{count} eventos nuevos - actualizar", + "logs.timeline.loading_error": "Error al cargar los datos de la línea de tiempo", + "logs.timeline.preset_14d": "14d", + "logs.timeline.preset_24h": "24h", + "logs.timeline.preset_30d": "30d", + "logs.timeline.preset_48h": "48h", + "logs.timeline.preset_7d": "7d", + "logs.timeline.preset_8h": "8h", + "logs.timeline.selection_pin_a": "Fijar como incidente A", + "logs.timeline.selection_pin_b": "Fijar como incidente B", + "logs.timeline.series_bans": "Baneos", + "logs.timeline.series_unbans": "Desbaneos", + "logs.timeline.suggestions_empty": "No se encontraron períodos similares.", + "logs.timeline.suggestions_title": "Períodos pasados similares", + "logs.timeline.title": "Actividad de baneos y desbaneos" } diff --git a/pkg/web/locales/fr.json b/pkg/web/locales/fr.json index 022dc8a..ee715f2 100644 --- a/pkg/web/locales/fr.json +++ b/pkg/web/locales/fr.json @@ -701,5 +701,57 @@ "filters.toast.test_error": "Erreur lors du test du filtre", "filters.confirm.delete": "Voulez-vous vraiment supprimer le filtre \"{name}\" ? Cette action est irréversible.", "globe.bans_singular": "blocage", - "globe.bans_plural": "blocages" + "globe.bans_plural": "blocages", + "logs.timeline.back": "Retour à la période précédente", + "logs.timeline.block_aborted": "interrompues", + "logs.timeline.block_all": "Tout bloquer via le pare-feu", + "logs.timeline.block_already": "déjà bloquées", + "logs.timeline.block_capped": "Liste limitée à 500 IP par requête.", + "logs.timeline.block_confirm_body": "Cela bloquera définitivement {count} adresses IP via l'intégration pare-feu configurée. Vérifiez la liste ci-dessous.", + "logs.timeline.block_confirm_button": "Bloquer {count} IP", + "logs.timeline.block_confirm_title": "Confirmer le blocage permanent", + "logs.timeline.block_error": "Erreur lors de l'envoi de la demande de blocage", + "logs.timeline.block_failed": "échouées", + "logs.timeline.block_skipped": "ignorées", + "logs.timeline.block_success": "{count} IP soumises au blocage.", + "logs.timeline.compare_clear": "Effacer", + "logs.timeline.compare_incident_a": "Incident A", + "logs.timeline.compare_incident_b": "Incident B", + "logs.timeline.compare_overlap": "IP présentes dans les deux incidents", + "logs.timeline.compare_overlap_empty": "Aucune IP commune entre les deux incidents.", + "logs.timeline.compare_pin_hint": "Épinglez deux incidents pour comparer leurs IP.", + "logs.timeline.compare_title": "Comparaison d'incidents", + "logs.timeline.compare_unpinned": "non épinglé", + "logs.timeline.custom_apply": "Appliquer", + "logs.timeline.custom_invalid": "Saisissez une période valide (1 heure à 12 mois).", + "logs.timeline.custom_unit_days": "Jours", + "logs.timeline.custom_unit_hours": "Heures", + "logs.timeline.custom_unit_months": "Mois", + "logs.timeline.custom_unit_weeks": "Semaines", + "logs.timeline.empty": "Aucun événement dans cette période.", + "logs.timeline.events_failures": "échecs", + "logs.timeline.events_no_logs": "Aucune ligne de journal correspondante enregistrée pour cet événement.", + "logs.timeline.events_reason": "Motif du blocage", + "logs.timeline.events_title": "Événements dans la période sélectionnée", + "logs.timeline.events_total_suffix": "événements", + "logs.timeline.export_csv": "Exporter en CSV", + "logs.timeline.export_empty": "Aucune IP à exporter pour cette sélection.", + "logs.timeline.export_error": "Erreur lors de l'export de la liste d'IP", + "logs.timeline.export_json": "Exporter en JSON", + "logs.timeline.hint": "Faites glisser sur le graphique pour zoomer sur une période.", + "logs.timeline.live_new_events": "{count} nouveaux événements - actualiser", + "logs.timeline.loading_error": "Erreur lors du chargement des données de la chronologie", + "logs.timeline.preset_14d": "14d", + "logs.timeline.preset_24h": "24h", + "logs.timeline.preset_30d": "30d", + "logs.timeline.preset_48h": "48h", + "logs.timeline.preset_7d": "7d", + "logs.timeline.preset_8h": "8h", + "logs.timeline.selection_pin_a": "Épingler comme incident A", + "logs.timeline.selection_pin_b": "Épingler comme incident B", + "logs.timeline.series_bans": "Bannissements", + "logs.timeline.series_unbans": "Débannissements", + "logs.timeline.suggestions_empty": "Aucune période similaire trouvée.", + "logs.timeline.suggestions_title": "Périodes passées similaires", + "logs.timeline.title": "Activité de bannissement et débannissement" } diff --git a/pkg/web/locales/it.json b/pkg/web/locales/it.json index a99db29..9e4533a 100644 --- a/pkg/web/locales/it.json +++ b/pkg/web/locales/it.json @@ -701,5 +701,57 @@ "filters.toast.test_error": "Errore durante il test del filtro", "filters.confirm.delete": "Eliminare davvero il filtro \"{name}\"? Questa azione non può essere annullata.", "globe.bans_singular": "blocco", - "globe.bans_plural": "blocchi" + "globe.bans_plural": "blocchi", + "logs.timeline.back": "Torna all'intervallo precedente", + "logs.timeline.block_aborted": "interrotti", + "logs.timeline.block_all": "Blocca tutti tramite firewall", + "logs.timeline.block_already": "già bloccati", + "logs.timeline.block_capped": "Elenco limitato a 500 IP per richiesta.", + "logs.timeline.block_confirm_body": "Verranno bloccati permanentemente {count} indirizzi IP tramite l'integrazione firewall configurata. Controlla l'elenco qui sotto.", + "logs.timeline.block_confirm_button": "Blocca {count} IP", + "logs.timeline.block_confirm_title": "Conferma blocco permanente", + "logs.timeline.block_error": "Errore durante l'invio della richiesta di blocco", + "logs.timeline.block_failed": "falliti", + "logs.timeline.block_skipped": "saltati", + "logs.timeline.block_success": "{count} IP inviati per il blocco.", + "logs.timeline.compare_clear": "Rimuovi", + "logs.timeline.compare_incident_a": "Incidente A", + "logs.timeline.compare_incident_b": "Incidente B", + "logs.timeline.compare_overlap": "IP presenti in entrambi gli incidenti", + "logs.timeline.compare_overlap_empty": "Nessun IP in comune tra i due incidenti.", + "logs.timeline.compare_pin_hint": "Fissa due incidenti per confrontarne gli IP.", + "logs.timeline.compare_title": "Confronto incidenti", + "logs.timeline.compare_unpinned": "non fissato", + "logs.timeline.custom_apply": "Applica", + "logs.timeline.custom_invalid": "Inserisci un intervallo valido (da 1 ora a 12 mesi).", + "logs.timeline.custom_unit_days": "Giorni", + "logs.timeline.custom_unit_hours": "Ore", + "logs.timeline.custom_unit_months": "Mesi", + "logs.timeline.custom_unit_weeks": "Settimane", + "logs.timeline.empty": "Nessun evento in questo intervallo di tempo.", + "logs.timeline.events_failures": "tentativi falliti", + "logs.timeline.events_no_logs": "Nessuna riga di log memorizzata per questo evento.", + "logs.timeline.events_reason": "Motivo del blocco", + "logs.timeline.events_title": "Eventi nell'intervallo selezionato", + "logs.timeline.events_total_suffix": "eventi", + "logs.timeline.export_csv": "Esporta CSV", + "logs.timeline.export_empty": "Nessun IP da esportare per questa selezione.", + "logs.timeline.export_error": "Errore durante l'esportazione dell'elenco IP", + "logs.timeline.export_json": "Esporta JSON", + "logs.timeline.hint": "Trascina sul grafico per ingrandire un intervallo di tempo.", + "logs.timeline.live_new_events": "{count} nuovi eventi - aggiorna", + "logs.timeline.loading_error": "Errore durante il caricamento dei dati della timeline", + "logs.timeline.preset_14d": "14d", + "logs.timeline.preset_24h": "24h", + "logs.timeline.preset_30d": "30d", + "logs.timeline.preset_48h": "48h", + "logs.timeline.preset_7d": "7d", + "logs.timeline.preset_8h": "8h", + "logs.timeline.selection_pin_a": "Fissa come incidente A", + "logs.timeline.selection_pin_b": "Fissa come incidente B", + "logs.timeline.series_bans": "Ban", + "logs.timeline.series_unbans": "Unban", + "logs.timeline.suggestions_empty": "Nessun periodo simile trovato.", + "logs.timeline.suggestions_title": "Periodi passati simili", + "logs.timeline.title": "Attività di ban e unban" } diff --git a/pkg/web/locales/ja.json b/pkg/web/locales/ja.json index e792eb0..1cd4ac5 100644 --- a/pkg/web/locales/ja.json +++ b/pkg/web/locales/ja.json @@ -701,5 +701,57 @@ "filters.toast.test_error": "フィルターのテストエラー", "filters.confirm.delete": "フィルター「{name}」を削除してもよろしいですか?この操作は取り消せません。", "globe.bans_singular": "件のブロック", - "globe.bans_plural": "件のブロック" + "globe.bans_plural": "件のブロック", + "logs.timeline.back": "前の期間に戻る", + "logs.timeline.block_aborted": "中止", + "logs.timeline.block_all": "すべてファイアウォールでブロック", + "logs.timeline.block_already": "ブロック済み", + "logs.timeline.block_capped": "リストは1リクエストあたり500 IPに制限されます。", + "logs.timeline.block_confirm_body": "設定されたファイアウォール統合を通じて {count} 件のIPアドレスを永久にブロックします。以下のリストを確認してください。", + "logs.timeline.block_confirm_button": "{count} 件のIPをブロック", + "logs.timeline.block_confirm_title": "永久ブロックの確認", + "logs.timeline.block_error": "ブロックリクエストの送信エラー", + "logs.timeline.block_failed": "失敗", + "logs.timeline.block_skipped": "スキップ", + "logs.timeline.block_success": "{count} 件のIPをブロック依頼しました。", + "logs.timeline.compare_clear": "解除", + "logs.timeline.compare_incident_a": "インシデントA", + "logs.timeline.compare_incident_b": "インシデントB", + "logs.timeline.compare_overlap": "両方のインシデントに存在するIP", + "logs.timeline.compare_overlap_empty": "2つのインシデントに共通するIPはありません。", + "logs.timeline.compare_pin_hint": "2つのインシデントを固定してIPを比較します。", + "logs.timeline.compare_title": "インシデント比較", + "logs.timeline.compare_unpinned": "未固定", + "logs.timeline.custom_apply": "適用", + "logs.timeline.custom_invalid": "有効な期間を入力してください(1時間~12か月)。", + "logs.timeline.custom_unit_days": "日", + "logs.timeline.custom_unit_hours": "時間", + "logs.timeline.custom_unit_months": "月", + "logs.timeline.custom_unit_weeks": "週", + "logs.timeline.empty": "この期間にイベントはありません。", + "logs.timeline.events_failures": "失敗回数", + "logs.timeline.events_no_logs": "このイベントに保存されたログ行はありません。", + "logs.timeline.events_reason": "ブロック理由", + "logs.timeline.events_title": "選択した期間のイベント", + "logs.timeline.events_total_suffix": "件のイベント", + "logs.timeline.export_csv": "CSVエクスポート", + "logs.timeline.export_empty": "この選択にはエクスポートするIPがありません。", + "logs.timeline.export_error": "IPリストのエクスポートエラー", + "logs.timeline.export_json": "JSONエクスポート", + "logs.timeline.hint": "グラフ上をドラッグして期間を拡大します。", + "logs.timeline.live_new_events": "{count} 件の新しいイベント - 更新", + "logs.timeline.loading_error": "タイムラインデータの読み込みエラー", + "logs.timeline.preset_14d": "14d", + "logs.timeline.preset_24h": "24h", + "logs.timeline.preset_30d": "30d", + "logs.timeline.preset_48h": "48h", + "logs.timeline.preset_7d": "7d", + "logs.timeline.preset_8h": "8h", + "logs.timeline.selection_pin_a": "インシデントAとして固定", + "logs.timeline.selection_pin_b": "インシデントBとして固定", + "logs.timeline.series_bans": "BAN", + "logs.timeline.series_unbans": "BAN解除", + "logs.timeline.suggestions_empty": "類似した期間は見つかりませんでした。", + "logs.timeline.suggestions_title": "類似した過去の期間", + "logs.timeline.title": "BAN・BAN解除のアクティビティ" } diff --git a/pkg/web/locales/zh.json b/pkg/web/locales/zh.json index cbc3d6b..f8ce2be 100644 --- a/pkg/web/locales/zh.json +++ b/pkg/web/locales/zh.json @@ -701,5 +701,57 @@ "filters.toast.test_error": "测试过滤器出错", "filters.confirm.delete": "确定要删除过滤器\"{name}\"吗?此操作无法撤销。", "globe.bans_singular": "次封禁", - "globe.bans_plural": "次封禁" + "globe.bans_plural": "次封禁", + "logs.timeline.back": "返回上一个时间范围", + "logs.timeline.block_aborted": "已中止", + "logs.timeline.block_all": "通过防火墙全部封禁", + "logs.timeline.block_already": "已封禁", + "logs.timeline.block_capped": "每次请求的列表上限为500个IP。", + "logs.timeline.block_confirm_body": "这将通过已配置的防火墙集成永久封禁 {count} 个IP地址。请检查下面的列表。", + "logs.timeline.block_confirm_button": "封禁 {count} 个IP", + "logs.timeline.block_confirm_title": "确认永久封禁", + "logs.timeline.block_error": "提交封禁请求出错", + "logs.timeline.block_failed": "失败", + "logs.timeline.block_skipped": "已跳过", + "logs.timeline.block_success": "已提交 {count} 个IP进行封禁。", + "logs.timeline.compare_clear": "清除", + "logs.timeline.compare_incident_a": "事件A", + "logs.timeline.compare_incident_b": "事件B", + "logs.timeline.compare_overlap": "两个事件中都出现的IP", + "logs.timeline.compare_overlap_empty": "两个事件之间没有重叠的IP。", + "logs.timeline.compare_pin_hint": "固定两个事件以对比其IP。", + "logs.timeline.compare_title": "事件对比", + "logs.timeline.compare_unpinned": "未固定", + "logs.timeline.custom_apply": "应用", + "logs.timeline.custom_invalid": "请输入有效的时间范围(1小时至12个月)。", + "logs.timeline.custom_unit_days": "天", + "logs.timeline.custom_unit_hours": "小时", + "logs.timeline.custom_unit_months": "月", + "logs.timeline.custom_unit_weeks": "周", + "logs.timeline.empty": "此时间范围内没有事件。", + "logs.timeline.events_failures": "失败次数", + "logs.timeline.events_no_logs": "此事件没有存储匹配的日志行。", + "logs.timeline.events_reason": "封禁原因", + "logs.timeline.events_title": "所选范围内的事件", + "logs.timeline.events_total_suffix": "个事件", + "logs.timeline.export_csv": "导出CSV", + "logs.timeline.export_empty": "此选择没有可导出的IP。", + "logs.timeline.export_error": "导出IP列表出错", + "logs.timeline.export_json": "导出JSON", + "logs.timeline.hint": "在图表上拖动以放大时间范围。", + "logs.timeline.live_new_events": "{count} 个新事件 - 刷新", + "logs.timeline.loading_error": "加载时间线数据出错", + "logs.timeline.preset_14d": "14d", + "logs.timeline.preset_24h": "24h", + "logs.timeline.preset_30d": "30d", + "logs.timeline.preset_48h": "48h", + "logs.timeline.preset_7d": "7d", + "logs.timeline.preset_8h": "8h", + "logs.timeline.selection_pin_a": "固定为事件A", + "logs.timeline.selection_pin_b": "固定为事件B", + "logs.timeline.series_bans": "封禁", + "logs.timeline.series_unbans": "解封", + "logs.timeline.suggestions_empty": "未找到相似的时段。", + "logs.timeline.suggestions_title": "相似的历史时段", + "logs.timeline.title": "封禁与解封活动" } diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 368000f..9cf2a72 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -86,6 +86,7 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) { // Internal API calls for advanced actions api.GET("/advanced-actions/blocks", RequirePermission(PermissionAdmin), ListPermanentBlocksHandler) + api.POST("/advanced-actions/blocks", RequirePermission(PermissionAdmin), BulkPermanentBlockHandler) api.DELETE("/advanced-actions/blocks", RequirePermission(PermissionAdmin), ClearPermanentBlocksHandler) api.POST("/advanced-actions/test", RequirePermission(PermissionAdmin), AdvancedActionsTestHandler) @@ -106,6 +107,9 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) { api.DELETE("/events/bans", RequirePermission(PermissionAdmin), ClearBanEventsHandler) api.GET("/events/bans/stats", RequirePermission(PermissionRead), BanStatisticsHandler) api.GET("/events/bans/insights", RequirePermission(PermissionRead), BanInsightsHandler) + api.GET("/events/bans/timeline", RequirePermission(PermissionRead), BanTimelineHandler) + api.GET("/events/bans/ips", RequirePermission(PermissionRead), ListBanEventIPsHandler) + api.GET("/events/bans/ips/activity", RequirePermission(PermissionRead), BanEventIPActivityHandler) api.GET("/events/bans/:id", RequirePermission(PermissionRead), GetBanEventHandler) api.GET("/threat-intel/:ip", RequirePermission(PermissionRead), ThreatIntelHandler) diff --git a/pkg/web/static/js/core.js b/pkg/web/static/js/core.js index c55a8b3..afe9e56 100644 --- a/pkg/web/static/js/core.js +++ b/pkg/web/static/js/core.js @@ -206,6 +206,21 @@ function formatDateTime(value) { return year + '.' + month + '.' + day + ', ' + hours + ':' + minutes + ':' + seconds; } +function renderStatCard(card) { + var isCard = card.variant === 'card'; + var wrapperClass = isCard ? 'bg-white rounded-lg shadow p-4' : 'border border-gray-200 rounded-lg p-4 bg-gray-50'; + var labelClass = isCard ? 'text-sm text-gray-500' : 'text-xs uppercase tracking-wide text-gray-500'; + var valueClass = isCard ? 'text-2xl font-semibold text-gray-800' : 'text-3xl font-semibold text-gray-900 mt-1'; + var html = '
' + escapeHtml(card.label || '') + '
' + + '' + escapeHtml(card.value) + '
'; + if (card.sub || card.subKey) { + html += '' + escapeHtml(card.sub || '') + '
'; + } + html += 'Active Jails
' - + '' + (summary.jails ? summary.jails.length : 0) + '
' - + 'Total Banned IPs
' - + '' + totalBanned + '
' - + 'New Last Hour
' - + '' + newLastHour + '
' - + 'Recurring IPs
' - + '' + recurringWeekCount + '
' - + 'Keep an eye on repeated offenders across all servers.
' - + '' + + escapeHtml(t('logs.timeline.empty', 'No events in this time range.')) + '
'; + if (moreContainer) moreContainer.innerHTML = ''; + return; + } + + var html = ''; + for (var i = 0; i < state.items.length; i++) { + var event = state.items[i]; + var isUnban = (event.eventType || 'ban') === 'unban'; + var typeBadge = isUnban + ? '' + t('logs.badge.unbanned', 'Unbanned') + '' + : '' + t('logs.badge.banned', 'Banned') + ''; + var expanded = !!state.expanded[event.id]; + var country = (typeof countryLabel === 'function') ? countryLabel(event.country) : (event.country || ''); + + html += '' + escapeHtml(t('loading', 'Loading...')) + '
'; + } + if (event.logs && event.logs.trim()) { + var logsHtml; + if (typeof buildHighlightedLogsHtml === 'function') { + logsHtml = buildHighlightedLogsHtml(event.logs, event.ip || '').html; + } else { + logsHtml = escapeHtml(event.logs); + } + html += '' + logsHtml + ''; + } else { + html += '
' + escapeHtml(t('logs.timeline.events_no_logs', 'No matched log lines stored for this event.')) + '
'; + } + return html; +} + +function toggleTimelineEventDetail(eventId) { + var state = insightsTimeline.events; + var event = null; + for (var i = 0; i < state.items.length; i++) { + if (state.items[i].id === eventId) { event = state.items[i]; break; } + } + if (!event) return; + + if (state.expanded[eventId]) { + delete state.expanded[eventId]; + renderTimelineEventList(); + return; + } + state.expanded[eventId] = true; + renderTimelineEventList(); + + if (!event._detailLoaded && typeof ensureBanEventDetail === 'function') { + ensureBanEventDetail(event) + .then(function() { + if (insightsTimeline.events.expanded[eventId]) { + renderTimelineEventList(); + } + }) + .catch(function(err) { + console.error('Error loading event detail:', err); + }); + } +} + +// ========================================================================= +// Incident compare +// ========================================================================= + +function fetchRangeIPs(since, until) { + var query = '?since=' + encodeURIComponent(since.toISOString()) + + '&until=' + encodeURIComponent(until.toISOString()); + return fetch(appPath('/api/events/bans/ips' + query)) + .then(function(res) { + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }); +} + +function pinIncident(slot) { + var range = insightsTimeline.range; + if (!range.since || !range.until) return; + + var incident = { + since: new Date(range.since.getTime()), + until: new Date(range.until.getTime()), + ips: [], + truncated: false, + loading: true + }; + insightsTimeline.incidents[slot] = incident; + renderIncidentCompare(); + + fetchRangeIPs(incident.since, incident.until) + .then(function(data) { + if (insightsTimeline.incidents[slot] !== incident) return; + incident.ips = data.ips || []; + incident.truncated = data.truncated === true; + incident.loading = false; + renderIncidentCompare(); + }) + .catch(function(err) { + console.error('Error fetching incident IPs:', err); + if (insightsTimeline.incidents[slot] === incident) { + incident.loading = false; + incident.error = true; + renderIncidentCompare(); + } + }); + + if (slot === 'A') { + fetchTimelineSuggestions(incident); + } +} + +function clearIncident(slot) { + insightsTimeline.incidents[slot] = null; + if (slot === 'A') { + insightsTimeline.suggestions = []; + } + renderIncidentCompare(); +} + +function computeIncidentOverlap() { + var a = insightsTimeline.incidents.A; + var b = insightsTimeline.incidents.B; + if (!a || !b || a.loading || b.loading) return null; + var mapB = {}; + for (var i = 0; i < b.ips.length; i++) { + mapB[b.ips[i].ip] = b.ips[i]; + } + var overlap = []; + for (var j = 0; j < a.ips.length; j++) { + var statA = a.ips[j]; + var statB = mapB[statA.ip]; + if (statB) { + overlap.push({ + ip: statA.ip, + country: statA.country || statB.country || '', + countA: statA.count, + countB: statB.count, + firstSeen: statA.firstSeen < statB.firstSeen ? statA.firstSeen : statB.firstSeen, + lastSeen: statA.lastSeen > statB.lastSeen ? statA.lastSeen : statB.lastSeen + }); + } + } + return overlap; +} + +function fetchTimelineSuggestions(incident) { + var query = '?since=' + encodeURIComponent(incident.since.toISOString()) + + '&until=' + encodeURIComponent(incident.until.toISOString()); + fetch(appPath('/api/events/bans/ips/activity' + query)) + .then(function(res) { + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }) + .then(function(data) { + if (insightsTimeline.incidents.A !== incident) return; + insightsTimeline.suggestions = data.periods || []; + renderIncidentCompare(); + }) + .catch(function(err) { + console.error('Error fetching similar periods:', err); + }); +} + +function applySuggestedPeriod(index) { + var period = insightsTimeline.suggestions[index]; + if (!period) return; + var since = new Date(period.day + 'T00:00:00Z'); + if (isNaN(since.getTime())) return; + var until = new Date(since.getTime() + 24 * 3600 * 1000); + zoomIntoRange(since, until); +} + +function renderIncidentCompare() { + var panel = document.getElementById('timelineCompare'); + var chipsEl = document.getElementById('timelineIncidentChips'); + var overlapEl = document.getElementById('timelineOverlapPanel'); + var suggestionsEl = document.getElementById('timelineSuggestions'); + if (!panel || !chipsEl || !overlapEl || !suggestionsEl) return; + + var a = insightsTimeline.incidents.A; + var b = insightsTimeline.incidents.B; + if (!a && !b) { + panel.classList.add('hidden'); + return; + } + panel.classList.remove('hidden'); + + var chipsHtml = ''; + ['A', 'B'].forEach(function(slot) { + var incident = insightsTimeline.incidents[slot]; + var slotLabel = t('logs.timeline.compare_incident_' + slot.toLowerCase(), 'Incident ' + slot); + if (incident) { + var detail = incident.loading + ? t('loading', 'Loading...') + : formatNumber(incident.ips.length) + ' IPs' + (incident.truncated ? ' (max)' : ''); + chipsHtml += '' + + '' + escapeHtml(slotLabel) + ' ' + + escapeHtml(formatDateTime(incident.since) + ' – ' + formatDateTime(incident.until)) + + ' · ' + escapeHtml(detail) + + '' + + ''; + } else { + chipsHtml += '' + + escapeHtml(slotLabel) + ' – ' + escapeHtml(t('logs.timeline.compare_unpinned', 'not pinned')) + + ''; + } + }); + chipsEl.innerHTML = chipsHtml; + + var overlap = computeIncidentOverlap(); + if (overlap === null) { + overlapEl.innerHTML = (a && b) + ? '' + escapeHtml(t('loading', 'Loading...')) + '
' + : '' + escapeHtml(t('logs.timeline.compare_pin_hint', 'Pin two incidents to compare their IPs.')) + '
'; + } else if (!overlap.length) { + overlapEl.innerHTML = '' + escapeHtml(t('logs.timeline.compare_overlap_empty', 'No overlapping IPs between the two incidents.')) + '
'; + } else { + var html = '' + escapeHtml(t('logs.timeline.suggestions_title', 'Similar past periods')) + '
' + + '' + escapeHtml(t('logs.timeline.suggestions_empty', 'No similar periods found.')) + '
'; + } else { + suggestionsEl.innerHTML = ''; + } + + if (typeof applyAuthorizationUI === 'function') { + applyAuthorizationUI(); + } +} + +// ========================================================================= +// Export +// ========================================================================= + +function timelineCsvField(value) { + var s = String(value === null || value === undefined ? '' : value); + if (/^[=+\-@\t\r]/.test(s)) { + s = "'" + s; // CSV formula-injection guard + } + return '"' + s.replace(/"/g, '""') + '"'; +} + +function timelineSanitizeFilename(s) { + return String(s).replace(/[^0-9A-Za-z._-]+/g, '-'); +} + +function timelineDownloadBlob(content, mimeType, filename) { + var blob = new Blob([content], { type: mimeType }); + var url = URL.createObjectURL(blob); + var link = document.createElement('a'); + link.href = url; + link.download = timelineSanitizeFilename(filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +// Resolves the IP list for a scope: 'overlap' uses the compare result, +// 'range' fetches the aggregate for the currently visible window. +function resolveTimelineIPs(scope) { + if (scope === 'overlap') { + var overlap = computeIncidentOverlap(); + return Promise.resolve(overlap || []); + } + var range = insightsTimeline.range; + if (!range.since || !range.until) return Promise.resolve([]); + return fetchRangeIPs(range.since, range.until).then(function(data) { + return data.ips || []; + }); +} + +function exportTimelineIPs(format, scope) { + scope = scope || 'range'; + resolveTimelineIPs(scope) + .then(function(ips) { + if (!ips.length) { + showToast(t('logs.timeline.export_empty', 'No IPs to export for this selection.'), 'info'); + return; + } + var range = insightsTimeline.range; + var a = insightsTimeline.incidents.A; + var b = insightsTimeline.incidents.B; + var stamp = (scope === 'overlap' && a && b) + ? a.since.toISOString() + '_' + b.since.toISOString() + : range.since.toISOString() + '_' + range.until.toISOString(); + + if (format === 'json') { + var payload = { scope: scope, exportedAt: new Date().toISOString(), ips: ips }; + if (scope === 'overlap' && a && b) { + payload.rangeA = { since: a.since.toISOString(), until: a.until.toISOString() }; + payload.rangeB = { since: b.since.toISOString(), until: b.until.toISOString() }; + } else { + payload.range = { since: range.since.toISOString(), until: range.until.toISOString() }; + } + timelineDownloadBlob(JSON.stringify(payload, null, 2), 'application/json', 'fail2ban-ips_' + stamp + '.json'); + return; + } + + var lines = []; + if (scope === 'overlap') { + lines.push(['ip', 'country', 'count_a', 'count_b', 'first_seen', 'last_seen'].map(timelineCsvField).join(',')); + ips.forEach(function(entry) { + lines.push([entry.ip, entry.country, entry.countA, entry.countB, entry.firstSeen, entry.lastSeen].map(timelineCsvField).join(',')); + }); + } else { + lines.push(['ip', 'country', 'ban_count', 'first_seen', 'last_seen', 'jails'].map(timelineCsvField).join(',')); + ips.forEach(function(entry) { + lines.push([entry.ip, entry.country, entry.count, entry.firstSeen, entry.lastSeen, entry.jails].map(timelineCsvField).join(',')); + }); + } + timelineDownloadBlob(lines.join('\r\n') + '\r\n', 'text/csv;charset=utf-8', 'fail2ban-ips_' + stamp + '.csv'); + }) + .catch(function(err) { + console.error('Error exporting IPs:', err); + showToast(t('logs.timeline.export_error', 'Error exporting IP list'), 'error'); + }); +} + +// ========================================================================= +// Bulk permanent block +// ========================================================================= + +function openBulkBlockConfirm(scope) { + if (typeof hasAccess === 'function' && !hasAccess('admin')) return; + resolveTimelineIPs(scope || 'range') + .then(function(ips) { + var addresses = ips.map(function(entry) { return entry.ip; }).filter(Boolean); + if (!addresses.length) { + showToast(t('logs.timeline.export_empty', 'No IPs to export for this selection.'), 'info'); + return; + } + if (addresses.length > 500) { + addresses = addresses.slice(0, 500); + showToast(t('logs.timeline.block_capped', 'List capped at 500 IPs per request.'), 'info'); + } + insightsTimeline.pendingBlockIPs = addresses; + + var textEl = document.getElementById('bulkBlockConfirmText'); + if (textEl) { + textEl.textContent = t('logs.timeline.block_confirm_body', 'This will permanently block {count} IP addresses on the configured firewall integration. Review the list below.') + .replace('{count}', formatNumber(addresses.length)); + } + var listEl = document.getElementById('bulkBlockIPList'); + if (listEl) { + listEl.innerHTML = addresses.map(function(ip) { + return '' + escapeHtml(card.label) + '
' - + '' + escapeHtml(card.value) + '
' - + '' + escapeHtml(card.sub) + '
' - + 'No recurring IPs detected.
'; } else { var recurringHTML = recurring.map(function(stat) { - var countryLabel = stat.country || t('logs.overview.country_unknown', 'Unknown'); + var statCountry = countryLabel(stat.country); var lastSeenLabel = stat.lastSeen ? formatDateTime(stat.lastSeen) : ' - '; return '' + '' + escapeHtml(stat.ip || ' - ') + '
' - + '' + escapeHtml(countryLabel) + '
' + + '' + escapeHtml(statCountry) + '
' + 'u-d&&(s=u-d,a.length=s);for(var f=0;f =a)}}for(var h=this.__startIndex;h n?a:o,h=Math.abs(l.label.y-n);if(h>=u.maxY){var c=l.label.x-e-l.len2*r,p=i+l.len,f=Math.abs(c) t.unconstrainedWidth?null:d:null;i.setStyle("width",f)}var g=i.getBoundingRect();o.width=g.width;var y=(i.style.margin||0)+2.1;o.height=g.height+y,o.y-=(o.height-c)/2}}}function kM(t){return"center"===t.position}function LM(t){var e,n,i=t.getData(),r=[],o=!1,a=(t.get("minShowLabelAngle")||0)*CM,s=i.getLayout("viewRect"),l=i.getLayout("r"),u=s.width,h=s.x,c=s.y,p=s.height;function d(t){t.ignore=!0}i.each((function(t){var s=i.getItemGraphicEl(t),c=s.shape,p=s.getTextContent(),f=s.getTextGuideLine(),g=i.getItemModel(t),y=g.getModel("label"),v=y.get("position")||g.get(["emphasis","label","position"]),m=y.get("distanceToLabelLine"),x=y.get("alignTo"),_=$r(y.get("edgeDistance"),u),b=y.get("bleedMargin"),w=g.getModel("labelLine"),S=w.get("length");S=$r(S,u);var M=w.get("length2");if(M=$r(M,u),Math.abs(c.endAngle-c.startAngle)0?"right":"left":k>0?"left":"right"}var B=Math.PI,F=0,G=y.get("rotate");if(j(G))F=G*(B/180);else if("center"===v)F=0;else if("radial"===G||!0===G){F=k<0?-A+B:-A}else if("tangential"===G&&"outside"!==v&&"outer"!==v){var W=Math.atan2(k,L);W<0&&(W=2*B+W),L>0&&(W=B+W),F=W-B}if(o=!!F,p.x=I,p.y=T,p.rotation=F,p.setStyle({verticalAlign:"middle"}),P){p.setStyle({align:D});var H=p.states.select;H&&(H.x+=p.x,H.y+=p.y)}else{var Y=p.getBoundingRect().clone();Y.applyTransform(p.getComputedTransform());var X=(p.style.margin||0)+2.1;Y.y-=X/2,Y.height+=X,r.push({label:p,labelLine:f,position:v,len:S,len2:M,minTurnAngle:w.get("minTurnAngle"),maxSurfaceAngle:w.get("maxSurfaceAngle"),surfaceNormal:new De(k,L),linePoints:C,textAlign:D,labelDistance:m,labelAlignTo:x,edgeDistance:_,bleedMargin:b,rect:Y,unconstrainedWidth:Y.width,labelStyleWidth:p.style.width})}s.setTextConfig({inside:P})}})),!o&&t.get("avoidLabelOverlap")&&function(t,e,n,i,r,o,a,s){for(var l=[],u=[],h=Number.MAX_VALUE,c=-Number.MAX_VALUE,p=0;p i&&(i=e);var o=i%2?i+2:i+3;r=[];for(var a=0;ah[1]&&(h[1]=y),c[p++]=v}return r._count=p,r._indices=c,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();r1||n>0&&!t.noHeader;return E(t.blocks,(function(t){var n=lg(t);n>=e&&(e=n+ +(i&&(!n||ag(t)&&!t.noHeader)))})),e}return 0}function ug(t,e,n,i){var r,o=e.noHeader,a=(r=lg(e),{html:ig[r],richText:rg[r]}),s=[],l=e.blocks||[];lt(!l||Y(l)),l=l||[];var u=t.orderMode;if(e.sortBlocks&&u){l=l.slice();var h={valueAsc:"asc",valueDesc:"desc"};if(_t(h,u)){var c=new kf(h[u],null);l.sort((function(t,e){return c.evaluate(t.sortParam,e.sortParam)}))}else"seriesDesc"===u&&l.reverse()}E(l,(function(n,r){var o=e.valueFormatter,l=sg(n)(o?A(A({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)}));var p="richText"===t.renderMode?s.join(a.richText):pg(i,s.join(""),o?n:a.html);if(o)return p;var d=mp(e.header,"ordinal",t.useUTC),f=ng(i,t.renderMode).nameStyle,g=eg(i);return"richText"===t.renderMode?dg(t,d,f)+a.richText+p:pg(i,'5)return;var i=this._model.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]);"none"!==i.behavior&&this._dispatchExpand({axisExpandWindow:i.axisExpandWindow})}this._mouseDownPoint=null},mousemove:function(t){if(!this._mouseDownPoint&&Rk(this,"mousemove")){var e=this._model,n=e.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]),i=n.behavior;"jump"===i&&this._throttledDispatchExpand.debounceNextCall(e.get("axisExpandDebounce")),this._throttledDispatchExpand("none"===i?null:{axisExpandWindow:n.axisExpandWindow,animation:"jump"===i?null:{duration:0}})}}};function Rk(t,e){var n=t._model;return n.get("axisExpandable")&&n.get("axisExpandTriggerOn")===e}var Nk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(){t.prototype.init.apply(this,arguments),this.mergeOption({})},e.prototype.mergeOption=function(t){var e=this.option;t&&C(e,t,!0),this._initDimensions()},e.prototype.contains=function(t,e){var n=t.get("parallelIndex");return null!=n&&e.getComponent("parallel",n)===this},e.prototype.setAxisExpand=function(t){E(["axisExpandable","axisExpandCenter","axisExpandCount","axisExpandWidth","axisExpandWindow"],(function(e){t.hasOwnProperty(e)&&(this.option[e]=t[e])}),this)},e.prototype._initDimensions=function(){var t=this.dimensions=[],e=this.parallelAxisIndex=[];E(B(this.ecModel.queryComponents({mainType:"parallelAxis"}),(function(t){return(t.get("parallelIndex")||0)===this.componentIndex}),this),(function(n){t.push("dim"+n.get("dim")),e.push(n.componentIndex)}))},e.type="parallel",e.dependencies=["parallelAxis"],e.layoutMode="box",e.defaultOption={z:0,left:80,top:60,right:80,bottom:60,layout:"horizontal",axisExpandable:!1,axisExpandCenter:null,axisExpandCount:0,axisExpandWidth:50,axisExpandRate:17,axisExpandDebounce:50,axisExpandSlideTriggerArea:[-.15,.05,.4],axisExpandTriggerOn:"click",parallelAxisDefault:null},e}(zp),Ek=function(t){function e(e,n,i,r,o){var a=t.call(this,e,n,i)||this;return a.type=r||"value",a.axisIndex=o,a}return n(e,t),e.prototype.isHorizontal=function(){return"horizontal"!==this.coordinateSystem.getModel().get("layout")},e}(ab);function zk(t,e,n,i,r,o){t=t||0;var a=n[1]-n[0];if(null!=r&&(r=Bk(r,[0,a])),null!=o&&(o=Math.max(o,null!=r?r:0)),"all"===i){var s=Math.abs(e[1]-e[0]);s=Bk(s,[0,a]),r=o=Bk(s,[r,o]),i=0}e[0]=Bk(e[0],n),e[1]=Bk(e[1],n);var l=Vk(e,i);e[i]+=t;var u,h=r||0,c=n.slice();return l.sign<0?c[0]+=h:c[1]-=h,e[i]=Bk(e[i],c),u=Vk(e,i),null!=r&&(u.sign!==l.sign||u.span