This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/storage/memstore.go
Torjus Håkestad 96c8476f77 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>
2026-02-14 20:59:12 +01:00

232 lines
5.1 KiB
Go

package storage
import (
"context"
"sort"
"sync"
"time"
"github.com/google/uuid"
)
// MemoryStore is an in-memory implementation of Store for use in tests.
type MemoryStore struct {
mu sync.Mutex
LoginAttempts []LoginAttempt
Sessions map[string]*Session
SessionLogs []SessionLog
}
// NewMemoryStore returns a new empty MemoryStore.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
Sessions: make(map[string]*Session),
}
}
func (m *MemoryStore) RecordLoginAttempt(_ context.Context, username, password, ip string) error {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now().UTC()
for i := range m.LoginAttempts {
a := &m.LoginAttempts[i]
if a.Username == username && a.Password == password && a.IP == ip {
a.Count++
a.LastSeen = now
return nil
}
}
m.LoginAttempts = append(m.LoginAttempts, LoginAttempt{
ID: int64(len(m.LoginAttempts) + 1),
Username: username,
Password: password,
IP: ip,
Count: 1,
FirstSeen: now,
LastSeen: now,
})
return nil
}
func (m *MemoryStore) CreateSession(_ context.Context, ip, username, shellName string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
id := uuid.New().String()
now := time.Now().UTC()
m.Sessions[id] = &Session{
ID: id,
IP: ip,
Username: username,
ShellName: shellName,
ConnectedAt: now,
}
return id, nil
}
func (m *MemoryStore) EndSession(_ context.Context, sessionID string, disconnectedAt time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.Sessions[sessionID]; ok {
t := disconnectedAt.UTC()
s.DisconnectedAt = &t
}
return nil
}
func (m *MemoryStore) UpdateHumanScore(_ context.Context, sessionID string, score float64) error {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.Sessions[sessionID]; ok {
s.HumanScore = &score
}
return nil
}
func (m *MemoryStore) AppendSessionLog(_ context.Context, sessionID, input, output string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.SessionLogs = append(m.SessionLogs, SessionLog{
ID: int64(len(m.SessionLogs) + 1),
SessionID: sessionID,
Timestamp: time.Now().UTC(),
Input: input,
Output: output,
})
return nil
}
func (m *MemoryStore) DeleteRecordsBefore(_ context.Context, cutoff time.Time) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
var total int64
// Delete old login attempts.
kept := m.LoginAttempts[:0]
for _, a := range m.LoginAttempts {
if a.LastSeen.Before(cutoff) {
total++
} else {
kept = append(kept, a)
}
}
m.LoginAttempts = kept
// Delete old sessions and their logs.
for id, s := range m.Sessions {
if s.ConnectedAt.Before(cutoff) {
delete(m.Sessions, id)
total++
}
}
keptLogs := m.SessionLogs[:0]
for _, l := range m.SessionLogs {
if _, ok := m.Sessions[l.SessionID]; ok {
keptLogs = append(keptLogs, l)
} else {
total++
}
}
m.SessionLogs = keptLogs
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
}