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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,6 @@
|
|||||||
result
|
result
|
||||||
oubliette.toml
|
oubliette.toml
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
/oubliette
|
||||||
|
|||||||
2
PLAN.md
2
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
|
- Configurable credential list that triggers "successful" login
|
||||||
- Basic login realism: reject first N attempts before accepting
|
- 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: 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: 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)
|
- Schema: session_logs table for command logging (session_id, timestamp, input, output)
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ Key settings:
|
|||||||
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
||||||
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
|
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
|
||||||
- `auth.static_credentials` — always-accepted username/password pairs
|
- `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
|
### Run
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/server"
|
"git.t-juice.club/torjus/oubliette/internal/server"
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.1.0"
|
const Version = "0.1.0"
|
||||||
@@ -46,10 +47,19 @@ func main() {
|
|||||||
logger := slog.New(handler)
|
logger := slog.New(handler)
|
||||||
slog.SetDefault(logger)
|
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)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
logger.Error("failed to create server", "err", err)
|
logger.Error("failed to create server", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
pname = "oubliette";
|
pname = "oubliette";
|
||||||
inherit version;
|
inherit version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
vendorHash = "sha256-z/E1ZDfedOxI8CSUfcpFGYX0SrdcnAYuu2p0ATozDaA=";
|
vendorHash = "sha256-EbJ90e4Jco7CvYYJLrewFLD5XF+Wv6TsT8RRLcj+ijU=";
|
||||||
subPackages = [ "cmd/oubliette" ];
|
subPackages = [ "cmd/oubliette" ];
|
||||||
meta = {
|
meta = {
|
||||||
description = "SSH honeypot";
|
description = "SSH honeypot";
|
||||||
|
|||||||
19
go.mod
19
go.mod
@@ -3,7 +3,20 @@ module git.t-juice.club/torjus/oubliette
|
|||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
github.com/google/uuid v1.6.0
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
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
53
go.sum
@@ -1,6 +1,59 @@
|
|||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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=
|
||||||
|
|||||||
@@ -11,10 +11,20 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
SSH SSHConfig `toml:"ssh"`
|
SSH SSHConfig `toml:"ssh"`
|
||||||
Auth AuthConfig `toml:"auth"`
|
Auth AuthConfig `toml:"auth"`
|
||||||
|
Storage StorageConfig `toml:"storage"`
|
||||||
LogLevel string `toml:"log_level"`
|
LogLevel string `toml:"log_level"`
|
||||||
LogFormat string `toml:"log_format"` // "text" (default) or "json"
|
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 {
|
type SSHConfig struct {
|
||||||
ListenAddr string `toml:"listen_addr"`
|
ListenAddr string `toml:"listen_addr"`
|
||||||
HostKeyPath string `toml:"host_key_path"`
|
HostKeyPath string `toml:"host_key_path"`
|
||||||
@@ -77,6 +87,15 @@ func applyDefaults(cfg *Config) {
|
|||||||
if cfg.LogFormat == "" {
|
if cfg.LogFormat == "" {
|
||||||
cfg.LogFormat = "text"
|
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 {
|
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)
|
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 {
|
for i, cred := range cfg.Auth.StaticCredentials {
|
||||||
if cred.Username == "" {
|
if cred.Username == "" {
|
||||||
return fmt.Errorf("static_credentials[%d]: username must not be empty", i)
|
return fmt.Errorf("static_credentials[%d]: username must not be empty", i)
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
if cfg.LogLevel != "info" {
|
if cfg.LogLevel != "info" {
|
||||||
t.Errorf("default log_level = %q, want %q", 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) {
|
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) {
|
func TestLoadMissingFile(t *testing.T) {
|
||||||
_, err := Load("/nonexistent/path/config.toml")
|
_, err := Load("/nonexistent/path/config.toml")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"git.t-juice.club/torjus/oubliette/internal/auth"
|
"git.t-juice.club/torjus/oubliette/internal/auth"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,15 +22,17 @@ const sessionTimeout = 30 * time.Second
|
|||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
|
store storage.Store
|
||||||
authenticator *auth.Authenticator
|
authenticator *auth.Authenticator
|
||||||
sshConfig *ssh.ServerConfig
|
sshConfig *ssh.ServerConfig
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
connSem chan struct{} // semaphore limiting concurrent connections
|
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{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
store: store,
|
||||||
authenticator: auth.NewAuthenticator(cfg.Auth),
|
authenticator: auth.NewAuthenticator(cfg.Auth),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
connSem: make(chan struct{}, cfg.SSH.MaxConnections),
|
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) {
|
func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request, conn *ssh.ServerConn) {
|
||||||
defer channel.Close()
|
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.)
|
// Handle session requests (pty-req, shell, etc.)
|
||||||
go func() {
|
go func() {
|
||||||
for req := range requests {
|
for req := range requests {
|
||||||
@@ -179,6 +194,10 @@ func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.
|
|||||||
"reason", d.Reason,
|
"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 {
|
if d.Accepted {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
"golang.org/x/crypto/ssh"
|
"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}))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("creating server: %v", err)
|
t.Fatalf("creating server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
143
internal/storage/memstore.go
Normal file
143
internal/storage/memstore.go
Normal 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
|
||||||
|
}
|
||||||
124
internal/storage/migrations.go
Normal file
124
internal/storage/migrations.go
Normal 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
|
||||||
|
}
|
||||||
36
internal/storage/migrations/001_initial.sql
Normal file
36
internal/storage/migrations/001_initial.sql
Normal 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);
|
||||||
83
internal/storage/migrations_test.go
Normal file
83
internal/storage/migrations_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
internal/storage/retention.go
Normal file
38
internal/storage/retention.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/storage/retention_test.go
Normal file
69
internal/storage/retention_test.go
Normal 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
144
internal/storage/sqlite.go
Normal 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()
|
||||||
|
}
|
||||||
224
internal/storage/sqlite_test.go
Normal file
224
internal/storage/sqlite_test.go
Normal 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
63
internal/storage/store.go
Normal 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
|
||||||
|
}
|
||||||
@@ -17,3 +17,8 @@ password = "toor"
|
|||||||
[[auth.static_credentials]]
|
[[auth.static_credentials]]
|
||||||
username = "admin"
|
username = "admin"
|
||||||
password = "admin"
|
password = "admin"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
db_path = "oubliette.db"
|
||||||
|
retention_days = 90
|
||||||
|
retention_interval = "1h"
|
||||||
|
|||||||
Reference in New Issue
Block a user