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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user