233 lines
8.1 KiB
Go
233 lines
8.1 KiB
Go
package companion
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf16"
|
|
)
|
|
|
|
const (
|
|
SCUMLoginLogParserKey = "scum-login-log-parser-v1"
|
|
SCUMLoginLogParserVersion = "scum-login-log-v1"
|
|
SCUMLoginLogMaxLineBytes = 4096
|
|
)
|
|
|
|
var scumLoginLogPattern = regexp.MustCompile(`^(\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}): '(.+)\s+(\d{17}):(.+)\(([^()]*)\)' logged (in|out) at: X=([+-]?\d+(?:\.\d+)?) Y=([+-]?\d+(?:\.\d+)?) Z=([+-]?\d+(?:\.\d+)?)$`)
|
|
|
|
// SCUMLoginLogRecord is a single complete or buffered line from a plugin-declared
|
|
// UTF-16LE SCUM login log source. It carries transport identity, not host paths.
|
|
type SCUMLoginLogRecord struct {
|
|
ServerID string
|
|
SourceIdentity string
|
|
StreamGeneration string
|
|
Sequence uint64
|
|
RawLine []byte
|
|
Partial bool
|
|
}
|
|
|
|
type SCUMLoginLogParseOptions struct {
|
|
MaxLineBytes int
|
|
AcknowledgedTransportCursors map[SCUMTransportCursor]struct{}
|
|
AcknowledgedLogicalEventIdentities map[string]struct{}
|
|
}
|
|
|
|
type SCUMTransportCursor struct {
|
|
SourceIdentity string
|
|
StreamGeneration string
|
|
Sequence uint64
|
|
}
|
|
|
|
type SCUMLoginLogEvent struct {
|
|
ServerID string
|
|
ParserKey string
|
|
ParserVersion string
|
|
TransportCursor SCUMTransportCursor
|
|
Type string
|
|
ExternalPlayerID string
|
|
DisplayName string
|
|
ProfileLocalID string
|
|
OccurredAt time.Time
|
|
OccurredAtSourceText string
|
|
LogicalEventIdentity string
|
|
}
|
|
|
|
type SCUMLoginLogDiagnostic struct {
|
|
ServerID string
|
|
TransportCursor SCUMTransportCursor
|
|
Code string
|
|
}
|
|
|
|
type SCUMLoginLogBatch struct {
|
|
ServerID string
|
|
ParserKey string
|
|
ParserVersion string
|
|
Events []SCUMLoginLogEvent
|
|
Diagnostics []SCUMLoginLogDiagnostic
|
|
}
|
|
|
|
type SCUMLoginLogParserAvailability struct {
|
|
Available bool
|
|
ParserKey string
|
|
ParserVersion string
|
|
MaxLineBytes int
|
|
Reason string
|
|
}
|
|
|
|
func VerifiedSCUMLoginLogParser() SCUMLoginLogParserAvailability {
|
|
return SCUMLoginLogParserAvailability{Available: true, ParserKey: SCUMLoginLogParserKey, ParserVersion: SCUMLoginLogParserVersion, MaxLineBytes: SCUMLoginLogMaxLineBytes, Reason: "SCUM UTF-16LE login log parser is available"}
|
|
}
|
|
|
|
func ParseSCUMLoginLogRecords(serverID string, records []SCUMLoginLogRecord, options SCUMLoginLogParseOptions) SCUMLoginLogBatch {
|
|
maxLineBytes := options.MaxLineBytes
|
|
if maxLineBytes <= 0 || maxLineBytes > SCUMLoginLogMaxLineBytes {
|
|
maxLineBytes = SCUMLoginLogMaxLineBytes
|
|
}
|
|
batch := SCUMLoginLogBatch{ServerID: serverID, ParserKey: SCUMLoginLogParserKey, ParserVersion: SCUMLoginLogParserVersion}
|
|
seenTransport := cloneTransportSet(options.AcknowledgedTransportCursors)
|
|
seenLogical := cloneStringSet(options.AcknowledgedLogicalEventIdentities)
|
|
lastSequenceByGeneration := map[string]uint64{}
|
|
if len(records) > 100 {
|
|
records = records[:100]
|
|
}
|
|
for _, record := range records {
|
|
cursor := SCUMTransportCursor{SourceIdentity: record.SourceIdentity, StreamGeneration: record.StreamGeneration, Sequence: record.Sequence}
|
|
if record.ServerID != serverID || cursor.SourceIdentity == "" || cursor.StreamGeneration == "" || cursor.Sequence == 0 {
|
|
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "invalid-transport-cursor")
|
|
continue
|
|
}
|
|
generationKey := cursor.SourceIdentity + "\x00" + cursor.StreamGeneration
|
|
if last := lastSequenceByGeneration[generationKey]; last != 0 && cursor.Sequence < last {
|
|
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "out-of-order-transport")
|
|
}
|
|
if cursor.Sequence > lastSequenceByGeneration[generationKey] {
|
|
lastSequenceByGeneration[generationKey] = cursor.Sequence
|
|
}
|
|
if _, ok := seenTransport[cursor]; ok {
|
|
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "duplicate-transport-cursor")
|
|
continue
|
|
}
|
|
seenTransport[cursor] = struct{}{}
|
|
if record.Partial {
|
|
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "partial-line")
|
|
continue
|
|
}
|
|
if len(record.RawLine) == 0 || len(record.RawLine) > maxLineBytes {
|
|
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "oversized-line")
|
|
continue
|
|
}
|
|
line, ok := decodeUTF16LELine(record.RawLine)
|
|
if !ok {
|
|
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "undecodable-line")
|
|
continue
|
|
}
|
|
event, diagnosticCode := parseSCUMLoginLogLine(serverID, cursor, line)
|
|
if diagnosticCode != "" {
|
|
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, diagnosticCode)
|
|
continue
|
|
}
|
|
if _, ok := seenLogical[event.LogicalEventIdentity]; ok {
|
|
batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "duplicate-logical-event")
|
|
continue
|
|
}
|
|
seenLogical[event.LogicalEventIdentity] = struct{}{}
|
|
batch.Events = append(batch.Events, event)
|
|
}
|
|
return batch
|
|
}
|
|
|
|
func parseSCUMLoginLogLine(serverID string, cursor SCUMTransportCursor, rawLine string) (SCUMLoginLogEvent, string) {
|
|
line := strings.TrimPrefix(strings.TrimRight(rawLine, "\r\n"), "\ufeff")
|
|
lower := strings.ToLower(line)
|
|
if strings.Contains(lower, "failed") || strings.Contains(lower, "rejected") {
|
|
return SCUMLoginLogEvent{}, "failed-login"
|
|
}
|
|
match := scumLoginLogPattern.FindStringSubmatch(line)
|
|
if match == nil {
|
|
return SCUMLoginLogEvent{}, "malformed-line"
|
|
}
|
|
occurredAt, err := time.ParseInLocation("2006.01.02-15.04.05", match[1], time.UTC)
|
|
if err != nil {
|
|
return SCUMLoginLogEvent{}, "malformed-line"
|
|
}
|
|
eventType := "scum.login"
|
|
if match[6] == "out" {
|
|
eventType = "scum.logout"
|
|
}
|
|
displayName := strings.TrimSpace(match[4])
|
|
profileLocalID := strings.TrimSpace(match[5])
|
|
if displayName == "" || profileLocalID == "" {
|
|
return SCUMLoginLogEvent{}, "malformed-line"
|
|
}
|
|
event := SCUMLoginLogEvent{
|
|
ServerID: serverID,
|
|
ParserKey: SCUMLoginLogParserKey,
|
|
ParserVersion: SCUMLoginLogParserVersion,
|
|
TransportCursor: cursor,
|
|
Type: eventType,
|
|
ExternalPlayerID: match[3],
|
|
DisplayName: displayName,
|
|
ProfileLocalID: profileLocalID,
|
|
OccurredAt: occurredAt,
|
|
OccurredAtSourceText: match[1],
|
|
}
|
|
event.LogicalEventIdentity = scumLoginLogicalIdentity(serverID, eventType, match[1], event.ExternalPlayerID, displayName, profileLocalID)
|
|
return event, ""
|
|
}
|
|
|
|
func scumLoginLogicalIdentity(serverID, eventType, occurrenceText, externalPlayerID, displayName, profileLocalID string) string {
|
|
normalized := strings.Join([]string{SCUMLoginLogParserVersion, serverID, eventType, occurrenceText, externalPlayerID, displayName, profileLocalID}, "\x00")
|
|
digest := sha256.Sum256([]byte(normalized))
|
|
return "sha256:" + hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func decodeUTF16LELine(raw []byte) (string, bool) {
|
|
if len(raw)%2 != 0 {
|
|
return "", false
|
|
}
|
|
codeUnits := make([]uint16, len(raw)/2)
|
|
for index := range codeUnits {
|
|
codeUnits[index] = binary.LittleEndian.Uint16(raw[index*2:])
|
|
}
|
|
for index := 0; index < len(codeUnits); index++ {
|
|
unit := codeUnits[index]
|
|
if 0xD800 <= unit && unit <= 0xDBFF {
|
|
if index+1 >= len(codeUnits) || codeUnits[index+1] < 0xDC00 || codeUnits[index+1] > 0xDFFF {
|
|
return "", false
|
|
}
|
|
index++
|
|
continue
|
|
}
|
|
if 0xDC00 <= unit && unit <= 0xDFFF {
|
|
return "", false
|
|
}
|
|
}
|
|
return string(utf16.Decode(codeUnits)), true
|
|
}
|
|
|
|
func appendSCUMLoginDiagnostic(existing []SCUMLoginLogDiagnostic, serverID string, cursor SCUMTransportCursor, code string) []SCUMLoginLogDiagnostic {
|
|
if len(existing) >= 64 {
|
|
return existing
|
|
}
|
|
return append(existing, SCUMLoginLogDiagnostic{ServerID: serverID, TransportCursor: cursor, Code: code})
|
|
}
|
|
|
|
func cloneTransportSet(source map[SCUMTransportCursor]struct{}) map[SCUMTransportCursor]struct{} {
|
|
target := make(map[SCUMTransportCursor]struct{}, len(source))
|
|
for key, value := range source {
|
|
target[key] = value
|
|
}
|
|
return target
|
|
}
|
|
|
|
func cloneStringSet(source map[string]struct{}) map[string]struct{} {
|
|
target := make(map[string]struct{}, len(source))
|
|
for key, value := range source {
|
|
target[key] = value
|
|
}
|
|
return target
|
|
}
|