feat: add minimal web dashboard with stats, top credentials, and sessions
Implements Phase 1.5 — an embedded web UI using Go templates, Pico CSS (dark theme), and htmx for auto-refreshing stats and active sessions. Adds read query methods to the Store interface (GetDashboardStats, GetTopUsernames, GetTopPasswords, GetTopIPs, GetRecentSessions) with implementations for both SQLite and MemoryStore. Introduces the internal/web package with server, handlers, templates, and tests. Web server is opt-in via [web] config section and runs alongside SSH with graceful shutdown. Bumps version to 0.2.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -138,6 +139,93 @@ func (m *MemoryStore) DeleteRecordsBefore(_ context.Context, cutoff time.Time) (
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetDashboardStats(_ context.Context) (*DashboardStats, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
stats := &DashboardStats{}
|
||||
ips := make(map[string]struct{})
|
||||
for _, a := range m.LoginAttempts {
|
||||
stats.TotalAttempts += int64(a.Count)
|
||||
ips[a.IP] = struct{}{}
|
||||
}
|
||||
stats.UniqueIPs = int64(len(ips))
|
||||
stats.TotalSessions = int64(len(m.Sessions))
|
||||
for _, s := range m.Sessions {
|
||||
if s.DisconnectedAt == nil {
|
||||
stats.ActiveSessions++
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetTopUsernames(_ context.Context, limit int) ([]TopEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.topN("username", limit), nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetTopPasswords(_ context.Context, limit int) ([]TopEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.topN("password", limit), nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetTopIPs(_ context.Context, limit int) ([]TopEntry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.topN("ip", limit), nil
|
||||
}
|
||||
|
||||
// topN aggregates login attempts by the given field and returns the top N. Must be called with m.mu held.
|
||||
func (m *MemoryStore) topN(field string, limit int) []TopEntry {
|
||||
counts := make(map[string]int64)
|
||||
for _, a := range m.LoginAttempts {
|
||||
var key string
|
||||
switch field {
|
||||
case "username":
|
||||
key = a.Username
|
||||
case "password":
|
||||
key = a.Password
|
||||
case "ip":
|
||||
key = a.IP
|
||||
}
|
||||
counts[key] += int64(a.Count)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly bool) ([]Session, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
var sessions []Session
|
||||
for _, s := range m.Sessions {
|
||||
if activeOnly && s.DisconnectedAt != nil {
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, *s)
|
||||
}
|
||||
sort.Slice(sessions, func(i, j int) bool {
|
||||
return sessions[i].ConnectedAt.After(sessions[j].ConnectedAt)
|
||||
})
|
||||
if limit > 0 && len(sessions) > limit {
|
||||
sessions = sessions[:limit]
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -139,6 +139,102 @@ func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time)
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||
stats := &DashboardStats{}
|
||||
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(count), 0), COUNT(DISTINCT ip)
|
||||
FROM login_attempts`).Scan(&stats.TotalAttempts, &stats.UniqueIPs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying attempt stats: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&stats.TotalSessions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying total sessions: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM sessions WHERE disconnected_at IS NULL`).Scan(&stats.ActiveSessions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying active sessions: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetTopUsernames(ctx context.Context, limit int) ([]TopEntry, error) {
|
||||
return s.queryTopN(ctx, "username", limit)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetTopPasswords(ctx context.Context, limit int) ([]TopEntry, error) {
|
||||
return s.queryTopN(ctx, "password", limit)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetTopIPs(ctx context.Context, limit int) ([]TopEntry, error) {
|
||||
return s.queryTopN(ctx, "ip", limit)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) ([]TopEntry, error) {
|
||||
query := fmt.Sprintf(`
|
||||
SELECT %s, SUM(count) AS total
|
||||
FROM login_attempts
|
||||
GROUP BY %s
|
||||
ORDER BY total DESC
|
||||
LIMIT ?`, column, column)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying top %s: %w", column, 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 %s: %w", column, err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) {
|
||||
query := `SELECT id, ip, username, shell_name, connected_at, disconnected_at, human_score FROM sessions`
|
||||
if activeOnly {
|
||||
query += ` WHERE disconnected_at IS NULL`
|
||||
}
|
||||
query += ` ORDER BY connected_at DESC LIMIT ?`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying recent sessions: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var sessions []Session
|
||||
for rows.Next() {
|
||||
var s Session
|
||||
var connectedAt string
|
||||
var disconnectedAt sql.NullString
|
||||
var humanScore sql.NullFloat64
|
||||
if err := rows.Scan(&s.ID, &s.IP, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore); err != nil {
|
||||
return nil, fmt.Errorf("scanning session: %w", err)
|
||||
}
|
||||
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
||||
if disconnectedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, disconnectedAt.String)
|
||||
s.DisconnectedAt = &t
|
||||
}
|
||||
if humanScore.Valid {
|
||||
s.HumanScore = &humanScore.Float64
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
@@ -36,6 +36,20 @@ type SessionLog struct {
|
||||
Output string
|
||||
}
|
||||
|
||||
// DashboardStats holds aggregate counts for the web dashboard.
|
||||
type DashboardStats struct {
|
||||
TotalAttempts int64
|
||||
UniqueIPs int64
|
||||
TotalSessions int64
|
||||
ActiveSessions int64
|
||||
}
|
||||
|
||||
// TopEntry represents a value and its count for top-N queries.
|
||||
type TopEntry struct {
|
||||
Value string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// Store is the interface for persistent storage of honeypot data.
|
||||
type Store interface {
|
||||
// RecordLoginAttempt upserts a login attempt, incrementing the count
|
||||
@@ -58,6 +72,22 @@ type Store interface {
|
||||
// and returns the total number of deleted rows.
|
||||
DeleteRecordsBefore(ctx context.Context, cutoff time.Time) (int64, error)
|
||||
|
||||
// GetDashboardStats returns aggregate counts for the dashboard.
|
||||
GetDashboardStats(ctx context.Context) (*DashboardStats, error)
|
||||
|
||||
// GetTopUsernames returns the top N usernames by total attempt count.
|
||||
GetTopUsernames(ctx context.Context, limit int) ([]TopEntry, error)
|
||||
|
||||
// GetTopPasswords returns the top N passwords by total attempt count.
|
||||
GetTopPasswords(ctx context.Context, limit int) ([]TopEntry, error)
|
||||
|
||||
// GetTopIPs returns the top N IPs by total attempt count.
|
||||
GetTopIPs(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)
|
||||
|
||||
// Close releases any resources held by the store.
|
||||
Close() error
|
||||
}
|
||||
|
||||
252
internal/storage/store_test.go
Normal file
252
internal/storage/store_test.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// storeFactory returns a clean Store and a cleanup function.
|
||||
type storeFactory func(t *testing.T) Store
|
||||
|
||||
func testStores(t *testing.T, f func(t *testing.T, newStore storeFactory)) {
|
||||
t.Helper()
|
||||
t.Run("SQLite", func(t *testing.T) {
|
||||
f(t, func(t *testing.T) Store {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := NewSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("creating SQLiteStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
})
|
||||
})
|
||||
t.Run("Memory", func(t *testing.T) {
|
||||
f(t, func(t *testing.T) Store {
|
||||
t.Helper()
|
||||
return NewMemoryStore()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func seedData(t *testing.T, store Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
// Login attempts: root/toor from two IPs, admin/admin from one IP.
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1"); err != nil {
|
||||
t.Fatalf("seeding attempt: %v", err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.2"); err != nil {
|
||||
t.Fatalf("seeding attempt: %v", err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.1"); err != nil {
|
||||
t.Fatalf("seeding attempt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions: one active, one ended.
|
||||
id1, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("creating session: %v", err)
|
||||
}
|
||||
if err := store.EndSession(ctx, id1, time.Now()); err != nil {
|
||||
t.Fatalf("ending session: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash"); err != nil {
|
||||
t.Fatalf("creating session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardStats(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
ctx := context.Background()
|
||||
stats, err := store.GetDashboardStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDashboardStats: %v", err)
|
||||
}
|
||||
if stats.TotalAttempts != 0 || stats.UniqueIPs != 0 || stats.TotalSessions != 0 || stats.ActiveSessions != 0 {
|
||||
t.Errorf("expected all zeros, got %+v", stats)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with data", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedData(t, store)
|
||||
ctx := context.Background()
|
||||
|
||||
stats, err := store.GetDashboardStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDashboardStats: %v", err)
|
||||
}
|
||||
// 5 + 3 + 2 = 10 total attempts
|
||||
if stats.TotalAttempts != 10 {
|
||||
t.Errorf("TotalAttempts = %d, want 10", stats.TotalAttempts)
|
||||
}
|
||||
// 2 unique IPs: 10.0.0.1 and 10.0.0.2
|
||||
if stats.UniqueIPs != 2 {
|
||||
t.Errorf("UniqueIPs = %d, want 2", stats.UniqueIPs)
|
||||
}
|
||||
if stats.TotalSessions != 2 {
|
||||
t.Errorf("TotalSessions = %d, want 2", stats.TotalSessions)
|
||||
}
|
||||
if stats.ActiveSessions != 1 {
|
||||
t.Errorf("ActiveSessions = %d, want 1", stats.ActiveSessions)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTopUsernames(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
entries, err := store.GetTopUsernames(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopUsernames: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected empty, got %v", entries)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with data", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedData(t, store)
|
||||
|
||||
entries, err := store.GetTopUsernames(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopUsernames: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(entries))
|
||||
}
|
||||
// root: 5 + 3 = 8, admin: 2
|
||||
if entries[0].Value != "root" || entries[0].Count != 8 {
|
||||
t.Errorf("entries[0] = %+v, want root/8", entries[0])
|
||||
}
|
||||
if entries[1].Value != "admin" || entries[1].Count != 2 {
|
||||
t.Errorf("entries[1] = %+v, want admin/2", entries[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("limit", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedData(t, store)
|
||||
|
||||
entries, err := store.GetTopUsernames(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopUsernames: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(entries))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTopPasswords(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
store := newStore(t)
|
||||
seedData(t, store)
|
||||
|
||||
entries, err := store.GetTopPasswords(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopPasswords: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(entries))
|
||||
}
|
||||
// toor: 8, admin: 2
|
||||
if entries[0].Value != "toor" || entries[0].Count != 8 {
|
||||
t.Errorf("entries[0] = %+v, want toor/8", entries[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTopIPs(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
store := newStore(t)
|
||||
seedData(t, store)
|
||||
|
||||
entries, err := store.GetTopIPs(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopIPs: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(entries))
|
||||
}
|
||||
// 10.0.0.1: 5 + 2 = 7, 10.0.0.2: 3
|
||||
if entries[0].Value != "10.0.0.1" || entries[0].Count != 7 {
|
||||
t.Errorf("entries[0] = %+v, want 10.0.0.1/7", entries[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRecentSessions(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
sessions, err := store.GetRecentSessions(context.Background(), 10, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentSessions: %v", err)
|
||||
}
|
||||
if len(sessions) != 0 {
|
||||
t.Errorf("expected empty, got %d", len(sessions))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all sessions", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedData(t, store)
|
||||
|
||||
sessions, err := store.GetRecentSessions(context.Background(), 10, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentSessions: %v", err)
|
||||
}
|
||||
if len(sessions) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(sessions))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("active only", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedData(t, store)
|
||||
|
||||
sessions, err := store.GetRecentSessions(context.Background(), 10, true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentSessions: %v", err)
|
||||
}
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(sessions))
|
||||
}
|
||||
if sessions[0].DisconnectedAt != nil {
|
||||
t.Error("active session should have nil DisconnectedAt")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("limit", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
seedData(t, store)
|
||||
|
||||
sessions, err := store.GetRecentSessions(context.Background(), 1, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecentSessions: %v", err)
|
||||
}
|
||||
if len(sessions) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(sessions))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user