summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/data/db.go2
-rw-r--r--internal/data/device_analytics.sql.go50
-rw-r--r--internal/data/frequencies.sql.go2
-rw-r--r--internal/data/members.sql.go2
-rw-r--r--internal/data/messages.sql.go2
-rw-r--r--internal/data/models.go10
-rw-r--r--internal/data/networks.sql.go2
-rw-r--r--internal/data/notifications.sql.go2
-rw-r--r--internal/data/trusted_and_blocked_users.sql.go2
-rw-r--r--internal/data/users.sql.go2
-rw-r--r--internal/server/api/api.go58
-rw-r--r--internal/server/api/helpers.go36
-rw-r--r--internal/server/api/migrations/20250717130820_device_analytics.sql11
-rw-r--r--internal/server/metrics/metrics.go7
-rw-r--r--internal/server/server.go18
-rw-r--r--internal/server/session/session.go24
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 {