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.
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user