From 7ddcd982bce5e82cefc7acc8d88f50325d5ee5ba Mon Sep 17 00:00:00 2001 From: mkwcat Date: Tue, 6 Feb 2024 01:43:21 -0500 Subject: [PATCH] GameStats: Add standard command server --- database/login.go | 25 +++++++ gamestats/auth.go | 99 +++++++++++++++++++++++++++ gamestats/error.go | 11 +++ gamestats/main.go | 166 +++++++++++++++++++++++++++++++++++++++++++++ gamestats/setpd.go | 21 ++++++ gpcm/error.go | 2 + gpcm/main.go | 10 +-- 7 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 gamestats/auth.go create mode 100644 gamestats/error.go create mode 100644 gamestats/setpd.go diff --git a/database/login.go b/database/login.go index 91ae16e..52cb8f2 100644 --- a/database/login.go +++ b/database/login.go @@ -127,3 +127,28 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsb return user, nil } + +func LoginUserToGameStats(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsbrcd string) (User, error) { + user := User{ + UserId: userId, + GsbrCode: gsbrcd, + } + + var expectedNgId *uint32 + var firstName *string + var lastName *string + err := pool.QueryRow(ctx, GetUserProfileID, userId, gsbrcd).Scan(&user.ProfileId, &expectedNgId, &user.Email, &user.UniqueNick, &firstName, &lastName) + if err != nil { + return User{}, err + } + + if firstName != nil { + user.FirstName = *firstName + } + + if lastName != nil { + user.LastName = *lastName + } + + return user, nil +} diff --git a/gamestats/auth.go b/gamestats/auth.go new file mode 100644 index 0000000..0f0d3e5 --- /dev/null +++ b/gamestats/auth.go @@ -0,0 +1,99 @@ +package gamestats + +import ( + "math/rand" + "strconv" + "time" + "wwfc/common" + "wwfc/database" + "wwfc/gpcm" + "wwfc/logging" + + "github.com/logrusorgru/aurora/v3" +) + +func (g *GameStatsSession) auth(command common.GameSpyCommand) { + game := common.GetGameInfoByName(command.OtherValues["gamename"]) + if game == nil { + g.replyError(gpcm.ErrDatabase) + return + } + + // TODO: Validate "response" + g.SessionKey = rand.Int31n(290000000) + 10000000 + g.GameInfo = game + + g.Write(common.GameSpyCommand{ + Command: "lc", + CommandValue: "2", + OtherValues: map[string]string{ + "sesskey": strconv.FormatInt(int64(g.SessionKey), 10), + "proof": "0", + "id": "1", + }, + }) +} + +func (g *GameStatsSession) authp(command common.GameSpyCommand) { + lid := command.OtherValues["lid"] + errorCmd := common.GameSpyCommand{ + Command: "pauthr", + CommandValue: "-3", + OtherValues: map[string]string{ + "lid": lid, + "errmsg": "Invalid Validation", + }, + } + + if lid != "" { + var err error + g.LoginID, err = strconv.Atoi(lid) + if err != nil { + logging.Error(g.ModuleName, "Error parsing login ID:", err.Error()) + g.Write(errorCmd) + return + } + } + + authToken := command.OtherValues["authtoken"] + if authToken == "" { + logging.Error(g.ModuleName, "No authtoken provided") + g.Write(errorCmd) + return + } + + err, _, issueTime, userId, gsbrcd, _, _, _, _, _, _, _ := common.UnmarshalNASAuthToken(authToken) + if err != nil { + logging.Error(g.ModuleName, "Error unmarshalling authtoken:", err.Error()) + g.Write(errorCmd) + return + } + + currentTime := time.Now() + if issueTime.Before(currentTime.Add(-10*time.Minute)) || issueTime.After(currentTime) { + logging.Error(g.ModuleName, "Authtoken has expired") + g.Write(errorCmd) + return + } + + g.User, err = database.LoginUserToGameStats(pool, ctx, userId, gsbrcd) + if err != nil { + logging.Error(g.ModuleName, "Error logging in user:", err.Error()) + g.Write(errorCmd) + return + } + + g.ModuleName = "GSTATS:" + strconv.FormatInt(int64(g.User.ProfileId), 10) + g.ModuleName += "/" + common.CalcFriendCodeString(g.User.ProfileId, "RMCJ") + g.Authenticated = true + + logging.Notice(g.ModuleName, "Authenticated, game name:", aurora.Cyan(g.GameInfo.Name)) + + g.Write(common.GameSpyCommand{ + Command: "pauthr", + CommandValue: strconv.FormatUint(uint64(g.User.ProfileId), 10), + OtherValues: map[string]string{ + "lid": lid, + }, + }) +} diff --git a/gamestats/error.go b/gamestats/error.go new file mode 100644 index 0000000..8e16018 --- /dev/null +++ b/gamestats/error.go @@ -0,0 +1,11 @@ +package gamestats + +import ( + "wwfc/gpcm" + "wwfc/logging" +) + +func (g *GameStatsSession) replyError(err gpcm.GPError) { + logging.Error(g.ModuleName, "Reply error:", err.ErrorString) + g.Conn.Write([]byte(err.GetMessage())) +} diff --git a/gamestats/main.go b/gamestats/main.go index 9215bf4..b5139c8 100644 --- a/gamestats/main.go +++ b/gamestats/main.go @@ -1,13 +1,35 @@ package gamestats import ( + "bufio" + "bytes" "context" "fmt" + "net" "wwfc/common" + "wwfc/database" + "wwfc/gpcm" + "wwfc/logging" "github.com/jackc/pgx/v4/pgxpool" + "github.com/logrusorgru/aurora/v3" ) +type GameStatsSession struct { + Conn net.Conn + ModuleName string + Challenge string + + SessionKey int32 + GameInfo *common.GameInfo + + Authenticated bool + LoginID int + User database.User + + WriteBuffer []byte +} + var ( ctx = context.Background() pool *pgxpool.Pool @@ -38,4 +60,148 @@ func StartServer() { if err != nil { panic(err) } + + address := *config.GameSpyAddress + ":29920" + + l, err := net.Listen("tcp", address) + if err != nil { + panic(err) + } + + // Close the listener when the application closes. + defer l.Close() + logging.Notice("GSTATS", "Listening on", address) + + for { + // Listen for an incoming connection. + conn, err := l.Accept() + if err != nil { + panic(err) + } + + // Handle connections in a new goroutine. + go handleRequest(conn) + } +} + +// Handles incoming requests. +func handleRequest(conn net.Conn) { + session := GameStatsSession{ + Conn: conn, + ModuleName: "GSTATS:" + conn.RemoteAddr().String(), + Challenge: common.RandomString(10), + + WriteBuffer: []byte{}, + } + + defer conn.Close() + + moduleName := "GSTATS" + + err := conn.(*net.TCPConn).SetKeepAlive(true) + if err != nil { + logging.Notice(moduleName, "Unable to set keepalive:", err.Error()) + } + + // Send challenge + session.Write(common.GameSpyCommand{ + Command: "lc", + CommandValue: "1", + OtherValues: map[string]string{ + "challenge": session.Challenge, + "id": "1", + }, + }) + conn.Write(session.WriteBuffer) + session.WriteBuffer = []byte{} + + // Here we go into the listening loop + for { + // TODO: Handle split packets + buffer := make([]byte, 1024) + n, err := bufio.NewReader(conn).Read(buffer) + if err != nil { + return + } + + // Decrypt the data + for i := 0; i < n; i++ { + if i+7 <= n && bytes.Equal(buffer[i:i+7], []byte(`\final\`)) { + i += 6 + continue + } + + buffer[i] ^= "GameSpy3D"[i%9] + } + + commands, err := common.ParseGameSpyMessage(string(buffer[:n])) + if err != nil { + logging.Error(moduleName, "Error parsing message:", err.Error()) + logging.Error(moduleName, "Raw data:", string(buffer[:n])) + session.replyError(gpcm.ErrParse) + return + } + + commands = session.handleCommand("ka", commands, func(command common.GameSpyCommand) { + session.Conn.Write([]byte(`\ka\\final\`)) + }) + + commands = session.handleCommand("auth", commands, session.auth) + commands = session.handleCommand("authp", commands, session.authp) + + if len(commands) != 0 && session.Authenticated == false { + logging.Error(session.ModuleName, "Attempt to run command before authentication:", aurora.Cyan(commands[0])) + session.replyError(gpcm.ErrNotLoggedIn) + return + } + + commands = session.handleCommand("setpd", commands, session.setpd) + + for _, command := range commands { + logging.Error(session.ModuleName, "Unknown command:", aurora.Cyan(command)) + } + + if len(session.WriteBuffer) > 0 { + conn.Write(session.WriteBuffer) + session.WriteBuffer = []byte{} + } + } +} + +func (g *GameStatsSession) handleCommand(name string, commands []common.GameSpyCommand, handler func(command common.GameSpyCommand)) []common.GameSpyCommand { + var unhandled []common.GameSpyCommand + + for _, command := range commands { + if command.Command != name { + unhandled = append(unhandled, command) + continue + } + + logging.Info(g.ModuleName, "Command:", aurora.Yellow(command.Command)) + handler(command) + } + + return unhandled +} + +func (g *GameStatsSession) ignoreCommand(name string, commands []common.GameSpyCommand) []common.GameSpyCommand { + var unhandled []common.GameSpyCommand + + for _, command := range commands { + if command.Command != name { + unhandled = append(unhandled, command) + } + } + + return unhandled +} + +func (g *GameStatsSession) Write(command common.GameSpyCommand) { + // Encrypt the data and append it to be sent + payload := []byte(common.CreateGameSpyMessage(command)) + // Exclude trailing \final\ + for i := 0; i < len(payload)-7; i++ { + payload[i] ^= "GameSpy3D"[i%9] + } + g.WriteBuffer = append(g.WriteBuffer, payload...) } diff --git a/gamestats/setpd.go b/gamestats/setpd.go new file mode 100644 index 0000000..9c1fa8f --- /dev/null +++ b/gamestats/setpd.go @@ -0,0 +1,21 @@ +package gamestats + +import ( + "strconv" + "time" + "wwfc/common" +) + +func (g *GameStatsSession) setpd(command common.GameSpyCommand) { + g.Write(common.GameSpyCommand{ + Command: "setpdr", + CommandValue: "1", + OtherValues: map[string]string{ + "lid": strconv.Itoa(g.LoginID), + "pid": command.OtherValues["pid"], + "mod": strconv.Itoa(int(time.Now().Unix())), + "length": "0", + "data": `\\`, + }, + }) +} diff --git a/gpcm/error.go b/gpcm/error.go index 80dda5d..b383311 100644 --- a/gpcm/error.go +++ b/gpcm/error.go @@ -45,6 +45,8 @@ func MakeGPError(errorCode int, errorString string, fatal bool) GPError { } var ( + ErrNone = MakeGPError(0xFFFF, "No error.", false) + // General errors ErrGeneral = MakeGPError(0x0000, "There was an unknown error.", true) ErrParse = MakeGPError(0x0001, "There was an error parsing an incoming request.", true) diff --git a/gpcm/main.go b/gpcm/main.go index 9ac2c81..6d61324 100644 --- a/gpcm/main.go +++ b/gpcm/main.go @@ -166,7 +166,7 @@ func handleRequest(conn net.Conn) { for { // TODO: Handle split packets buffer := make([]byte, 1024) - _, err := bufio.NewReader(conn).Read(buffer) + n, err := bufio.NewReader(conn).Read(buffer) if err != nil { if errors.Is(err, io.EOF) { // Client closed connection, terminate. @@ -178,10 +178,10 @@ func handleRequest(conn net.Conn) { return } - commands, err := common.ParseGameSpyMessage(string(buffer)) + commands, err := common.ParseGameSpyMessage(string(buffer[:n])) if err != nil { logging.Error(session.ModuleName, "Error parsing message:", err.Error()) - logging.Error(session.ModuleName, "Raw data:", string(buffer)) + logging.Error(session.ModuleName, "Raw data:", string(buffer[:n])) session.replyError(ErrParse) return } @@ -196,7 +196,7 @@ func handleRequest(conn net.Conn) { commands = session.ignoreCommand("logout", commands) if len(commands) != 0 && session.LoggedIn == false { - logging.Error(session.ModuleName, "Attempt to run command before login!") + logging.Error(session.ModuleName, "Attempt to run command before login:", aurora.Cyan(commands[0])) session.replyError(ErrNotLoggedIn) return } @@ -211,7 +211,7 @@ func handleRequest(conn net.Conn) { commands = session.handleCommand("getprofile", commands, session.getProfile) for _, command := range commands { - logging.Error(session.ModuleName, "Unknown command:", aurora.Cyan(command.Command)) + logging.Error(session.ModuleName, "Unknown command:", aurora.Cyan(command)) } if session.WriteBuffer != "" {