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

4
.gitignore vendored
View File

@@ -1,2 +1,6 @@
result
oubliette.toml
*.db
*.db-wal
*.db-shm
/oubliette

View File

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

View File

@@ -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

View File

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

View File

@@ -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";

19
go.mod
View File

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

53
go.sum
View File

@@ -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=

View File

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

View File

@@ -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 {

View File

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

View File

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

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
}

View File

@@ -17,3 +17,8 @@ password = "toor"
[[auth.static_credentials]]
username = "admin"
password = "admin"
[storage]
db_path = "oubliette.db"
retention_days = 90
retention_interval = "1h"