From d6559682162e1fc1ad19fbd2988da39d159acef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Sat, 14 Feb 2026 17:33:45 +0100 Subject: [PATCH] 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 --- .gitignore | 4 + PLAN.md | 2 +- README.md | 3 + cmd/oubliette/main.go | 12 +- flake.nix | 2 +- go.mod | 19 +- go.sum | 53 +++++ internal/config/config.go | 36 +++- internal/config/config_test.go | 56 +++++ internal/server/server.go | 21 +- internal/server/server_test.go | 4 +- internal/storage/memstore.go | 143 +++++++++++++ internal/storage/migrations.go | 124 +++++++++++ internal/storage/migrations/001_initial.sql | 36 ++++ internal/storage/migrations_test.go | 83 ++++++++ internal/storage/retention.go | 38 ++++ internal/storage/retention_test.go | 69 ++++++ internal/storage/sqlite.go | 144 +++++++++++++ internal/storage/sqlite_test.go | 224 ++++++++++++++++++++ internal/storage/store.go | 63 ++++++ oubliette.toml.example | 5 + 21 files changed, 1131 insertions(+), 10 deletions(-) create mode 100644 internal/storage/memstore.go create mode 100644 internal/storage/migrations.go create mode 100644 internal/storage/migrations/001_initial.sql create mode 100644 internal/storage/migrations_test.go create mode 100644 internal/storage/retention.go create mode 100644 internal/storage/retention_test.go create mode 100644 internal/storage/sqlite.go create mode 100644 internal/storage/sqlite_test.go create mode 100644 internal/storage/store.go diff --git a/.gitignore b/.gitignore index 4a07280..86e054e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ result oubliette.toml +*.db +*.db-wal +*.db-shm +/oubliette diff --git a/PLAN.md b/PLAN.md index af6c52f..1beabc4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -67,7 +67,7 @@ Goal: A working SSH honeypot that logs attempts, stores them in SQLite, and can - Configurable credential list that triggers "successful" login - Basic login realism: reject first N attempts before accepting -### 1.3 SQLite Storage +### 1.3 SQLite Storage ✅ - Schema: login_attempts table with deduplication (username, password, ip, count, first_seen, last_seen) - Schema: sessions table for successful logins (id, ip, username, shell_name, connected_at, disconnected_at, human_score) - Schema: session_logs table for command logging (session_id, timestamp, input, output) diff --git a/README.md b/README.md index c289b17..6b7b8f1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ Key settings: - `auth.accept_after` — accept login after N failures per IP (default `10`) - `auth.credential_ttl` — how long to remember accepted credentials (default `24h`) - `auth.static_credentials` — always-accepted username/password pairs +- `storage.db_path` — SQLite database path (default `oubliette.db`) +- `storage.retention_days` — auto-prune records older than N days (default `90`) +- `storage.retention_interval` — how often to run retention (default `1h`) ### Run diff --git a/cmd/oubliette/main.go b/cmd/oubliette/main.go index 9e59884..e8f5b7a 100644 --- a/cmd/oubliette/main.go +++ b/cmd/oubliette/main.go @@ -10,6 +10,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/config" "git.t-juice.club/torjus/oubliette/internal/server" + "git.t-juice.club/torjus/oubliette/internal/storage" ) const Version = "0.1.0" @@ -46,10 +47,19 @@ func main() { logger := slog.New(handler) slog.SetDefault(logger) + store, err := storage.NewSQLiteStore(cfg.Storage.DBPath) + if err != nil { + logger.Error("failed to open database", "err", err) + os.Exit(1) + } + defer store.Close() + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() - srv, err := server.New(*cfg, logger) + go storage.RunRetention(ctx, store, cfg.Storage.RetentionDays, cfg.Storage.RetentionIntervalDuration, logger) + + srv, err := server.New(*cfg, store, logger) if err != nil { logger.Error("failed to create server", "err", err) os.Exit(1) diff --git a/flake.nix b/flake.nix index 490cefb..f27760d 100644 --- a/flake.nix +++ b/flake.nix @@ -24,7 +24,7 @@ pname = "oubliette"; inherit version; src = ./.; - vendorHash = "sha256-z/E1ZDfedOxI8CSUfcpFGYX0SrdcnAYuu2p0ATozDaA="; + vendorHash = "sha256-EbJ90e4Jco7CvYYJLrewFLD5XF+Wv6TsT8RRLcj+ijU="; subPackages = [ "cmd/oubliette" ]; meta = { description = "SSH honeypot"; diff --git a/go.mod b/go.mod index 77e5311..f41df74 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,20 @@ module git.t-juice.club/torjus/oubliette go 1.25.5 require ( - github.com/BurntSushi/toml v1.6.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sys v0.41.0 // indirect + github.com/BurntSushi/toml v1.6.0 + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.48.0 + modernc.org/sqlite v1.45.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.41.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index df3fc95..10c12aa 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,59 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= +modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go index 6b08979..5f4cc30 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,12 +9,22 @@ import ( ) type Config struct { - SSH SSHConfig `toml:"ssh"` - Auth AuthConfig `toml:"auth"` + SSH SSHConfig `toml:"ssh"` + Auth AuthConfig `toml:"auth"` + Storage StorageConfig `toml:"storage"` LogLevel string `toml:"log_level"` LogFormat string `toml:"log_format"` // "text" (default) or "json" } +type StorageConfig struct { + DBPath string `toml:"db_path"` + RetentionDays int `toml:"retention_days"` + RetentionInterval string `toml:"retention_interval"` + + // Parsed duration, not from TOML directly. + RetentionIntervalDuration time.Duration `toml:"-"` +} + type SSHConfig struct { ListenAddr string `toml:"listen_addr"` HostKeyPath string `toml:"host_key_path"` @@ -77,6 +87,15 @@ func applyDefaults(cfg *Config) { if cfg.LogFormat == "" { cfg.LogFormat = "text" } + if cfg.Storage.DBPath == "" { + cfg.Storage.DBPath = "oubliette.db" + } + if cfg.Storage.RetentionDays == 0 { + cfg.Storage.RetentionDays = 90 + } + if cfg.Storage.RetentionInterval == "" { + cfg.Storage.RetentionInterval = "1h" + } } func validate(cfg *Config) error { @@ -93,6 +112,19 @@ func validate(cfg *Config) error { return fmt.Errorf("accept_after must be at least 1, got %d", cfg.Auth.AcceptAfter) } + ri, err := time.ParseDuration(cfg.Storage.RetentionInterval) + if err != nil { + return fmt.Errorf("invalid retention_interval %q: %w", cfg.Storage.RetentionInterval, err) + } + if ri <= 0 { + return fmt.Errorf("retention_interval must be positive, got %s", ri) + } + cfg.Storage.RetentionIntervalDuration = ri + + if cfg.Storage.RetentionDays < 1 { + return fmt.Errorf("retention_days must be at least 1, got %d", cfg.Storage.RetentionDays) + } + for i, cred := range cfg.Auth.StaticCredentials { if cred.Username == "" { return fmt.Errorf("static_credentials[%d]: username must not be empty", i) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c6b5288..6cc0f42 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -74,6 +74,15 @@ func TestLoadDefaults(t *testing.T) { if cfg.LogLevel != "info" { t.Errorf("default log_level = %q, want %q", cfg.LogLevel, "info") } + if cfg.Storage.DBPath != "oubliette.db" { + t.Errorf("default db_path = %q, want %q", cfg.Storage.DBPath, "oubliette.db") + } + if cfg.Storage.RetentionDays != 90 { + t.Errorf("default retention_days = %d, want %d", cfg.Storage.RetentionDays, 90) + } + if cfg.Storage.RetentionIntervalDuration != time.Hour { + t.Errorf("default retention_interval = %v, want %v", cfg.Storage.RetentionIntervalDuration, time.Hour) + } } func TestLoadInvalidTTL(t *testing.T) { @@ -113,6 +122,53 @@ password = "test" } } +func TestLoadInvalidRetentionInterval(t *testing.T) { + content := ` +[storage] +retention_interval = "notaduration" +` + path := writeTemp(t, content) + _, err := Load(path) + if err == nil { + t.Fatal("expected error for invalid retention_interval") + } +} + +func TestLoadInvalidRetentionDays(t *testing.T) { + content := ` +[storage] +retention_days = -1 +` + path := writeTemp(t, content) + _, err := Load(path) + if err == nil { + t.Fatal("expected error for negative retention_days") + } +} + +func TestLoadStorageConfig(t *testing.T) { + content := ` +[storage] +db_path = "/tmp/test.db" +retention_days = 30 +retention_interval = "2h" +` + path := writeTemp(t, content) + cfg, err := Load(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Storage.DBPath != "/tmp/test.db" { + t.Errorf("db_path = %q, want %q", cfg.Storage.DBPath, "/tmp/test.db") + } + if cfg.Storage.RetentionDays != 30 { + t.Errorf("retention_days = %d, want 30", cfg.Storage.RetentionDays) + } + if cfg.Storage.RetentionIntervalDuration != 2*time.Hour { + t.Errorf("retention_interval = %v, want 2h", cfg.Storage.RetentionIntervalDuration) + } +} + func TestLoadMissingFile(t *testing.T) { _, err := Load("/nonexistent/path/config.toml") if err == nil { diff --git a/internal/server/server.go b/internal/server/server.go index 17c9540..46a8f85 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,6 +14,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/auth" "git.t-juice.club/torjus/oubliette/internal/config" + "git.t-juice.club/torjus/oubliette/internal/storage" "golang.org/x/crypto/ssh" ) @@ -21,15 +22,17 @@ const sessionTimeout = 30 * time.Second type Server struct { cfg config.Config + store storage.Store authenticator *auth.Authenticator sshConfig *ssh.ServerConfig logger *slog.Logger connSem chan struct{} // semaphore limiting concurrent connections } -func New(cfg config.Config, logger *slog.Logger) (*Server, error) { +func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server, error) { s := &Server{ cfg: cfg, + store: store, authenticator: auth.NewAuthenticator(cfg.Auth), logger: logger, connSem: make(chan struct{}, cfg.SSH.MaxConnections), @@ -123,6 +126,18 @@ func (s *Server) handleConn(conn net.Conn) { func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request, conn *ssh.ServerConn) { defer channel.Close() + ip := extractIP(conn.RemoteAddr()) + sessionID, err := s.store.CreateSession(context.Background(), ip, conn.User(), "") + if err != nil { + s.logger.Error("failed to create session", "err", err) + } else { + defer func() { + if err := s.store.EndSession(context.Background(), sessionID, time.Now()); err != nil { + s.logger.Error("failed to end session", "err", err) + } + }() + } + // Handle session requests (pty-req, shell, etc.) go func() { for req := range requests { @@ -179,6 +194,10 @@ func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh. "reason", d.Reason, ) + if err := s.store.RecordLoginAttempt(context.Background(), conn.User(), string(password), ip); err != nil { + s.logger.Error("failed to record login attempt", "err", err) + } + if d.Accepted { return nil, nil } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index d8a83d1..7de127a 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -10,6 +10,7 @@ import ( "time" "git.t-juice.club/torjus/oubliette/internal/config" + "git.t-juice.club/torjus/oubliette/internal/storage" "golang.org/x/crypto/ssh" ) @@ -112,7 +113,8 @@ func TestIntegrationSSHConnect(t *testing.T) { } logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) - srv, err := New(cfg, logger) + store := storage.NewMemoryStore() + srv, err := New(cfg, store, logger) if err != nil { t.Fatalf("creating server: %v", err) } diff --git a/internal/storage/memstore.go b/internal/storage/memstore.go new file mode 100644 index 0000000..5bdd43d --- /dev/null +++ b/internal/storage/memstore.go @@ -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 +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go new file mode 100644 index 0000000..42fb33f --- /dev/null +++ b/internal/storage/migrations.go @@ -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 +} diff --git a/internal/storage/migrations/001_initial.sql b/internal/storage/migrations/001_initial.sql new file mode 100644 index 0000000..94b04d2 --- /dev/null +++ b/internal/storage/migrations/001_initial.sql @@ -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); diff --git a/internal/storage/migrations_test.go b/internal/storage/migrations_test.go new file mode 100644 index 0000000..e1d4de5 --- /dev/null +++ b/internal/storage/migrations_test.go @@ -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) + } +} diff --git a/internal/storage/retention.go b/internal/storage/retention.go new file mode 100644 index 0000000..26cd47c --- /dev/null +++ b/internal/storage/retention.go @@ -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() + } + } +} diff --git a/internal/storage/retention_test.go b/internal/storage/retention_test.go new file mode 100644 index 0000000..4ab1fab --- /dev/null +++ b/internal/storage/retention_test.go @@ -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") + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go new file mode 100644 index 0000000..922ae1c --- /dev/null +++ b/internal/storage/sqlite.go @@ -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() +} diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go new file mode 100644 index 0000000..4e93b2a --- /dev/null +++ b/internal/storage/sqlite_test.go @@ -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) + } +} diff --git a/internal/storage/store.go b/internal/storage/store.go new file mode 100644 index 0000000..2d2bd4f --- /dev/null +++ b/internal/storage/store.go @@ -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 +} diff --git a/oubliette.toml.example b/oubliette.toml.example index f834bf8..7ea3044 100644 --- a/oubliette.toml.example +++ b/oubliette.toml.example @@ -17,3 +17,8 @@ password = "toor" [[auth.static_credentials]] username = "admin" password = "admin" + +[storage] +db_path = "oubliette.db" +retention_days = 90 +retention_interval = "1h"