From 4f10a8a4220c6903dc67da5c0045a7d9eba6c789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Sun, 15 Feb 2026 19:38:10 +0100 Subject: [PATCH] feat: add session indicators and top exec commands to dashboard Add visual indicators to session tables (replay badge when events exist, exec badge for exec sessions) and a new "Top Exec Commands" table on the dashboard. Includes EventCount field on Session, GetTopExecCommands on Store interface, and truncateCommand template function. Co-Authored-By: Claude Opus 4.6 --- internal/storage/memstore.go | 34 ++++++++- internal/storage/sqlite.go | 32 +++++++- internal/storage/sqlite_test.go | 73 +++++++++++++++++++ internal/storage/store.go | 4 + internal/web/handlers.go | 37 ++++++---- internal/web/templates.go | 6 ++ internal/web/templates/dashboard.html | 21 +++++- .../templates/fragments/active_sessions.html | 6 +- internal/web/web_test.go | 55 ++++++++++++++ 9 files changed, 243 insertions(+), 25 deletions(-) diff --git a/internal/storage/memstore.go b/internal/storage/memstore.go index 90a8fe0..380c639 100644 --- a/internal/storage/memstore.go +++ b/internal/storage/memstore.go @@ -336,12 +336,20 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly m.mu.Lock() defer m.mu.Unlock() + // Count events per session. + eventCounts := make(map[string]int) + for _, e := range m.SessionEvents { + eventCounts[e.SessionID]++ + } + var sessions []Session for _, s := range m.Sessions { if activeOnly && s.DisconnectedAt != nil { continue } - sessions = append(sessions, *s) + sess := *s + sess.EventCount = eventCounts[s.ID] + sessions = append(sessions, sess) } sort.Slice(sessions, func(i, j int) bool { return sessions[i].ConnectedAt.After(sessions[j].ConnectedAt) @@ -352,6 +360,30 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly return sessions, nil } +func (m *MemoryStore) GetTopExecCommands(_ context.Context, limit int) ([]TopEntry, error) { + m.mu.Lock() + defer m.mu.Unlock() + + counts := make(map[string]int64) + for _, s := range m.Sessions { + if s.ExecCommand != nil { + counts[*s.ExecCommand]++ + } + } + + entries := make([]TopEntry, 0, len(counts)) + for k, v := range counts { + entries = append(entries, TopEntry{Value: k, Count: v}) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Count > entries[j].Count + }) + if limit > 0 && len(entries) > limit { + entries = entries[:limit] + } + return entries, nil +} + func (m *MemoryStore) CloseActiveSessions(_ context.Context, disconnectedAt time.Time) (int64, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 93672ca..3782c15 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -382,11 +382,11 @@ func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) ( } func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) { - query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command FROM sessions` + query := `SELECT s.id, s.ip, s.country, s.username, s.shell_name, s.connected_at, s.disconnected_at, s.human_score, s.exec_command, COUNT(e.id) as event_count FROM sessions s LEFT JOIN session_events e ON s.id = e.session_id` if activeOnly { - query += ` WHERE disconnected_at IS NULL` + query += ` WHERE s.disconnected_at IS NULL` } - query += ` ORDER BY connected_at DESC LIMIT ?` + query += ` GROUP BY s.id ORDER BY s.connected_at DESC LIMIT ?` rows, err := s.db.QueryContext(ctx, query, limit) if err != nil { @@ -401,7 +401,7 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn var disconnectedAt sql.NullString var humanScore sql.NullFloat64 var execCommand sql.NullString - if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand); err != nil { + if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand, &s.EventCount); err != nil { return nil, fmt.Errorf("scanning session: %w", err) } s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt) @@ -420,6 +420,30 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn return sessions, rows.Err() } +func (s *SQLiteStore) GetTopExecCommands(ctx context.Context, limit int) ([]TopEntry, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT exec_command, COUNT(*) as total + FROM sessions + WHERE exec_command IS NOT NULL + GROUP BY exec_command + ORDER BY total DESC + LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("querying top exec commands: %w", err) + } + defer func() { _ = rows.Close() }() + + var entries []TopEntry + for rows.Next() { + var e TopEntry + if err := rows.Scan(&e.Value, &e.Count); err != nil { + return nil, fmt.Errorf("scanning top exec commands: %w", err) + } + entries = append(entries, e) + } + return entries, rows.Err() +} + func (s *SQLiteStore) CloseActiveSessions(ctx context.Context, disconnectedAt time.Time) (int64, error) { res, err := s.db.ExecContext(ctx, ` UPDATE sessions SET disconnected_at = ? WHERE disconnected_at IS NULL`, diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 26ac212..dee94ff 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -204,6 +204,79 @@ func TestDeleteRecordsBefore(t *testing.T) { } } +func TestGetTopExecCommands(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // Create sessions with exec commands. + for range 3 { + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "", "") + if err != nil { + t.Fatalf("creating session: %v", err) + } + if err := store.SetExecCommand(ctx, id, "uname -a"); err != nil { + t.Fatalf("setting exec command: %v", err) + } + } + for range 2 { + id, err := store.CreateSession(ctx, "10.0.0.2", "admin", "", "") + if err != nil { + t.Fatalf("creating session: %v", err) + } + if err := store.SetExecCommand(ctx, id, "cat /etc/passwd"); err != nil { + t.Fatalf("setting exec command: %v", err) + } + } + // Session without exec command — should not appear. + if _, err := store.CreateSession(ctx, "10.0.0.3", "test", "bash", ""); err != nil { + t.Fatalf("creating session: %v", err) + } + + entries, err := store.GetTopExecCommands(ctx, 10) + if err != nil { + t.Fatalf("GetTopExecCommands: %v", err) + } + if len(entries) != 2 { + t.Fatalf("len = %d, want 2", len(entries)) + } + if entries[0].Value != "uname -a" || entries[0].Count != 3 { + t.Errorf("entries[0] = %+v, want uname -a:3", entries[0]) + } + if entries[1].Value != "cat /etc/passwd" || entries[1].Count != 2 { + t.Errorf("entries[1] = %+v, want cat /etc/passwd:2", entries[1]) + } +} + +func TestGetRecentSessionsEventCount(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") + if err != nil { + t.Fatalf("creating session: %v", err) + } + + // Add some events. + events := []SessionEvent{ + {SessionID: id, Timestamp: time.Now(), Direction: 0, Data: []byte("ls\n")}, + {SessionID: id, Timestamp: time.Now(), Direction: 1, Data: []byte("file1\n")}, + } + if err := store.AppendSessionEvents(ctx, events); err != nil { + t.Fatalf("appending events: %v", err) + } + + sessions, err := store.GetRecentSessions(ctx, 10, false) + if err != nil { + t.Fatalf("GetRecentSessions: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("len = %d, want 1", len(sessions)) + } + if sessions[0].EventCount != 2 { + t.Errorf("EventCount = %d, want 2", sessions[0].EventCount) + } +} + func TestNewSQLiteStoreCreatesFile(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") store, err := NewSQLiteStore(dbPath) diff --git a/internal/storage/store.go b/internal/storage/store.go index d0289b8..e5bdaca 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -28,6 +28,7 @@ type Session struct { DisconnectedAt *time.Time HumanScore *float64 ExecCommand *string + EventCount int } // SessionLog represents a single log entry for a session. @@ -102,6 +103,9 @@ type Store interface { // GetTopCountries returns the top N countries by total attempt count. GetTopCountries(ctx context.Context, limit int) ([]TopEntry, error) + // GetTopExecCommands returns the top N exec commands by session count. + GetTopExecCommands(ctx context.Context, limit int) ([]TopEntry, error) + // GetRecentSessions returns the most recent sessions ordered by connected_at DESC. // If activeOnly is true, only sessions with no disconnected_at are returned. GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 9759515..604dc60 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -9,13 +9,14 @@ import ( ) type dashboardData struct { - Stats *storage.DashboardStats - TopUsernames []storage.TopEntry - TopPasswords []storage.TopEntry - TopIPs []storage.TopEntry - TopCountries []storage.TopEntry - ActiveSessions []storage.Session - RecentSessions []storage.Session + Stats *storage.DashboardStats + TopUsernames []storage.TopEntry + TopPasswords []storage.TopEntry + TopIPs []storage.TopEntry + TopCountries []storage.TopEntry + TopExecCommands []storage.TopEntry + ActiveSessions []storage.Session + RecentSessions []storage.Session } func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { @@ -56,6 +57,13 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { return } + topExecCommands, err := s.store.GetTopExecCommands(ctx, 10) + if err != nil { + s.logger.Error("failed to get top exec commands", "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + activeSessions, err := s.store.GetRecentSessions(ctx, 50, true) if err != nil { s.logger.Error("failed to get active sessions", "err", err) @@ -71,13 +79,14 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { } data := dashboardData{ - Stats: stats, - TopUsernames: topUsernames, - TopPasswords: topPasswords, - TopIPs: topIPs, - TopCountries: topCountries, - ActiveSessions: activeSessions, - RecentSessions: recentSessions, + Stats: stats, + TopUsernames: topUsernames, + TopPasswords: topPasswords, + TopIPs: topIPs, + TopCountries: topCountries, + TopExecCommands: topExecCommands, + ActiveSessions: activeSessions, + RecentSessions: recentSessions, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/web/templates.go b/internal/web/templates.go index 6713b14..0b579fb 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -50,6 +50,12 @@ func templateFuncMap() template.FuncMap { } return *s }, + "truncateCommand": func(s string) string { + if len(s) > 50 { + return s[:50] + "..." + } + return s + }, } } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 513870a..009c2f2 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -66,6 +66,21 @@ +
+
Top Exec Commands
+ + + + + + {{range .TopExecCommands}} + + {{else}} + + {{end}} + +
CommandCount
{{truncateCommand .Value}}{{.Count}}
No data
+
@@ -85,7 +100,7 @@ IP Country Username - Shell + Type Score Connected Disconnected @@ -94,11 +109,11 @@ {{range .RecentSessions}} - {{truncateID .ID}} + {{truncateID .ID}}{{if gt .EventCount 0}} replay{{end}} {{.IP}} {{.Country}} {{.Username}} - {{.ShellName}} + {{if .ExecCommand}}exec{{else}}{{.ShellName}}{{end}} {{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}{{formatScore .HumanScore}}{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}} {{formatTime .ConnectedAt}} {{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}active{{end}} diff --git a/internal/web/templates/fragments/active_sessions.html b/internal/web/templates/fragments/active_sessions.html index 582b5be..21af2b8 100644 --- a/internal/web/templates/fragments/active_sessions.html +++ b/internal/web/templates/fragments/active_sessions.html @@ -6,7 +6,7 @@ IP Country Username - Shell + Type Score Connected @@ -14,11 +14,11 @@ {{range .}} - {{truncateID .ID}} + {{truncateID .ID}}{{if gt .EventCount 0}} replay{{end}} {{.IP}} {{.Country}} {{.Username}} - {{.ShellName}} + {{if .ExecCommand}}exec{{else}}{{.ShellName}}{{end}} {{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}{{formatScore .HumanScore}}{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}} {{formatTime .ConnectedAt}} diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 70ef0cb..f266e75 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -340,6 +340,61 @@ func TestMetricsBearerToken(t *testing.T) { }) } +func TestTruncateCommand(t *testing.T) { + funcMap := templateFuncMap() + fn := funcMap["truncateCommand"].(func(string) string) + + tests := []struct { + input string + want string + }{ + {"short", "short"}, + {"exactly fifty characters long! that is what it i.", "exactly fifty characters long! that is what it i."}, + {"this string is definitely longer than fifty characters and should be truncated", "this string is definitely longer than fifty charac..."}, + {"", ""}, + } + + for _, tt := range tests { + got := fn(tt.input) + if got != tt.want { + t.Errorf("truncateCommand(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestDashboardExecCommands(t *testing.T) { + store := storage.NewMemoryStore() + ctx := context.Background() + + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") + if err != nil { + t.Fatalf("creating session: %v", err) + } + if err := store.SetExecCommand(ctx, id, "uname -a"); err != nil { + t.Fatalf("setting exec command: %v", err) + } + + srv, err := NewServer(store, slog.Default(), nil, "") + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + body := w.Body.String() + if !strings.Contains(body, "Top Exec Commands") { + t.Error("response should contain 'Top Exec Commands'") + } + if !strings.Contains(body, "uname -a") { + t.Error("response should contain exec command 'uname -a'") + } +} + func TestStaticAssets(t *testing.T) { srv := newTestServer(t)