diff --git a/api/ban.go b/api/ban.go new file mode 100644 index 0000000..f158eab --- /dev/null +++ b/api/ban.go @@ -0,0 +1,126 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "time" + "wwfc/database" + "wwfc/gpcm" +) + +func HandleBan(w http.ResponseWriter, r *http.Request) { + errorString := handleBanImpl(w, r) + if errorString != "" { + jsonData, _ := json.Marshal(map[string]string{"error": errorString}) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Length", strconv.Itoa(len(jsonData))) + w.Write(jsonData) + } else { + jsonData, _ := json.Marshal(map[string]string{"success": "true"}) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Length", strconv.Itoa(len(jsonData))) + w.Write(jsonData) + } +} + +func handleBanImpl(w http.ResponseWriter, r *http.Request) string { + // TODO: Actual authentication rather than a fixed secret + // TODO: Use POST instead of GET + + u, err := url.Parse(r.URL.String()) + if err != nil { + return "Bad request" + } + + query, err := url.ParseQuery(u.RawQuery) + if err != nil { + return "Bad request" + } + + if apiSecret == "" || query.Get("secret") != apiSecret { + return "Invalid API secret" + } + + pidStr := query.Get("pid") + if pidStr == "" { + return "Missing pid in request" + } + + pid, err := strconv.ParseUint(pidStr, 10, 32) + if err != nil { + return "Invalid pid" + } + + tosStr := query.Get("tos") + if tosStr == "" { + return "Missing tos in request" + } + + tos, err := strconv.ParseBool(tosStr) + if err != nil { + return "Invalid tos" + } + + minutes := uint64(0) + if query.Get("minutes") != "" { + minutesStr := query.Get("minutes") + minutes, err = strconv.ParseUint(minutesStr, 10, 32) + if err != nil { + return "Invalid minutes" + } + } + + hours := uint64(0) + if query.Get("hours") != "" { + hoursStr := query.Get("hours") + hours, err = strconv.ParseUint(hoursStr, 10, 32) + if err != nil { + return "Invalid hours" + } + } + + days := uint64(0) + if query.Get("days") != "" { + daysStr := query.Get("days") + days, err = strconv.ParseUint(daysStr, 10, 32) + if err != nil { + return "Invalid days" + } + } + + reason := query.Get("reason") + if "reason" == "" { + return "Missing ban reason" + } + + // reason_hidden is optional + reasonHidden := query.Get("reason_hidden") + + moderator := query.Get("moderator") + if "moderator" == "" { + moderator = "admin" + } + + minutes = days*24*60 + hours*60 + minutes + if minutes == 0 { + return "Missing ban length" + } + + length := time.Duration(minutes) * time.Minute + + if !database.BanUser(pool, ctx, uint32(pid), tos, length, reason, reasonHidden, moderator) { + return "Failed to ban user" + } + + if tos { + gpcm.KickPlayer(uint32(pid), "banned") + } else { + gpcm.KickPlayer(uint32(pid), "restricted") + } + + return "" +} diff --git a/api/kick.go b/api/kick.go new file mode 100644 index 0000000..09e1c8b --- /dev/null +++ b/api/kick.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "wwfc/gpcm" +) + +func HandleKick(w http.ResponseWriter, r *http.Request) { + errorString := handleKickImpl(w, r) + if errorString != "" { + jsonData, _ := json.Marshal(map[string]string{"error": errorString}) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Length", strconv.Itoa(len(jsonData))) + w.Write(jsonData) + } else { + jsonData, _ := json.Marshal(map[string]string{"success": "true"}) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Length", strconv.Itoa(len(jsonData))) + w.Write(jsonData) + } +} + +func handleKickImpl(w http.ResponseWriter, r *http.Request) string { + // TODO: Actual authentication rather than a fixed secret + // TODO: Use POST instead of GET + + u, err := url.Parse(r.URL.String()) + if err != nil { + return "Bad request" + } + + query, err := url.ParseQuery(u.RawQuery) + if err != nil { + return "Bad request" + } + + if apiSecret == "" || query.Get("secret") != apiSecret { + return "Invalid API secret" + } + + pidStr := query.Get("pid") + if pidStr == "" { + return "Missing pid in request" + } + + pid, err := strconv.ParseUint(pidStr, 10, 32) + if err != nil { + return "Invalid pid" + } + + gpcm.KickPlayer(uint32(pid), "moderator_kick") + return "" +} diff --git a/api/main.go b/api/main.go new file mode 100644 index 0000000..5428208 --- /dev/null +++ b/api/main.go @@ -0,0 +1,34 @@ +package api + +import ( + "context" + "fmt" + "wwfc/common" + + "github.com/jackc/pgx/v4/pgxpool" +) + +var ( + ctx = context.Background() + pool *pgxpool.Pool + apiSecret string +) + +func StartServer() { + // Get config + config := common.GetConfig() + + apiSecret = config.APISecret + + // Start SQL + dbString := fmt.Sprintf("postgres://%s:%s@%s/%s", config.Username, config.Password, config.DatabaseAddress, config.DatabaseName) + dbConf, err := pgxpool.ParseConfig(dbString) + if err != nil { + panic(err) + } + + pool, err = pgxpool.ConnectConfig(ctx, dbConf) + if err != nil { + panic(err) + } +} diff --git a/api/unban.go b/api/unban.go new file mode 100644 index 0000000..ad258f8 --- /dev/null +++ b/api/unban.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "wwfc/database" +) + +func HandleUnban(w http.ResponseWriter, r *http.Request) { + errorString := handleUnbanImpl(w, r) + if errorString != "" { + jsonData, _ := json.Marshal(map[string]string{"error": errorString}) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Length", strconv.Itoa(len(jsonData))) + w.Write(jsonData) + } else { + jsonData, _ := json.Marshal(map[string]string{"success": "true"}) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Length", strconv.Itoa(len(jsonData))) + w.Write(jsonData) + } +} + +func handleUnbanImpl(w http.ResponseWriter, r *http.Request) string { + // TODO: Actual authentication rather than a fixed secret + // TODO: Use POST instead of GET + + u, err := url.Parse(r.URL.String()) + if err != nil { + return "Bad request" + } + + query, err := url.ParseQuery(u.RawQuery) + if err != nil { + return "Bad request" + } + + if apiSecret == "" || query.Get("secret") != apiSecret { + return "Invalid API secret" + } + + pidStr := query.Get("pid") + if pidStr == "" { + return "Missing pid in request" + } + + pid, err := strconv.ParseUint(pidStr, 10, 32) + if err != nil { + return "Invalid pid" + } + + database.UnbanUser(pool, ctx, uint32(pid)) + return "" +} diff --git a/common/config.go b/common/config.go index a764499..466ce97 100644 --- a/common/config.go +++ b/common/config.go @@ -6,23 +6,25 @@ import ( ) type Config struct { - Username string `xml:"username"` - Password string `xml:"password"` - DatabaseAddress string `xml:"databaseAddress"` - DatabaseName string `xml:"databaseName"` - DefaultAddress string `xml:"address"` - GameSpyAddress *string `xml:"gsAddress,omitempty"` - NASAddress *string `xml:"nasAddress,omitempty"` - NASPort string `xml:"nasPort"` - NASAddressHTTPS *string `xml:"nasAddressHttps,omitempty"` - NASPortHTTPS string `xml:"nasPortHttps"` - EnableHTTPS bool `xml:"enableHttps"` - EnableHTTPSExploit *bool `xml:"enableHttpsExploit,omitempty"` - LogLevel *int `xml:"logLevel"` - CertPath string `xml:"certPath"` - KeyPath string `xml:"keyPath"` - CertPathWii string `xml:"certDerPathWii"` - KeyPathWii string `xml:"keyPathWii"` + Username string `xml:"username"` + Password string `xml:"password"` + DatabaseAddress string `xml:"databaseAddress"` + DatabaseName string `xml:"databaseName"` + DefaultAddress string `xml:"address"` + GameSpyAddress *string `xml:"gsAddress,omitempty"` + NASAddress *string `xml:"nasAddress,omitempty"` + NASPort string `xml:"nasPort"` + NASAddressHTTPS *string `xml:"nasAddressHttps,omitempty"` + NASPortHTTPS string `xml:"nasPortHttps"` + EnableHTTPS bool `xml:"enableHttps"` + EnableHTTPSExploit *bool `xml:"enableHttpsExploit,omitempty"` + LogLevel *int `xml:"logLevel"` + CertPath string `xml:"certPath"` + KeyPath string `xml:"keyPath"` + CertPathWii string `xml:"certDerPathWii"` + KeyPathWii string `xml:"keyPathWii"` + APISecret string `xml:"apiSecret"` + AllowDefaultDolphinKeys bool `xml:"allowDefaultDolphinKeys"` } func GetConfig() Config { @@ -32,6 +34,8 @@ func GetConfig() Config { } var config Config + config.AllowDefaultDolphinKeys = true + err = xml.Unmarshal(data, &config) if err != nil { panic(err) diff --git a/config_example.xml b/config_example.xml index f515031..376ff3e 100644 --- a/config_example.xml +++ b/config_example.xml @@ -20,6 +20,9 @@ naswii-cert.der naswii-key.pem + + true + username password @@ -30,4 +33,7 @@ 4 + + + hQ3f57b3tW2WnjJH3v diff --git a/database/login.go b/database/login.go index a2b92ec..91ae16e 100644 --- a/database/login.go +++ b/database/login.go @@ -2,19 +2,27 @@ package database import ( "context" + "errors" "fmt" + "time" "wwfc/common" "wwfc/logging" + "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/pgxpool" "github.com/logrusorgru/aurora/v3" ) -func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsbrcd string, profileId uint32, ngDeviceId uint32) (User, bool) { +var ( + ErrDeviceIDMismatch = errors.New("NG device ID mismatch") + ErrProfileBannedTOS = errors.New("Profile is banned for violating the Terms of Service") +) + +func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsbrcd string, profileId uint32, ngDeviceId uint32, ipAddress string, ingamesn string) (User, error) { var exists bool err := pool.QueryRow(ctx, DoesUserExist, userId, gsbrcd).Scan(&exists) if err != nil { - return User{}, false + return User{}, err } user := User{ @@ -32,7 +40,7 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsb err := user.CreateUser(pool, ctx) if err != nil { logging.Error("DATABASE", "Error creating user:", aurora.Cyan(userId), aurora.Cyan(gsbrcd), aurora.Cyan(user.ProfileId), "\nerror:", err.Error()) - return User{}, false + return User{}, err } logging.Notice("DATABASE", "Created new GPCM user:", aurora.Cyan(userId), aurora.Cyan(gsbrcd), aurora.Cyan(user.ProfileId)) @@ -42,7 +50,7 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsb var lastName *string err := pool.QueryRow(ctx, GetUserProfileID, userId, gsbrcd).Scan(&user.ProfileId, &expectedNgId, &user.Email, &user.UniqueNick, &firstName, &lastName) if err != nil { - panic(err) + return User{}, err } if firstName != nil { @@ -53,17 +61,17 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsb user.LastName = *lastName } - if expectedNgId != nil && user.NgDeviceId != 0 { + if expectedNgId != nil && *expectedNgId != 0 { user.NgDeviceId = *expectedNgId if ngDeviceId != 0 && user.NgDeviceId != ngDeviceId { logging.Error("DATABASE", "NG device ID mismatch for profile", aurora.Cyan(user.ProfileId), "- expected", aurora.Cyan(fmt.Sprintf("%08x", user.NgDeviceId)), "but got", aurora.Cyan(fmt.Sprintf("%08x", ngDeviceId))) - return User{}, false + return User{}, ErrDeviceIDMismatch } } else if ngDeviceId != 0 { user.NgDeviceId = ngDeviceId _, err := pool.Exec(ctx, UpdateUserNGDeviceID, user.ProfileId, ngDeviceId) if err != nil { - panic(err) + return User{}, err } } @@ -86,5 +94,36 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsb }) } - return user, true + // Update the user's last IP address and ingamesn + _, err = pool.Exec(ctx, UpdateUserLastIPAddress, user.ProfileId, ipAddress, ingamesn) + if err != nil { + return User{}, err + } + + // Find ban from device ID or IP address + var banExists bool + var banTOS bool + var bannedDeviceId uint32 + timeNow := time.Now() + err = pool.QueryRow(ctx, SearchUserBan, user.ProfileId, user.NgDeviceId, ipAddress, timeNow).Scan(&banExists, &banTOS, &bannedDeviceId) + if err != nil { + if err != pgx.ErrNoRows { + return User{}, err + } + + banExists = false + } + + if banExists { + if banTOS { + logging.Warn("DATABASE", "Profile", aurora.Cyan(user.ProfileId), "is banned") + return User{RestrictedDeviceId: bannedDeviceId}, ErrProfileBannedTOS + } + + logging.Warn("DATABASE", "Profile", aurora.Cyan(user.ProfileId), "is restricted") + user.Restricted = true + user.RestrictedDeviceId = bannedDeviceId + } + + return user, nil } diff --git a/database/schema.go b/database/schema.go new file mode 100644 index 0000000..1bc6762 --- /dev/null +++ b/database/schema.go @@ -0,0 +1,22 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4/pgxpool" +) + +func UpdateTables(pool *pgxpool.Pool, ctx context.Context) { + pool.Exec(ctx, ` +ALTER TABLE ONLY public.users + ADD IF NOT EXISTS last_ip_address character varying DEFAULT ''::character varying, + ADD IF NOT EXISTS last_ingamesn character varying DEFAULT ''::character varying, + ADD IF NOT EXISTS has_ban boolean DEFAULT false, + ADD IF NOT EXISTS ban_issued timestamp without time zone, + ADD IF NOT EXISTS ban_expires timestamp without time zone, + ADD IF NOT EXISTS ban_reason character varying, + ADD IF NOT EXISTS ban_reason_hidden character varying, + ADD IF NOT EXISTS ban_moderator character varying, + ADD IF NOT EXISTS ban_tos boolean +`) +} diff --git a/database/user.go b/database/user.go index 7736745..0a9c9c8 100644 --- a/database/user.go +++ b/database/user.go @@ -4,6 +4,7 @@ import ( "context" "errors" "math/rand" + "time" "github.com/jackc/pgx/v4/pgxpool" ) @@ -19,20 +20,26 @@ const ( IsProfileIDInUse = `SELECT EXISTS(SELECT 1 FROM users WHERE profile_id = $1)` DeleteUserSession = `DELETE FROM sessions WHERE profile_id = $1` GetUserProfileID = `SELECT profile_id, ng_device_id, email, unique_nick, firstname, lastname FROM users WHERE user_id = $1 AND gsbrcd = $2` + UpdateUserLastIPAddress = `UPDATE users SET last_ip_address = $2, last_ingamesn = $3 WHERE profile_id = $1` + UpdateUserBan = `UPDATE users SET has_ban = true, ban_issued = $2, ban_expires = $3, ban_reason = $4, ban_reason_hidden = $5, ban_moderator = $6, ban_tos = $7 WHERE profile_id = $1` + SearchUserBan = `SELECT has_ban, ban_tos, ng_device_id FROM users WHERE has_ban = true AND (profile_id = $1 OR ng_device_id = $2 OR last_ip_address = $3) AND (ban_expires IS NULL OR ban_expires > $4) ORDER BY ban_tos DESC LIMIT 1` + DisableUserBan = `UPDATE users SET has_ban = false WHERE profile_id = $1` GetMKWFriendInfoQuery = `SELECT mariokartwii_friend_info FROM users WHERE profile_id = $1` UpdateMKWFriendInfoQuery = `UPDATE users SET mariokartwii_friend_info = $2 WHERE profile_id = $1` ) type User struct { - ProfileId uint32 - UserId uint64 - GsbrCode string - NgDeviceId uint32 - Email string - UniqueNick string - FirstName string - LastName string + ProfileId uint32 + UserId uint64 + GsbrCode string + NgDeviceId uint32 + Email string + UniqueNick string + FirstName string + LastName string + Restricted bool + RestrictedDeviceId uint32 } var ( @@ -130,6 +137,24 @@ func GetProfile(pool *pgxpool.Pool, ctx context.Context, profileId uint32) (User return user, true } +func BanUser(pool *pgxpool.Pool, ctx context.Context, profileId uint32, tos bool, length time.Duration, reason string, reasonHidden string, moderator string) bool { + _, err := pool.Exec(ctx, UpdateUserBan, profileId, time.Now(), time.Now().Add(length), reason, reasonHidden, moderator, tos) + if err != nil { + return false + } + + return true +} + +func UnbanUser(pool *pgxpool.Pool, ctx context.Context, profileId uint32) bool { + _, err := pool.Exec(ctx, DisableUserBan, profileId) + if err != nil { + return false + } + + return true +} + func GetMKWFriendInfo(pool *pgxpool.Pool, ctx context.Context, profileId uint32) string { var info string err := pool.QueryRow(ctx, GetMKWFriendInfoQuery, profileId).Scan(&info) diff --git a/gpcm/error.go b/gpcm/error.go index 71b56c0..e34211e 100644 --- a/gpcm/error.go +++ b/gpcm/error.go @@ -1,15 +1,39 @@ package gpcm import ( + "fmt" "strconv" "wwfc/common" "wwfc/logging" ) +const ( + LangJapanese = 0x00 + LangEnglish = 0x01 + LangGerman = 0x02 + LangFrench = 0x03 + LangSpanish = 0x04 + LangItalian = 0x05 + LangDutch = 0x06 + LangSimpChinese = 0x07 + LangTradChinese = 0x08 + LangKorean = 0x09 + + LangEnglishEU = 0x81 + LangFrenchEU = 0x83 + LangSpanishEU = 0x84 +) + +type WWFCErrorMessage struct { + ErrorCode int + MessageRMC map[byte]string +} + type GPError struct { ErrorCode int ErrorString string Fatal bool + WWFCMessage WWFCErrorMessage } func MakeGPError(errorCode int, errorString string, fatal bool) GPError { @@ -139,6 +163,181 @@ var ( ErrRemoveBlockNotBlocked = MakeGPError(0x1301, "The profile specified was not a member of the blocked list.", false) ) +var ( + // WWFC errors with custom messages + WWFCMsgUnknownLoginError = WWFCErrorMessage{ + ErrorCode: 22000, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "An unknown error has occurred\n" + + "while logging in to WiiLink WFC.\n" + + "\n" + + "Error Code: %[1]d", + }, + } + + WWFCMsgDolphinSetupRequired = WWFCErrorMessage{ + ErrorCode: 22001, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "Additional setup is required\n" + + "to use WiiLink WFC on Dolphin.\n" + + "Visit wfc.wiilink24.com/dolphin\n" + + "\n" + + "Error Code: %[1]d", + }, + } + + WWFCMsgProfileBannedTOS = WWFCErrorMessage{ + ErrorCode: 22002, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "You are banned from WiiLink WFC\n" + + "due to a violation of the\n" + + "Terms of Service.\n" + + "Visit wfc.wiilink24.com/tos\n" + + "\n" + + "Error Code: %[1]d\n" + + "Support Info: NG%08[2]x", + }, + } + + WWFCMsgProfileBannedTOSNow = WWFCErrorMessage{ + ErrorCode: 22002, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "You have been banned from\n" + + "WiiLink WFC due to a violation\n" + + "of the Terms of Service.\n" + + "Visit wfc.wiilink24.com/tos\n" + + "\n" + + "Error Code: %[1]d\n" + + "Support Info: NG%08[2]x", + }, + } + + WWFCMsgProfileRestricted = WWFCErrorMessage{ + ErrorCode: 22003, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "You are banned from public\n" + + "matches due to a violation\n" + + "of the WiiLink WFC Rules.\n" + + "Visit wfc.wiilink24.com/rules\n" + + "\n" + + "Error Code: %[1]d\n" + + "Support Info: NG%08[2]x", + }, + } + + WWFCMsgProfileRestrictedNow = WWFCErrorMessage{ + ErrorCode: 22003, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "You have been banned from public\n" + + "matches due to a violation\n" + + "of the WiiLink WFC Rules.\n" + + "Visit wfc.wiilink24.com/rules\n" + + "\n" + + "Error Code: %[1]d\n" + + "Support Info: NG%08[2]x", + }, + } + + WWFCMsgKickedGeneric = WWFCErrorMessage{ + ErrorCode: 22004, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "You have been kicked from\n" + + "WiiLink WFC.\n" + + "\n" + + "Error Code: %[1]d", + }, + } + + WWFCMsgKickedModerator = WWFCErrorMessage{ + ErrorCode: 22004, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "You have been kicked from\n" + + "WiiLink WFC by a moderator.\n" + + "Visit wfc.wiilink24.com/rules\n" + + "\n" + + "Error Code: %[1]d", + }, + } + + WWFCMsgKickedRoomHost = WWFCErrorMessage{ + ErrorCode: 22004, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "You have been kicked from the\n" + + "friend room by the room creator.\n" + + "\n" + + "Error Code: %[1]d", + }, + } + + WWFCMsgConsoleMismatch = WWFCErrorMessage{ + ErrorCode: 22005, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "The console you are using is not\n" + + "the device used to register this\n" + + "profile.\n" + + "\n" + + "Error Code: %[1]d", + }, + } + + WWFCMsgConsoleMismatchDolphin = WWFCErrorMessage{ + ErrorCode: 22005, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "The console you are using is not\n" + + "the device used to register this\n" + + "profile. Please make sure you've\n" + + "set up your NAND correctly.\n" + + "\n" + + "Error Code: %[1]d", + }, + } + + WWFCMsgProfileIDInvalid = WWFCErrorMessage{ + ErrorCode: 22006, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "The profile ID you are trying to\n" + + "register is invalid.\n" + + "Please create a new license.\n" + + "\n" + + "Error Code: %[1]d", + }, + } + + WWFCMsgProfileIDInUse = WWFCErrorMessage{ + ErrorCode: 22007, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "The friend code you are trying to\n" + + "register is already in use.\n" + + "\n" + + "Error Code: %[1]d", + }, + } + + WWFCMsgPayloadInvalid = WWFCErrorMessage{ + ErrorCode: 22008, + MessageRMC: map[byte]string{ + LangEnglish: "" + + "The WiiLink WFC payload is invalid.\n" + + "Try restarting your game.\n" + + "\n" + + "Error Code: %[1]d", + }, + } +) + func (err GPError) GetMessage() string { command := common.GameSpyCommand{ Command: "error", @@ -156,7 +355,53 @@ func (err GPError) GetMessage() string { return common.CreateGameSpyMessage(command) } +func (err GPError) GetMessageTranslate(gameName string, region byte, lang byte, cfc uint64, ngid uint32) string { + command := common.GameSpyCommand{ + Command: "error", + CommandValue: "", + OtherValues: map[string]string{ + "err": strconv.Itoa(err.ErrorCode), + "errmsg": err.ErrorString, + }, + } + + if err.Fatal { + command.OtherValues["fatal"] = "" + } + + if err.Fatal && err.WWFCMessage.ErrorCode != 0 { + switch gameName { + case "mariokartwii": + errMsg := err.WWFCMessage.MessageRMC[lang] + if errMsg == "" { + errMsg = err.WWFCMessage.MessageRMC[LangEnglish] + } + + errMsg = fmt.Sprintf(errMsg, err.WWFCMessage.ErrorCode, ngid) + + command.OtherValues["wwfc_err"] = strconv.Itoa(err.WWFCMessage.ErrorCode) + command.OtherValues["wwfc_errmsg"] = errMsg + } + } + + return common.CreateGameSpyMessage(command) +} + func (g *GameSpySession) replyError(err GPError) { logging.Error(g.ModuleName, "Reply error:", err.ErrorString) - g.Conn.Write([]byte(err.GetMessage())) + if !g.LoginInfoSet { + msg := err.GetMessage() + // logging.Info(g.ModuleName, "Sending error message:", msg) + g.Conn.Write([]byte(msg)) + return + } + + deviceId := g.User.RestrictedDeviceId + if deviceId == 0 { + deviceId = g.User.NgDeviceId + } + + msg := err.GetMessageTranslate(g.GameName, g.Region, g.Language, g.ConsoleFriendCode, deviceId) + // logging.Info(g.ModuleName, "Sending error message:", msg) + g.Conn.Write([]byte(msg)) } diff --git a/gpcm/kick.go b/gpcm/kick.go new file mode 100644 index 0000000..4aefd0a --- /dev/null +++ b/gpcm/kick.go @@ -0,0 +1,35 @@ +package gpcm + +func KickPlayer(profileID uint32, reason string) { + mutex.Lock() + defer mutex.Unlock() + + if session, exists := sessions[profileID]; exists { + errorMessage := WWFCMsgKickedGeneric + + switch reason { + case "banned": + errorMessage = WWFCMsgProfileBannedTOSNow + + case "restricted": + errorMessage = WWFCMsgProfileRestrictedNow + + case "restricted_join": + errorMessage = WWFCMsgProfileRestricted + + case "moderator_kick": + errorMessage = WWFCMsgKickedModerator + + case "room_kick": + errorMessage = WWFCMsgKickedRoomHost + } + + session.replyError(GPError{ + ErrorCode: ErrConnectionClosed.ErrorCode, + ErrorString: "The player was kicked from the game. Reason: " + reason, + Fatal: true, + WWFCMessage: errorMessage, + }) + session.Conn.Close() + } +} diff --git a/gpcm/login.go b/gpcm/login.go index 023ed8d..2a29444 100644 --- a/gpcm/login.go +++ b/gpcm/login.go @@ -44,6 +44,11 @@ var msPublicKey = []byte{ 0xF9, 0x5B, 0x4D, 0x11, 0x04, 0x44, 0x64, 0x35, 0xC0, 0xED, 0xA4, 0x2F, } +var commonDeviceIds = []uint32{ + 0x02000001, + 0x0403ac68, +} + func verifySignature(moduleName string, authToken string, signature string) uint32 { sigBytes, err := common.Base64DwcEncoding.DecodeString(signature) if err != nil || len(sigBytes) != 0x144 { @@ -51,6 +56,16 @@ func verifySignature(moduleName string, authToken string, signature string) uint } ngId := sigBytes[0x000:0x004] + + if !allowDefaultDolphinKeys { + // Skip authentication signature verification for common device IDs (the caller should handle this) + for _, defaultDeviceId := range commonDeviceIds { + if binary.BigEndian.Uint32(ngId) == defaultDeviceId { + return defaultDeviceId + } + } + } + ngTimestamp := sigBytes[0x004:0x008] caId := sigBytes[0x008:0x00C] msId := sigBytes[0x00C:0x010] @@ -122,7 +137,7 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { return } - err, gamecd, issueTime, userId, gsbrcd, cfc, _, _, ingamesn, challenge, isLocalhost := common.UnmarshalNASAuthToken(authToken) + err, gamecd, issueTime, userId, gsbrcd, cfc, region, lang, ingamesn, challenge, isLocalhost := common.UnmarshalNASAuthToken(authToken) if err != nil { g.replyError(ErrLogin) return @@ -134,39 +149,32 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { return } - payloadVer, payloadVerExists := command.OtherValues["payload_ver"] - signature, signatureExists := command.OtherValues["wwfc_sig"] + _, payloadVerExists := command.OtherValues["payload_ver"] + _, signatureExists := command.OtherValues["wwfc_sig"] deviceId := uint32(0) + g.GameName = command.OtherValues["gamename"] + g.GameCode = gamecd + g.Region = region + g.Language = lang + g.ConsoleFriendCode = cfc + g.InGameName = ingamesn + + if hostPlatform, exists := command.OtherValues["wwfc_host"]; exists { + g.HostPlatform = hostPlatform + } else { + g.HostPlatform = "Wii" + } + + g.LoginInfoSet = true + if isLocalhost && !payloadVerExists && !signatureExists { // Players using the DNS exploit, need patching using a QR2 exploit // TODO: Check that the game is compatible with the DNS g.NeedsExploit = true } else { - if !payloadVerExists || payloadVer != "2" { - g.replyError(GPError{ - ErrorCode: ErrLogin.ErrorCode, - ErrorString: "The payload version is invalid.", - Fatal: true, - }) - return - } - - if !signatureExists { - g.replyError(GPError{ - ErrorCode: ErrLogin.ErrorCode, - ErrorString: "Missing authentication signature.", - Fatal: true, - }) - return - } - - if deviceId = verifySignature(g.ModuleName, authToken, signature); deviceId == 0 { - g.replyError(GPError{ - ErrorCode: ErrLogin.ErrorCode, - ErrorString: "The authentication signature is invalid.", - Fatal: true, - }) + deviceId = g.verifyExLoginInfo(command, authToken) + if deviceId == 0 { return } } @@ -187,6 +195,7 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { ErrorCode: ErrLogin.ErrorCode, ErrorString: "The provided profile ID is invalid.", Fatal: true, + WWFCMessage: WWFCMsgUnknownLoginError, }) return } @@ -194,14 +203,9 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { cmdProfileId = uint32(cmdProfileId2) } - // Perform the login with the database. - user, ok := database.LoginUserToGPCM(pool, ctx, userId, gsbrcd, cmdProfileId, deviceId) - if !ok { - // There was an error logging in to the GP backend. - g.replyError(ErrLogin) + if !g.performLoginWithDatabase(userId, gsbrcd, cmdProfileId, deviceId) { return } - g.User = user g.ModuleName = "GPCM:" + strconv.FormatInt(int64(g.User.ProfileId), 10) + "*" g.ModuleName += "/" + common.CalcFriendCodeString(g.User.ProfileId, "RMCJ") + "*" @@ -237,8 +241,6 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { g.AuthToken = authToken g.LoginTicket = common.MarshalGPCMLoginTicket(g.User.ProfileId) g.SessionKey = rand.Int31n(290000000) + 10000000 - g.GameCode = gamecd - g.InGameName = ingamesn g.DeviceAuthenticated = !g.NeedsExploit g.LoggedIn = true @@ -246,7 +248,7 @@ func (g *GameSpySession) login(command common.GameSpyCommand) { g.ModuleName += "/" + common.CalcFriendCodeString(g.User.ProfileId, "RMCJ") // Notify QR2 of the login - qr2.Login(g.User.ProfileId, gamecd, ingamesn, cfc, g.Conn.RemoteAddr().String(), g.NeedsExploit, g.DeviceAuthenticated) + qr2.Login(g.User.ProfileId, gamecd, ingamesn, cfc, g.Conn.RemoteAddr().String(), g.NeedsExploit, g.DeviceAuthenticated, g.User.Restricted) payload := common.CreateGameSpyMessage(common.GameSpyCommand{ Command: "lc", @@ -273,6 +275,20 @@ func (g *GameSpySession) exLogin(command common.GameSpyCommand) { return } + deviceId := g.verifyExLoginInfo(command, g.AuthToken) + if deviceId == 0 { + return + } + + if !g.performLoginWithDatabase(g.User.UserId, g.User.GsbrCode, 0, deviceId) { + return + } + + g.DeviceAuthenticated = true + qr2.SetDeviceAuthenticated(g.User.ProfileId) +} + +func (g *GameSpySession) verifyExLoginInfo(command common.GameSpyCommand, authToken string) uint32 { payloadVer, payloadVerExists := command.OtherValues["payload_ver"] signature, signatureExists := command.OtherValues["wwfc_sig"] deviceId := uint32(0) @@ -282,8 +298,9 @@ func (g *GameSpySession) exLogin(command common.GameSpyCommand) { ErrorCode: ErrLogin.ErrorCode, ErrorString: "The payload version is invalid.", Fatal: true, + WWFCMessage: WWFCMsgPayloadInvalid, }) - return + return 0 } if !signatureExists { @@ -291,21 +308,112 @@ func (g *GameSpySession) exLogin(command common.GameSpyCommand) { ErrorCode: ErrLogin.ErrorCode, ErrorString: "Missing authentication signature.", Fatal: true, + WWFCMessage: WWFCMsgUnknownLoginError, }) - return + return 0 } - if deviceId = verifySignature(g.ModuleName, g.AuthToken, signature); deviceId == 0 { + if deviceId = verifySignature(g.ModuleName, authToken, signature); deviceId == 0 { g.replyError(GPError{ ErrorCode: ErrLogin.ErrorCode, ErrorString: "The authentication signature is invalid.", Fatal: true, + WWFCMessage: WWFCMsgUnknownLoginError, }) - return + return 0 } - g.DeviceAuthenticated = true - qr2.SetDeviceAuthenticated(g.User.ProfileId) + g.DeviceId = deviceId + + if !allowDefaultDolphinKeys { + // Check common device IDs + for _, defaultDeviceId := range commonDeviceIds { + if deviceId != defaultDeviceId { + continue + } + + if strings.HasPrefix(g.HostPlatform, "Dolphin") { + g.replyError(GPError{ + ErrorCode: ErrLogin.ErrorCode, + ErrorString: "Prohibited device ID used in signature.", + Fatal: true, + WWFCMessage: WWFCMsgDolphinSetupRequired, + }) + } else { + g.replyError(GPError{ + ErrorCode: ErrLogin.ErrorCode, + ErrorString: "Prohibited device ID used in signature.", + Fatal: true, + WWFCMessage: WWFCMsgUnknownLoginError, + }) + } + } + } + + return deviceId +} + +func (g *GameSpySession) performLoginWithDatabase(userId uint64, gsbrCode string, profileId uint32, deviceId uint32) bool { + // Get IP address without port + ipAddress := g.Conn.RemoteAddr().String() + if strings.Contains(ipAddress, ":") { + ipAddress = ipAddress[:strings.Index(ipAddress, ":")] + } + + user, err := database.LoginUserToGPCM(pool, ctx, userId, gsbrCode, profileId, deviceId, ipAddress, g.InGameName) + g.User = user + + if err != nil { + if err == database.ErrProfileIDInUse { + g.replyError(GPError{ + ErrorCode: ErrLogin.ErrorCode, + ErrorString: "The profile ID is already in use.", + Fatal: true, + WWFCMessage: WWFCMsgProfileIDInUse, + }) + } else if err == database.ErrReservedProfileIDRange { + g.replyError(GPError{ + ErrorCode: ErrLogin.ErrorCode, + ErrorString: "The profile ID is in a reserved range.", + Fatal: true, + WWFCMessage: WWFCMsgProfileIDInvalid, + }) + } else if err == database.ErrDeviceIDMismatch { + if strings.HasPrefix(g.HostPlatform, "Dolphin") { + g.replyError(GPError{ + ErrorCode: ErrLogin.ErrorCode, + ErrorString: "The device ID does not match the one on record.", + Fatal: true, + WWFCMessage: WWFCMsgConsoleMismatchDolphin, + }) + } else { + g.replyError(GPError{ + ErrorCode: ErrLogin.ErrorCode, + ErrorString: "The device ID does not match the one on record.", + Fatal: true, + WWFCMessage: WWFCMsgConsoleMismatch, + }) + } + } else if err == database.ErrProfileBannedTOS { + g.replyError(GPError{ + ErrorCode: ErrLogin.ErrorCode, + ErrorString: "The profile is banned from the service.", + Fatal: true, + WWFCMessage: WWFCMsgProfileBannedTOS, + }) + } else { + g.replyError(GPError{ + ErrorCode: ErrLogin.ErrorCode, + ErrorString: "There was an error logging in to the GP backend.", + Fatal: true, + WWFCMessage: WWFCMsgUnknownLoginError, + }) + } + + return false + } + + return true } func IsLoggedIn(profileID uint32) bool { diff --git a/gpcm/main.go b/gpcm/main.go index 9fa4812..aeea542 100644 --- a/gpcm/main.go +++ b/gpcm/main.go @@ -27,12 +27,21 @@ type GameSpySession struct { AuthToken string LoginTicket string SessionKey int32 - GameCode string - InGameName string - Status string - LocString string - FriendList []uint32 - AuthFriendList []uint32 + + LoginInfoSet bool + GameName string + GameCode string + Region byte + Language byte + InGameName string + ConsoleFriendCode uint64 + DeviceId uint32 + HostPlatform string + + Status string + LocString string + FriendList []uint32 + AuthFriendList []uint32 QR2IP uint64 Reservation common.MatchCommandData @@ -47,6 +56,8 @@ var ( // I would use a sync.Map instead of the map mutex combo, but this performs better. sessions = map[uint32]*GameSpySession{} mutex = deadlock.Mutex{} + + allowDefaultDolphinKeys bool ) func StartServer() { @@ -65,6 +76,10 @@ func StartServer() { panic(err) } + database.UpdateTables(pool, ctx) + + allowDefaultDolphinKeys = config.AllowDefaultDolphinKeys + address := *config.GameSpyAddress + ":29900" l, err := net.Listen("tcp", address) if err != nil { diff --git a/main.go b/main.go index edbe73a..a4eae9f 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "sync" + "wwfc/api" "wwfc/common" "wwfc/gpcm" "wwfc/gpsp" @@ -18,7 +19,7 @@ func main() { logging.SetLevel(*config.LogLevel) wg := &sync.WaitGroup{} - actions := []func(){nas.StartServer, gpcm.StartServer, qr2.StartServer, gpsp.StartServer, serverbrowser.StartServer, sake.StartServer, natneg.StartServer} + actions := []func(){nas.StartServer, gpcm.StartServer, qr2.StartServer, gpsp.StartServer, serverbrowser.StartServer, sake.StartServer, natneg.StartServer, api.StartServer} wg.Add(5) for _, action := range actions { go func(ac func()) { diff --git a/nas/main.go b/nas/main.go index c203c6d..68da418 100644 --- a/nas/main.go +++ b/nas/main.go @@ -59,6 +59,9 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { return } + logging.Info("NAS", aurora.Yellow(r.Method), aurora.Cyan(r.URL), "via", aurora.Cyan(r.Host), "from", aurora.BrightCyan(r.RemoteAddr)) + moduleName := "NAS:" + r.RemoteAddr + // Handle conntest server if strings.HasPrefix(r.Host, "conntest.") { handleConnectionTest(w) @@ -77,8 +80,23 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { return } - logging.Info("NAS", aurora.Yellow(r.Method), aurora.Cyan(r.URL), "via", aurora.Cyan(r.Host), "from", aurora.BrightCyan(r.RemoteAddr)) - moduleName := "NAS:" + r.RemoteAddr + // Check for /api/ban + if r.URL.Path == "/api/ban" { + api.HandleBan(w, r) + return + } + + // Check for /api/unban + if r.URL.Path == "/api/unban" { + api.HandleUnban(w, r) + return + } + + // Check for /api/kick + if r.URL.Path == "/api/kick" { + api.HandleKick(w, r) + return + } if r.URL.String() == "/ac" || r.URL.String() == "/pr" || r.URL.String() == "/download" { handleAuthRequest(moduleName, w, r) diff --git a/qr2/logins.go b/qr2/logins.go index cd8bf85..90fe2a9 100644 --- a/qr2/logins.go +++ b/qr2/logins.go @@ -8,12 +8,13 @@ type LoginInfo struct { GPPublicIP string NeedsExploit bool DeviceAuthenticated bool + Restricted bool Session *Session } var logins = map[uint32]*LoginInfo{} -func Login(profileID uint32, gameCode string, inGameName string, consoleFriendCode uint64, publicIP string, needsExploit bool, deviceAuthenticated bool) { +func Login(profileID uint32, gameCode string, inGameName string, consoleFriendCode uint64, publicIP string, needsExploit bool, deviceAuthenticated bool, restricted bool) { mutex.Lock() defer mutex.Unlock() @@ -25,6 +26,7 @@ func Login(profileID uint32, gameCode string, inGameName string, consoleFriendCo GPPublicIP: publicIP, NeedsExploit: needsExploit, DeviceAuthenticated: deviceAuthenticated, + Restricted: restricted, Session: nil, } } diff --git a/schema.sql b/schema.sql index f321ee3..5bbc04c 100644 --- a/schema.sql +++ b/schema.sql @@ -24,7 +24,7 @@ SET default_table_access_method = heap; -- Name: users; Type: TABLE; Schema: public; Owner: wiilink -- -CREATE TABLE public.users ( +CREATE TABLE IF NOT EXISTS public.users ( profile_id bigint NOT NULL, user_id bigint NOT NULL, gsbrcd character varying NOT NULL, @@ -38,13 +38,25 @@ CREATE TABLE public.users ( ); +ALTER TABLE ONLY public.users + ADD IF NOT EXISTS last_ip_address character varying DEFAULT ''::character varying, + ADD IF NOT EXISTS last_ingamesn character varying DEFAULT ''::character varying, + ADD IF NOT EXISTS has_ban boolean DEFAULT false, + ADD IF NOT EXISTS ban_issued timestamp without time zone, + ADD IF NOT EXISTS ban_expires timestamp without time zone, + ADD IF NOT EXISTS ban_reason character varying, + ADD IF NOT EXISTS ban_reason_hidden character varying, + ADD IF NOT EXISTS ban_moderator character varying, + ADD IF NOT EXISTS ban_tos boolean + + ALTER TABLE public.users OWNER TO wiilink; -- -- Name: users_profile_id_seq; Type: SEQUENCE; Schema: public; Owner: wiilink -- -CREATE SEQUENCE public.users_profile_id_seq +CREATE SEQUENCE IF NOT EXISTS public.users_profile_id_seq AS integer START WITH 1 INCREMENT BY 1