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

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