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