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

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.