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 @@
+
+
+
+
+ | Command | Count |
+
+
+ {{range .TopExecCommands}}
+ {{truncateCommand .Value}} | {{.Count}} |
+ {{else}}
+ | No data |
+ {{end}}
+
+
+
@@ -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)