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:
2026-02-14 20:59:12 +01:00
parent 85e79c97ac
commit 96c8476f77
20 changed files with 1104 additions and 2 deletions

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View 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))
}
})
})
}