Added session duration metrics with device analytics along with device

analytics metrics in the DB to aggregate and DeviceID abuse prevention
This commit is contained in:
2025-07-17 18:45:43 +03:00
parent aaea258a21
commit f793203e04
18 changed files with 230 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.29.0
package data

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

@@ -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 {

View File

@@ -0,0 +1,11 @@
-- name: SetDeviceAnalytics :one
INSERT INTO device_analytics (
device_id, os, arch, term, colorterm
) VALUES (
@device_id, @os, @arch, @term, @colorterm
)
ON CONFLICT DO
UPDATE SET
os = EXCLUDED.os, arch = EXCLUDED.arch, term = EXCLUDED.term, colorterm = EXCLUDED.colorterm
WHERE device_id = EXCLUDED.device_id
RETURNING *;

View File

@@ -9,6 +9,8 @@ sql:
out: "./internal/data/"
emit_pointers_for_null_types: true
overrides:
- column: "device_analytics.device_id"
go_type: "string"
- column: "users.public_key"
go_type: "crypto/ed25519.PublicKey"
- column: "trusted_users.trusted_public_key"