From 10765a176462a00e7573871746525601702d0338 Mon Sep 17 00:00:00 2001 From: Palapeli <26661008+mkwcat@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:49:05 -0400 Subject: [PATCH 1/3] NAS: Refactor auth requests and auth token --- common/auth_token.go | 187 +++++++++-------- common/encoding.go | 33 +++ gamestats/auth.go | 13 +- gpcm/login.go | 59 +++--- nas/account.go | 244 ++++++++++++++++++++++ nas/auth.go | 469 ++++--------------------------------------- nas/dlc.go | 68 +++++++ nas/main.go | 23 ++- nas/profanity.go | 104 ++++++++++ sake/storage.go | 11 +- 10 files changed, 634 insertions(+), 577 deletions(-) create mode 100644 nas/account.go create mode 100644 nas/dlc.go diff --git a/common/auth_token.go b/common/auth_token.go index 85a0c42..7500430 100644 --- a/common/auth_token.go +++ b/common/auth_token.go @@ -5,6 +5,7 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "encoding/base64" "encoding/binary" "errors" "strings" @@ -29,143 +30,141 @@ func generateRandom(n int) []byte { var ( authTokenKey = generateRandom(16) authTokenIV = generateRandom(16) - authTokenMagic = generateRandom(14) + authTokenMagic = generateRandom(8) loginTicketKey = generateRandom(16) loginTicketIV = generateRandom(16) loginTicketMagic = generateRandom(4) ) -func appendString(blob []byte, value string, maxlen int) []byte { - if len([]byte(value)) < maxlen { - blob = append(blob, append([]byte(value), make([]byte, maxlen-len(value))...)...) - } else { - blob = append(blob, []byte(value)[:maxlen]...) - } +var ( + ErrTokenMagic = errors.New("invalid auth token or login ticket magic") + ErrTokenExpired = errors.New("auth token or login ticket expired") + ErrTokenLength = errors.New("invalid auth token or login ticket length") +) - return blob +type NASAuthToken struct { + IssueTime uint64 + UserID uint64 + ConsoleFriendCode uint64 + Region byte + Lang byte + UnitCode byte + GameCode [4]byte + GsbrCode [16]byte + Challenge [8]byte + InGameScreenName [64]byte + Magic [8]byte } -func MarshalNASAuthToken(gamecd string, userid uint64, gsbrcd string, cfc uint64, region byte, lang byte, ingamesn string, unitcd byte, isLocalhost bool) (string, string) { - blob := binary.LittleEndian.AppendUint64([]byte{}, uint64(time.Now().UTC().Unix())) +var nasAuthTokenSize = (binary.Size(NASAuthToken{}) + aes.BlockSize - 1) & ^(aes.BlockSize - 1) - blob = appendString(blob, gamecd, 4) +func (t NASAuthToken) Marshal() string { + t.IssueTime = uint64(time.Now().UTC().Unix()) + copy(t.Magic[:], authTokenMagic) - blob = append(blob, binary.LittleEndian.AppendUint64([]byte{}, userid)[:6]...) + var buf bytes.Buffer + ShouldNotError(binary.Write(&buf, binary.LittleEndian, t)) - blob = append(blob, byte(min(len([]byte(gsbrcd)), 16))) - blob = appendString(blob, gsbrcd, 16) - - blob = append(blob, binary.LittleEndian.AppendUint64([]byte{}, cfc)[:7]...) - blob = append(blob, region, lang) - - blob = append(blob, byte(min(len([]byte(ingamesn)), 75))) - blob = appendString(blob, ingamesn, 75) - - challenge := RandomString(8) - blob = append(blob, []byte(challenge)...) - - blob = append(blob, byte(unitcd)) - - if isLocalhost { - blob = append(blob, 0x01) - } else { - blob = append(blob, 0x00) - } - - blob = append(blob, authTokenMagic...) + // Pad to CBC block size + data := append(buf.Bytes(), make([]byte, nasAuthTokenSize-len(buf.Bytes()))...) block, err := aes.NewCipher(authTokenKey) - if err != nil { - panic(err) - } + ShouldNotError(err) + cipher.NewCBCEncrypter(block, authTokenIV).CryptBlocks(data, data) - cipher.NewCBCEncrypter(block, authTokenIV).CryptBlocks(blob, blob) - return "NDS" + Base64DwcEncoding.EncodeToString(blob), challenge + return "NDS" + Base64DwcEncoding.EncodeToString(data) } -func UnmarshalNASAuthToken(token string) (gamecd string, issuetime time.Time, userid uint64, gsbrcd string, cfc uint64, region byte, lang byte, ingamesn string, challenge string, unitcd byte, isLocalhost bool, err error) { - if !strings.HasPrefix(token, "NDS") { - err = errors.New("invalid auth token prefix") - return +func (t *NASAuthToken) Unmarshal(data string) error { + if !strings.HasPrefix(data, "NDS") { + return ErrTokenLength } - blob, err := Base64DwcEncoding.DecodeString(token[3:]) + blob, err := Base64DwcEncoding.DecodeString(data[3:]) if err != nil { - return + return err } - if len(blob) != 0x90 { - err = errors.New("invalid auth token length") - return + if len(blob) != nasAuthTokenSize { + return ErrTokenLength } block, err := aes.NewCipher(authTokenKey) - if err != nil { - panic(err) - } - + ShouldNotError(err) cipher.NewCBCDecrypter(block, authTokenIV).CryptBlocks(blob, blob) - if !bytes.Equal(blob[0x90-len(authTokenMagic):0x90], authTokenMagic) { - err = errors.New("invalid auth token magic") - return + reader := bytes.NewReader(blob) + if err := binary.Read(reader, binary.LittleEndian, t); err != nil { + return err } - issuetime = time.Unix(int64(binary.LittleEndian.Uint64(blob[0x0:0x8])), 0) - gamecd = string(blob[0x8:0xC]) - userid = binary.LittleEndian.Uint64(append(bytes.Clone(blob[0xC:0x12]), 0, 0)) - gsbrcd = string(blob[0x13 : 0x13+min(blob[0x12], 16)]) - cfc = binary.LittleEndian.Uint64(append(bytes.Clone(blob[0x23:0x2A]), 0)) - region = blob[0x2A] - lang = blob[0x2B] - ingamesn = string(blob[0x2D : 0x2D+min(blob[0x2C], 75)]) - challenge = string(blob[0x78:0x80]) - unitcd = blob[0x80] - isLocalhost = blob[0x81] == 0x01 - return + if !bytes.Equal(t.Magic[:], authTokenMagic) { + return ErrTokenMagic + } + + currentTime := time.Now().UTC() + issueTime := time.Unix(int64(t.IssueTime), 0) + if issueTime.Before(currentTime.Add(-10*time.Minute)) || issueTime.After(currentTime) { + return ErrTokenExpired + } + + return nil } -func MarshalGPCMLoginTicket(profileId uint32) string { - blob := binary.LittleEndian.AppendUint64([]byte{}, uint64(time.Now().UTC().Unix())) - blob = binary.LittleEndian.AppendUint32(blob, profileId) - blob = append(blob, loginTicketMagic...) +type GPCMLoginTicket struct { + IssueTime uint64 + ProfileID uint32 + Magic [4]byte +} + +var gpcmLoginTicketSize = (binary.Size(GPCMLoginTicket{}) + aes.BlockSize - 1) & ^(aes.BlockSize - 1) + +func (t GPCMLoginTicket) Marshal() string { + t.IssueTime = uint64(time.Now().UTC().Unix()) + copy(t.Magic[:], loginTicketMagic) + + var buf bytes.Buffer + ShouldNotError(binary.Write(&buf, binary.LittleEndian, t)) + + // Pad to CBC block size + data := append(buf.Bytes(), make([]byte, gpcmLoginTicketSize-len(buf.Bytes()))...) block, err := aes.NewCipher(loginTicketKey) - if err != nil { - panic(err) - } + ShouldNotError(err) + cipher.NewCBCEncrypter(block, loginTicketIV).CryptBlocks(data, data) - cipher.NewCBCEncrypter(block, loginTicketIV).CryptBlocks(blob, blob) - return Base64DwcEncoding.EncodeToString(blob) + return base64.StdEncoding.EncodeToString(data) } -func UnmarshalGPCMLoginTicket(ticket string) (profileId uint32, issuetime time.Time, err error) { - var blob []byte - blob, err = Base64DwcEncoding.DecodeString(ticket) +func (t *GPCMLoginTicket) Unmarshal(ticket string) error { + blob, err := base64.StdEncoding.DecodeString(ticket) if err != nil { - return + return err } - if len(blob) != 0x10 { - err = errors.New("invalid login ticket length") - return - } - - var block cipher.Block - block, err = aes.NewCipher(loginTicketKey) - if err != nil { - panic(err) + if len(blob) != gpcmLoginTicketSize { + return ErrTokenLength } + block, err := aes.NewCipher(loginTicketKey) + ShouldNotError(err) cipher.NewCBCDecrypter(block, loginTicketIV).CryptBlocks(blob, blob) - if !bytes.Equal(blob[0xC:0x10], loginTicketMagic) { - err = errors.New("invalid login ticket magic") - return + reader := bytes.NewReader(blob) + if err := binary.Read(reader, binary.LittleEndian, t); err != nil { + return err } - issuetime = time.Unix(int64(binary.LittleEndian.Uint64(blob[0x0:0x8])), 0) - profileId = binary.LittleEndian.Uint32(blob[0x8:0xC]) - return + if !bytes.Equal(t.Magic[:], loginTicketMagic) { + return ErrTokenMagic + } + + currentTime := time.Now().UTC() + issueTime := time.Unix(int64(t.IssueTime), 0) + if issueTime.Before(currentTime.Add(-48*time.Hour)) || issueTime.After(currentTime) { + return ErrTokenExpired + } + + return nil } diff --git a/common/encoding.go b/common/encoding.go index f4b8a7d..a4da6c6 100644 --- a/common/encoding.go +++ b/common/encoding.go @@ -2,6 +2,8 @@ package common import ( "encoding/base64" + "encoding/binary" + "unicode/utf16" ) var ( @@ -40,3 +42,34 @@ func reverse(s string) string { return string(rns) } + +func UTF16Encode(s string, order binary.ByteOrder) []byte { + encoded := utf16.Encode([]rune(s)) + buf := make([]byte, len(encoded)*2) + for i, v := range encoded { + order.PutUint16(buf[i*2:], v) + } + return buf +} + +func UTF16Decode(u []byte, order binary.ByteOrder) string { + decoded := make([]uint16, len(u)/2) + for i := range decoded { + v := order.Uint16(u[i*2:]) + if v == 0 { + decoded = decoded[:i] + break + } + decoded[i] = v + } + return string(utf16.Decode(decoded)) +} + +func NullTerminatedString(u []byte) string { + for i, b := range u { + if b == 0 { + return string(u[:i]) + } + } + return string(u) +} diff --git a/gamestats/auth.go b/gamestats/auth.go index 85bbcca..598700c 100644 --- a/gamestats/auth.go +++ b/gamestats/auth.go @@ -3,7 +3,6 @@ package gamestats import ( "math/rand" "strconv" - "time" "wwfc/common" "wwfc/gpcm" "wwfc/logging" @@ -62,21 +61,15 @@ func (g *GameStatsSession) authp(command common.GameSpyCommand) { return } - _, issueTime, userId, gsbrcd, _, _, _, _, _, _, _, err := common.UnmarshalNASAuthToken(authToken) + authTokenObj := common.NASAuthToken{} + err := authTokenObj.Unmarshal(authToken) if err != nil { logging.Error(g.ModuleName, "Error unmarshalling authtoken:", err.Error()) g.Write(errorCmd) return } - currentTime := time.Now().UTC() - 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 = db.LoginUserToGameStats(userId, gsbrcd) + g.User, err = db.LoginUserToGameStats(authTokenObj.UserID, common.NullTerminatedString(authTokenObj.GsbrCode[:])) if err != nil { logging.Error(g.ModuleName, "Error logging in user:", err.Error()) g.Write(errorCmd) diff --git a/gpcm/login.go b/gpcm/login.go index d3f5bce..3b19481 100644 --- a/gpcm/login.go +++ b/gpcm/login.go @@ -189,26 +189,32 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { return } - gamecd, issueTime, userId, gsbrcd, cfc, region, lang, ingamesn, challenge, unitcd, isLocalhost, err := common.UnmarshalNASAuthToken(authToken) + authTokenObj := common.NASAuthToken{} + err := authTokenObj.Unmarshal(authToken) if err != nil { + logging.Error(g.ModuleName, "Failed to unmarshal auth token:", err) + if err == common.ErrTokenExpired { + g.replyError(ErrLoginLoginTicketExpired) + return + } g.replyError(ErrLogin) return } - currentTime := time.Now().UTC() - if issueTime.Before(currentTime.Add(-10*time.Minute)) || issueTime.After(currentTime) { - g.replyError(ErrLoginLoginTicketExpired) - return - } - g.GameName = command.OtherValues["gamename"] logging.Info(g.ModuleName, "Game name:", aurora.Cyan(g.GameName)) - g.GameCode = gamecd - g.Region = region - g.Language = lang - g.ConsoleFriendCode = cfc - g.InGameName = ingamesn - g.UnitCode = unitcd + g.GameCode = common.NullTerminatedString(authTokenObj.GameCode[:]) + g.Region = authTokenObj.Region + g.Language = authTokenObj.Lang + g.ConsoleFriendCode = authTokenObj.ConsoleFriendCode + g.UnitCode = authTokenObj.UnitCode + + var endianness binary.ByteOrder = binary.LittleEndian + if g.UnitCode == UnitCodeWii { + endianness = binary.BigEndian + } + + g.InGameName = common.UTF16Decode(authTokenObj.InGameScreenName[:], endianness) _, payloadVerExists := command.OtherValues["wl:ver"] _, signatureExists := command.OtherValues["wl:sig"] @@ -229,18 +235,18 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { logging.Event( "received_login_info", map[string]any{ - "user_id": userId, + "user_id": authTokenObj.UserID, "game_name": g.GameName, - "wii_number": cfc, - "in_game_name": ingamesn, - "unit_code": unitcd, + "wii_number": g.ConsoleFriendCode, + "in_game_name": g.InGameName, + "unit_code": g.UnitCode, "ip_address": g.RemoteAddr, }, ) expectedUnitCode := common.GetExpectedUnitCode(g.GameName) if (g.UnitCode != UnitCodeDS && g.UnitCode != UnitCodeWii) || (g.UnitCode != expectedUnitCode && expectedUnitCode != UnitCodeDSAndWii) { - logging.Error(g.ModuleName, "Incorrect unit code specified:", aurora.Cyan(unitcd)) + logging.Error(g.ModuleName, "Incorrect unit code specified:", aurora.Cyan(g.UnitCode)) g.replyError(ErrLogin) return } @@ -253,7 +259,7 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { deviceAuth = true case UnitCodeWii: - if isLocalhost && !payloadVerExists && !signatureExists { + if !payloadVerExists && !signatureExists { // Players using the DNS, need patching using a QR2 exploit if !common.DoesGameNeedExploit(g.GameName) { logging.Error(g.ModuleName, "Using DNS for incompatible game:", aurora.Cyan(g.GameName)) @@ -276,18 +282,20 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { } default: - logging.Error(g.ModuleName, "Invalid unit code specified:", aurora.Cyan(unitcd)) + logging.Error(g.ModuleName, "Invalid unit code specified:", aurora.Cyan(g.UnitCode)) g.replyError(ErrLogin) return } - response := generateResponse(g.Challenge, challenge, authToken, command.OtherValues["challenge"]) + nasChallenge := common.NullTerminatedString(authTokenObj.Challenge[:]) + + response := generateResponse(g.Challenge, nasChallenge, authToken, command.OtherValues["challenge"]) if response != command.OtherValues["response"] { g.replyError(ErrLogin) return } - proof := generateProof(g.Challenge, challenge, command.OtherValues["authtoken"], command.OtherValues["challenge"]) + proof := generateProof(g.Challenge, nasChallenge, command.OtherValues["authtoken"], command.OtherValues["challenge"]) cmdProfileId := uint32(0) if cmdProfileIdStr, exists := command.OtherValues["profileid"]; exists { @@ -305,7 +313,7 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { cmdProfileId = uint32(cmdProfileId2) } - if !g.performLoginWithDatabase(userId, gsbrcd, cmdProfileId, defaultKey, deviceId, deviceAuth) { + if !g.performLoginWithDatabase(authTokenObj.UserID, common.NullTerminatedString(authTokenObj.GsbrCode[:]), cmdProfileId, defaultKey, deviceId, deviceAuth) { return } @@ -353,16 +361,17 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { mutex.Unlock() g.AuthToken = authToken - g.LoginTicket = common.MarshalGPCMLoginTicket(g.User.ProfileId) + g.LoginTicket = common.GPCMLoginTicket{ProfileID: g.User.ProfileId}.Marshal() g.SessionKey = rand.Int31n(290000000) + 10000000 g.DeviceAuthenticated = deviceAuth g.LoggedIn = true + g.ModuleName = "GPCM:" + strconv.FormatInt(int64(g.User.ProfileId), 10) g.ModuleName += "/" + common.CalcFriendCodeString(g.User.ProfileId, g.User.GsbrCode[:4]) // Notify QR2 of the login - qr2.Login(g.User.ProfileId, gamecd, ingamesn, cfc, g.User.GsbrCode[:4], g.RemoteAddr, g.NeedsExploit, g.DeviceAuthenticated, g.User.Restricted) + qr2.Login(g.User.ProfileId, g.GameCode, g.InGameName, g.ConsoleFriendCode, g.User.GsbrCode[:4], g.RemoteAddr, g.NeedsExploit, g.DeviceAuthenticated, g.User.Restricted) replyUserId := g.User.UserId if g.UnitCode == UnitCodeDS { diff --git a/nas/account.go b/nas/account.go new file mode 100644 index 0000000..62d02e8 --- /dev/null +++ b/nas/account.go @@ -0,0 +1,244 @@ +package nas + +import ( + "encoding/binary" + "encoding/hex" + "net/http" + "strconv" + "strings" + "wwfc/common" + "wwfc/database" + "wwfc/logging" + + "github.com/logrusorgru/aurora/v3" +) + +var accountActions = map[string]func(moduleName string, fields map[string][]byte) map[string]string{ + "acctcreate": acctcreate, + "login": login, + "svcloc": svcloc, +} + +func handleAuthAccountEndpoint(w http.ResponseWriter, r *http.Request) { + moduleName := getModuleName(r) + + fields, err := parseAuthRequest(r) + if err != nil { + replyHTTPError(w, 400, "400 Bad Request") + return + } + + action := string(fields["action"]) + if action == "" { + logging.Error(moduleName, "No action in form") + replyHTTPError(w, 400, "400 Bad Request") + return + } + + if actionFunc, exists := accountActions[action]; exists { + reply := actionFunc(moduleName, fields) + writeAuthResponse(w, reply) + return + } + + logging.Error(moduleName, "Unknown action:", aurora.Cyan(action)) + replyHTTPError(w, 400, "400 Bad Request") +} + +func acctcreate(moduleName string, fields map[string][]byte) map[string]string { + return map[string]string{ + "retry": "0", + "datetime": getDateTime(), + "returncd": "002", + "userid": strconv.FormatUint(database.GetUniqueUserID(), 10), + } +} + +func login(moduleName string, fields map[string][]byte) map[string]string { + param := map[string]string{ + "retry": "0", + "datetime": getDateTime(), + "locator": "gamespy.com", + } + + token := common.NASAuthToken{} + + gamecd, ok := fields["gamecd"] + if !ok { + logging.Error(moduleName, "No gamecd in form") + param["returncd"] = "103" + return param + } + copy(token.GameCode[:], gamecd) + + strUserId, ok := fields["userid"] + if !ok { + logging.Error(moduleName, "No userid in form") + param["returncd"] = "103" + return param + } + + var err error + token.UserID, err = strconv.ParseUint(string(strUserId), 10, 64) + if err != nil || token.UserID >= 0x80000000000 { + logging.Error(moduleName, "Invalid userid string in form") + param["returncd"] = "103" + return param + } + + gsbrcd, ok := fields["gsbrcd"] + if !ok { + logging.Error(moduleName, "No gsbrcd in form") + param["returncd"] = "103" + return param + } + + if (len(gsbrcd) < 4 && len(gsbrcd) != 0) || strings.ContainsRune(string(gsbrcd), 0) { + logging.Error(moduleName, "Invalid gsbrcd string in form") + param["returncd"] = "103" + return param + } + + // Some games like Fortune Street make login requests without a gsbr code, so we temporarily fake one + if len(gsbrcd) == 0 { + if len(gamecd) < 4 { + logging.Error(moduleName, "Invalid gamecd string in form") + param["returncd"] = "103" + return param + } + + gsbrcd = append(gamecd[:3], 'J') + } + + copy(token.GsbrCode[:], gsbrcd) + + lang, ok := fields["lang"] + if !ok { + lang = []byte("ff") + } + + langByte, err := hex.DecodeString(string(lang)) + if err != nil || len(langByte) != 1 { + logging.Error(moduleName, "Invalid lang byte in form") + param["returncd"] = "103" + return param + } + token.Lang = langByte[0] + + unitcd, ok := fields["unitcd"] + if !ok { + logging.Error(moduleName, "No unitcd in form") + param["returncd"] = "103" + return param + } + + isWii := len(unitcd) > 1 || unitcd[0] != '0' + var endianness binary.ByteOrder + switch isWii { + case false: + token.UnitCode = 0 + endianness = binary.LittleEndian + case true: + token.UnitCode = 1 + endianness = binary.BigEndian + } + + hasProfaneName := false + ingamesn, hasIngamesn := fields["ingamesn"] + ingamesnStr := "" + if hasIngamesn { + ingamesnStr = common.UTF16Decode(ingamesn, endianness) + if hasProfaneName, _ = IsBadWord(ingamesnStr); hasProfaneName { + logging.Info(moduleName, "Provided in-game screen name has a profane word:", aurora.Red(ingamesnStr).String()) + // Continue with different return code + } + } + + switch isWii { + case false: + devname, ok := fields["devname"] + if !ok { + logging.Error(moduleName, "No devname in form") + param["returncd"] = "103" + return param + } + + // Only later DS games send ingamesn + if !hasIngamesn { + ingamesn = devname + } + logging.Notice(moduleName, "Login (DS)", aurora.Cyan(token.UserID), aurora.Cyan(gsbrcd), "devname:", aurora.Cyan(devname), "name:", aurora.Cyan(ingamesnStr)) + + case true: + cfc, ok := fields["cfc"] + if !ok { + logging.Error(moduleName, "No cfc in form") + param["returncd"] = "103" + return param + } + + token.ConsoleFriendCode, err = strconv.ParseUint(string(cfc), 10, 64) + if err != nil || token.ConsoleFriendCode > 9999999999999999 { + logging.Error(moduleName, "Invalid cfc string in form") + param["returncd"] = "103" + return param + } + + region, ok := fields["region"] + if !ok { + region = []byte("ff") + } + + regionByte, err := hex.DecodeString(string(region)) + if err != nil || len(regionByte) != 1 { + logging.Error(moduleName, "Invalid region byte in form") + param["returncd"] = "103" + return param + } + token.Region = regionByte[0] + + logging.Notice(moduleName, "Login (Wii)", aurora.Cyan(token.UserID), aurora.Cyan(string(gsbrcd)), "name:", aurora.Cyan(ingamesnStr)) + } + + challenge := common.RandomString(8) + copy(token.Challenge[:], []byte(challenge)) + copy(token.InGameScreenName[:], ingamesn) + + if hasProfaneName { + param["returncd"] = "040" + } else { + param["returncd"] = "001" + } + + param["challenge"] = challenge + param["token"] = token.Marshal() + + return param +} + +func svcloc(moduleName string, fields map[string][]byte) map[string]string { + param := map[string]string{ + "retry": "0", + "datetime": getDateTime(), + "returncd": "007", + "statusdata": "Y", + } + + authToken := "NDS/SVCLOC/TOKEN" + + switch string(fields["svc"]) { + default: + param["servicetoken"] = authToken + param["svchost"] = "n/a" + + case "9000": + param["token"] = authToken + param["svchost"] = "dls1.nintendowifi.net" + + case "9001": + param["servicetoken"] = authToken + param["svchost"] = "dls1.nintendowifi.net" + } + + return param +} diff --git a/nas/auth.go b/nas/auth.go index 39439ef..83fe6dd 100644 --- a/nas/auth.go +++ b/nas/auth.go @@ -2,18 +2,14 @@ package nas import ( "encoding/binary" - "encoding/hex" + "errors" "fmt" "net/http" "net/url" - "os" - "path/filepath" "strconv" "strings" "time" - "unicode/utf16" "wwfc/common" - "wwfc/database" "wwfc/logging" "github.com/logrusorgru/aurora/v3" @@ -23,470 +19,83 @@ var ( dlcDir = "./dlc" ) -func handleAuthRequest(w http.ResponseWriter, r *http.Request) { +func parseAuthRequest(r *http.Request) (map[string][]byte, error) { moduleName := getModuleName(r) err := r.ParseForm() if err != nil { logging.Error(moduleName, "Failed to parse form") - replyHTTPError(w, 400, "400 Bad Request") - return + return nil, errors.New("failed to parse form") } // Need to know this here to determine UTF-16 endianness (LE for DS, BE for Wii) // unitcd 0 = DS, 1 = Wii - unitcd := "1" + isWii := false if unitcdValues, ok := r.PostForm["unitcd"]; ok { unitcdDecoded, err := common.Base64DwcEncoding.DecodeString(unitcdValues[0]) if err != nil { logging.Error(moduleName, "Invalid unitcd string in form") - replyHTTPError(w, 400, "400 Bad Request") - return + return nil, errors.New("invalid unitcd string in form") } - unitcd = string(unitcdDecoded) + isWii = len(unitcdDecoded) != 1 || unitcdDecoded[0] != '0' } - fields := map[string]string{} + var endianness binary.ByteOrder = binary.LittleEndian + if isWii { + endianness = binary.BigEndian + } + + fields := map[string][]byte{} for key, values := range r.PostForm { if len(values) != 1 { logging.Warn(moduleName, "Ignoring none or multiple POST form values:", aurora.Cyan(key).String()+":", aurora.Cyan(values)) continue } - var value string - if !strings.HasPrefix(key, "_") { - parsed, err := common.Base64DwcEncoding.DecodeString(values[0]) - if err != nil { - logging.Error(moduleName, "Invalid POST form value:", aurora.Cyan(key).String()+":", aurora.Cyan(values[0])) - replyHTTPError(w, 400, "400 Bad Request") - return - } - - if key == "ingamesn" || key == "devname" || key == "words" { - // Special handling required for the UTF-16 string - var utf16String []uint16 - if unitcd == "0" { - for i := 0; i < len(parsed)/2; i++ { - utf16String = append(utf16String, binary.LittleEndian.Uint16(parsed[i*2:i*2+2])) - } - } else { - for i := 0; i < len(parsed)/2; i++ { - utf16String = append(utf16String, binary.BigEndian.Uint16(parsed[i*2:i*2+2])) - } - } - value = string(utf16.Decode(utf16String)) - } else { - value = string(parsed) - } - } else { - // Values unique to CTGP/the Wiimmfi payload, for compatibility reasons. Some of these are not base64 encoded. - value = values[0] + if strings.HasPrefix(key, "_") { + // Values unique to CTGP/the Wiimmfi payload. Ignored for compatibility reasons. + continue } - logging.Info(moduleName, aurora.Cyan(key).String()+":", aurora.Cyan(value)) - fields[key] = value + parsed, err := common.Base64DwcEncoding.DecodeString(values[0]) + if err != nil { + logging.Error(moduleName, "Invalid POST form value:", aurora.Cyan(key).String()+":", aurora.Cyan(values[0])) + return nil, errors.New("invalid POST form value: " + key) + } + + fields[key] = parsed + + reported := string(parsed) + if key == "ingamesn" || key == "devname" || key == "words" { + // Special handling required for reporting the UTF-16 strings + reported = common.UTF16Decode(parsed, endianness) + } + logging.Info(moduleName, aurora.Cyan(key).String()+":", aurora.Cyan(reported)) } - reply := map[string]string{} + return fields, nil +} + +func writeAuthResponse(w http.ResponseWriter, reply map[string]string) { var response []byte - - switch r.URL.String() { - case "/ac": - action, ok := fields["action"] - if !ok || action == "" { - logging.Error(moduleName, "No action in form") - replyHTTPError(w, 400, "400 Bad Request") - return - } - - switch strings.ToLower(action) { - case "acctcreate": - reply = acctcreate() - - case "login": - isLocalhost := strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") || strings.HasPrefix(r.RemoteAddr, "[::1]:") - reply = login(moduleName, fields, isLocalhost) - - case "svcloc": - reply = svcloc(fields) - - default: - logging.Error(moduleName, "Unknown action:", aurora.Cyan(action)) - reply = map[string]string{ - "retry": "0", - "returncd": "109", - } - } - - case "/pr": - words, ok := fields["words"] - if words == "" || !ok { - logging.Error(moduleName, "No words in form") - replyHTTPError(w, 400, "400 Bad Request") - return - } - - reply = handleProfanity(r.PostForm, unitcd) - - case "/download": - action, ok := fields["action"] - if !ok || action == "" { - logging.Error(moduleName, "No action in form") - replyHTTPError(w, 400, "400 Bad Request") - return - } - - rhgamecd, ok := fields["rhgamecd"] - if !ok || !isValidRhgamecd(rhgamecd) { - logging.Error(moduleName, "Missing or invalid rhgamecd") - replyHTTPError(w, 400, "400 Bad Request") - return - } - - switch strings.ToLower(action) { - case "count": - response = []byte(dlsCount(fields)) - - default: - logging.Error(moduleName, "Unknown action:", aurora.Cyan(action)) - reply = map[string]string{ - "retry": "0", - "returncd": "109", - } - } - - w.Header().Set("X-DLS-Host", "http://127.0.0.1/") - } - - if len(response) == 0 { - param := url.Values{} - for key, value := range reply { - param.Set(key, common.Base64DwcEncoding.EncodeToString([]byte(value))) - } - response = []byte(param.Encode()) - response = []byte(strings.ReplaceAll(string(response), "%2A", "*")) + param := url.Values{} + for key, value := range reply { + param.Set(key, common.Base64DwcEncoding.EncodeToString([]byte(value))) } + response = []byte(param.Encode()) + response = []byte(strings.ReplaceAll(string(response), "%2A", "*")) // DWC treats the response like a null terminated string response = append(response, 0x00) w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Length", strconv.Itoa(len(response))) - _, err = w.Write(response) + _, err := w.Write(response) if err != nil { logging.Error("NAS", "Error writing response:", err) } } -func acctcreate() map[string]string { - return map[string]string{ - "retry": "0", - "datetime": getDateTime(), - "returncd": "002", - "userid": strconv.FormatUint(database.GetUniqueUserID(), 10), - } -} - -func login(moduleName string, fields map[string]string, isLocalhost bool) map[string]string { - param := map[string]string{ - "retry": "0", - "datetime": getDateTime(), - "locator": "gamespy.com", - } - - gamecd, ok := fields["gamecd"] - if !ok { - logging.Error(moduleName, "No gamecd in form") - param["returncd"] = "103" - return param - } - - strUserId, ok := fields["userid"] - if !ok { - logging.Error(moduleName, "No userid in form") - param["returncd"] = "103" - return param - } - - userId, err := strconv.ParseUint(strUserId, 10, 64) - if err != nil || userId >= 0x80000000000 { - logging.Error(moduleName, "Invalid userid string in form") - param["returncd"] = "103" - return param - } - - gsbrcd, ok := fields["gsbrcd"] - if !ok { - logging.Error(moduleName, "No gsbrcd in form") - param["returncd"] = "103" - return param - } - - if (len(gsbrcd) < 4 && len(gsbrcd) != 0) || strings.ContainsRune(gsbrcd, 0) { - logging.Error(moduleName, "Invalid gsbrcd string in form") - param["returncd"] = "103" - return param - } - - // Some games like Fortune Street make login requests without a gsbr code, so we temporarily fake one - if len(gsbrcd) == 0 { - if len(gamecd) < 4 { - logging.Error(moduleName, "Invalid gamecd string in form") - param["returncd"] = "103" - return param - } - - gsbrcd = gamecd[:3] + "J" - } - - lang, ok := fields["lang"] - if !ok { - lang = "ff" - } - - langByte, err := hex.DecodeString(lang) - if err != nil || len(langByte) != 1 { - logging.Error(moduleName, "Invalid lang byte in form") - param["returncd"] = "103" - return param - } - - unitcd, ok := fields["unitcd"] - if !ok { - logging.Error(moduleName, "No unitcd in form") - param["returncd"] = "103" - return param - } - - unitcdInt, err := strconv.ParseUint(unitcd, 10, 64) - if err != nil || unitcdInt > 1 { - logging.Error(moduleName, "Invalid unitcd string in form") - param["returncd"] = "103" - return param - } - - hasProfaneName := false - ingamesn, ok := fields["ingamesn"] - if ok { - if hasProfaneName, _ = IsBadWord(ingamesn); hasProfaneName { - logging.Info(moduleName, aurora.Cyan(strconv.FormatUint(userId, 10)), "has a profane name ("+aurora.Red(ingamesn).String()+")") - } - } - - var authToken, challenge string - switch unitcdInt { - // ds - case 0: - devname, ok := fields["devname"] - if !ok { - logging.Error(moduleName, "No devname in form") - param["returncd"] = "103" - return param - } - - // Only later DS games send this - ingamesn, ok := fields["ingamesn"] - if ok { - authToken, challenge = common.MarshalNASAuthToken(gamecd, userId, gsbrcd, 0, 0, langByte[0], ingamesn, 0, isLocalhost) - logging.Notice(moduleName, "Login (DS)", aurora.Cyan(strconv.FormatUint(userId, 10)), aurora.Cyan(gsbrcd), "devname:", aurora.Cyan(devname), "ingamesn:", aurora.Cyan(ingamesn)) - } else { - authToken, challenge = common.MarshalNASAuthToken(gamecd, userId, gsbrcd, 0, 0, langByte[0], "", 0, isLocalhost) - logging.Notice(moduleName, "Login (DS)", aurora.Cyan(strconv.FormatUint(userId, 10)), aurora.Cyan(gsbrcd), "devname:", aurora.Cyan(devname)) - } - - // wii - case 1: - cfc, ok := fields["cfc"] - if !ok { - logging.Error(moduleName, "No cfc in form") - param["returncd"] = "103" - return param - } - - cfcInt, err := strconv.ParseUint(cfc, 10, 64) - if err != nil || cfcInt > 9999999999999999 { - logging.Error(moduleName, "Invalid cfc string in form") - param["returncd"] = "103" - return param - } - - region, ok := fields["region"] - if !ok { - region = "ff" - } - - regionByte, err := hex.DecodeString(region) - if err != nil || len(regionByte) != 1 { - logging.Error(moduleName, "Invalid region byte in form") - param["returncd"] = "103" - return param - } - - authToken, challenge = common.MarshalNASAuthToken(gamecd, userId, gsbrcd, cfcInt, regionByte[0], langByte[0], fields["ingamesn"], 1, isLocalhost) - logging.Notice(moduleName, "Login (Wii)", aurora.Cyan(strconv.FormatUint(userId, 10)), aurora.Cyan(gsbrcd), "ingamesn:", aurora.Cyan(fields["ingamesn"])) - } - - if hasProfaneName { - param["returncd"] = "040" - } else { - param["returncd"] = "001" - } - param["challenge"] = challenge - param["token"] = authToken - - return param -} - -func svcloc(fields map[string]string) map[string]string { - param := map[string]string{ - "retry": "0", - "datetime": getDateTime(), - "returncd": "007", - "statusdata": "Y", - } - - authToken := "NDS/SVCLOC/TOKEN" - - switch fields["svc"] { - default: - param["servicetoken"] = authToken - param["svchost"] = "n/a" - - case "9000": - param["token"] = authToken - param["svchost"] = "dls1.nintendowifi.net" - - case "9001": - param["servicetoken"] = authToken - param["svchost"] = "dls1.nintendowifi.net" - } - - return param -} - -func handleProfanity(form url.Values, unitcd string) map[string]string { - var wordsEncoding string - var wordsDefaultEncoding string - var wordsBytes []byte - var words string - var wordsRegion string - var prwords string - - if unitcd == "0" { - wordsEncoding = "UTF-16LE" - wordsDefaultEncoding = "UTF-16LE" - } else { - wordsEncoding = "UTF-16BE" - wordsDefaultEncoding = "UTF-16BE" - } - - if wencValues, ok := form["wenc"]; ok { - // It's okay for this to error, the real server - // just falls back to the default encoding in - // this case even if it cant properly handle it - wencDecoded, err := common.Base64DwcEncoding.DecodeString(wencValues[0]) - if err == nil { - wordsEncoding = string(wencDecoded) - } - } - - if wordsEncoding != "UTF-8" && wordsEncoding != "UTF-16LE" && wordsEncoding != "UTF-16BE" { - wordsEncoding = wordsDefaultEncoding - } - - // It's okay for this to not exist/be valid, the real - // server will just treat the missing input as a single - // non-profane word - if wordsValues, ok := form["words"]; ok { - wordsDecoded, err := common.Base64DwcEncoding.DecodeString(wordsValues[0]) - if err == nil { - wordsBytes = wordsDecoded - } - } - - // This field is entirely optional, unsure what - // specifically it does. Adds extra data to the - // reply, probably used for handling the word - // list differently for different regions? - if wordsRegionValues, ok := form["wregion"]; ok { - wordsRegionDecoded, err := common.Base64DwcEncoding.DecodeString(wordsRegionValues[0]) - if err == nil { - wordsRegion = string(wordsRegionDecoded) - } - } - - if wordsEncoding == "UTF-8" { - words = string(wordsBytes) - } else { - var utf16String []uint16 - if wordsEncoding == "UTF-16LE" { - for i := 0; i < len(wordsBytes)/2; i++ { - utf16String = append(utf16String, binary.LittleEndian.Uint16(wordsBytes[i*2:i*2+2])) - } - } else { - for i := 0; i < len(wordsBytes)/2; i++ { - utf16String = append(utf16String, binary.BigEndian.Uint16(wordsBytes[i*2:i*2+2])) - } - } - - words = string(utf16.Decode(utf16String)) - } - - // TODO - Handle wtype? Unsure what this field does, seems to always be an emtpy string - - for _, word := range strings.Split(words, "\t") { - if isBadWord, _ := IsBadWord(word); isBadWord { - prwords += "1" - } else { - prwords += "0" - } - } - - var returncd string - if strings.Contains(prwords, "1") { - returncd = "040" - } else { - returncd = "000" - } - - reply := map[string]string{ - "returncd": returncd, - "prwords": prwords, - } - - // Only known value of this field that works this way - if wordsRegion == "A" { - // TODO - The real server seems to handle the input words differently per region? These values are supposed to differ from prwords - reply["prwordsA"] = prwords - reply["prwordsC"] = prwords - reply["prwordsE"] = prwords - reply["prwordsJ"] = prwords - reply["prwordsK"] = prwords - reply["prwordsP"] = prwords - } - - return reply -} - -func dlsCount(fields map[string]string) string { - dlcFolder := filepath.Join(dlcDir, fields["rhgamecd"]) - - dir, ok := os.ReadDir(dlcFolder) - if ok != nil { - return "0" - } - - return strconv.Itoa(len(dir)) -} - -func isValidRhgamecd(rhgamecd string) bool { - if len(rhgamecd) != 4 { - return false - } - - return common.IsUppercaseAlphanumeric(rhgamecd) -} - func getDateTime() string { t := time.Now().UTC() return fmt.Sprintf("%04d%02d%02d%02d%02d%02d", t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second()) diff --git a/nas/dlc.go b/nas/dlc.go new file mode 100644 index 0000000..be5c0cc --- /dev/null +++ b/nas/dlc.go @@ -0,0 +1,68 @@ +package nas + +import ( + "net/http" + "os" + "path/filepath" + "strconv" + "wwfc/common" + "wwfc/logging" + + "github.com/logrusorgru/aurora/v3" +) + +var dlsActions = map[string]func(moduleName string, fields map[string][]byte) []byte{ + "count": dlsCount, +} + +func handleDownloadEndpoint(w http.ResponseWriter, r *http.Request) { + moduleName := getModuleName(r) + + fields, err := parseAuthRequest(r) + if err != nil { + replyHTTPError(w, 400, "400 Bad Request") + return + } + + action := string(fields["action"]) + if action == "" { + logging.Error(moduleName, "No action in form") + replyHTTPError(w, 400, "400 Bad Request") + return + } + + rhgamecd, ok := fields["rhgamecd"] + if !ok || !isValidRHGameCode(string(rhgamecd)) { + logging.Error(moduleName, "Missing or invalid rhgamecd") + replyHTTPError(w, 400, "400 Bad Request") + return + } + + if actionFunc, exists := dlsActions[action]; exists { + reply := actionFunc(moduleName, fields) + writeAuthResponse(w, reply) + return + } + + logging.Error(moduleName, "Unknown action:", aurora.Cyan(action)) + replyHTTPError(w, 400, "400 Bad Request") +} + +func dlsCount(moduleName string, fields map[string]string) []byte { + dlcFolder := filepath.Join(dlcDir, fields["rhgamecd"]) + + dir, err := os.ReadDir(dlcFolder) + if err != nil { + return []byte{'0', 0} + } + + return append([]byte(strconv.Itoa(len(dir))), 0) +} + +func isValidRHGameCode(rhgamecd string) bool { + if len(rhgamecd) != 4 { + return false + } + + return common.IsUppercaseAlphanumeric(rhgamecd) +} diff --git a/nas/main.go b/nas/main.go index 9b8c1e9..583ab6b 100644 --- a/nas/main.go +++ b/nas/main.go @@ -24,11 +24,20 @@ var ( var ( authMux = http.NewServeMux() + dlsMux = http.NewServeMux() sakeMux = http.NewServeMux() gamestatsMux = http.NewServeMux() raceMux = http.NewServeMux() ) +var hostMuxes = map[*regexp.Regexp]*http.ServeMux{ + regexp.MustCompile(`^(nas|naswii)\.`): authMux, + regexp.MustCompile(`^dls1\.`): dlsMux, + regexp.MustCompile(`(\.|^)gamestats2?\.(gs\.|gamespy\.com$)`): gamestatsMux, + regexp.MustCompile(`(\.|^)sake\.(gs\.|gamespy\.com$)`): sakeMux, + regexp.MustCompile(`(\.|^)race\.(gs\.|gamespy\.com$)`): raceMux, +} + func StartServer(reload bool) { // Get config config := common.GetConfig() @@ -52,9 +61,10 @@ func StartServer(reload bool) { ReadTimeout: 10 * time.Second, } - authMux.HandleFunc("/ac", handleAuthRequest) - authMux.HandleFunc("/pr", handleAuthRequest) - authMux.HandleFunc("/download", handleAuthRequest) + authMux.HandleFunc("/ac", handleAuthAccountEndpoint) + authMux.HandleFunc("/pr", handleAuthProfanityEndpoint) + + dlsMux.HandleFunc("/download", handleDownloadEndpoint) if payloadServerAddress != "" { // Forward the request to the payload server @@ -98,13 +108,6 @@ func Shutdown() { } } -var hostMuxes = map[*regexp.Regexp]*http.ServeMux{ - regexp.MustCompile(`^(nas|naswii|dls1)\.`): authMux, - regexp.MustCompile(`(\.|^)gamestats2?\.(gs\.|gamespy\.com$)`): gamestatsMux, - regexp.MustCompile(`(\.|^)sake\.(gs\.|gamespy\.com$)`): sakeMux, - regexp.MustCompile(`(\.|^)race\.(gs\.|gamespy\.com$)`): raceMux, -} - func handleRequest(w http.ResponseWriter, r *http.Request) { // Check for host-specific muxes for regex, mux := range hostMuxes { diff --git a/nas/profanity.go b/nas/profanity.go index 2d9e3cd..14f5f3f 100644 --- a/nas/profanity.go +++ b/nas/profanity.go @@ -2,7 +2,9 @@ package nas import ( "bufio" + "encoding/binary" "errors" + "net/http" "os" "strings" "time" @@ -101,3 +103,105 @@ func isProfanityFileCached() bool { } return profanityFileLines != nil && !fileInfo.ModTime().After(lastModTime) } + +func handleAuthProfanityEndpoint(w http.ResponseWriter, r *http.Request) { + form, err := parseAuthRequest(r) + if err != nil { + replyHTTPError(w, 400, "400 Bad Request") + return + } + + unitcd := form["unitcd"] + var wordsEncoding string + var wordsDefaultEncoding string + if len(unitcd) != 1 || unitcd[0] != '0' { + wordsEncoding = "UTF-16BE" + wordsDefaultEncoding = "UTF-16BE" + } else { + wordsEncoding = "UTF-16LE" + wordsDefaultEncoding = "UTF-16LE" + } + + if wencValues, ok := form["wenc"]; ok { + // It's okay for this to error, the real server + // just falls back to the default encoding in + // this case even if it cant properly handle it + wencDecoded, err := common.Base64DwcEncoding.DecodeString(string(wencValues[0])) + if err == nil { + wordsEncoding = string(wencDecoded) + } + } + + if wordsEncoding != "UTF-8" && wordsEncoding != "UTF-16LE" && wordsEncoding != "UTF-16BE" { + wordsEncoding = wordsDefaultEncoding + } + + // It's okay for this to not exist/be valid, the real + // server will just treat the missing input as a single + // non-profane word + wordsBytes := []byte{} + if wordsValues, ok := form["words"]; ok { + wordsDecoded, err := common.Base64DwcEncoding.DecodeString(string(wordsValues[0])) + if err == nil { + wordsBytes = wordsDecoded + } + } + + // This field is entirely optional, unsure what + // specifically it does. Adds extra data to the + // reply, probably used for handling the word + // list differently for different regions? + var wordsRegion string + if wordsRegionValues, ok := form["wregion"]; ok { + wordsRegionDecoded, err := common.Base64DwcEncoding.DecodeString(string(wordsRegionValues[0])) + if err == nil { + wordsRegion = string(wordsRegionDecoded) + } + } + + var words string + switch wordsEncoding { + case "UTF-8": + words = string(wordsBytes) + case "UTF-16LE": + words = common.UTF16Decode(wordsBytes, binary.LittleEndian) + case "UTF-16BE": + words = common.UTF16Decode(wordsBytes, binary.BigEndian) + } + + // TODO - Handle wtype? Unsure what this field does, seems to always be an empty string + + prwords := "" + for _, word := range strings.Split(words, "\t") { + if isBadWord, _ := IsBadWord(word); isBadWord { + prwords += "1" + } else { + prwords += "0" + } + } + + returncd := "" + if strings.Contains(prwords, "1") { + returncd = "040" + } else { + returncd = "000" + } + + reply := map[string]string{ + "returncd": returncd, + "prwords": prwords, + } + + // Only known value of this field that works this way + if wordsRegion == "A" { + // TODO - The real server seems to handle the input words differently per region? These values are supposed to differ from prwords + reply["prwordsA"] = prwords + reply["prwordsC"] = prwords + reply["prwordsE"] = prwords + reply["prwordsJ"] = prwords + reply["prwordsK"] = prwords + reply["prwordsP"] = prwords + } + + writeAuthResponse(w, reply) +} diff --git a/sake/storage.go b/sake/storage.go index 2c0b716..98c3051 100644 --- a/sake/storage.go +++ b/sake/storage.go @@ -6,7 +6,6 @@ import ( "net/http" "sort" "strconv" - "time" "wwfc/common" "wwfc/database" "wwfc/logging" @@ -251,17 +250,13 @@ func getRequestIdentity(moduleName string, request StorageRequestCommon) (uint32 return 0, common.GameInfo{}, ResultSecretKeyInvalid } - profileId, issueTime, err := common.UnmarshalGPCMLoginTicket(request.LoginTicket) - if err != nil { + loginTicket := common.GPCMLoginTicket{} + if err := loginTicket.Unmarshal(request.LoginTicket); err != nil { logging.Error(moduleName, err) return 0, common.GameInfo{}, ResultLoginTicketInvalid } - if issueTime.Add(48 * time.Hour).Before(time.Now()) { - return 0, common.GameInfo{}, ResultLoginTicketExpired - } - - return profileId, *gameInfo, ResultSuccess + return loginTicket.ProfileID, *gameInfo, ResultSuccess } func createRecord(moduleName string, profileId uint32, gameInfo common.GameInfo, request StorageRequestCommon) StorageResponseBody { From cf40cf2b3e2371b10e34c512437aaacd550fa570 Mon Sep 17 00:00:00 2001 From: Palapeli <26661008+mkwcat@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:43:01 -0400 Subject: [PATCH 2/3] NAS: Fix /download endpoint --- nas/dlc.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/nas/dlc.go b/nas/dlc.go index be5c0cc..bacfda5 100644 --- a/nas/dlc.go +++ b/nas/dlc.go @@ -40,7 +40,12 @@ func handleDownloadEndpoint(w http.ResponseWriter, r *http.Request) { if actionFunc, exists := dlsActions[action]; exists { reply := actionFunc(moduleName, fields) - writeAuthResponse(w, reply) + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Length", strconv.Itoa(len(reply))) + _, err := w.Write(reply) + if err != nil { + logging.Error(moduleName, "Error writing response:", err) + } return } @@ -48,8 +53,8 @@ func handleDownloadEndpoint(w http.ResponseWriter, r *http.Request) { replyHTTPError(w, 400, "400 Bad Request") } -func dlsCount(moduleName string, fields map[string]string) []byte { - dlcFolder := filepath.Join(dlcDir, fields["rhgamecd"]) +func dlsCount(moduleName string, fields map[string][]byte) []byte { + dlcFolder := filepath.Join(dlcDir, string(fields["rhgamecd"])) dir, err := os.ReadDir(dlcFolder) if err != nil { From b3ef500fcea83c732e22ca48f31e77343cd9e54e Mon Sep 17 00:00:00 2001 From: Palapeli <26661008+mkwcat@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:07:06 -0400 Subject: [PATCH 3/3] NAS: Rename dlc.go to dls.go --- nas/{dlc.go => dls.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename nas/{dlc.go => dls.go} (100%) diff --git a/nas/dlc.go b/nas/dls.go similarity index 100% rename from nas/dlc.go rename to nas/dls.go