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

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