Add Chart.js line/bar charts for attack trends (attempts over time, hourly pattern), an SVG world map choropleth colored by attack origin country, and a collapsible filter form (date range, IP, country, username) that narrows both charts and top-N tables. New store methods: GetAttemptsOverTime, GetHourlyPattern, GetCountryStats, and filtered variants of dashboard stats/top-N queries. New JSON API endpoints at /api/charts/* and an htmx fragment at /fragments/dashboard-content for filtered table updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
720 lines
21 KiB
Go
720 lines
21 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// SQLiteStore implements Store using a SQLite database.
|
|
type SQLiteStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewSQLiteStore opens or creates a SQLite database at the given path,
|
|
// runs pending migrations, and returns a ready-to-use store.
|
|
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
|
|
dsn := dbPath + "?_pragma=journal_mode(wal)&_pragma=foreign_keys(on)&_pragma=busy_timeout(5000)"
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening database: %w", err)
|
|
}
|
|
|
|
db.SetMaxOpenConns(1)
|
|
|
|
if err := Migrate(db); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("running migrations: %w", err)
|
|
}
|
|
|
|
return &SQLiteStore{db: db}, nil
|
|
}
|
|
|
|
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, country, count, first_seen, last_seen)
|
|
VALUES (?, ?, ?, ?, 1, ?, ?)
|
|
ON CONFLICT(username, password, ip) DO UPDATE SET
|
|
count = count + 1,
|
|
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, 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, country, connected_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
id, ip, username, shellName, country, now)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating session: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) EndSession(ctx context.Context, sessionID string, disconnectedAt time.Time) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
UPDATE sessions SET disconnected_at = ? WHERE id = ?`,
|
|
disconnectedAt.UTC().Format(time.RFC3339), sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("ending session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) UpdateHumanScore(ctx context.Context, sessionID string, score float64) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
UPDATE sessions SET human_score = ? WHERE id = ?`,
|
|
score, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("updating human score: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) SetExecCommand(ctx context.Context, sessionID string, command string) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
UPDATE sessions SET exec_command = ? WHERE id = ?`,
|
|
command, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("setting exec command: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) AppendSessionLog(ctx context.Context, sessionID, input, output string) error {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO session_logs (session_id, timestamp, input, output)
|
|
VALUES (?, ?, ?, ?)`,
|
|
sessionID, now, input, output)
|
|
if err != nil {
|
|
return fmt.Errorf("appending session log: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
|
var sess Session
|
|
var connectedAt string
|
|
var disconnectedAt sql.NullString
|
|
var humanScore sql.NullFloat64
|
|
var execCommand sql.NullString
|
|
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command
|
|
FROM sessions WHERE id = ?`, sessionID).Scan(
|
|
&sess.ID, &sess.IP, &sess.Country, &sess.Username, &sess.ShellName,
|
|
&connectedAt, &disconnectedAt, &humanScore, &execCommand,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying session: %w", err)
|
|
}
|
|
|
|
sess.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
|
if disconnectedAt.Valid {
|
|
t, _ := time.Parse(time.RFC3339, disconnectedAt.String)
|
|
sess.DisconnectedAt = &t
|
|
}
|
|
if humanScore.Valid {
|
|
sess.HumanScore = &humanScore.Float64
|
|
}
|
|
if execCommand.Valid {
|
|
sess.ExecCommand = &execCommand.String
|
|
}
|
|
return &sess, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) GetSessionLogs(ctx context.Context, sessionID string) ([]SessionLog, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT id, session_id, timestamp, input, output
|
|
FROM session_logs WHERE session_id = ?
|
|
ORDER BY timestamp`, sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying session logs: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var logs []SessionLog
|
|
for rows.Next() {
|
|
var l SessionLog
|
|
var ts string
|
|
if err := rows.Scan(&l.ID, &l.SessionID, &ts, &l.Input, &l.Output); err != nil {
|
|
return nil, fmt.Errorf("scanning session log: %w", err)
|
|
}
|
|
l.Timestamp, _ = time.Parse(time.RFC3339, ts)
|
|
logs = append(logs, l)
|
|
}
|
|
return logs, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) AppendSessionEvents(ctx context.Context, events []SessionEvent) error {
|
|
if len(events) == 0 {
|
|
return nil
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
stmt, err := tx.PrepareContext(ctx, `
|
|
INSERT INTO session_events (session_id, timestamp, direction, data)
|
|
VALUES (?, ?, ?, ?)`)
|
|
if err != nil {
|
|
return fmt.Errorf("preparing statement: %w", err)
|
|
}
|
|
defer stmt.Close()
|
|
|
|
for _, e := range events {
|
|
_, err := stmt.ExecContext(ctx, e.SessionID, e.Timestamp.UTC().Format(time.RFC3339Nano), e.Direction, e.Data)
|
|
if err != nil {
|
|
return fmt.Errorf("inserting session event: %w", err)
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *SQLiteStore) GetSessionEvents(ctx context.Context, sessionID string) ([]SessionEvent, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT session_id, timestamp, direction, data
|
|
FROM session_events WHERE session_id = ?
|
|
ORDER BY id`, sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying session events: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var events []SessionEvent
|
|
for rows.Next() {
|
|
var e SessionEvent
|
|
var ts string
|
|
if err := rows.Scan(&e.SessionID, &ts, &e.Direction, &e.Data); err != nil {
|
|
return nil, fmt.Errorf("scanning session event: %w", err)
|
|
}
|
|
e.Timestamp, _ = time.Parse(time.RFC3339Nano, ts)
|
|
events = append(events, e)
|
|
}
|
|
return events, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
|
cutoffStr := cutoff.UTC().Format(time.RFC3339)
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var total int64
|
|
|
|
// Delete session events for old sessions.
|
|
res, err := tx.ExecContext(ctx, `
|
|
DELETE FROM session_events WHERE session_id IN (
|
|
SELECT id FROM sessions WHERE connected_at < ?
|
|
)`, cutoffStr)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting session events: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
total += n
|
|
|
|
// Delete session logs for old sessions.
|
|
res, err = tx.ExecContext(ctx, `
|
|
DELETE FROM session_logs WHERE session_id IN (
|
|
SELECT id FROM sessions WHERE connected_at < ?
|
|
)`, cutoffStr)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting session logs: %w", err)
|
|
}
|
|
n, _ = res.RowsAffected()
|
|
total += n
|
|
|
|
// Delete old sessions.
|
|
res, err = tx.ExecContext(ctx, `DELETE FROM sessions WHERE connected_at < ?`, cutoffStr)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting sessions: %w", err)
|
|
}
|
|
n, _ = res.RowsAffected()
|
|
total += n
|
|
|
|
// Delete old login attempts.
|
|
res, err = tx.ExecContext(ctx, `DELETE FROM login_attempts WHERE last_seen < ?`, cutoffStr)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting login attempts: %w", err)
|
|
}
|
|
n, _ = res.RowsAffected()
|
|
total += n
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, fmt.Errorf("commit transaction: %w", err)
|
|
}
|
|
|
|
return total, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
|
stats := &DashboardStats{}
|
|
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT COALESCE(SUM(count), 0), COUNT(DISTINCT ip)
|
|
FROM login_attempts`).Scan(&stats.TotalAttempts, &stats.UniqueIPs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying attempt stats: %w", err)
|
|
}
|
|
|
|
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&stats.TotalSessions)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying total sessions: %w", err)
|
|
}
|
|
|
|
err = s.db.QueryRowContext(ctx, `
|
|
SELECT COUNT(*) FROM sessions WHERE disconnected_at IS NULL`).Scan(&stats.ActiveSessions)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying active sessions: %w", err)
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) GetTopUsernames(ctx context.Context, limit int) ([]TopEntry, error) {
|
|
return s.queryTopN(ctx, "username", limit)
|
|
}
|
|
|
|
func (s *SQLiteStore) GetTopPasswords(ctx context.Context, limit int) ([]TopEntry, error) {
|
|
return s.queryTopN(ctx, "password", limit)
|
|
}
|
|
|
|
func (s *SQLiteStore) GetTopIPs(ctx context.Context, limit int) ([]TopEntry, error) {
|
|
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) {
|
|
switch column {
|
|
case "username", "password", "ip":
|
|
// valid columns
|
|
default:
|
|
return nil, fmt.Errorf("invalid column: %s", column)
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT %s, SUM(count) AS total
|
|
FROM login_attempts
|
|
GROUP BY %s
|
|
ORDER BY total DESC
|
|
LIMIT ?`, column, column)
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying top %s: %w", column, 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 %s: %w", column, err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) {
|
|
query := `SELECT s.id, s.ip, s.country, s.username, s.shell_name, s.connected_at, s.disconnected_at, s.human_score, s.exec_command, COUNT(e.id) as event_count FROM sessions s LEFT JOIN session_events e ON s.id = e.session_id`
|
|
if activeOnly {
|
|
query += ` WHERE s.disconnected_at IS NULL`
|
|
}
|
|
query += ` GROUP BY s.id ORDER BY s.connected_at DESC LIMIT ?`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying recent sessions: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var sessions []Session
|
|
for rows.Next() {
|
|
var s Session
|
|
var connectedAt string
|
|
var disconnectedAt sql.NullString
|
|
var humanScore sql.NullFloat64
|
|
var execCommand sql.NullString
|
|
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand, &s.EventCount); err != nil {
|
|
return nil, fmt.Errorf("scanning session: %w", err)
|
|
}
|
|
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
|
if disconnectedAt.Valid {
|
|
t, _ := time.Parse(time.RFC3339, disconnectedAt.String)
|
|
s.DisconnectedAt = &t
|
|
}
|
|
if humanScore.Valid {
|
|
s.HumanScore = &humanScore.Float64
|
|
}
|
|
if execCommand.Valid {
|
|
s.ExecCommand = &execCommand.String
|
|
}
|
|
sessions = append(sessions, s)
|
|
}
|
|
return sessions, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) GetTopExecCommands(ctx context.Context, limit int) ([]TopEntry, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT exec_command, COUNT(*) as total
|
|
FROM sessions
|
|
WHERE exec_command IS NOT NULL
|
|
GROUP BY exec_command
|
|
ORDER BY total DESC
|
|
LIMIT ?`, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying top exec commands: %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 exec commands: %w", err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) CloseActiveSessions(ctx context.Context, disconnectedAt time.Time) (int64, error) {
|
|
res, err := s.db.ExecContext(ctx, `
|
|
UPDATE sessions SET disconnected_at = ? WHERE disconnected_at IS NULL`,
|
|
disconnectedAt.UTC().Format(time.RFC3339))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("closing active sessions: %w", err)
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
func (s *SQLiteStore) GetAttemptsOverTime(ctx context.Context, days int, since, until *time.Time) ([]TimeSeriesPoint, error) {
|
|
query := `SELECT DATE(last_seen) AS d, SUM(count) FROM login_attempts WHERE 1=1`
|
|
var args []any
|
|
|
|
if since != nil {
|
|
query += ` AND last_seen >= ?`
|
|
args = append(args, since.UTC().Format(time.RFC3339))
|
|
} else {
|
|
query += ` AND last_seen >= ?`
|
|
args = append(args, time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02"))
|
|
}
|
|
if until != nil {
|
|
query += ` AND last_seen <= ?`
|
|
args = append(args, until.UTC().Format(time.RFC3339))
|
|
}
|
|
query += ` GROUP BY d ORDER BY d`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying attempts over time: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var points []TimeSeriesPoint
|
|
for rows.Next() {
|
|
var dateStr string
|
|
var p TimeSeriesPoint
|
|
if err := rows.Scan(&dateStr, &p.Count); err != nil {
|
|
return nil, fmt.Errorf("scanning time series point: %w", err)
|
|
}
|
|
p.Timestamp, _ = time.Parse("2006-01-02", dateStr)
|
|
points = append(points, p)
|
|
}
|
|
return points, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) GetHourlyPattern(ctx context.Context, since, until *time.Time) ([]HourlyCount, error) {
|
|
query := `SELECT CAST(STRFTIME('%H', last_seen) AS INTEGER) AS h, SUM(count) FROM login_attempts WHERE 1=1`
|
|
var args []any
|
|
|
|
if since != nil {
|
|
query += ` AND last_seen >= ?`
|
|
args = append(args, since.UTC().Format(time.RFC3339))
|
|
}
|
|
if until != nil {
|
|
query += ` AND last_seen <= ?`
|
|
args = append(args, until.UTC().Format(time.RFC3339))
|
|
}
|
|
query += ` GROUP BY h ORDER BY h`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying hourly pattern: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var counts []HourlyCount
|
|
for rows.Next() {
|
|
var c HourlyCount
|
|
if err := rows.Scan(&c.Hour, &c.Count); err != nil {
|
|
return nil, fmt.Errorf("scanning hourly count: %w", err)
|
|
}
|
|
counts = append(counts, c)
|
|
}
|
|
return counts, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) GetCountryStats(ctx context.Context) ([]CountryCount, 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`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying country stats: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var counts []CountryCount
|
|
for rows.Next() {
|
|
var c CountryCount
|
|
if err := rows.Scan(&c.Country, &c.Count); err != nil {
|
|
return nil, fmt.Errorf("scanning country count: %w", err)
|
|
}
|
|
counts = append(counts, c)
|
|
}
|
|
return counts, rows.Err()
|
|
}
|
|
|
|
// buildAttemptWhereClause builds a dynamic WHERE clause for login_attempts filtering.
|
|
func buildAttemptWhereClause(f DashboardFilter) (string, []any) {
|
|
var clauses []string
|
|
var args []any
|
|
|
|
if f.Since != nil {
|
|
clauses = append(clauses, "last_seen >= ?")
|
|
args = append(args, f.Since.UTC().Format(time.RFC3339))
|
|
}
|
|
if f.Until != nil {
|
|
clauses = append(clauses, "last_seen <= ?")
|
|
args = append(args, f.Until.UTC().Format(time.RFC3339))
|
|
}
|
|
if f.IP != "" {
|
|
clauses = append(clauses, "ip = ?")
|
|
args = append(args, f.IP)
|
|
}
|
|
if f.Country != "" {
|
|
clauses = append(clauses, "country = ?")
|
|
args = append(args, f.Country)
|
|
}
|
|
if f.Username != "" {
|
|
clauses = append(clauses, "username = ?")
|
|
args = append(args, f.Username)
|
|
}
|
|
|
|
if len(clauses) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
return " WHERE " + strings.Join(clauses, " AND "), args
|
|
}
|
|
|
|
func (s *SQLiteStore) GetFilteredDashboardStats(ctx context.Context, f DashboardFilter) (*DashboardStats, error) {
|
|
where, args := buildAttemptWhereClause(f)
|
|
stats := &DashboardStats{}
|
|
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT COALESCE(SUM(count), 0), COUNT(DISTINCT ip) FROM login_attempts`+where, args...).
|
|
Scan(&stats.TotalAttempts, &stats.UniqueIPs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying filtered attempt stats: %w", err)
|
|
}
|
|
|
|
// Sessions don't have username/password, so only filter by time, IP, country.
|
|
sessQuery := `SELECT COUNT(*) FROM sessions WHERE 1=1`
|
|
var sessArgs []any
|
|
if f.Since != nil {
|
|
sessQuery += ` AND connected_at >= ?`
|
|
sessArgs = append(sessArgs, f.Since.UTC().Format(time.RFC3339))
|
|
}
|
|
if f.Until != nil {
|
|
sessQuery += ` AND connected_at <= ?`
|
|
sessArgs = append(sessArgs, f.Until.UTC().Format(time.RFC3339))
|
|
}
|
|
if f.IP != "" {
|
|
sessQuery += ` AND ip = ?`
|
|
sessArgs = append(sessArgs, f.IP)
|
|
}
|
|
if f.Country != "" {
|
|
sessQuery += ` AND country = ?`
|
|
sessArgs = append(sessArgs, f.Country)
|
|
}
|
|
|
|
err = s.db.QueryRowContext(ctx, sessQuery, sessArgs...).Scan(&stats.TotalSessions)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying filtered total sessions: %w", err)
|
|
}
|
|
|
|
err = s.db.QueryRowContext(ctx, sessQuery+` AND disconnected_at IS NULL`, sessArgs...).Scan(&stats.ActiveSessions)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying filtered active sessions: %w", err)
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *SQLiteStore) GetFilteredTopUsernames(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
|
|
return s.queryFilteredTopN(ctx, "username", limit, f)
|
|
}
|
|
|
|
func (s *SQLiteStore) GetFilteredTopPasswords(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
|
|
return s.queryFilteredTopN(ctx, "password", limit, f)
|
|
}
|
|
|
|
func (s *SQLiteStore) GetFilteredTopIPs(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
|
|
where, args := buildAttemptWhereClause(f)
|
|
args = append(args, limit)
|
|
//nolint:gosec // where clause built from trusted constants, not user input
|
|
query := `SELECT ip, country, SUM(count) AS total FROM login_attempts` + where + ` GROUP BY ip ORDER BY total DESC LIMIT ?`
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying filtered 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 filtered top IPs: %w", err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) GetFilteredTopCountries(ctx context.Context, limit int, f DashboardFilter) ([]TopEntry, error) {
|
|
where, args := buildAttemptWhereClause(f)
|
|
countryClause := "country != ''"
|
|
if where == "" {
|
|
where = " WHERE " + countryClause
|
|
} else {
|
|
where += " AND " + countryClause
|
|
}
|
|
args = append(args, limit)
|
|
//nolint:gosec // where clause built from trusted constants, not user input
|
|
query := `SELECT country, SUM(count) AS total FROM login_attempts` + where + ` GROUP BY country ORDER BY total DESC LIMIT ?`
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying filtered 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 filtered top countries: %w", err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) queryFilteredTopN(ctx context.Context, column string, limit int, f DashboardFilter) ([]TopEntry, error) {
|
|
switch column {
|
|
case "username", "password":
|
|
// valid columns
|
|
default:
|
|
return nil, fmt.Errorf("invalid column: %s", column)
|
|
}
|
|
|
|
where, args := buildAttemptWhereClause(f)
|
|
args = append(args, limit)
|
|
query := fmt.Sprintf(`
|
|
SELECT %s, SUM(count) AS total
|
|
FROM login_attempts%s
|
|
GROUP BY %s
|
|
ORDER BY total DESC
|
|
LIMIT ?`, column, where, column)
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying filtered top %s: %w", column, 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 filtered top %s: %w", column, err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
func (s *SQLiteStore) Close() error {
|
|
return s.db.Close()
|
|
}
|