diff options
| author | Kyren223 <Kyren223@proton.me> | 2025-07-17 18:45:43 +0300 |
|---|---|---|
| committer | Kyren223 <Kyren223@proton.me> | 2025-07-19 18:32:12 +0300 |
| commit | f793203e043aaa2db38823c84500e82756348ac0 (patch) | |
| tree | 88a8a7b78069427f11acb0f2040e9f1d391fab55 /internal | |
| parent | aaea258a21226a893025a65222e244c6b3f4454c (diff) | |
Added session duration metrics with device analytics along with device
analytics metrics in the DB to aggregate and DeviceID abuse prevention
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/data/db.go | 2 | ||||
| -rw-r--r-- | internal/data/device_analytics.sql.go | 50 | ||||
| -rw-r--r-- | internal/data/frequencies.sql.go | 2 | ||||
| -rw-r--r-- | internal/data/members.sql.go | 2 | ||||
| -rw-r--r-- | internal/data/messages.sql.go | 2 | ||||
| -rw-r--r-- | internal/data/models.go | 10 | ||||
| -rw-r--r-- | internal/data/networks.sql.go | 2 | ||||
| -rw-r--r-- | internal/data/notifications.sql.go | 2 | ||||
| -rw-r--r-- | internal/data/trusted_and_blocked_users.sql.go | 2 | ||||
| -rw-r--r-- | internal/data/users.sql.go | 2 | ||||
| -rw-r--r-- | internal/server/api/api.go | 58 | ||||
| -rw-r--r-- | internal/server/api/helpers.go | 36 | ||||
| -rw-r--r-- | internal/server/api/migrations/20250717130820_device_analytics.sql | 11 | ||||
| -rw-r--r-- | internal/server/metrics/metrics.go | 7 | ||||
| -rw-r--r-- | internal/server/server.go | 18 | ||||
| -rw-r--r-- | internal/server/session/session.go | 24 |
16 files changed, 217 insertions, 13 deletions
diff --git a/internal/data/db.go b/internal/data/db.go index 15d00a9..3bd4996 100644 --- a/internal/data/db.go +++ b/internal/data/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 package data diff --git a/internal/data/device_analytics.sql.go b/internal/data/device_analytics.sql.go new file mode 100644 index 0000000..62ac93a --- /dev/null +++ b/internal/data/device_analytics.sql.go @@ -0,0 +1,50 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: device_analytics.sql + +package data + +import ( + "context" +) + +const setDeviceAnalytics = `-- name: SetDeviceAnalytics :one +INSERT INTO device_analytics ( + device_id, os, arch, term, colorterm +) VALUES ( + ?1, ?2, ?3, ?4, ?5 +) +ON CONFLICT DO +UPDATE SET + os = EXCLUDED.os, arch = EXCLUDED.arch, term = EXCLUDED.term, colorterm = EXCLUDED.colorterm +WHERE device_id = EXCLUDED.device_id +RETURNING device_id, os, arch, term, colorterm +` + +type SetDeviceAnalyticsParams struct { + DeviceID string + Os *string + Arch *string + Term *string + Colorterm *string +} + +func (q *Queries) SetDeviceAnalytics(ctx context.Context, arg SetDeviceAnalyticsParams) (DeviceAnalytic, error) { + row := q.db.QueryRowContext(ctx, setDeviceAnalytics, + arg.DeviceID, + arg.Os, + arg.Arch, + arg.Term, + arg.Colorterm, + ) + var i DeviceAnalytic + err := row.Scan( + &i.DeviceID, + &i.Os, + &i.Arch, + &i.Term, + &i.Colorterm, + ) + return i, err +} diff --git a/internal/data/frequencies.sql.go b/internal/data/frequencies.sql.go index 4971238..026daa0 100644 --- a/internal/data/frequencies.sql.go +++ b/internal/data/frequencies.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: frequencies.sql package data diff --git a/internal/data/members.sql.go b/internal/data/members.sql.go index 38dfd3b..018d6a5 100644 --- a/internal/data/members.sql.go +++ b/internal/data/members.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: members.sql package data diff --git a/internal/data/messages.sql.go b/internal/data/messages.sql.go index ed94b95..87d8c3a 100644 --- a/internal/data/messages.sql.go +++ b/internal/data/messages.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: messages.sql package data diff --git a/internal/data/models.go b/internal/data/models.go index 02949d0..f052fcd 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 package data @@ -14,6 +14,14 @@ type BlockedUser struct { BlockedUserID snowflake.ID } +type DeviceAnalytic struct { + DeviceID string + Os *string + Arch *string + Term *string + Colorterm *string +} + type Frequency struct { ID snowflake.ID NetworkID snowflake.ID diff --git a/internal/data/networks.sql.go b/internal/data/networks.sql.go index 0439caf..5737de8 100644 --- a/internal/data/networks.sql.go +++ b/internal/data/networks.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: networks.sql package data diff --git a/internal/data/notifications.sql.go b/internal/data/notifications.sql.go index 393d90a..8ec2718 100644 --- a/internal/data/notifications.sql.go +++ b/internal/data/notifications.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: notifications.sql package data diff --git a/internal/data/trusted_and_blocked_users.sql.go b/internal/data/trusted_and_blocked_users.sql.go index 0d91ba5..8e82019 100644 --- a/internal/data/trusted_and_blocked_users.sql.go +++ b/internal/data/trusted_and_blocked_users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: trusted_and_blocked_users.sql package data diff --git a/internal/data/users.sql.go b/internal/data/users.sql.go index dad7aea..1ba8f9d 100644 --- a/internal/data/users.sql.go +++ b/internal/data/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.29.0 // source: users.sql package data diff --git a/internal/server/api/api.go b/internal/server/api/api.go index 8b1d711..5159186 100644 --- a/internal/server/api/api.go +++ b/internal/server/api/api.go @@ -5,10 +5,12 @@ import ( "context" "crypto/ed25519" "database/sql" + "encoding/binary" "fmt" "log/slog" "strconv" "strings" + "sync" "github.com/kyren223/eko/internal/data" "github.com/kyren223/eko/internal/packet" @@ -34,7 +36,7 @@ func SendMessage(ctx context.Context, sess *session.Session, request *packet.Sen if len(request.Content) > packet.MaxMessageBytes { return &packet.Error{Error: fmt.Sprintf( - "message conent must not exceed %v bytes", + "message content must not exceed %v bytes", packet.MaxMessageBytes, )} } @@ -1647,7 +1649,57 @@ func sendInitialAuthPackets(ctx context.Context, sess *session.Session) bool { return success } +var ( + ipDeviceID map[uint32]string = map[uint32]string{} + deviceIdMu sync.Mutex +) + func DeviceAnalytics(ctx context.Context, sess *session.Session, request *packet.DeviceAnalytics) packet.Payload { - // TODO: implement this - return &ErrNotImplemented + const DeviceIdLength = 64 + + if len(request.DeviceID) != DeviceIdLength { + return &packet.Error{Error: fmt.Sprintf( + "DeviceID must be exactly %v bytes", DeviceIdLength, + )} + } + + for _, c := range request.DeviceID { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return &packet.Error{ + Error: "DeviceID must be all lowercase hexadecimal", + } + } + } + + ip := binary.BigEndian.Uint32(sess.Addr().IP.To4()) + deviceIdMu.Lock() + if deviceId, ok := ipDeviceID[ip]; ok { + if request.DeviceID != deviceId { + slog.WarnContext(ctx, "device ID mismatch", "existing_device_id", deviceId, "request_device_id", request.DeviceID) + request.DeviceID = deviceId + // Override the request to use the known ID + // This avoids abuse + } + } else { + ipDeviceID[ip] = request.DeviceID + } + deviceIdMu.Unlock() + + if !IsValidAnalytics(ctx, request) { + // This is either malicious or we should actually add new variations + // In either case the client shouldn't need a response + return nil + } + + sess.SetAnalytics(request) + queries := data.New(db) + queries.SetDeviceAnalytics(ctx, data.SetDeviceAnalyticsParams{ + DeviceID: request.DeviceID, + Os: &request.OS, + Arch: &request.Arch, + Term: &request.Term, + Colorterm: &request.Colorterm, + }) + + return nil } diff --git a/internal/server/api/helpers.go b/internal/server/api/helpers.go index ea192df..6993d0a 100644 --- a/internal/server/api/helpers.go +++ b/internal/server/api/helpers.go @@ -3,6 +3,7 @@ package api import ( "context" "log/slog" + "slices" "strings" "time" @@ -190,3 +191,38 @@ func getNotifications(ctx context.Context, userId snowflake.ID) (packet.Notifica } return items, nil } + +var ( + ValidOs = []string{"linux", "darwin", "windows", "android", ""} + ValidArch = []string{"amd64", "arm64", "386", ""} + ValidTerm = []string{"xterm-256color", "tmux-256color", "xterm-ghostty", "xterm-kitty", "alacritty", "foot", "xterm", ""} + ValidColorterm = []string{"truecolor", "24bit", ""} +) + +func IsValidAnalytics(ctx context.Context, analytics *packet.DeviceAnalytics) bool { + if analytics == nil { + return false + } + + if !slices.Contains(ValidOs, analytics.OS) { + slog.WarnContext(ctx, "unrecognized analytics value", "os", analytics.OS, "analytics", analytics) + return false + } + + if !slices.Contains(ValidArch, analytics.Arch) { + slog.WarnContext(ctx, "unrecognized analytics value", "arch", analytics.Arch, "analytics", analytics) + return false + } + + if !slices.Contains(ValidTerm, analytics.Term) { + slog.WarnContext(ctx, "unrecognized analytics value", "term", analytics.Term, "analytics", analytics) + return false + } + + if !slices.Contains(ValidColorterm, analytics.Colorterm) { + slog.WarnContext(ctx, "unrecognized analytics value", "colorterm", analytics.Colorterm, "analytics", analytics) + return false + } + + return true +} diff --git a/internal/server/api/migrations/20250717130820_device_analytics.sql b/internal/server/api/migrations/20250717130820_device_analytics.sql new file mode 100644 index 0000000..b4797a4 --- /dev/null +++ b/internal/server/api/migrations/20250717130820_device_analytics.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS device_analytics ( + device_id TEXT PRIMARY KEY, + os TEXT, + arch TEXT, + term TEXT, + colorterm TEXT +); + +-- +goose Down +DROP TABLE IF EXISTS device_analytics; diff --git a/internal/server/metrics/metrics.go b/internal/server/metrics/metrics.go index 1ce907c..d465b6c 100644 --- a/internal/server/metrics/metrics.go +++ b/internal/server/metrics/metrics.go @@ -55,3 +55,10 @@ var UsersActive = promauto.NewGauge(prometheus.GaugeOpts{ Name: "users_active_total", Help: "The total number of active users", }) + +var SessionDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Name: "session_duration_seconds", + Help: "The duration in seconds of an authenticated session", + NativeHistogramBucketFactor: 1.00271, +}, []string{"os", "arch", "term", "colorterm"}) diff --git a/internal/server/server.go b/internal/server/server.go index ecb5234..d94dca6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -276,8 +276,10 @@ func (server *server) handleConnection(conn net.Conn) { sess := session.NewSession(server, addr, cancel, &writerWg) go func() { <-ctx.Done() - // Remove session after cancellation if sess.IsAuthenticated() { + server.handleSessionMetrics(ctx, sess) + + // Remove session after cancellation sameAddress := addr.String() == server.Session(sess.ID()).Addr().String() // false if the user signed in from a different connection if sameAddress { @@ -680,3 +682,17 @@ func formatIPv4(ip uint32) string { n = strconv.AppendUint(n, uint64(ip&0xFF), 10) return string(n) } + +func (s *server) handleSessionMetrics(ctx context.Context, sess *session.Session) { + duration := sess.Duration() + analytics := sess.Analytics() + if api.IsValidAnalytics(ctx, analytics) { + metrics.SessionDuration.WithLabelValues( + analytics.OS, analytics.Arch, analytics.Term, analytics.Colorterm, + ).Observe(duration.Seconds()) + } else { + metrics.SessionDuration.WithLabelValues( + "", "", "", "", + ).Observe(duration.Seconds()) + } +} diff --git a/internal/server/session/session.go b/internal/server/session/session.go index f0f82bd..1eb2e80 100644 --- a/internal/server/session/session.go +++ b/internal/server/session/session.go @@ -52,6 +52,8 @@ type Session struct { pubKey ed25519.PublicKey id snowflake.ID rl rate.Limiter + start time.Time + analytics *packet.DeviceAnalytics mu sync.RWMutex } @@ -77,6 +79,8 @@ func NewSession( challengeMu: sync.Mutex{}, isTosAccepted: false, rl: rate.NewLimiter(DefaultRate, DefaultLimit), + start: time.Time{}, + analytics: nil, mu: sync.RWMutex{}, } return session @@ -130,6 +134,26 @@ func (s *Session) Promote(userId snowflake.ID, pubKey ed25519.PublicKey) { s.pubKey = pubKey s.rl.SetLimit(AuthenticatedLimit) s.rl.SetRate(AuthenticatedRate) + s.start = time.Now().UTC() +} + +func (s *Session) Duration() time.Duration { + assert.Assert(s.IsAuthenticated(), "tried accessing session duration in an authenticated session") + s.mu.RLock() + defer s.mu.RUnlock() + return time.Since(s.start) +} + +func (s *Session) Analytics() *packet.DeviceAnalytics { + s.mu.RLock() + defer s.mu.RUnlock() + return s.analytics +} + +func (s *Session) SetAnalytics(analytics *packet.DeviceAnalytics) { + s.mu.Lock() + defer s.mu.Unlock() + s.analytics = analytics } func (s *Session) Manager() SessionManager { |
