From 94f1f1c266f011fa0288bf955f66991165559920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Sun, 15 Feb 2026 15:27:46 +0100 Subject: [PATCH] feat: add GeoIP country lookup with embedded DB-IP Lite database (PLAN.md 4.3) Embeds a DB-IP Lite country MMDB (~5MB) in the binary via go:embed, keeping the single-binary deployment story clean. Country codes are stored alongside login attempts and sessions, shown in the dashboard (Top IPs, Top Countries card, Recent/Active Sessions, session detail). Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + PLAN.md | 8 +-- README.md | 6 ++ flake.nix | 10 ++- go.mod | 1 + go.sum | 2 + internal/geoip/geoip.go | 51 +++++++++++++ internal/geoip/geoip_test.go | 44 ++++++++++++ internal/server/server.go | 16 ++++- internal/shell/adventure/adventure_test.go | 4 +- internal/shell/banking/banking_test.go | 2 +- internal/shell/bash/bash_test.go | 4 +- internal/shell/eventrecorder_test.go | 4 +- internal/shell/fridge/fridge_test.go | 4 +- internal/storage/memstore.go | 62 +++++++++++++++- .../storage/migrations/003_add_country.sql | 3 + internal/storage/migrations_test.go | 8 +-- internal/storage/retention_test.go | 2 +- internal/storage/sqlite.go | 72 +++++++++++++++---- internal/storage/sqlite_test.go | 18 ++--- internal/storage/store.go | 14 ++-- internal/storage/store_test.go | 22 +++--- internal/web/handlers.go | 9 +++ internal/web/templates/dashboard.html | 21 +++++- .../templates/fragments/active_sessions.html | 4 +- internal/web/templates/session_detail.html | 1 + internal/web/web_test.go | 12 ++-- scripts/fetch-geoip.sh | 18 +++++ 28 files changed, 353 insertions(+), 71 deletions(-) create mode 100644 internal/geoip/geoip.go create mode 100644 internal/geoip/geoip_test.go create mode 100644 internal/storage/migrations/003_add_country.sql create mode 100755 scripts/fetch-geoip.sh diff --git a/.gitignore b/.gitignore index 86e054e..168073e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ oubliette.toml *.db-wal *.db-shm /oubliette +*.mmdb +*.mmdb.gz diff --git a/PLAN.md b/PLAN.md index 2b58d43..32b213e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -196,7 +196,7 @@ Goal: Make the web UI great and add operational niceties. - Docker image (nix dockerTools) ✅ - Systemd unit file / deployment docs ✅ -### 4.3 GeoIP -- Embed a lightweight GeoIP database or use an API -- Store country/city with each attempt -- Aggregate stats by country +### 4.3 GeoIP ✅ +- Embed a lightweight GeoIP database or use an API ✅ +- Store country/city with each attempt ✅ +- Aggregate stats by country ✅ diff --git a/README.md b/README.md index d0f1718..d618a5e 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ Key settings: - `detection.update_interval` — how often to recompute scores (default `5s`) - `notify.webhooks` — list of webhook endpoints for notifications (see example config) +### GeoIP + +Country-level GeoIP lookups are embedded in the binary using the [DB-IP Lite](https://db-ip.com/db/lite.php) database (CC-BY-4.0). The dashboard shows country alongside IPs and includes a "Top Countries" table. + +For local development, run `scripts/fetch-geoip.sh` to download the MMDB file. The Nix build fetches it automatically. + ### Run ```sh diff --git a/flake.nix b/flake.nix index 0c92d66..55b4cb7 100644 --- a/flake.nix +++ b/flake.nix @@ -18,14 +18,22 @@ pkgs = nixpkgs.legacyPackages.${system}; mainGo = builtins.readFile ./cmd/oubliette/main.go; version = builtins.head (builtins.match ''.*const Version = "([^"]+)".*'' mainGo); + geoipDb = pkgs.fetchurl { + url = "https://download.db-ip.com/free/dbip-country-lite-2026-02.mmdb.gz"; + hash = "sha256-xmQZEJZ5WzE9uQww1Sdb8248l+liYw46tjbfJeu945Q="; + }; in { default = pkgs.buildGoModule { pname = "oubliette"; inherit version; src = ./.; - vendorHash = "sha256-smMg/J1igSoSBkzdm9HJOp5OYY8MEccodCD/zVK31IQ="; + vendorHash = "sha256-/zxK6CABLYBNtuSOI8dIVgMNxKiDIcbZUS7bQR5TenA="; subPackages = [ "cmd/oubliette" ]; + nativeBuildInputs = [ pkgs.gzip ]; + preBuild = '' + gunzip -c ${geoipDb} > internal/geoip/dbip-country-lite.mmdb + ''; meta = { description = "SSH honeypot"; mainProgram = "oubliette"; diff --git a/go.mod b/go.mod index 6a034fa..fd8d90b 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/google/uuid v1.6.0 + github.com/oschwald/maxminddb-golang v1.13.1 github.com/prometheus/client_golang v1.23.2 golang.org/x/crypto v0.48.0 modernc.org/sqlite v1.45.0 diff --git a/go.sum b/go.sum index 93ca867..b58607b 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= diff --git a/internal/geoip/geoip.go b/internal/geoip/geoip.go new file mode 100644 index 0000000..a25074f --- /dev/null +++ b/internal/geoip/geoip.go @@ -0,0 +1,51 @@ +package geoip + +import ( + _ "embed" + "net" + + "github.com/oschwald/maxminddb-golang" +) + +//go:embed dbip-country-lite.mmdb +var mmdbData []byte + +// Reader provides country-level GeoIP lookups using an embedded DB-IP Lite database. +type Reader struct { + db *maxminddb.Reader +} + +// New opens the embedded MMDB and returns a ready-to-use Reader. +func New() (*Reader, error) { + db, err := maxminddb.FromBytes(mmdbData) + if err != nil { + return nil, err + } + return &Reader{db: db}, nil +} + +type countryRecord struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + } `maxminddb:"country"` +} + +// Lookup returns the ISO 3166-1 alpha-2 country code for the given IP address, +// or an empty string if the lookup fails or no result is found. +func (r *Reader) Lookup(ipStr string) string { + ip := net.ParseIP(ipStr) + if ip == nil { + return "" + } + + var record countryRecord + if err := r.db.Lookup(ip, &record); err != nil { + return "" + } + return record.Country.ISOCode +} + +// Close releases resources held by the reader. +func (r *Reader) Close() error { + return r.db.Close() +} diff --git a/internal/geoip/geoip_test.go b/internal/geoip/geoip_test.go new file mode 100644 index 0000000..677797c --- /dev/null +++ b/internal/geoip/geoip_test.go @@ -0,0 +1,44 @@ +package geoip + +import "testing" + +func TestLookup(t *testing.T) { + reader, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + defer reader.Close() + + tests := []struct { + ip string + want string + }{ + {"8.8.8.8", "US"}, + {"1.1.1.1", "AU"}, + {"invalid", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + got := reader.Lookup(tt.ip) + if got != tt.want { + t.Errorf("Lookup(%q) = %q, want %q", tt.ip, got, tt.want) + } + }) + } +} + +func TestLookupPrivateIP(t *testing.T) { + reader, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + defer reader.Close() + + // Private IPs should return empty string (no country). + got := reader.Lookup("10.0.0.1") + if got != "" { + t.Errorf("Lookup(10.0.0.1) = %q, want empty", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 059d656..a446a17 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,6 +15,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/detection" + "git.t-juice.club/torjus/oubliette/internal/geoip" "git.t-juice.club/torjus/oubliette/internal/metrics" "git.t-juice.club/torjus/oubliette/internal/notify" "git.t-juice.club/torjus/oubliette/internal/shell" @@ -37,6 +38,7 @@ type Server struct { shellRegistry *shell.Registry notifier notify.Sender metrics *metrics.Metrics + geoip *geoip.Reader } func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics.Metrics) (*Server, error) { @@ -57,6 +59,11 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics return nil, fmt.Errorf("registering cisco shell: %w", err) } + geo, err := geoip.New() + if err != nil { + return nil, fmt.Errorf("opening geoip database: %w", err) + } + s := &Server{ cfg: cfg, store: store, @@ -66,6 +73,7 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics shellRegistry: registry, notifier: notify.NewSender(cfg.Notify.Webhooks, logger), metrics: m, + geoip: geo, } hostKey, err := loadOrGenerateHostKey(cfg.SSH.HostKeyPath) @@ -83,6 +91,8 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics } func (s *Server) ListenAndServe(ctx context.Context) error { + defer s.geoip.Close() + listener, err := net.Listen("tcp", s.cfg.SSH.ListenAddr) if err != nil { return fmt.Errorf("listen: %w", err) @@ -185,8 +195,9 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request } ip := extractIP(conn.RemoteAddr()) + country := s.geoip.Lookup(ip) sessionStart := time.Now() - sessionID, err := s.store.CreateSession(context.Background(), ip, conn.User(), selectedShell.Name()) + sessionID, err := s.store.CreateSession(context.Background(), ip, conn.User(), selectedShell.Name(), country) if err != nil { s.logger.Error("failed to create session", "err", err) } else { @@ -350,7 +361,8 @@ 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 { + country := s.geoip.Lookup(ip) + if err := s.store.RecordLoginAttempt(context.Background(), conn.User(), string(password), ip, country); err != nil { s.logger.Error("failed to record login attempt", "err", err) } diff --git a/internal/shell/adventure/adventure_test.go b/internal/shell/adventure/adventure_test.go index a9eaf29..5b61a42 100644 --- a/internal/shell/adventure/adventure_test.go +++ b/internal/shell/adventure/adventure_test.go @@ -22,7 +22,7 @@ func (r *rwCloser) Close() error { return nil } func runShell(t *testing.T, commands string) string { t.Helper() store := storage.NewMemoryStore() - sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "adventure") + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "adventure", "") sess := &shell.SessionContext{ SessionID: sessID, @@ -287,7 +287,7 @@ func TestEthernetCable(t *testing.T) { func TestSessionLogs(t *testing.T) { store := storage.NewMemoryStore() - sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "adventure") + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "adventure", "") sess := &shell.SessionContext{ SessionID: sessID, diff --git a/internal/shell/banking/banking_test.go b/internal/shell/banking/banking_test.go index 48878c5..b78ab4a 100644 --- a/internal/shell/banking/banking_test.go +++ b/internal/shell/banking/banking_test.go @@ -17,7 +17,7 @@ import ( func newTestModel(t *testing.T) (*model, *storage.MemoryStore) { t.Helper() store := storage.NewMemoryStore() - sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "banker", "banking") + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "banker", "banking", "") sess := &shell.SessionContext{ SessionID: sessID, Username: "banker", diff --git a/internal/shell/bash/bash_test.go b/internal/shell/bash/bash_test.go index c56f3ff..9da7c50 100644 --- a/internal/shell/bash/bash_test.go +++ b/internal/shell/bash/bash_test.go @@ -116,7 +116,7 @@ func TestReadLineCtrlD(t *testing.T) { func TestBashShellHandle(t *testing.T) { store := storage.NewMemoryStore() - sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "bash") + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "bash", "") sess := &shell.SessionContext{ SessionID: sessID, @@ -166,7 +166,7 @@ func TestBashShellHandle(t *testing.T) { func TestBashShellFakeUser(t *testing.T) { store := storage.NewMemoryStore() - sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "attacker", "bash") + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "attacker", "bash", "") sess := &shell.SessionContext{ SessionID: sessID, diff --git a/internal/shell/eventrecorder_test.go b/internal/shell/eventrecorder_test.go index 8e035b7..3473723 100644 --- a/internal/shell/eventrecorder_test.go +++ b/internal/shell/eventrecorder_test.go @@ -14,7 +14,7 @@ func TestEventRecorderFlush(t *testing.T) { ctx := context.Background() // Create a session so events have a valid session ID. - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") if err != nil { t.Fatalf("CreateSession: %v", err) } @@ -55,7 +55,7 @@ func TestEventRecorderPeriodicFlush(t *testing.T) { store := storage.NewMemoryStore() ctx := context.Background() - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") if err != nil { t.Fatalf("CreateSession: %v", err) } diff --git a/internal/shell/fridge/fridge_test.go b/internal/shell/fridge/fridge_test.go index 15f775b..cafc79b 100644 --- a/internal/shell/fridge/fridge_test.go +++ b/internal/shell/fridge/fridge_test.go @@ -22,7 +22,7 @@ func (r *rwCloser) Close() error { return nil } func runShell(t *testing.T, commands string) string { t.Helper() store := storage.NewMemoryStore() - sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "fridge") + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "fridge", "") sess := &shell.SessionContext{ SessionID: sessID, @@ -205,7 +205,7 @@ func TestLogoutCommand(t *testing.T) { func TestSessionLogs(t *testing.T) { store := storage.NewMemoryStore() - sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "fridge") + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "root", "fridge", "") sess := &shell.SessionContext{ SessionID: sessID, diff --git a/internal/storage/memstore.go b/internal/storage/memstore.go index 15b483e..9b2e0e8 100644 --- a/internal/storage/memstore.go +++ b/internal/storage/memstore.go @@ -25,7 +25,7 @@ func NewMemoryStore() *MemoryStore { } } -func (m *MemoryStore) RecordLoginAttempt(_ context.Context, username, password, ip string) error { +func (m *MemoryStore) RecordLoginAttempt(_ context.Context, username, password, ip, country string) error { m.mu.Lock() defer m.mu.Unlock() @@ -35,6 +35,7 @@ func (m *MemoryStore) RecordLoginAttempt(_ context.Context, username, password, if a.Username == username && a.Password == password && a.IP == ip { a.Count++ a.LastSeen = now + a.Country = country return nil } } @@ -44,6 +45,7 @@ func (m *MemoryStore) RecordLoginAttempt(_ context.Context, username, password, Username: username, Password: password, IP: ip, + Country: country, Count: 1, FirstSeen: now, LastSeen: now, @@ -51,7 +53,7 @@ func (m *MemoryStore) RecordLoginAttempt(_ context.Context, username, password, return nil } -func (m *MemoryStore) CreateSession(_ context.Context, ip, username, shellName string) (string, error) { +func (m *MemoryStore) CreateSession(_ context.Context, ip, username, shellName, country string) (string, error) { m.mu.Lock() defer m.mu.Unlock() @@ -60,6 +62,7 @@ func (m *MemoryStore) CreateSession(_ context.Context, ip, username, shellName s m.Sessions[id] = &Session{ ID: id, IP: ip, + Country: country, Username: username, ShellName: shellName, ConnectedAt: now, @@ -234,7 +237,60 @@ func (m *MemoryStore) GetTopPasswords(_ context.Context, limit int) ([]TopEntry, func (m *MemoryStore) GetTopIPs(_ context.Context, limit int) ([]TopEntry, error) { m.mu.Lock() defer m.mu.Unlock() - return m.topN("ip", limit), nil + + type ipInfo struct { + count int64 + country string + } + agg := make(map[string]*ipInfo) + for _, a := range m.LoginAttempts { + info, ok := agg[a.IP] + if !ok { + info = &ipInfo{} + agg[a.IP] = info + } + info.count += int64(a.Count) + if a.Country != "" { + info.country = a.Country + } + } + + entries := make([]TopEntry, 0, len(agg)) + for ip, info := range agg { + entries = append(entries, TopEntry{Value: ip, Country: info.country, Count: info.count}) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Count > entries[j].Count + }) + if limit > 0 && len(entries) > limit { + entries = entries[:limit] + } + return entries, nil +} + +func (m *MemoryStore) GetTopCountries(_ context.Context, limit int) ([]TopEntry, error) { + m.mu.Lock() + defer m.mu.Unlock() + + counts := make(map[string]int64) + for _, a := range m.LoginAttempts { + if a.Country == "" { + continue + } + counts[a.Country] += int64(a.Count) + } + + entries := make([]TopEntry, 0, len(counts)) + for k, v := range counts { + entries = append(entries, TopEntry{Value: k, Count: v}) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Count > entries[j].Count + }) + if limit > 0 && len(entries) > limit { + entries = entries[:limit] + } + return entries, nil } // topN aggregates login attempts by the given field and returns the top N. Must be called with m.mu held. diff --git a/internal/storage/migrations/003_add_country.sql b/internal/storage/migrations/003_add_country.sql new file mode 100644 index 0000000..5a4c003 --- /dev/null +++ b/internal/storage/migrations/003_add_country.sql @@ -0,0 +1,3 @@ +ALTER TABLE login_attempts ADD COLUMN country TEXT NOT NULL DEFAULT ''; +ALTER TABLE sessions ADD COLUMN country TEXT NOT NULL DEFAULT ''; +CREATE INDEX idx_login_attempts_country ON login_attempts(country); diff --git a/internal/storage/migrations_test.go b/internal/storage/migrations_test.go index 3fee321..8c88730 100644 --- a/internal/storage/migrations_test.go +++ b/internal/storage/migrations_test.go @@ -25,8 +25,8 @@ func TestMigrateCreatesTablesAndVersion(t *testing.T) { if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil { t.Fatalf("query version: %v", err) } - if version != 2 { - t.Errorf("version = %d, want 2", version) + if version != 3 { + t.Errorf("version = %d, want 3", version) } // Verify tables exist by inserting into them. @@ -64,8 +64,8 @@ func TestMigrateIdempotent(t *testing.T) { if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil { t.Fatalf("query version: %v", err) } - if version != 2 { - t.Errorf("version = %d after double migrate, want 2", version) + if version != 3 { + t.Errorf("version = %d after double migrate, want 3", version) } } diff --git a/internal/storage/retention_test.go b/internal/storage/retention_test.go index 4ab1fab..5e1460f 100644 --- a/internal/storage/retention_test.go +++ b/internal/storage/retention_test.go @@ -22,7 +22,7 @@ func TestRunRetentionDeletesOldRecords(t *testing.T) { } // Insert a recent login attempt. - if err := store.RecordLoginAttempt(ctx, "new", "new", "2.2.2.2"); err != nil { + if err := store.RecordLoginAttempt(ctx, "new", "new", "2.2.2.2", ""); err != nil { t.Fatalf("insert recent attempt: %v", err) } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 072ca65..976f0a8 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -34,28 +34,29 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { return &SQLiteStore{db: db}, nil } -func (s *SQLiteStore) RecordLoginAttempt(ctx context.Context, username, password, ip string) error { +func (s *SQLiteStore) RecordLoginAttempt(ctx context.Context, username, password, ip, country 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, ?, ?) + INSERT INTO login_attempts (username, password, ip, country, 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) + last_seen = ?, + country = ?`, + username, password, ip, country, now, now, now, country) 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) { +func (s *SQLiteStore) CreateSession(ctx context.Context, ip, username, shellName, country 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) + INSERT INTO sessions (id, ip, username, shell_name, country, connected_at) + VALUES (?, ?, ?, ?, ?, ?)`, + id, ip, username, shellName, country, now) if err != nil { return "", fmt.Errorf("creating session: %w", err) } @@ -101,9 +102,9 @@ func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Sessio var humanScore sql.NullFloat64 err := s.db.QueryRowContext(ctx, ` - SELECT id, ip, username, shell_name, connected_at, disconnected_at, human_score + SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score FROM sessions WHERE id = ?`, sessionID).Scan( - &sess.ID, &sess.IP, &sess.Username, &sess.ShellName, + &sess.ID, &sess.IP, &sess.Country, &sess.Username, &sess.ShellName, &connectedAt, &disconnectedAt, &humanScore, ) if err == sql.ErrNoRows { @@ -288,7 +289,50 @@ func (s *SQLiteStore) GetTopPasswords(ctx context.Context, limit int) ([]TopEntr } func (s *SQLiteStore) GetTopIPs(ctx context.Context, limit int) ([]TopEntry, error) { - return s.queryTopN(ctx, "ip", limit) + rows, err := s.db.QueryContext(ctx, ` + SELECT ip, country, SUM(count) AS total + FROM login_attempts + GROUP BY ip + ORDER BY total DESC + LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("querying top IPs: %w", err) + } + defer func() { _ = rows.Close() }() + + var entries []TopEntry + for rows.Next() { + var e TopEntry + if err := rows.Scan(&e.Value, &e.Country, &e.Count); err != nil { + return nil, fmt.Errorf("scanning top IPs: %w", err) + } + entries = append(entries, e) + } + return entries, rows.Err() +} + +func (s *SQLiteStore) GetTopCountries(ctx context.Context, limit int) ([]TopEntry, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT country, SUM(count) AS total + FROM login_attempts + WHERE country != '' + GROUP BY country + ORDER BY total DESC + LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("querying top countries: %w", err) + } + defer func() { _ = rows.Close() }() + + var entries []TopEntry + for rows.Next() { + var e TopEntry + if err := rows.Scan(&e.Value, &e.Count); err != nil { + return nil, fmt.Errorf("scanning top countries: %w", err) + } + entries = append(entries, e) + } + return entries, rows.Err() } func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) ([]TopEntry, error) { @@ -324,7 +368,7 @@ func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) ( } func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) { - query := `SELECT id, ip, username, shell_name, connected_at, disconnected_at, human_score FROM sessions` + query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score FROM sessions` if activeOnly { query += ` WHERE disconnected_at IS NULL` } @@ -342,7 +386,7 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn var connectedAt string var disconnectedAt sql.NullString var humanScore sql.NullFloat64 - if err := rows.Scan(&s.ID, &s.IP, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore); err != nil { + if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore); err != nil { return nil, fmt.Errorf("scanning session: %w", err) } s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt) diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 5739cd8..26ac212 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -23,17 +23,17 @@ func TestRecordLoginAttempt(t *testing.T) { ctx := context.Background() // First attempt creates a new record. - if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1"); err != nil { + 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 { + 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 { + if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.2", ""); err != nil { t.Fatalf("different IP: %v", err) } @@ -62,7 +62,7 @@ func TestCreateAndEndSession(t *testing.T) { store := newTestStore(t) ctx := context.Background() - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "", "") if err != nil { t.Fatalf("creating session: %v", err) } @@ -100,7 +100,7 @@ func TestUpdateHumanScore(t *testing.T) { store := newTestStore(t) ctx := context.Background() - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "", "") if err != nil { t.Fatalf("creating session: %v", err) } @@ -123,7 +123,7 @@ func TestAppendSessionLog(t *testing.T) { store := newTestStore(t) ctx := context.Background() - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "", "") if err != nil { t.Fatalf("creating session: %v", err) } @@ -159,7 +159,7 @@ func TestDeleteRecordsBefore(t *testing.T) { } // Insert a recent login attempt. - if err := store.RecordLoginAttempt(ctx, "new", "new", "2.2.2.2"); err != nil { + if err := store.RecordLoginAttempt(ctx, "new", "new", "2.2.2.2", ""); err != nil { t.Fatalf("insert recent attempt: %v", err) } @@ -178,7 +178,7 @@ func TestDeleteRecordsBefore(t *testing.T) { } // Insert a recent session. - if _, err := store.CreateSession(ctx, "2.2.2.2", "new", ""); err != nil { + if _, err := store.CreateSession(ctx, "2.2.2.2", "new", "", ""); err != nil { t.Fatalf("insert recent session: %v", err) } @@ -214,7 +214,7 @@ func TestNewSQLiteStoreCreatesFile(t *testing.T) { // Verify we can use the store. ctx := context.Background() - if err := store.RecordLoginAttempt(ctx, "test", "test", "127.0.0.1"); err != nil { + if err := store.RecordLoginAttempt(ctx, "test", "test", "127.0.0.1", ""); err != nil { t.Fatalf("recording attempt: %v", err) } } diff --git a/internal/storage/store.go b/internal/storage/store.go index e600bc2..66f7c5b 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -11,6 +11,7 @@ type LoginAttempt struct { Username string Password string IP string + Country string Count int FirstSeen time.Time LastSeen time.Time @@ -20,6 +21,7 @@ type LoginAttempt struct { type Session struct { ID string IP string + Country string Username string ShellName string ConnectedAt time.Time @@ -54,18 +56,19 @@ type DashboardStats struct { // TopEntry represents a value and its count for top-N queries. type TopEntry struct { - Value string - Count int64 + Value string + Country string // populated by GetTopIPs + Count int64 } // 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 + RecordLoginAttempt(ctx context.Context, username, password, ip, country string) error // CreateSession creates a new session record and returns its UUID. - CreateSession(ctx context.Context, ip, username, shellName string) (string, error) + CreateSession(ctx context.Context, ip, username, shellName, country string) (string, error) // EndSession sets the disconnected_at timestamp for a session. EndSession(ctx context.Context, sessionID string, disconnectedAt time.Time) error @@ -92,6 +95,9 @@ type Store interface { // GetTopIPs returns the top N IPs by total attempt count. GetTopIPs(ctx context.Context, limit int) ([]TopEntry, error) + // GetTopCountries returns the top N countries by total attempt count. + GetTopCountries(ctx context.Context, limit int) ([]TopEntry, error) + // GetRecentSessions returns the most recent sessions ordered by connected_at DESC. // If activeOnly is true, only sessions with no disconnected_at are returned. GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go index deb91a8..b70fb21 100644 --- a/internal/storage/store_test.go +++ b/internal/storage/store_test.go @@ -38,23 +38,23 @@ func seedData(t *testing.T, store Store) { // Login attempts: root/toor from two IPs, admin/admin from one IP. for range 5 { - if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1"); err != nil { + if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1", ""); err != nil { t.Fatalf("seeding attempt: %v", err) } } for range 3 { - if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.2"); err != nil { + if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.2", ""); err != nil { t.Fatalf("seeding attempt: %v", err) } } for range 2 { - if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.1"); err != nil { + if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.1", ""); err != nil { t.Fatalf("seeding attempt: %v", err) } } // Sessions: one active, one ended. - id1, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash") + id1, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") if err != nil { t.Fatalf("creating session: %v", err) } @@ -62,7 +62,7 @@ func seedData(t *testing.T, store Store) { t.Fatalf("ending session: %v", err) } - if _, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash"); err != nil { + if _, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash", ""); err != nil { t.Fatalf("creating session: %v", err) } } @@ -210,7 +210,7 @@ func TestGetSession(t *testing.T) { t.Run("found", func(t *testing.T) { store := newStore(t) ctx := context.Background() - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") if err != nil { t.Fatalf("CreateSession: %v", err) } @@ -233,7 +233,7 @@ func TestGetSessionLogs(t *testing.T) { store := newStore(t) ctx := context.Background() - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") if err != nil { t.Fatalf("CreateSession: %v", err) } @@ -277,7 +277,7 @@ func TestSessionEvents(t *testing.T) { store := newStore(t) ctx := context.Background() - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") if err != nil { t.Fatalf("CreateSession: %v", err) } @@ -336,9 +336,9 @@ func TestCloseActiveSessions(t *testing.T) { ctx := context.Background() // Create 3 sessions: end one, leave two active. - id1, _ := store.CreateSession(ctx, "10.0.0.1", "root", "bash") - store.CreateSession(ctx, "10.0.0.2", "admin", "bash") - store.CreateSession(ctx, "10.0.0.3", "test", "bash") + id1, _ := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") + store.CreateSession(ctx, "10.0.0.2", "admin", "bash", "") + store.CreateSession(ctx, "10.0.0.3", "test", "bash", "") store.EndSession(ctx, id1, time.Now()) n, err := store.CloseActiveSessions(ctx, time.Now()) diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 947a4e4..9759515 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -13,6 +13,7 @@ type dashboardData struct { TopUsernames []storage.TopEntry TopPasswords []storage.TopEntry TopIPs []storage.TopEntry + TopCountries []storage.TopEntry ActiveSessions []storage.Session RecentSessions []storage.Session } @@ -48,6 +49,13 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { return } + topCountries, err := s.store.GetTopCountries(ctx, 10) + if err != nil { + s.logger.Error("failed to get top countries", "err", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + activeSessions, err := s.store.GetRecentSessions(ctx, 50, true) if err != nil { s.logger.Error("failed to get active sessions", "err", err) @@ -67,6 +75,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { TopUsernames: topUsernames, TopPasswords: topPasswords, TopIPs: topIPs, + TopCountries: topCountries, ActiveSessions: activeSessions, RecentSessions: recentSessions, } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index eab3262..513870a 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -40,10 +40,25 @@
Top IPs
- + {{range .TopIPs}} + + {{else}} + + {{end}} + +
IPAttempts
IPCountryAttempts
{{.Value}}{{.Country}}{{.Count}}
No data
+ +
+
Top Countries
+ + + + + + {{range .TopCountries}} {{else}} @@ -68,6 +83,7 @@ + @@ -80,6 +96,7 @@ + @@ -87,7 +104,7 @@ {{else}} - + {{end}}
CountryAttempts
{{.Value}}{{.Count}}
No data
ID IPCountry Username Shell Score
{{truncateID .ID}} {{.IP}}{{.Country}} {{.Username}} {{.ShellName}} {{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}{{formatScore .HumanScore}}{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}active{{end}}
No sessions
No sessions
diff --git a/internal/web/templates/fragments/active_sessions.html b/internal/web/templates/fragments/active_sessions.html index bc645dc..582b5be 100644 --- a/internal/web/templates/fragments/active_sessions.html +++ b/internal/web/templates/fragments/active_sessions.html @@ -4,6 +4,7 @@ ID IP + Country Username Shell Score @@ -15,13 +16,14 @@ {{truncateID .ID}} {{.IP}} + {{.Country}} {{.Username}} {{.ShellName}} {{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}{{formatScore .HumanScore}}{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}} {{formatTime .ConnectedAt}} {{else}} - No active sessions + No active sessions {{end}} diff --git a/internal/web/templates/session_detail.html b/internal/web/templates/session_detail.html index e46f481..a44e18f 100644 --- a/internal/web/templates/session_detail.html +++ b/internal/web/templates/session_detail.html @@ -7,6 +7,7 @@ + diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 4d362dd..c8b81a9 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -31,18 +31,18 @@ func newSeededTestServer(t *testing.T) *Server { ctx := context.Background() for range 5 { - if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1"); err != nil { + if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1", ""); err != nil { t.Fatalf("seeding attempt: %v", err) } } - if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2"); err != nil { + if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2", ""); err != nil { t.Fatalf("seeding attempt: %v", err) } - if _, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash"); err != nil { + if _, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", ""); err != nil { t.Fatalf("creating session: %v", err) } - if _, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash"); err != nil { + if _, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash", ""); err != nil { t.Fatalf("creating session: %v", err) } @@ -150,7 +150,7 @@ func TestSessionDetailHandler(t *testing.T) { t.Run("found", func(t *testing.T) { store := storage.NewMemoryStore() ctx := context.Background() - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") if err != nil { t.Fatalf("CreateSession: %v", err) } @@ -181,7 +181,7 @@ func TestSessionDetailHandler(t *testing.T) { func TestAPISessionEvents(t *testing.T) { store := storage.NewMemoryStore() ctx := context.Background() - id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash") + id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "") if err != nil { t.Fatalf("CreateSession: %v", err) } diff --git a/scripts/fetch-geoip.sh b/scripts/fetch-geoip.sh new file mode 100755 index 0000000..f01e9cd --- /dev/null +++ b/scripts/fetch-geoip.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Downloads the DB-IP Lite country MMDB database for development. +# The Nix build fetches this automatically; this script is for local dev only. +set -euo pipefail + +URL="https://download.db-ip.com/free/dbip-country-lite-2026-02.mmdb.gz" +DEST="internal/geoip/dbip-country-lite.mmdb" + +cd "$(git rev-parse --show-toplevel)" + +if [ -f "$DEST" ]; then + echo "GeoIP database already exists at $DEST" + exit 0 +fi + +echo "Downloading DB-IP Lite country database..." +curl -fSL "$URL" | gunzip > "$DEST" +echo "Saved to $DEST"
IP{{.Session.IP}}
Country{{.Session.Country}}
Username{{.Session.Username}}
Shell{{.Session.ShellName}}
Score{{formatScore .Session.HumanScore}}