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 @@
- | IP | Attempts |
+ | IP | Country | Attempts |
{{range .TopIPs}}
+ | {{.Value}} | {{.Country}} | {{.Count}} |
+ {{else}}
+ | No data |
+ {{end}}
+
+
+
+
+
+
+
+ | Country | Attempts |
+
+
+ {{range .TopCountries}}
| {{.Value}} | {{.Count}} |
{{else}}
| No data |
@@ -68,6 +83,7 @@
| ID |
IP |
+ Country |
Username |
Shell |
Score |
@@ -80,6 +96,7 @@
{{truncateID .ID}} |
{{.IP}} |
+ {{.Country}} |
{{.Username}} |
{{.ShellName}} |
{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}{{formatScore .HumanScore}}{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}} |
@@ -87,7 +104,7 @@
{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}active{{end}} |
{{else}}
- | No sessions |
+ | No sessions |
{{end}}
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 @@
| IP | {{.Session.IP}} |
+ | Country | {{.Session.Country}} |
| Username | {{.Session.Username}} |
| Shell | {{.Session.ShellName}} |
| Score | {{formatScore .Session.HumanScore}} |
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"