feat: add SQLite storage for login attempts and sessions

Adds persistent storage using modernc.org/sqlite (pure Go). Login
attempts are deduplicated by (username, password, ip) with counts.
Sessions and session logs are tracked with UUID IDs. Includes embedded
SQL migrations, configurable retention with background pruning, and
an in-memory store for tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 17:33:45 +01:00
parent 75bac814d4
commit d655968216
21 changed files with 1131 additions and 10 deletions

View File

@@ -0,0 +1,143 @@
package storage
import (
"context"
"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) Close() error {
return nil
}

View File

@@ -0,0 +1,124 @@
package storage
import (
"database/sql"
"embed"
"fmt"
"sort"
"strconv"
"strings"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// migration represents a single database migration.
type migration struct {
Version int
Name string
SQL string
}
// Migrate applies any pending migrations to the database.
func Migrate(db *sql.DB) error {
// Ensure the schema_version table exists.
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil {
return fmt.Errorf("creating schema_version table: %w", err)
}
current, err := currentVersion(db)
if err != nil {
return fmt.Errorf("reading schema version: %w", err)
}
migrations, err := loadMigrations()
if err != nil {
return fmt.Errorf("loading migrations: %w", err)
}
for _, m := range migrations {
if m.Version <= current {
continue
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin migration %d: %w", m.Version, err)
}
if _, err := tx.Exec(m.SQL); err != nil {
tx.Rollback()
return fmt.Errorf("applying migration %d (%s): %w", m.Version, m.Name, err)
}
if current == 0 {
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.Version); err != nil {
tx.Rollback()
return fmt.Errorf("inserting schema version %d: %w", m.Version, err)
}
} else {
if _, err := tx.Exec(`UPDATE schema_version SET version = ?`, m.Version); err != nil {
tx.Rollback()
return fmt.Errorf("updating schema version to %d: %w", m.Version, err)
}
}
current = m.Version
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %d: %w", m.Version, err)
}
}
return nil
}
func currentVersion(db *sql.DB) (int, error) {
var version int
err := db.QueryRow(`SELECT version FROM schema_version LIMIT 1`).Scan(&version)
if err == sql.ErrNoRows {
return 0, nil
}
return version, err
}
func loadMigrations() ([]migration, error) {
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return nil, fmt.Errorf("reading migrations dir: %w", err)
}
var migrations []migration
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
// Parse version from filename: NNN_description.sql
parts := strings.SplitN(entry.Name(), "_", 2)
if len(parts) < 2 {
return nil, fmt.Errorf("invalid migration filename: %s", entry.Name())
}
version, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("parsing version from %s: %w", entry.Name(), err)
}
data, err := migrationFS.ReadFile("migrations/" + entry.Name())
if err != nil {
return nil, fmt.Errorf("reading migration %s: %w", entry.Name(), err)
}
migrations = append(migrations, migration{
Version: version,
Name: entry.Name(),
SQL: string(data),
})
}
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].Version < migrations[j].Version
})
return migrations, nil
}

View File

@@ -0,0 +1,36 @@
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password TEXT NOT NULL,
ip TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
UNIQUE(username, password, ip)
);
CREATE INDEX idx_login_attempts_last_seen ON login_attempts(last_seen);
CREATE INDEX idx_login_attempts_ip ON login_attempts(ip);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
ip TEXT NOT NULL,
username TEXT NOT NULL,
shell_name TEXT NOT NULL DEFAULT '',
connected_at TEXT NOT NULL,
disconnected_at TEXT,
human_score REAL
);
CREATE INDEX idx_sessions_connected_at ON sessions(connected_at);
CREATE TABLE session_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL,
input TEXT NOT NULL DEFAULT '',
output TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_session_logs_session_id ON session_logs(session_id);
CREATE INDEX idx_session_logs_timestamp ON session_logs(timestamp);

View File

@@ -0,0 +1,83 @@
package storage
import (
"database/sql"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
func TestMigrateCreatesTablesAndVersion(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
if err := Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
// Verify schema version.
var version int
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
t.Fatalf("query version: %v", err)
}
if version != 1 {
t.Errorf("version = %d, want 1", version)
}
// Verify tables exist by inserting into them.
_, err = db.Exec(`INSERT INTO login_attempts (username, password, ip, count, first_seen, last_seen) VALUES ('a', 'b', 'c', 1, '2024-01-01', '2024-01-01')`)
if err != nil {
t.Fatalf("insert into login_attempts: %v", err)
}
_, err = db.Exec(`INSERT INTO sessions (id, ip, username, shell_name, connected_at) VALUES ('test-id', 'c', 'a', '', '2024-01-01')`)
if err != nil {
t.Fatalf("insert into sessions: %v", err)
}
_, err = db.Exec(`INSERT INTO session_logs (session_id, timestamp, input, output) VALUES ('test-id', '2024-01-01', '', '')`)
if err != nil {
t.Fatalf("insert into session_logs: %v", err)
}
}
func TestMigrateIdempotent(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
// Run twice; second should be a no-op.
if err := Migrate(db); err != nil {
t.Fatalf("first migrate: %v", err)
}
if err := Migrate(db); err != nil {
t.Fatalf("second migrate: %v", err)
}
var version int
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
t.Fatalf("query version: %v", err)
}
if version != 1 {
t.Errorf("version = %d after double migrate, want 1", version)
}
}
func TestLoadMigrations(t *testing.T) {
migrations, err := loadMigrations()
if err != nil {
t.Fatalf("load: %v", err)
}
if len(migrations) == 0 {
t.Fatal("no migrations found")
}
if migrations[0].Version != 1 {
t.Errorf("first migration version = %d, want 1", migrations[0].Version)
}
}

View File

@@ -0,0 +1,38 @@
package storage
import (
"context"
"log/slog"
"time"
)
// RunRetention periodically deletes records older than retentionDays.
// It runs one prune immediately on startup, then on the given interval.
// It returns when ctx is cancelled.
func RunRetention(ctx context.Context, store Store, retentionDays int, interval time.Duration, logger *slog.Logger) {
prune := func() {
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
n, err := store.DeleteRecordsBefore(ctx, cutoff)
if err != nil {
logger.Error("retention prune failed", "err", err)
return
}
if n > 0 {
logger.Info("retention prune completed", "deleted_rows", n)
}
}
prune()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
prune()
}
}
}

View File

@@ -0,0 +1,69 @@
package storage
import (
"context"
"log/slog"
"testing"
"time"
)
func TestRunRetentionDeletesOldRecords(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
logger := slog.Default()
// Insert an old login attempt (200 days ago).
oldTime := time.Now().AddDate(0, 0, -200).UTC().Format(time.RFC3339)
_, err := store.db.Exec(`
INSERT INTO login_attempts (username, password, ip, count, first_seen, last_seen)
VALUES ('old', 'old', '1.1.1.1', 1, ?, ?)`, oldTime, oldTime)
if err != nil {
t.Fatalf("insert old attempt: %v", err)
}
// Insert a recent login attempt.
if err := store.RecordLoginAttempt(ctx, "new", "new", "2.2.2.2"); err != nil {
t.Fatalf("insert recent attempt: %v", err)
}
// Run retention with a short interval. Cancel immediately after first run.
retentionCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
go func() {
RunRetention(retentionCtx, store, 90, 24*time.Hour, logger)
close(done)
}()
// Give it a moment to run the initial prune.
time.Sleep(100 * time.Millisecond)
cancel()
<-done
// Verify old record was deleted.
var count int
store.db.QueryRow(`SELECT COUNT(*) FROM login_attempts`).Scan(&count)
if count != 1 {
t.Errorf("remaining attempts = %d, want 1", count)
}
}
func TestRunRetentionCancellation(t *testing.T) {
store := newTestStore(t)
logger := slog.Default()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
RunRetention(ctx, store, 90, time.Millisecond, logger)
close(done)
}()
// Cancel and verify it exits.
cancel()
select {
case <-done:
// OK
case <-time.After(5 * time.Second):
t.Fatal("RunRetention did not exit after cancel")
}
}

144
internal/storage/sqlite.go Normal file
View File

@@ -0,0 +1,144 @@
package storage
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
_ "modernc.org/sqlite"
)
// SQLiteStore implements Store using a SQLite database.
type SQLiteStore struct {
db *sql.DB
}
// NewSQLiteStore opens or creates a SQLite database at the given path,
// runs pending migrations, and returns a ready-to-use store.
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
dsn := dbPath + "?_pragma=journal_mode(wal)&_pragma=foreign_keys(on)&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
db.SetMaxOpenConns(1)
if err := Migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("running migrations: %w", err)
}
return &SQLiteStore{db: db}, nil
}
func (s *SQLiteStore) RecordLoginAttempt(ctx context.Context, username, password, ip string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.ExecContext(ctx, `
INSERT INTO login_attempts (username, password, ip, count, first_seen, last_seen)
VALUES (?, ?, ?, 1, ?, ?)
ON CONFLICT(username, password, ip) DO UPDATE SET
count = count + 1,
last_seen = ?`,
username, password, ip, now, now, now)
if err != nil {
return fmt.Errorf("recording login attempt: %w", err)
}
return nil
}
func (s *SQLiteStore) CreateSession(ctx context.Context, ip, username, shellName string) (string, error) {
id := uuid.New().String()
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.ExecContext(ctx, `
INSERT INTO sessions (id, ip, username, shell_name, connected_at)
VALUES (?, ?, ?, ?, ?)`,
id, ip, username, shellName, now)
if err != nil {
return "", fmt.Errorf("creating session: %w", err)
}
return id, nil
}
func (s *SQLiteStore) EndSession(ctx context.Context, sessionID string, disconnectedAt time.Time) error {
_, err := s.db.ExecContext(ctx, `
UPDATE sessions SET disconnected_at = ? WHERE id = ?`,
disconnectedAt.UTC().Format(time.RFC3339), sessionID)
if err != nil {
return fmt.Errorf("ending session: %w", err)
}
return nil
}
func (s *SQLiteStore) UpdateHumanScore(ctx context.Context, sessionID string, score float64) error {
_, err := s.db.ExecContext(ctx, `
UPDATE sessions SET human_score = ? WHERE id = ?`,
score, sessionID)
if err != nil {
return fmt.Errorf("updating human score: %w", err)
}
return nil
}
func (s *SQLiteStore) AppendSessionLog(ctx context.Context, sessionID, input, output string) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.ExecContext(ctx, `
INSERT INTO session_logs (session_id, timestamp, input, output)
VALUES (?, ?, ?, ?)`,
sessionID, now, input, output)
if err != nil {
return fmt.Errorf("appending session log: %w", err)
}
return nil
}
func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
cutoffStr := cutoff.UTC().Format(time.RFC3339)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
var total int64
// Delete session logs for old sessions.
res, err := tx.ExecContext(ctx, `
DELETE FROM session_logs WHERE session_id IN (
SELECT id FROM sessions WHERE connected_at < ?
)`, cutoffStr)
if err != nil {
return 0, fmt.Errorf("deleting session logs: %w", err)
}
n, _ := res.RowsAffected()
total += n
// Delete old sessions.
res, err = tx.ExecContext(ctx, `DELETE FROM sessions WHERE connected_at < ?`, cutoffStr)
if err != nil {
return 0, fmt.Errorf("deleting sessions: %w", err)
}
n, _ = res.RowsAffected()
total += n
// Delete old login attempts.
res, err = tx.ExecContext(ctx, `DELETE FROM login_attempts WHERE last_seen < ?`, cutoffStr)
if err != nil {
return 0, fmt.Errorf("deleting login attempts: %w", err)
}
n, _ = res.RowsAffected()
total += n
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit transaction: %w", err)
}
return total, nil
}
func (s *SQLiteStore) Close() error {
return s.db.Close()
}

View File

@@ -0,0 +1,224 @@
package storage
import (
"context"
"path/filepath"
"testing"
"time"
)
func newTestStore(t *testing.T) *SQLiteStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := NewSQLiteStore(dbPath)
if err != nil {
t.Fatalf("creating store: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
func TestRecordLoginAttempt(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
// First attempt creates a new record.
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1"); err != nil {
t.Fatalf("first attempt: %v", err)
}
// Second attempt with same credentials increments count.
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1"); err != nil {
t.Fatalf("second attempt: %v", err)
}
// Different IP is a separate record.
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.2"); err != nil {
t.Fatalf("different IP: %v", err)
}
// Verify counts.
var count int
err := store.db.QueryRow(`SELECT count FROM login_attempts WHERE username = 'root' AND password = 'toor' AND ip = '10.0.0.1'`).Scan(&count)
if err != nil {
t.Fatalf("query: %v", err)
}
if count != 2 {
t.Errorf("count = %d, want 2", count)
}
// Verify total rows.
var total int
err = store.db.QueryRow(`SELECT COUNT(*) FROM login_attempts`).Scan(&total)
if err != nil {
t.Fatalf("query total: %v", err)
}
if total != 2 {
t.Errorf("total rows = %d, want 2", total)
}
}
func TestCreateAndEndSession(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "")
if err != nil {
t.Fatalf("creating session: %v", err)
}
if id == "" {
t.Fatal("session ID is empty")
}
// Verify session exists.
var username string
err = store.db.QueryRow(`SELECT username FROM sessions WHERE id = ?`, id).Scan(&username)
if err != nil {
t.Fatalf("query session: %v", err)
}
if username != "root" {
t.Errorf("username = %q, want %q", username, "root")
}
// End session.
now := time.Now()
if err := store.EndSession(ctx, id, now); err != nil {
t.Fatalf("ending session: %v", err)
}
var disconnectedAt string
err = store.db.QueryRow(`SELECT disconnected_at FROM sessions WHERE id = ?`, id).Scan(&disconnectedAt)
if err != nil {
t.Fatalf("query disconnected_at: %v", err)
}
if disconnectedAt == "" {
t.Error("disconnected_at is empty after EndSession")
}
}
func TestUpdateHumanScore(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "")
if err != nil {
t.Fatalf("creating session: %v", err)
}
if err := store.UpdateHumanScore(ctx, id, 0.85); err != nil {
t.Fatalf("updating score: %v", err)
}
var score float64
err = store.db.QueryRow(`SELECT human_score FROM sessions WHERE id = ?`, id).Scan(&score)
if err != nil {
t.Fatalf("query score: %v", err)
}
if score != 0.85 {
t.Errorf("score = %f, want 0.85", score)
}
}
func TestAppendSessionLog(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "")
if err != nil {
t.Fatalf("creating session: %v", err)
}
if err := store.AppendSessionLog(ctx, id, "ls -la", ""); err != nil {
t.Fatalf("append log: %v", err)
}
if err := store.AppendSessionLog(ctx, id, "", "total 4\ndrwxr-xr-x"); err != nil {
t.Fatalf("append log output: %v", err)
}
var count int
err = store.db.QueryRow(`SELECT COUNT(*) FROM session_logs WHERE session_id = ?`, id).Scan(&count)
if err != nil {
t.Fatalf("query logs: %v", err)
}
if count != 2 {
t.Errorf("log count = %d, want 2", count)
}
}
func TestDeleteRecordsBefore(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
// Insert an old login attempt.
oldTime := time.Now().AddDate(0, 0, -100).UTC().Format(time.RFC3339)
_, err := store.db.Exec(`
INSERT INTO login_attempts (username, password, ip, count, first_seen, last_seen)
VALUES ('old', 'old', '1.1.1.1', 1, ?, ?)`, oldTime, oldTime)
if err != nil {
t.Fatalf("insert old attempt: %v", err)
}
// Insert a recent login attempt.
if err := store.RecordLoginAttempt(ctx, "new", "new", "2.2.2.2"); err != nil {
t.Fatalf("insert recent attempt: %v", err)
}
// Insert an old session with a log entry.
_, err = store.db.Exec(`
INSERT INTO sessions (id, ip, username, shell_name, connected_at)
VALUES ('old-session', '1.1.1.1', 'old', '', ?)`, oldTime)
if err != nil {
t.Fatalf("insert old session: %v", err)
}
_, err = store.db.Exec(`
INSERT INTO session_logs (session_id, timestamp, input, output)
VALUES ('old-session', ?, 'ls', '')`, oldTime)
if err != nil {
t.Fatalf("insert old log: %v", err)
}
// Insert a recent session.
if _, err := store.CreateSession(ctx, "2.2.2.2", "new", ""); err != nil {
t.Fatalf("insert recent session: %v", err)
}
// Delete records older than 30 days.
cutoff := time.Now().AddDate(0, 0, -30)
deleted, err := store.DeleteRecordsBefore(ctx, cutoff)
if err != nil {
t.Fatalf("delete: %v", err)
}
if deleted != 3 {
t.Errorf("deleted = %d, want 3 (1 attempt + 1 session + 1 log)", deleted)
}
// Verify recent records remain.
var count int
store.db.QueryRow(`SELECT COUNT(*) FROM login_attempts`).Scan(&count)
if count != 1 {
t.Errorf("remaining attempts = %d, want 1", count)
}
store.db.QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&count)
if count != 1 {
t.Errorf("remaining sessions = %d, want 1", count)
}
}
func TestNewSQLiteStoreCreatesFile(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "subdir", "test.db")
// Parent directory doesn't exist yet; SQLite should create it.
// Actually, SQLite doesn't create parent dirs, but the file itself.
// Use a path in the temp dir directly.
dbPath = filepath.Join(t.TempDir(), "test.db")
store, err := NewSQLiteStore(dbPath)
if err != nil {
t.Fatalf("creating store: %v", err)
}
defer store.Close()
// Verify we can use the store.
ctx := context.Background()
if err := store.RecordLoginAttempt(ctx, "test", "test", "127.0.0.1"); err != nil {
t.Fatalf("recording attempt: %v", err)
}
}

63
internal/storage/store.go Normal file
View File

@@ -0,0 +1,63 @@
package storage
import (
"context"
"time"
)
// LoginAttempt represents a deduplicated login attempt.
type LoginAttempt struct {
ID int64
Username string
Password string
IP string
Count int
FirstSeen time.Time
LastSeen time.Time
}
// Session represents an authenticated SSH session.
type Session struct {
ID string
IP string
Username string
ShellName string
ConnectedAt time.Time
DisconnectedAt *time.Time
HumanScore *float64
}
// SessionLog represents a single log entry for a session.
type SessionLog struct {
ID int64
SessionID string
Timestamp time.Time
Input string
Output string
}
// Store is the interface for persistent storage of honeypot data.
type Store interface {
// RecordLoginAttempt upserts a login attempt, incrementing the count
// for existing (username, password, ip) combinations.
RecordLoginAttempt(ctx context.Context, username, password, ip string) error
// CreateSession creates a new session record and returns its UUID.
CreateSession(ctx context.Context, ip, username, shellName string) (string, error)
// EndSession sets the disconnected_at timestamp for a session.
EndSession(ctx context.Context, sessionID string, disconnectedAt time.Time) error
// UpdateHumanScore sets the human detection score for a session.
UpdateHumanScore(ctx context.Context, sessionID string, score float64) error
// AppendSessionLog adds a log entry to a session.
AppendSessionLog(ctx context.Context, sessionID, input, output string) error
// DeleteRecordsBefore removes all records older than the given cutoff
// and returns the total number of deleted rows.
DeleteRecordsBefore(ctx context.Context, cutoff time.Time) (int64, error)
// Close releases any resources held by the store.
Close() error
}