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"