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 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 15:27:46 +01:00
parent 8fff893d25
commit 94f1f1c266
28 changed files with 353 additions and 71 deletions

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ oubliette.toml
*.db-wal
*.db-shm
/oubliette
*.mmdb
*.mmdb.gz

View File

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

View File

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

View File

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

1
go.mod
View File

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

2
go.sum
View File

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

51
internal/geoip/geoip.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,10 +40,25 @@
<header>Top IPs</header>
<table>
<thead>
<tr><th>IP</th><th>Attempts</th></tr>
<tr><th>IP</th><th>Country</th><th>Attempts</th></tr>
</thead>
<tbody>
{{range .TopIPs}}
<tr><td>{{.Value}}</td><td>{{.Country}}</td><td>{{.Count}}</td></tr>
{{else}}
<tr><td colspan="3">No data</td></tr>
{{end}}
</tbody>
</table>
</article>
<article>
<header>Top Countries</header>
<table>
<thead>
<tr><th>Country</th><th>Attempts</th></tr>
</thead>
<tbody>
{{range .TopCountries}}
<tr><td>{{.Value}}</td><td>{{.Count}}</td></tr>
{{else}}
<tr><td colspan="2">No data</td></tr>
@@ -68,6 +83,7 @@
<tr>
<th>ID</th>
<th>IP</th>
<th>Country</th>
<th>Username</th>
<th>Shell</th>
<th>Score</th>
@@ -80,6 +96,7 @@
<tr>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
<td>{{.IP}}</td>
<td>{{.Country}}</td>
<td>{{.Username}}</td>
<td>{{.ShellName}}</td>
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
@@ -87,7 +104,7 @@
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="7">No sessions</td></tr>
<tr><td colspan="8">No sessions</td></tr>
{{end}}
</tbody>
</table>

View File

@@ -4,6 +4,7 @@
<tr>
<th>ID</th>
<th>IP</th>
<th>Country</th>
<th>Username</th>
<th>Shell</th>
<th>Score</th>
@@ -15,13 +16,14 @@
<tr>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
<td>{{.IP}}</td>
<td>{{.Country}}</td>
<td>{{.Username}}</td>
<td>{{.ShellName}}</td>
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
<td>{{formatTime .ConnectedAt}}</td>
</tr>
{{else}}
<tr><td colspan="6">No active sessions</td></tr>
<tr><td colspan="7">No active sessions</td></tr>
{{end}}
</tbody>
</table>

View File

@@ -7,6 +7,7 @@
<table>
<tbody>
<tr><td><strong>IP</strong></td><td>{{.Session.IP}}</td></tr>
<tr><td><strong>Country</strong></td><td>{{.Session.Country}}</td></tr>
<tr><td><strong>Username</strong></td><td>{{.Session.Username}}</td></tr>
<tr><td><strong>Shell</strong></td><td>{{.Session.ShellName}}</td></tr>
<tr><td><strong>Score</strong></td><td>{{formatScore .Session.HumanScore}}</td></tr>

View File

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

18
scripts/fetch-geoip.sh Executable file
View File

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