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:
@@ -13,10 +13,16 @@ type Config struct {
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Shell ShellConfig `toml:"shell"`
|
||||
Web WebConfig `toml:"web"`
|
||||
LogLevel string `toml:"log_level"`
|
||||
LogFormat string `toml:"log_format"` // "text" (default) or "json"
|
||||
}
|
||||
|
||||
type WebConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
ListenAddr string `toml:"listen_addr"`
|
||||
}
|
||||
|
||||
type ShellConfig struct {
|
||||
Hostname string `toml:"hostname"`
|
||||
Banner string `toml:"banner"`
|
||||
@@ -112,6 +118,9 @@ func applyDefaults(cfg *Config) {
|
||||
if cfg.Storage.RetentionInterval == "" {
|
||||
cfg.Storage.RetentionInterval = "1h"
|
||||
}
|
||||
if cfg.Web.ListenAddr == "" {
|
||||
cfg.Web.ListenAddr = ":8080"
|
||||
}
|
||||
if cfg.Shell.Hostname == "" {
|
||||
cfg.Shell.Hostname = "ubuntu-server"
|
||||
}
|
||||
|
||||
@@ -222,6 +222,39 @@ custom_key = "value"
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWebDefaults(t *testing.T) {
|
||||
path := writeTemp(t, "")
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Web.Enabled {
|
||||
t.Error("web should be disabled by default")
|
||||
}
|
||||
if cfg.Web.ListenAddr != ":8080" {
|
||||
t.Errorf("default web listen_addr = %q, want %q", cfg.Web.ListenAddr, ":8080")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWebConfig(t *testing.T) {
|
||||
content := `
|
||||
[web]
|
||||
enabled = true
|
||||
listen_addr = ":9090"
|
||||
`
|
||||
path := writeTemp(t, content)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !cfg.Web.Enabled {
|
||||
t.Error("web should be enabled")
|
||||
}
|
||||
if cfg.Web.ListenAddr != ":9090" {
|
||||
t.Errorf("web listen_addr = %q, want %q", cfg.Web.ListenAddr, ":9090")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
_, err := Load("/nonexistent/path/config.toml")
|
||||
if err == nil {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
104
internal/web/handlers.go
Normal file
104
internal/web/handlers.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
)
|
||||
|
||||
type dashboardData struct {
|
||||
Stats *storage.DashboardStats
|
||||
TopUsernames []storage.TopEntry
|
||||
TopPasswords []storage.TopEntry
|
||||
TopIPs []storage.TopEntry
|
||||
ActiveSessions []storage.Session
|
||||
RecentSessions []storage.Session
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
stats, err := s.store.GetDashboardStats(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get dashboard stats", "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
topUsernames, err := s.store.GetTopUsernames(ctx, 10)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get top usernames", "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
topPasswords, err := s.store.GetTopPasswords(ctx, 10)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get top passwords", "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
topIPs, err := s.store.GetTopIPs(ctx, 10)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get top IPs", "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)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
recentSessions, err := s.store.GetRecentSessions(ctx, 50, false)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get recent sessions", "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := dashboardData{
|
||||
Stats: stats,
|
||||
TopUsernames: topUsernames,
|
||||
TopPasswords: topPasswords,
|
||||
TopIPs: topIPs,
|
||||
ActiveSessions: activeSessions,
|
||||
RecentSessions: recentSessions,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
|
||||
s.logger.Error("failed to render dashboard", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleFragmentStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := s.store.GetDashboardStats(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get dashboard stats", "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "stats", stats); err != nil {
|
||||
s.logger.Error("failed to render stats fragment", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Request) {
|
||||
sessions, err := s.store.GetRecentSessions(r.Context(), 50, true)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get active sessions", "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "active_sessions", sessions); err != nil {
|
||||
s.logger.Error("failed to render active sessions fragment", "err", err)
|
||||
}
|
||||
}
|
||||
1
internal/web/static/htmx.min.js
vendored
Normal file
1
internal/web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
internal/web/static/pico.min.css
vendored
Normal file
4
internal/web/static/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
37
internal/web/templates.go
Normal file
37
internal/web/templates.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html templates/fragments/*.html
|
||||
var templateFS embed.FS
|
||||
|
||||
func loadTemplates() (*template.Template, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"formatTime": func(t time.Time) string {
|
||||
return t.Format("2006-01-02 15:04:05 UTC")
|
||||
},
|
||||
"truncateID": func(id string) string {
|
||||
if len(id) > 8 {
|
||||
return id[:8]
|
||||
}
|
||||
return id
|
||||
},
|
||||
"derefTime": func(t *time.Time) time.Time {
|
||||
if t == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *t
|
||||
},
|
||||
}
|
||||
|
||||
return template.New("").Funcs(funcMap).ParseFS(templateFS,
|
||||
"templates/layout.html",
|
||||
"templates/dashboard.html",
|
||||
"templates/fragments/stats.html",
|
||||
"templates/fragments/active_sessions.html",
|
||||
)
|
||||
}
|
||||
93
internal/web/templates/dashboard.html
Normal file
93
internal/web/templates/dashboard.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{{define "content"}}
|
||||
<section id="stats-section" hx-get="/fragments/stats" hx-trigger="every 30s" hx-swap="innerHTML">
|
||||
{{template "stats" .Stats}}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Top Credentials & IPs</h3>
|
||||
<div class="top-grid">
|
||||
<article>
|
||||
<header>Top Usernames</header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Username</th><th>Attempts</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .TopUsernames}}
|
||||
<tr><td>{{.Value}}</td><td>{{.Count}}</td></tr>
|
||||
{{else}}
|
||||
<tr><td colspan="2">No data</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<article>
|
||||
<header>Top Passwords</header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Password</th><th>Attempts</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .TopPasswords}}
|
||||
<tr><td>{{.Value}}</td><td>{{.Count}}</td></tr>
|
||||
{{else}}
|
||||
<tr><td colspan="2">No data</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<article>
|
||||
<header>Top IPs</header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>IP</th><th>Attempts</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .TopIPs}}
|
||||
<tr><td>{{.Value}}</td><td>{{.Count}}</td></tr>
|
||||
{{else}}
|
||||
<tr><td colspan="2">No data</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Active Sessions</h3>
|
||||
<div id="active-sessions" hx-get="/fragments/active-sessions" hx-trigger="every 10s" hx-swap="innerHTML">
|
||||
{{template "active_sessions" .ActiveSessions}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Recent Sessions</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>IP</th>
|
||||
<th>Username</th>
|
||||
<th>Shell</th>
|
||||
<th>Connected</th>
|
||||
<th>Disconnected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentSessions}}
|
||||
<tr>
|
||||
<td><code>{{truncateID .ID}}</code></td>
|
||||
<td>{{.IP}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.ShellName}}</td>
|
||||
<td>{{formatTime .ConnectedAt}}</td>
|
||||
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6">No sessions</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
26
internal/web/templates/fragments/active_sessions.html
Normal file
26
internal/web/templates/fragments/active_sessions.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "active_sessions"}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>IP</th>
|
||||
<th>Username</th>
|
||||
<th>Shell</th>
|
||||
<th>Connected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td><code>{{truncateID .ID}}</code></td>
|
||||
<td>{{.IP}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.ShellName}}</td>
|
||||
<td>{{formatTime .ConnectedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5">No active sessions</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
20
internal/web/templates/fragments/stats.html
Normal file
20
internal/web/templates/fragments/stats.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{{define "stats"}}
|
||||
<div class="stats-grid">
|
||||
<article class="stat-card">
|
||||
<h2>{{.TotalAttempts}}</h2>
|
||||
<p>Total Attempts</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>{{.UniqueIPs}}</h2>
|
||||
<p>Unique IPs</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>{{.TotalSessions}}</h2>
|
||||
<p>Total Sessions</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<h2>{{.ActiveSessions}}</h2>
|
||||
<p>Active Sessions</p>
|
||||
</article>
|
||||
</div>
|
||||
{{end}}
|
||||
56
internal/web/templates/layout.html
Normal file
56
internal/web/templates/layout.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Oubliette</title>
|
||||
<link rel="stylesheet" href="/static/pico.min.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--pico-font-size: 16px;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.stat-card h2 {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.stat-card p {
|
||||
margin: 0;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
.top-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
nav h1 {
|
||||
margin: 0;
|
||||
}
|
||||
nav small {
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="container">
|
||||
<ul>
|
||||
<li><h1>Oubliette</h1></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><small>SSH Honeypot Dashboard</small></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
48
internal/web/web.go
Normal file
48
internal/web/web.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
// Server is the web dashboard HTTP server.
|
||||
type Server struct {
|
||||
store storage.Store
|
||||
logger *slog.Logger
|
||||
mux *http.ServeMux
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// NewServer creates a new web Server with routes registered.
|
||||
func NewServer(store storage.Store, logger *slog.Logger) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
store: store,
|
||||
logger: logger,
|
||||
mux: http.NewServeMux(),
|
||||
tmpl: tmpl,
|
||||
}
|
||||
|
||||
s.mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||
s.mux.HandleFunc("GET /", s.handleDashboard)
|
||||
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
||||
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ServeHTTP delegates to the internal mux.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
161
internal/web/web_test.go
Normal file
161
internal/web/web_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
store := storage.NewMemoryStore()
|
||||
logger := slog.Default()
|
||||
srv, err := NewServer(store, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("creating server: %v", err)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
func newSeededTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
store := storage.NewMemoryStore()
|
||||
ctx := context.Background()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2"); err != nil {
|
||||
t.Fatalf("seeding attempt: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash"); err != nil {
|
||||
t.Fatalf("creating session: %v", err)
|
||||
}
|
||||
if _, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash"); err != nil {
|
||||
t.Fatalf("creating session: %v", err)
|
||||
}
|
||||
|
||||
logger := slog.Default()
|
||||
srv, err := NewServer(store, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("creating server: %v", err)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestDashboardHandler(t *testing.T) {
|
||||
t.Run("empty store", func(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/", 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, "Oubliette") {
|
||||
t.Error("response should contain 'Oubliette'")
|
||||
}
|
||||
if !strings.Contains(body, "No data") {
|
||||
t.Error("response should contain 'No data' for empty tables")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with data", func(t *testing.T) {
|
||||
srv := newSeededTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/", 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, "root") {
|
||||
t.Error("response should contain username 'root'")
|
||||
}
|
||||
if !strings.Contains(body, "10.0.0.1") {
|
||||
t.Error("response should contain IP '10.0.0.1'")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFragmentStats(t *testing.T) {
|
||||
srv := newSeededTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/fragments/stats", 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()
|
||||
// Should be a fragment, not a full HTML page.
|
||||
if strings.Contains(body, "<!DOCTYPE html>") {
|
||||
t.Error("stats fragment should not contain full HTML document")
|
||||
}
|
||||
if !strings.Contains(body, "Total Attempts") {
|
||||
t.Error("stats fragment should contain 'Total Attempts'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFragmentActiveSessions(t *testing.T) {
|
||||
srv := newSeededTestServer(t)
|
||||
req := httptest.NewRequest("GET", "/fragments/active-sessions", 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, "<!DOCTYPE html>") {
|
||||
t.Error("active sessions fragment should not contain full HTML document")
|
||||
}
|
||||
// Both sessions are active (not ended).
|
||||
if !strings.Contains(body, "10.0.0.1") {
|
||||
t.Error("active sessions should contain IP '10.0.0.1'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticAssets(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
contentType string
|
||||
}{
|
||||
{"/static/pico.min.css", "text/css"},
|
||||
{"/static/htmx.min.js", "text/javascript"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, tt.contentType) {
|
||||
t.Errorf("Content-Type = %q, want to contain %q", ct, tt.contentType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user