mirror of
https://github.com/Kyren223/eko.git
synced 2025-09-03 20:18:22 +00:00
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:
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.28.0
|
||||
// sqlc v1.29.0
|
||||
|
||||
package data
|
||||
|
||||
|
50
internal/data/device_analytics.sql.go
Normal file
50
internal/data/device_analytics.sql.go
Normal 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
|
||||
}
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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;
|
@@ -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"})
|
||||
|
@@ -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())
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
11
query/device_analytics.sql
Normal file
11
query/device_analytics.sql
Normal 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 *;
|
Reference in New Issue
Block a user