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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ oubliette.toml
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
/oubliette
|
/oubliette
|
||||||
|
*.mmdb
|
||||||
|
*.mmdb.gz
|
||||||
|
|||||||
8
PLAN.md
8
PLAN.md
@@ -196,7 +196,7 @@ Goal: Make the web UI great and add operational niceties.
|
|||||||
- Docker image (nix dockerTools) ✅
|
- Docker image (nix dockerTools) ✅
|
||||||
- Systemd unit file / deployment docs ✅
|
- Systemd unit file / deployment docs ✅
|
||||||
|
|
||||||
### 4.3 GeoIP
|
### 4.3 GeoIP ✅
|
||||||
- Embed a lightweight GeoIP database or use an API
|
- Embed a lightweight GeoIP database or use an API ✅
|
||||||
- Store country/city with each attempt
|
- Store country/city with each attempt ✅
|
||||||
- Aggregate stats by country
|
- Aggregate stats by country ✅
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ Key settings:
|
|||||||
- `detection.update_interval` — how often to recompute scores (default `5s`)
|
- `detection.update_interval` — how often to recompute scores (default `5s`)
|
||||||
- `notify.webhooks` — list of webhook endpoints for notifications (see example config)
|
- `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
|
### Run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
10
flake.nix
10
flake.nix
@@ -18,14 +18,22 @@
|
|||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
mainGo = builtins.readFile ./cmd/oubliette/main.go;
|
mainGo = builtins.readFile ./cmd/oubliette/main.go;
|
||||||
version = builtins.head (builtins.match ''.*const Version = "([^"]+)".*'' mainGo);
|
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
|
in
|
||||||
{
|
{
|
||||||
default = pkgs.buildGoModule {
|
default = pkgs.buildGoModule {
|
||||||
pname = "oubliette";
|
pname = "oubliette";
|
||||||
inherit version;
|
inherit version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
vendorHash = "sha256-smMg/J1igSoSBkzdm9HJOp5OYY8MEccodCD/zVK31IQ=";
|
vendorHash = "sha256-/zxK6CABLYBNtuSOI8dIVgMNxKiDIcbZUS7bQR5TenA=";
|
||||||
subPackages = [ "cmd/oubliette" ];
|
subPackages = [ "cmd/oubliette" ];
|
||||||
|
nativeBuildInputs = [ pkgs.gzip ];
|
||||||
|
preBuild = ''
|
||||||
|
gunzip -c ${geoipDb} > internal/geoip/dbip-country-lite.mmdb
|
||||||
|
'';
|
||||||
meta = {
|
meta = {
|
||||||
description = "SSH honeypot";
|
description = "SSH honeypot";
|
||||||
mainProgram = "oubliette";
|
mainProgram = "oubliette";
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
|||||||
51
internal/geoip/geoip.go
Normal file
51
internal/geoip/geoip.go
Normal 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()
|
||||||
|
}
|
||||||
44
internal/geoip/geoip_test.go
Normal file
44
internal/geoip/geoip_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/auth"
|
"git.t-juice.club/torjus/oubliette/internal/auth"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/config"
|
"git.t-juice.club/torjus/oubliette/internal/config"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/detection"
|
"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/metrics"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/notify"
|
"git.t-juice.club/torjus/oubliette/internal/notify"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||||
@@ -37,6 +38,7 @@ type Server struct {
|
|||||||
shellRegistry *shell.Registry
|
shellRegistry *shell.Registry
|
||||||
notifier notify.Sender
|
notifier notify.Sender
|
||||||
metrics *metrics.Metrics
|
metrics *metrics.Metrics
|
||||||
|
geoip *geoip.Reader
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics.Metrics) (*Server, error) {
|
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)
|
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{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
@@ -66,6 +73,7 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics
|
|||||||
shellRegistry: registry,
|
shellRegistry: registry,
|
||||||
notifier: notify.NewSender(cfg.Notify.Webhooks, logger),
|
notifier: notify.NewSender(cfg.Notify.Webhooks, logger),
|
||||||
metrics: m,
|
metrics: m,
|
||||||
|
geoip: geo,
|
||||||
}
|
}
|
||||||
|
|
||||||
hostKey, err := loadOrGenerateHostKey(cfg.SSH.HostKeyPath)
|
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 {
|
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||||
|
defer s.geoip.Close()
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", s.cfg.SSH.ListenAddr)
|
listener, err := net.Listen("tcp", s.cfg.SSH.ListenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listen: %w", err)
|
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())
|
ip := extractIP(conn.RemoteAddr())
|
||||||
|
country := s.geoip.Lookup(ip)
|
||||||
sessionStart := time.Now()
|
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 {
|
if err != nil {
|
||||||
s.logger.Error("failed to create session", "err", err)
|
s.logger.Error("failed to create session", "err", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -350,7 +361,8 @@ func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.
|
|||||||
"reason", d.Reason,
|
"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)
|
s.logger.Error("failed to record login attempt", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (r *rwCloser) Close() error { return nil }
|
|||||||
func runShell(t *testing.T, commands string) string {
|
func runShell(t *testing.T, commands string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store := storage.NewMemoryStore()
|
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{
|
sess := &shell.SessionContext{
|
||||||
SessionID: sessID,
|
SessionID: sessID,
|
||||||
@@ -287,7 +287,7 @@ func TestEthernetCable(t *testing.T) {
|
|||||||
|
|
||||||
func TestSessionLogs(t *testing.T) {
|
func TestSessionLogs(t *testing.T) {
|
||||||
store := storage.NewMemoryStore()
|
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{
|
sess := &shell.SessionContext{
|
||||||
SessionID: sessID,
|
SessionID: sessID,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
func newTestModel(t *testing.T) (*model, *storage.MemoryStore) {
|
func newTestModel(t *testing.T) (*model, *storage.MemoryStore) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store := storage.NewMemoryStore()
|
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{
|
sess := &shell.SessionContext{
|
||||||
SessionID: sessID,
|
SessionID: sessID,
|
||||||
Username: "banker",
|
Username: "banker",
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func TestReadLineCtrlD(t *testing.T) {
|
|||||||
|
|
||||||
func TestBashShellHandle(t *testing.T) {
|
func TestBashShellHandle(t *testing.T) {
|
||||||
store := storage.NewMemoryStore()
|
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{
|
sess := &shell.SessionContext{
|
||||||
SessionID: sessID,
|
SessionID: sessID,
|
||||||
@@ -166,7 +166,7 @@ func TestBashShellHandle(t *testing.T) {
|
|||||||
|
|
||||||
func TestBashShellFakeUser(t *testing.T) {
|
func TestBashShellFakeUser(t *testing.T) {
|
||||||
store := storage.NewMemoryStore()
|
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{
|
sess := &shell.SessionContext{
|
||||||
SessionID: sessID,
|
SessionID: sessID,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestEventRecorderFlush(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create a session so events have a valid session ID.
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateSession: %v", err)
|
t.Fatalf("CreateSession: %v", err)
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ func TestEventRecorderPeriodicFlush(t *testing.T) {
|
|||||||
store := storage.NewMemoryStore()
|
store := storage.NewMemoryStore()
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateSession: %v", err)
|
t.Fatalf("CreateSession: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (r *rwCloser) Close() error { return nil }
|
|||||||
func runShell(t *testing.T, commands string) string {
|
func runShell(t *testing.T, commands string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store := storage.NewMemoryStore()
|
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{
|
sess := &shell.SessionContext{
|
||||||
SessionID: sessID,
|
SessionID: sessID,
|
||||||
@@ -205,7 +205,7 @@ func TestLogoutCommand(t *testing.T) {
|
|||||||
|
|
||||||
func TestSessionLogs(t *testing.T) {
|
func TestSessionLogs(t *testing.T) {
|
||||||
store := storage.NewMemoryStore()
|
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{
|
sess := &shell.SessionContext{
|
||||||
SessionID: sessID,
|
SessionID: sessID,
|
||||||
|
|||||||
@@ -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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
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 {
|
if a.Username == username && a.Password == password && a.IP == ip {
|
||||||
a.Count++
|
a.Count++
|
||||||
a.LastSeen = now
|
a.LastSeen = now
|
||||||
|
a.Country = country
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +45,7 @@ func (m *MemoryStore) RecordLoginAttempt(_ context.Context, username, password,
|
|||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
IP: ip,
|
IP: ip,
|
||||||
|
Country: country,
|
||||||
Count: 1,
|
Count: 1,
|
||||||
FirstSeen: now,
|
FirstSeen: now,
|
||||||
LastSeen: now,
|
LastSeen: now,
|
||||||
@@ -51,7 +53,7 @@ func (m *MemoryStore) RecordLoginAttempt(_ context.Context, username, password,
|
|||||||
return nil
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ func (m *MemoryStore) CreateSession(_ context.Context, ip, username, shellName s
|
|||||||
m.Sessions[id] = &Session{
|
m.Sessions[id] = &Session{
|
||||||
ID: id,
|
ID: id,
|
||||||
IP: ip,
|
IP: ip,
|
||||||
|
Country: country,
|
||||||
Username: username,
|
Username: username,
|
||||||
ShellName: shellName,
|
ShellName: shellName,
|
||||||
ConnectedAt: now,
|
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) {
|
func (m *MemoryStore) GetTopIPs(_ context.Context, limit int) ([]TopEntry, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
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.
|
// topN aggregates login attempts by the given field and returns the top N. Must be called with m.mu held.
|
||||||
|
|||||||
3
internal/storage/migrations/003_add_country.sql
Normal file
3
internal/storage/migrations/003_add_country.sql
Normal 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);
|
||||||
@@ -25,8 +25,8 @@ func TestMigrateCreatesTablesAndVersion(t *testing.T) {
|
|||||||
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
||||||
t.Fatalf("query version: %v", err)
|
t.Fatalf("query version: %v", err)
|
||||||
}
|
}
|
||||||
if version != 2 {
|
if version != 3 {
|
||||||
t.Errorf("version = %d, want 2", version)
|
t.Errorf("version = %d, want 3", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify tables exist by inserting into them.
|
// 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 {
|
if err := db.QueryRow(`SELECT version FROM schema_version`).Scan(&version); err != nil {
|
||||||
t.Fatalf("query version: %v", err)
|
t.Fatalf("query version: %v", err)
|
||||||
}
|
}
|
||||||
if version != 2 {
|
if version != 3 {
|
||||||
t.Errorf("version = %d after double migrate, want 2", version)
|
t.Errorf("version = %d after double migrate, want 3", version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestRunRetentionDeletesOldRecords(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert a recent login attempt.
|
// 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)
|
t.Fatalf("insert recent attempt: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,28 +34,29 @@ func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
|
|||||||
return &SQLiteStore{db: db}, nil
|
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)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO login_attempts (username, password, ip, count, first_seen, last_seen)
|
INSERT INTO login_attempts (username, password, ip, country, count, first_seen, last_seen)
|
||||||
VALUES (?, ?, ?, 1, ?, ?)
|
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||||
ON CONFLICT(username, password, ip) DO UPDATE SET
|
ON CONFLICT(username, password, ip) DO UPDATE SET
|
||||||
count = count + 1,
|
count = count + 1,
|
||||||
last_seen = ?`,
|
last_seen = ?,
|
||||||
username, password, ip, now, now, now)
|
country = ?`,
|
||||||
|
username, password, ip, country, now, now, now, country)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("recording login attempt: %w", err)
|
return fmt.Errorf("recording login attempt: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
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()
|
id := uuid.New().String()
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
_, err := s.db.ExecContext(ctx, `
|
_, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO sessions (id, ip, username, shell_name, connected_at)
|
INSERT INTO sessions (id, ip, username, shell_name, country, connected_at)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
id, ip, username, shellName, now)
|
id, ip, username, shellName, country, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("creating session: %w", err)
|
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
|
var humanScore sql.NullFloat64
|
||||||
|
|
||||||
err := s.db.QueryRowContext(ctx, `
|
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(
|
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,
|
&connectedAt, &disconnectedAt, &humanScore,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
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) {
|
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) {
|
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) {
|
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 {
|
if activeOnly {
|
||||||
query += ` WHERE disconnected_at IS NULL`
|
query += ` WHERE disconnected_at IS NULL`
|
||||||
}
|
}
|
||||||
@@ -342,7 +386,7 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
|
|||||||
var connectedAt string
|
var connectedAt string
|
||||||
var disconnectedAt sql.NullString
|
var disconnectedAt sql.NullString
|
||||||
var humanScore sql.NullFloat64
|
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)
|
return nil, fmt.Errorf("scanning session: %w", err)
|
||||||
}
|
}
|
||||||
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
||||||
|
|||||||
@@ -23,17 +23,17 @@ func TestRecordLoginAttempt(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// First attempt creates a new record.
|
// 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)
|
t.Fatalf("first attempt: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second attempt with same credentials increments count.
|
// 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)
|
t.Fatalf("second attempt: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different IP is a separate record.
|
// 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)
|
t.Fatalf("different IP: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ func TestCreateAndEndSession(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("creating session: %v", err)
|
t.Fatalf("creating session: %v", err)
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ func TestUpdateHumanScore(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("creating session: %v", err)
|
t.Fatalf("creating session: %v", err)
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ func TestAppendSessionLog(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("creating session: %v", err)
|
t.Fatalf("creating session: %v", err)
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ func TestDeleteRecordsBefore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert a recent login attempt.
|
// 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)
|
t.Fatalf("insert recent attempt: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ func TestDeleteRecordsBefore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert a recent session.
|
// 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)
|
t.Fatalf("insert recent session: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ func TestNewSQLiteStoreCreatesFile(t *testing.T) {
|
|||||||
|
|
||||||
// Verify we can use the store.
|
// Verify we can use the store.
|
||||||
ctx := context.Background()
|
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)
|
t.Fatalf("recording attempt: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type LoginAttempt struct {
|
|||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
IP string
|
IP string
|
||||||
|
Country string
|
||||||
Count int
|
Count int
|
||||||
FirstSeen time.Time
|
FirstSeen time.Time
|
||||||
LastSeen time.Time
|
LastSeen time.Time
|
||||||
@@ -20,6 +21,7 @@ type LoginAttempt struct {
|
|||||||
type Session struct {
|
type Session struct {
|
||||||
ID string
|
ID string
|
||||||
IP string
|
IP string
|
||||||
|
Country string
|
||||||
Username string
|
Username string
|
||||||
ShellName string
|
ShellName string
|
||||||
ConnectedAt time.Time
|
ConnectedAt time.Time
|
||||||
@@ -54,18 +56,19 @@ type DashboardStats struct {
|
|||||||
|
|
||||||
// TopEntry represents a value and its count for top-N queries.
|
// TopEntry represents a value and its count for top-N queries.
|
||||||
type TopEntry struct {
|
type TopEntry struct {
|
||||||
Value string
|
Value string
|
||||||
Count int64
|
Country string // populated by GetTopIPs
|
||||||
|
Count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store is the interface for persistent storage of honeypot data.
|
// Store is the interface for persistent storage of honeypot data.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
// RecordLoginAttempt upserts a login attempt, incrementing the count
|
// RecordLoginAttempt upserts a login attempt, incrementing the count
|
||||||
// for existing (username, password, ip) combinations.
|
// 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 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 sets the disconnected_at timestamp for a session.
|
||||||
EndSession(ctx context.Context, sessionID string, disconnectedAt time.Time) error
|
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 returns the top N IPs by total attempt count.
|
||||||
GetTopIPs(ctx context.Context, limit int) ([]TopEntry, error)
|
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.
|
// GetRecentSessions returns the most recent sessions ordered by connected_at DESC.
|
||||||
// If activeOnly is true, only sessions with no disconnected_at are returned.
|
// If activeOnly is true, only sessions with no disconnected_at are returned.
|
||||||
GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error)
|
GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error)
|
||||||
|
|||||||
@@ -38,23 +38,23 @@ func seedData(t *testing.T, store Store) {
|
|||||||
|
|
||||||
// Login attempts: root/toor from two IPs, admin/admin from one IP.
|
// Login attempts: root/toor from two IPs, admin/admin from one IP.
|
||||||
for range 5 {
|
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)
|
t.Fatalf("seeding attempt: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for range 3 {
|
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)
|
t.Fatalf("seeding attempt: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for range 2 {
|
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)
|
t.Fatalf("seeding attempt: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sessions: one active, one ended.
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("creating session: %v", err)
|
t.Fatalf("creating session: %v", err)
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ func seedData(t *testing.T, store Store) {
|
|||||||
t.Fatalf("ending session: %v", err)
|
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)
|
t.Fatalf("creating session: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,7 @@ func TestGetSession(t *testing.T) {
|
|||||||
t.Run("found", func(t *testing.T) {
|
t.Run("found", func(t *testing.T) {
|
||||||
store := newStore(t)
|
store := newStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateSession: %v", err)
|
t.Fatalf("CreateSession: %v", err)
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ func TestGetSessionLogs(t *testing.T) {
|
|||||||
store := newStore(t)
|
store := newStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateSession: %v", err)
|
t.Fatalf("CreateSession: %v", err)
|
||||||
}
|
}
|
||||||
@@ -277,7 +277,7 @@ func TestSessionEvents(t *testing.T) {
|
|||||||
store := newStore(t)
|
store := newStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateSession: %v", err)
|
t.Fatalf("CreateSession: %v", err)
|
||||||
}
|
}
|
||||||
@@ -336,9 +336,9 @@ func TestCloseActiveSessions(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create 3 sessions: end one, leave two active.
|
// Create 3 sessions: end one, leave two active.
|
||||||
id1, _ := store.CreateSession(ctx, "10.0.0.1", "root", "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.2", "admin", "bash", "")
|
||||||
store.CreateSession(ctx, "10.0.0.3", "test", "bash")
|
store.CreateSession(ctx, "10.0.0.3", "test", "bash", "")
|
||||||
store.EndSession(ctx, id1, time.Now())
|
store.EndSession(ctx, id1, time.Now())
|
||||||
|
|
||||||
n, err := store.CloseActiveSessions(ctx, time.Now())
|
n, err := store.CloseActiveSessions(ctx, time.Now())
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type dashboardData struct {
|
|||||||
TopUsernames []storage.TopEntry
|
TopUsernames []storage.TopEntry
|
||||||
TopPasswords []storage.TopEntry
|
TopPasswords []storage.TopEntry
|
||||||
TopIPs []storage.TopEntry
|
TopIPs []storage.TopEntry
|
||||||
|
TopCountries []storage.TopEntry
|
||||||
ActiveSessions []storage.Session
|
ActiveSessions []storage.Session
|
||||||
RecentSessions []storage.Session
|
RecentSessions []storage.Session
|
||||||
}
|
}
|
||||||
@@ -48,6 +49,13 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
activeSessions, err := s.store.GetRecentSessions(ctx, 50, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("failed to get active sessions", "err", err)
|
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,
|
TopUsernames: topUsernames,
|
||||||
TopPasswords: topPasswords,
|
TopPasswords: topPasswords,
|
||||||
TopIPs: topIPs,
|
TopIPs: topIPs,
|
||||||
|
TopCountries: topCountries,
|
||||||
ActiveSessions: activeSessions,
|
ActiveSessions: activeSessions,
|
||||||
RecentSessions: recentSessions,
|
RecentSessions: recentSessions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,10 +40,25 @@
|
|||||||
<header>Top IPs</header>
|
<header>Top IPs</header>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>IP</th><th>Attempts</th></tr>
|
<tr><th>IP</th><th>Country</th><th>Attempts</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .TopIPs}}
|
{{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>
|
<tr><td>{{.Value}}</td><td>{{.Count}}</td></tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="2">No data</td></tr>
|
<tr><td colspan="2">No data</td></tr>
|
||||||
@@ -68,6 +83,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
|
<th>Country</th>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Shell</th>
|
<th>Shell</th>
|
||||||
<th>Score</th>
|
<th>Score</th>
|
||||||
@@ -80,6 +96,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
|
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
|
||||||
<td>{{.IP}}</td>
|
<td>{{.IP}}</td>
|
||||||
|
<td>{{.Country}}</td>
|
||||||
<td>{{.Username}}</td>
|
<td>{{.Username}}</td>
|
||||||
<td>{{.ShellName}}</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>{{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>
|
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="7">No sessions</td></tr>
|
<tr><td colspan="8">No sessions</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
|
<th>Country</th>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Shell</th>
|
<th>Shell</th>
|
||||||
<th>Score</th>
|
<th>Score</th>
|
||||||
@@ -15,13 +16,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
|
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
|
||||||
<td>{{.IP}}</td>
|
<td>{{.IP}}</td>
|
||||||
|
<td>{{.Country}}</td>
|
||||||
<td>{{.Username}}</td>
|
<td>{{.Username}}</td>
|
||||||
<td>{{.ShellName}}</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>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
|
||||||
<td>{{formatTime .ConnectedAt}}</td>
|
<td>{{formatTime .ConnectedAt}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="6">No active sessions</td></tr>
|
<tr><td colspan="7">No active sessions</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><strong>IP</strong></td><td>{{.Session.IP}}</td></tr>
|
<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>Username</strong></td><td>{{.Session.Username}}</td></tr>
|
||||||
<tr><td><strong>Shell</strong></td><td>{{.Session.ShellName}}</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>
|
<tr><td><strong>Score</strong></td><td>{{formatScore .Session.HumanScore}}</td></tr>
|
||||||
|
|||||||
@@ -31,18 +31,18 @@ func newSeededTestServer(t *testing.T) *Server {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
for range 5 {
|
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)
|
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)
|
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)
|
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)
|
t.Fatalf("creating session: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ func TestSessionDetailHandler(t *testing.T) {
|
|||||||
t.Run("found", func(t *testing.T) {
|
t.Run("found", func(t *testing.T) {
|
||||||
store := storage.NewMemoryStore()
|
store := storage.NewMemoryStore()
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateSession: %v", err)
|
t.Fatalf("CreateSession: %v", err)
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ func TestSessionDetailHandler(t *testing.T) {
|
|||||||
func TestAPISessionEvents(t *testing.T) {
|
func TestAPISessionEvents(t *testing.T) {
|
||||||
store := storage.NewMemoryStore()
|
store := storage.NewMemoryStore()
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateSession: %v", err)
|
t.Fatalf("CreateSession: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
18
scripts/fetch-geoip.sh
Executable file
18
scripts/fetch-geoip.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user