338 lines
13 KiB
Go
338 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const gameAccessRetention = 30 * 24 * time.Hour
|
|
const failedAccessWindow = 15 * time.Minute
|
|
const failedAccessThreshold = 5
|
|
|
|
func (svc *CoreService) ListGamePlayersForSession(sessionID string, filter domain.GamePlayerFilter) ([]domain.GamePlayer, error) {
|
|
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := svc.pruneGamePlayerEvidence(filter.ServerInstanceID); err != nil {
|
|
return nil, err
|
|
}
|
|
values, err := svc.store.GamePlayers().List(filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Slice(values, func(i, j int) bool { return values[i].LastSeenAt.After(values[j].LastSeenAt) })
|
|
if filter.Limit > 0 && len(values) > filter.Limit {
|
|
values = values[:filter.Limit]
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func (svc *CoreService) GetGamePlayerProfileForSession(sessionID, playerRecordID string) (domain.GamePlayerProfile, error) {
|
|
player, err := svc.store.GamePlayers().Get(playerRecordID)
|
|
if err != nil {
|
|
return domain.GamePlayerProfile{}, err
|
|
}
|
|
if err := svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil {
|
|
return domain.GamePlayerProfile{}, err
|
|
}
|
|
if err := svc.pruneGamePlayerEvidence(player.ServerInstanceID); err != nil {
|
|
return domain.GamePlayerProfile{}, err
|
|
}
|
|
aliases, err := svc.store.GamePlayerAliases().List(domain.GamePlayerAliasFilter{GamePlayerRecordID: player.ID})
|
|
if err != nil {
|
|
return domain.GamePlayerProfile{}, err
|
|
}
|
|
sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: player.ID})
|
|
if err != nil {
|
|
return domain.GamePlayerProfile{}, err
|
|
}
|
|
attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{GamePlayerRecordID: player.ID})
|
|
if err != nil {
|
|
return domain.GamePlayerProfile{}, err
|
|
}
|
|
signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{GamePlayerRecordID: player.ID})
|
|
if err != nil {
|
|
return domain.GamePlayerProfile{}, err
|
|
}
|
|
return domain.GamePlayerProfile{Player: player, Aliases: aliases, Sessions: sessions, AccessAttempts: attempts, SecuritySignals: signals}, nil
|
|
}
|
|
|
|
func (svc *CoreService) projectGamePlayerEvents(batch domain.LogBatchIngest) error {
|
|
for _, entry := range batch.Entries {
|
|
if err := svc.projectGamePlayerEvent(batch, entry); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return svc.pruneGamePlayerEvidence(batch.ServerInstanceID)
|
|
}
|
|
func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entry domain.LogEntry) error {
|
|
fields := entry.Fields
|
|
if fields == nil {
|
|
return nil
|
|
}
|
|
eventType := strings.TrimSpace(fields["eventType"])
|
|
if eventType != "scum.login" && eventType != "scum.logout" {
|
|
return nil
|
|
}
|
|
gameID, name := strings.TrimSpace(fields["playerId"]), strings.TrimSpace(fields["playerName"])
|
|
if gameID == "" || name == "" {
|
|
return nil
|
|
}
|
|
occurred := entry.Timestamp
|
|
if raw := strings.TrimSpace(fields["occurredAt"]); raw != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
|
occurred = parsed
|
|
}
|
|
}
|
|
if occurred.IsZero() {
|
|
occurred = svc.now()
|
|
}
|
|
recordID := gamePlayerRecordID(batch.ServerInstanceID, gameID)
|
|
player, err := svc.store.GamePlayers().Get(recordID)
|
|
if err == repo.ErrNotFound {
|
|
player = domain.GamePlayer{ID: recordID, ServerInstanceID: batch.ServerInstanceID, GamePlayerID: gameID, DisplayName: name, FirstSeenAt: occurred, LastSeenAt: occurred, LastEventAt: occurred, CreatedAt: svc.now(), UpdatedAt: svc.now()}
|
|
if err = svc.store.GamePlayers().Create(player); err != nil {
|
|
return err
|
|
}
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if occurred.After(player.LastEventAt) || occurred.Equal(player.LastEventAt) {
|
|
if name != player.DisplayName {
|
|
player.DisplayName = name
|
|
}
|
|
player.LastSeenAt = maxTime(player.LastSeenAt, occurred)
|
|
player.LastEventAt = occurred
|
|
player.UpdatedAt = svc.now()
|
|
if err := svc.store.GamePlayers().Update(player); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := svc.upsertGamePlayerAlias(player, name, occurred); err != nil {
|
|
return err
|
|
}
|
|
sourceSession := strings.TrimSpace(fields["sessionId"])
|
|
if sourceSession == "" {
|
|
sourceSession = "event-" + entryID(batch.LogStreamID, entry.Seq)
|
|
}
|
|
if eventType == "scum.login" {
|
|
outcome := strings.TrimSpace(fields["outcome"])
|
|
if outcome == "accepted" {
|
|
if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil {
|
|
return err
|
|
}
|
|
return svc.openGamePlayerSession(player, sourceSession, occurred)
|
|
}
|
|
return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"]))
|
|
}
|
|
return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"]))
|
|
}
|
|
|
|
func (svc *CoreService) recordSuccessfulGameAccess(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, at time.Time, raw string) error {
|
|
id := "attempt-" + entryID(batch.LogStreamID, entry.Seq)
|
|
if _, err := svc.store.GameAccessAttempts().Get(id); err == nil {
|
|
return nil
|
|
} else if err != repo.ErrNotFound {
|
|
return err
|
|
}
|
|
key := svc.networkCorrelation(batch.ServerInstanceID, raw)
|
|
if err := svc.store.GameAccessAttempts().Create(domain.GameAccessAttempt{ID: id, ServerInstanceID: batch.ServerInstanceID, GamePlayerRecordID: player.ID, EventID: entryID(batch.LogStreamID, entry.Seq), OccurredAt: at, Outcome: "accepted", Reason: "login-accepted", NetworkCorrelationKey: key, ExpiresAt: at.Add(gameAccessRetention)}); err != nil {
|
|
return err
|
|
}
|
|
if key != "" {
|
|
return svc.refreshPossibleAltSignal(player, key, at)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) refreshPossibleAltSignal(player domain.GamePlayer, key string, at time.Time) error {
|
|
attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: player.ServerInstanceID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
players := map[string]struct{}{}
|
|
for _, attempt := range attempts {
|
|
if attempt.Outcome == "accepted" && attempt.NetworkCorrelationKey == key && !attempt.OccurredAt.Before(at.Add(-gameAccessRetention)) {
|
|
players[attempt.GamePlayerRecordID] = struct{}{}
|
|
}
|
|
}
|
|
if len(players) < 2 {
|
|
return nil
|
|
}
|
|
id := "signal-alt-" + fingerprintID(player.ServerInstanceID, key)
|
|
signal, err := svc.store.GameSecuritySignals().Get(id)
|
|
if err == repo.ErrNotFound {
|
|
return svc.store.GameSecuritySignals().Create(domain.GameSecuritySignal{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, RuleKey: "possible-alt-account", Status: domain.GameSecuritySignalReviewRequired, EvidenceCount: len(players), Summary: "Multiple game identities share server-local access evidence; manual review required", FirstObservedAt: at, LastObservedAt: at, ExpiresAt: at.Add(gameAccessRetention)})
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
signal.EvidenceCount = len(players)
|
|
signal.LastObservedAt = at
|
|
signal.ExpiresAt = at.Add(gameAccessRetention)
|
|
return svc.store.GameSecuritySignals().Update(signal)
|
|
}
|
|
func (svc *CoreService) upsertGamePlayerAlias(player domain.GamePlayer, name string, at time.Time) error {
|
|
id := gamePlayerAliasID(player.ID, name)
|
|
item, err := svc.store.GamePlayerAliases().Get(id)
|
|
if err == repo.ErrNotFound {
|
|
return svc.store.GamePlayerAliases().Create(domain.GamePlayerAlias{ID: id, GamePlayerRecordID: player.ID, ServerInstanceID: player.ServerInstanceID, Alias: name, FirstSeenAt: at, LastSeenAt: at})
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if at.After(item.LastSeenAt) {
|
|
item.LastSeenAt = at
|
|
return svc.store.GamePlayerAliases().Update(item)
|
|
}
|
|
return nil
|
|
}
|
|
func (svc *CoreService) openGamePlayerSession(player domain.GamePlayer, source string, at time.Time) error {
|
|
id := gamePlayerSessionID(player.ID, source)
|
|
item, err := svc.store.GamePlayerSessions().Get(id)
|
|
if err == repo.ErrNotFound {
|
|
return svc.store.GamePlayerSessions().Create(domain.GamePlayerSession{ID: id, GamePlayerRecordID: player.ID, ServerInstanceID: player.ServerInstanceID, SourceSessionID: source, StartedAt: at, LastEventAt: at})
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if at.After(item.LastEventAt) {
|
|
item.LastEventAt = at
|
|
return svc.store.GamePlayerSessions().Update(item)
|
|
}
|
|
return nil
|
|
}
|
|
func (svc *CoreService) closeGamePlayerSession(player domain.GamePlayer, source string, at time.Time, reason string) error {
|
|
id := gamePlayerSessionID(player.ID, source)
|
|
item, err := svc.store.GamePlayerSessions().Get(id)
|
|
if err == repo.ErrNotFound {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if item.StartedAt.After(at) || (!item.EndedAt.IsZero() && !at.After(item.EndedAt)) {
|
|
return nil
|
|
}
|
|
item.EndedAt = at
|
|
item.EndReason = bounded(reason, 40)
|
|
item.LastEventAt = maxTime(item.LastEventAt, at)
|
|
return svc.store.GamePlayerSessions().Update(item)
|
|
}
|
|
func (svc *CoreService) recordFailedGameAccess(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, at time.Time, raw string) error {
|
|
id := "attempt-" + entryID(batch.LogStreamID, entry.Seq)
|
|
if _, err := svc.store.GameAccessAttempts().Get(id); err == nil {
|
|
return nil
|
|
} else if err != repo.ErrNotFound {
|
|
return err
|
|
}
|
|
key := svc.networkCorrelation(batch.ServerInstanceID, raw)
|
|
attempt := domain.GameAccessAttempt{ID: id, ServerInstanceID: batch.ServerInstanceID, GamePlayerRecordID: player.ID, EventID: entryID(batch.LogStreamID, entry.Seq), OccurredAt: at, Outcome: "rejected", Reason: "login-rejected", NetworkCorrelationKey: key, ExpiresAt: at.Add(gameAccessRetention)}
|
|
if err := svc.store.GameAccessAttempts().Create(attempt); err != nil {
|
|
return err
|
|
}
|
|
if key != "" {
|
|
return svc.refreshFailedAccessSignal(player, key, at)
|
|
}
|
|
return nil
|
|
}
|
|
func (svc *CoreService) refreshFailedAccessSignal(player domain.GamePlayer, key string, at time.Time) error {
|
|
attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: player.ServerInstanceID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
count := 0
|
|
for _, a := range attempts {
|
|
if a.NetworkCorrelationKey == key && !a.OccurredAt.Before(at.Add(-failedAccessWindow)) {
|
|
count++
|
|
}
|
|
}
|
|
if count < failedAccessThreshold {
|
|
return nil
|
|
}
|
|
id := "signal-failed-" + fingerprintID(player.ServerInstanceID, key)
|
|
s, err := svc.store.GameSecuritySignals().Get(id)
|
|
if err == repo.ErrNotFound {
|
|
return svc.store.GameSecuritySignals().Create(domain.GameSecuritySignal{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, RuleKey: "excessive-failed-access", Status: domain.GameSecuritySignalReviewRequired, EvidenceCount: count, Summary: "Repeated failed access requires manual review", FirstObservedAt: at, LastObservedAt: at, ExpiresAt: at.Add(gameAccessRetention)})
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.EvidenceCount = count
|
|
s.LastObservedAt = at
|
|
s.ExpiresAt = at.Add(gameAccessRetention)
|
|
return svc.store.GameSecuritySignals().Update(s)
|
|
}
|
|
func (svc *CoreService) networkCorrelation(serverID, raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
mac := hmac.New(sha256.New, svc.networkFingerprintKey)
|
|
mac.Write([]byte(serverID))
|
|
mac.Write([]byte{0})
|
|
mac.Write([]byte(raw))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
func (svc *CoreService) pruneGamePlayerEvidence(serverID string) error {
|
|
now := svc.now()
|
|
attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: serverID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, a := range attempts {
|
|
if !a.ExpiresAt.After(now) {
|
|
if err := svc.store.GameAccessAttempts().Delete(a.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: serverID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, s := range signals {
|
|
if !s.ExpiresAt.After(now) {
|
|
if err := svc.store.GameSecuritySignals().Delete(s.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func gamePlayerRecordID(server, id string) string { return "game-player-" + fingerprintID(server, id) }
|
|
func gamePlayerAliasID(player, name string) string {
|
|
return "game-player-alias-" + fingerprintID(player, name)
|
|
}
|
|
func gamePlayerSessionID(player, session string) string {
|
|
return "game-player-session-" + fingerprintID(player, session)
|
|
}
|
|
func entryID(stream string, seq uint64) string { return stream + "-" + itoa(seq) }
|
|
func fingerprintID(a, b string) string {
|
|
sum := sha256.Sum256([]byte(a + "\x00" + b))
|
|
return hex.EncodeToString(sum[:])[:24]
|
|
}
|
|
func itoa(v uint64) string {
|
|
return strconv.FormatUint(v, 10)
|
|
}
|
|
func maxTime(a, b time.Time) time.Time {
|
|
if b.After(a) {
|
|
return b
|
|
}
|
|
return a
|
|
}
|
|
func bounded(v string, n int) string {
|
|
v = strings.TrimSpace(v)
|
|
if len(v) > n {
|
|
return v[:n]
|
|
}
|
|
return v
|
|
}
|