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

@@ -105,7 +105,7 @@ This lets shells build realistic prompts (`username@hostname:~$`) and log activi
- This ensures consistent, complete capture regardless of shell implementation, and avoids needing to refactor shells when session replay is added in Phase 2.3 - This ensures consistent, complete capture regardless of shell implementation, and avoids needing to refactor shells when session replay is added in Phase 2.3
- The current `session_logs` schema (input/output text pairs) may need a companion `session_keystrokes` table with `(session_id, timestamp, direction, data)` for byte-level replay fidelity — evaluate when implementing - The current `session_logs` schema (input/output text pairs) may need a companion `session_keystrokes` table with `(session_id, timestamp, direction, data)` for byte-level replay fidelity — evaluate when implementing
### 1.5 Minimal Web UI ### 1.5 Minimal Web UI
- Embedded static assets (Go embed) - Embedded static assets (Go embed)
- Dashboard: total attempts, attempts over time, unique IPs - Dashboard: total attempts, attempts over time, unique IPs
- Tables: top usernames, top passwords, top source IPs - Tables: top usernames, top passwords, top source IPs

View File

@@ -40,6 +40,8 @@ Key settings:
- `shell.hostname` — hostname shown in shell prompts (default `ubuntu-server`) - `shell.hostname` — hostname shown in shell prompts (default `ubuntu-server`)
- `shell.banner` — banner displayed on connection - `shell.banner` — banner displayed on connection
- `shell.fake_user` — override username in prompt; empty uses the authenticated user - `shell.fake_user` — override username in prompt; empty uses the authenticated user
- `web.enabled` — enable the web dashboard (default `false`)
- `web.listen_addr` — web dashboard listen address (default `:8080`)
### Run ### Run

View File

@@ -2,18 +2,22 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"log/slog" "log/slog"
"net/http"
"os" "os"
"os/signal" "os/signal"
"sync"
"syscall" "syscall"
"git.t-juice.club/torjus/oubliette/internal/config" "git.t-juice.club/torjus/oubliette/internal/config"
"git.t-juice.club/torjus/oubliette/internal/server" "git.t-juice.club/torjus/oubliette/internal/server"
"git.t-juice.club/torjus/oubliette/internal/storage" "git.t-juice.club/torjus/oubliette/internal/storage"
"git.t-juice.club/torjus/oubliette/internal/web"
) )
const Version = "0.1.0" const Version = "0.2.0"
func main() { func main() {
configPath := flag.String("config", "oubliette.toml", "path to config file") configPath := flag.String("config", "oubliette.toml", "path to config file")
@@ -65,10 +69,44 @@ func main() {
os.Exit(1) os.Exit(1)
} }
var wg sync.WaitGroup
// Start web server if enabled.
if cfg.Web.Enabled {
webHandler, err := web.NewServer(store, logger.With("component", "web"))
if err != nil {
logger.Error("failed to create web server", "err", err)
os.Exit(1)
}
httpServer := &http.Server{
Addr: cfg.Web.ListenAddr,
Handler: webHandler,
}
wg.Add(1)
go func() {
defer wg.Done()
logger.Info("web server listening", "addr", cfg.Web.ListenAddr)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("web server error", "err", err)
}
}()
// Graceful shutdown on context cancellation.
go func() {
<-ctx.Done()
if err := httpServer.Shutdown(context.Background()); err != nil {
logger.Error("web server shutdown error", "err", err)
}
}()
}
if err := srv.ListenAndServe(ctx); err != nil { if err := srv.ListenAndServe(ctx); err != nil {
logger.Error("server error", "err", err) logger.Error("server error", "err", err)
os.Exit(1) os.Exit(1)
} }
wg.Wait()
logger.Info("server stopped") logger.Info("server stopped")
} }

View File

@@ -13,10 +13,16 @@ type Config struct {
Auth AuthConfig `toml:"auth"` Auth AuthConfig `toml:"auth"`
Storage StorageConfig `toml:"storage"` Storage StorageConfig `toml:"storage"`
Shell ShellConfig `toml:"shell"` Shell ShellConfig `toml:"shell"`
Web WebConfig `toml:"web"`
LogLevel string `toml:"log_level"` LogLevel string `toml:"log_level"`
LogFormat string `toml:"log_format"` // "text" (default) or "json" LogFormat string `toml:"log_format"` // "text" (default) or "json"
} }
type WebConfig struct {
Enabled bool `toml:"enabled"`
ListenAddr string `toml:"listen_addr"`
}
type ShellConfig struct { type ShellConfig struct {
Hostname string `toml:"hostname"` Hostname string `toml:"hostname"`
Banner string `toml:"banner"` Banner string `toml:"banner"`
@@ -112,6 +118,9 @@ func applyDefaults(cfg *Config) {
if cfg.Storage.RetentionInterval == "" { if cfg.Storage.RetentionInterval == "" {
cfg.Storage.RetentionInterval = "1h" cfg.Storage.RetentionInterval = "1h"
} }
if cfg.Web.ListenAddr == "" {
cfg.Web.ListenAddr = ":8080"
}
if cfg.Shell.Hostname == "" { if cfg.Shell.Hostname == "" {
cfg.Shell.Hostname = "ubuntu-server" cfg.Shell.Hostname = "ubuntu-server"
} }

View File

@@ -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) { func TestLoadMissingFile(t *testing.T) {
_, err := Load("/nonexistent/path/config.toml") _, err := Load("/nonexistent/path/config.toml")
if err == nil { if err == nil {

View File

@@ -2,6 +2,7 @@ package storage
import ( import (
"context" "context"
"sort"
"sync" "sync"
"time" "time"
@@ -138,6 +139,93 @@ func (m *MemoryStore) DeleteRecordsBefore(_ context.Context, cutoff time.Time) (
return total, nil 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 { func (m *MemoryStore) Close() error {
return nil return nil
} }

View File

@@ -139,6 +139,102 @@ func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time)
return total, nil 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 { func (s *SQLiteStore) Close() error {
return s.db.Close() return s.db.Close()
} }

View File

@@ -36,6 +36,20 @@ type SessionLog struct {
Output string 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. // Store is the interface for persistent storage of honeypot data.
type Store interface { type Store interface {
// RecordLoginAttempt upserts a login attempt, incrementing the count // RecordLoginAttempt upserts a login attempt, incrementing the count
@@ -58,6 +72,22 @@ type Store interface {
// and returns the total number of deleted rows. // and returns the total number of deleted rows.
DeleteRecordsBefore(ctx context.Context, cutoff time.Time) (int64, error) 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 releases any resources held by the store.
Close() error 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))
}
})
})
}

104
internal/web/handlers.go Normal file
View 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

File diff suppressed because one or more lines are too long

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

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

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

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

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

View File

@@ -23,6 +23,10 @@ db_path = "oubliette.db"
retention_days = 90 retention_days = 90
retention_interval = "1h" retention_interval = "1h"
# [web]
# enabled = true
# listen_addr = ":8080"
[shell] [shell]
hostname = "ubuntu-server" hostname = "ubuntu-server"
# banner = "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_64)\r\n\r\n" # banner = "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_64)\r\n\r\n"