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