Files
GaCy-GO/internal/sdkserver/server.go
T

387 lines
11 KiB
Go

// Package sdkserver is the Go port of the Rust sdkserver: HTTP dispatch +
// auth + SRTools save upload, using the shared runtime configuration.
package sdkserver
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sync"
"gac-hkrpg-go/internal/common"
google "google.golang.org/protobuf/proto"
goproto "gac-hkrpg-go/protocol/proto"
)
// VersionConfig is one entry of versions.json.
type VersionConfig struct {
AssetBundleURL string `json:"asset_bundle_url"`
ExResourceURL string `json:"ex_resource_url"`
LuaURL string `json:"lua_url"`
IfixURL string `json:"ifix_url"`
}
type appState struct {
runtime common.RuntimeConfig
mu sync.Mutex
hotfixMap map[string]VersionConfig
}
func loadHotfix() map[string]VersionConfig {
out := map[string]VersionConfig{}
if raw, err := os.ReadFile("hotfix.json"); err == nil {
_ = json.Unmarshal(raw, &out)
}
if raw, err := os.ReadFile("versions.json"); err == nil {
_ = json.Unmarshal(raw, &out)
}
return out
}
// getOrInsertHotfix mirrors AppState::get_or_insert_hotfix — unknown versions
// are fetched from the official gateway via a proxy, then persisted.
func (a *appState) getOrInsertHotfix(version, dispatchSeed string) VersionConfig {
a.mu.Lock()
defer a.mu.Unlock()
if cfg, ok := a.hotfixMap[version]; ok {
return cfg
}
if cfg, ok := a.hotfixMap["CNBETAWin"+version]; ok {
return cfg
}
if cfg, ok := a.hotfixMap["OSBETAWin"+version]; ok {
return cfg
}
Log(fmt.Sprintf("trying to fetch hotfix for version %s with dispatch seed %s", version, dispatchSeed))
hotfix, err := fetchHotfix(version, dispatchSeed)
if err != nil {
Log("failed to fetch hotfix. reason: " + err.Error())
hotfix = VersionConfig{}
}
a.hotfixMap[version] = hotfix
if out, err := json.MarshalIndent(a.hotfixMap, "", " "); err == nil {
_ = os.WriteFile("versions.json", out, 0o644)
}
return a.hotfixMap[version]
}
// fetchHotfix queries the official query_gateway endpoint through a proxy.
func fetchHotfix(version, dispatchSeed string) (VersionConfig, error) {
const (
proxyHost = "proxy1.neonteam.dev"
osBeta = "beta-release01-asia.starrails.com"
osProd = "prod-official-asia-dp01.starrails.com"
cnBeta = "beta-release01-cn.bhsr.com"
cnProd = "prod-gf-cn-dp01.bhsr.com"
)
region, branch := parseVersionString(version)
host := osBeta
switch {
case region == "OS" && branch == "PROD":
host = osProd
case region == "CN" && branch == "BETA":
host = cnBeta
case region == "CN" && branch == "PROD":
host = cnProd
}
url := fmt.Sprintf("https://%s/%s/query_gateway?version=%s&platform_type=1&language_type=3&dispatch_seed=%s&channel_id=1&sub_channel_id=1&is_need_url=1",
proxyHost, host, version, dispatchSeed)
Log("fetching hotfix: " + url)
resp, err := http.Get(url)
if err != nil {
return VersionConfig{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return VersionConfig{}, err
}
Log("raw gateway response: " + string(body))
bin, err := base64.StdEncoding.DecodeString(string(body))
if err != nil {
return VersionConfig{}, err
}
decoded := &goproto.Gateserver{}
if err := google.Unmarshal(bin, decoded); err != nil {
return VersionConfig{}, err
}
if decoded.AssetBundleUrl == "" && decoded.ExResourceUrl == "" {
return VersionConfig{}, fmt.Errorf("asset_bundle_url and ex_resource_url are empty! gateway response: %+v", decoded)
}
return VersionConfig{
AssetBundleURL: decoded.AssetBundleUrl,
ExResourceURL: decoded.ExResourceUrl,
LuaURL: decoded.LuaUrl,
IfixURL: decoded.IfixUrl,
}, nil
}
// parseVersionString extracts region/branch, e.g. a version with the OSBETAWin prefix -> OS,BETA.
func parseVersionString(s string) (string, string) {
branches := []string{"PREbeta", "BETA", "PROD", "DEV", "PRE", "GM", "CECREATION"}
if len(s) < 2 {
return "OS", "BETA"
}
region := s[:2]
rest := s[2:]
for _, b := range branches {
if len(rest) >= len(b) && rest[:len(b)] == b {
return region, b
}
}
return region, "BETA"
}
// ---------------------------------------------------------------------------
// handlers
// ---------------------------------------------------------------------------
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func queryDispatch(w http.ResponseWriter, _ *http.Request, runtime common.RuntimeConfig) {
rsp := &goproto.Dispatch{
Retcode: 0,
RegionList: []*goproto.RegionInfo{{
Name: "GaC hkrpg GO",
Title: "GaC hkrpg GO",
EnvType: "9",
DispatchUrl: runtime.PublicSDKURL + "/query_gateway",
}},
}
bin, err := google.Marshal(rsp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(bin)))
}
func queryGateway(state *appState) http.HandlerFunc {
type params struct {
Version string `json:"-"`
DispatchSeed string `json:"-"`
}
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
version := q.Get("version")
seed := q.Get("dispatch_seed")
_ = params{}
cfg := state.getOrInsertHotfix(version, seed)
rsp := &goproto.Gateserver{
Ip: state.runtime.PublicGameHost,
Port: uint32(state.runtime.GamePort),
AssetBundleUrl: cfg.AssetBundleURL,
AssetBundleUrlB: cfg.AssetBundleURL,
ExResourceUrl: cfg.ExResourceURL,
LuaUrl: cfg.LuaURL,
IfixVersion: "0",
Unk1: true,
Unk2: true,
Unk3: true,
Unk4: true,
Unk5: true,
Unk6: true,
Unk7: true,
}
bin, err := google.Marshal(rsp)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(bin)))
}
}
type accountPayload struct {
Data struct {
Account map[string]any `json:"account"`
} `json:"data"`
Message string `json:"message"`
Retcode int `json:"retcode"`
}
func standardAccount() map[string]any {
return map[string]any{
"area_code": "**",
"email": "ReversedRooms",
"country": "RU",
"is_email_verify": "1",
"token": "mostsecuretokenever",
"uid": "1337",
}
}
func loginReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
"account": standardAccount(),
"device_grant_required": false,
"reactivate_required": false,
"realperson_required": false,
"safe_mobile_required": false,
},
"message": "OK",
"retcode": 0,
})
}
func granterLoginReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
"account_type": 1,
"combo_id": "1337",
"combo_token": "9065ad8507d5a1991cb6fddacac5999b780bbd92",
"data": `{"guest":false}`,
"heartbeat": false,
"open_id": "1337",
},
"message": "OK",
"retcode": 0,
})
}
func riskyCheckReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
"id": "06611ed14c3131a676b19c0d34c0644b",
"action": "ACTION_NONE",
"geetest": nil,
},
"message": "OK",
"retcode": 0,
})
}
func apnLoginReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
"token": map[string]any{"token": "mostsecuretokenever", "token_type": 1},
"user_info": map[string]any{
"aid": "1337",
"mid": "1337",
"is_email_verify": 1,
"area_code": "**",
"country": "US",
"is_adult": 1,
"email": "motorized@wheel.chair",
},
},
"message": "OK",
"retcode": 0,
})
}
func apnVerifyReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
"tokens": []any{map[string]any{"token": "mostsecuretokenever", "token_type": 1}},
"user_info": map[string]any{
"aid": "1337",
"mid": "1337",
"is_email_verify": 1,
"area_code": "**",
"country": "US",
"is_adult": 1,
"email": "motorized@wheel.chair",
},
},
"message": "OK",
"retcode": 0,
})
}
func notFound(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{"message": "OK", "retcode": 0})
}
// srToolsSave is the /srtools upload endpoint (SRTools writes freesr-data.json).
func srToolsSave(w http.ResponseWriter, r *http.Request) {
var payload struct {
Data json.RawMessage `json:"data"`
}
body, err := io.ReadAll(r.Body)
if err != nil || len(body) == 0 {
writeJSON(w, map[string]any{"message": "empty body", "status": 400})
return
}
toWrite := body
if err := json.Unmarshal(body, &payload); err == nil && len(payload.Data) > 0 {
toWrite = payload.Data
}
// pretty-print and store; freesr-data.json uses the same layout the
// gameserver parses, so pass through unchanged
var pretty map[string]any
if err := json.Unmarshal(toWrite, &pretty); err != nil {
writeJSON(w, map[string]any{"message": "malformed json: " + err.Error(), "status": 500})
return
}
out, _ := json.MarshalIndent(pretty, "", " ")
if err := os.WriteFile("freesr-data.json", out, 0o644); err != nil {
writeJSON(w, map[string]any{"message": "failed to write freesr-data.json: " + err.Error(), "status": 500})
return
}
// Reload and sync persistent
if d, err := common.LoadFreesrData(); err == nil {
d.SavePersistent()
}
writeJSON(w, map[string]any{"message": "OK", "status": 200})
}
// Listen starts the HTTP sdkserver.
func Listen() error {
runtime, err := common.LoadRuntimeConfig()
if err != nil {
return err
}
state := &appState{hotfixMap: loadHotfix(), runtime: runtime}
Log(fmt.Sprintf("loaded %d hotfix versions", len(state.hotfixMap)))
mux := http.NewServeMux()
mux.HandleFunc("/query_dispatch", func(w http.ResponseWriter, r *http.Request) { queryDispatch(w, r, runtime) })
mux.HandleFunc("/query_gateway", queryGateway(state))
mux.HandleFunc("/{product_name}/mdk/shield/api/login", func(w http.ResponseWriter, r *http.Request) { loginReply(w) })
mux.HandleFunc("/{product_name}/mdk/shield/api/verify", func(w http.ResponseWriter, r *http.Request) { loginReply(w) })
mux.HandleFunc("/{product_name}/combo/granter/login/v2/login", func(w http.ResponseWriter, r *http.Request) { granterLoginReply(w) })
mux.HandleFunc("/account/risky/api/check", func(w http.ResponseWriter, r *http.Request) { riskyCheckReply(w) })
mux.HandleFunc("/account/ma-cn-passport/app/loginByPassword", func(w http.ResponseWriter, r *http.Request) { apnLoginReply(w) })
mux.HandleFunc("/account/ma-cn-session/app/verify", func(w http.ResponseWriter, r *http.Request) { apnVerifyReply(w) })
mux.HandleFunc("/srtools", corsWrapper(srToolsSave))
registerAPIRoutes(mux)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/dashboard" {
apiRoot(w, r)
return
}
notFound(w, r)
})
addr := runtime.SDKAddress()
Log("sdkserver is listening at " + addr)
Log(fmt.Sprintf("REST API Gateway is live at %s/ (for GaC-Srtool)", runtime.PublicSDKURL))
return http.ListenAndServe(addr, mux)
}
func Log(msg string) { fmt.Println("[sdkserver]", msg) }