393 lines
10 KiB
Go
393 lines
10 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"ananta-ps/internal/config"
|
|
)
|
|
|
|
type Gateway struct {
|
|
cfg *config.Config
|
|
publicDir string
|
|
certDir string
|
|
httpSrv *http.Server
|
|
httpsSrv *http.Server
|
|
listSrv *http.Server
|
|
cancelFunc context.CancelFunc
|
|
}
|
|
|
|
func New(cfg *config.Config, publicDir, certDir string) *Gateway {
|
|
if publicDir == "" {
|
|
candidates := []string{
|
|
"public",
|
|
"../public",
|
|
}
|
|
for _, c := range candidates {
|
|
if _, err := os.Stat(c); err == nil {
|
|
publicDir = c
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if certDir == "" {
|
|
candidates := []string{
|
|
"certs",
|
|
"../certs",
|
|
}
|
|
for _, c := range candidates {
|
|
if _, err := os.Stat(c); err == nil {
|
|
certDir = c
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return &Gateway{
|
|
cfg: cfg,
|
|
publicDir: publicDir,
|
|
certDir: certDir,
|
|
}
|
|
}
|
|
|
|
func (g *Gateway) Start() error {
|
|
cert, err := tls.LoadX509KeyPair(filepath.Join(g.certDir, "cert.pem"), filepath.Join(g.certDir, "key.pem"))
|
|
if err != nil {
|
|
return fmt.Errorf("load gateway TLS certificate: %w", err)
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/", g.handleDispatch)
|
|
addr := func(port int) string { return net.JoinHostPort(g.cfg.Network.BindHost, fmt.Sprint(port)) }
|
|
g.listSrv = &http.Server{Addr: addr(g.cfg.Network.Proxy.LoginListPort), Handler: mux}
|
|
g.httpSrv = &http.Server{Addr: addr(g.cfg.Network.Proxy.HttpPort), Handler: mux}
|
|
g.httpsSrv = &http.Server{Addr: addr(g.cfg.Network.Proxy.HttpsPort), Handler: mux,
|
|
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}}
|
|
servers := []*http.Server{g.listSrv, g.httpSrv, g.httpsSrv}
|
|
listeners := make([]net.Listener, 0, len(servers))
|
|
for _, srv := range servers {
|
|
ln, err := net.Listen("tcp", srv.Addr)
|
|
if err != nil {
|
|
for _, opened := range listeners {
|
|
opened.Close()
|
|
}
|
|
return fmt.Errorf("listen gateway %s: %w", srv.Addr, err)
|
|
}
|
|
listeners = append(listeners, ln)
|
|
}
|
|
for i, srv := range servers {
|
|
ln := listeners[i]
|
|
if srv == g.httpsSrv {
|
|
ln = tls.NewListener(ln, srv.TLSConfig)
|
|
}
|
|
log.Printf("[GATEWAY] Listening on %s", ln.Addr())
|
|
go func() {
|
|
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
|
log.Printf("[GATEWAY] Serve error: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *Gateway) Stop() error {
|
|
if g.cancelFunc != nil {
|
|
g.cancelFunc()
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
if g.listSrv != nil {
|
|
_ = g.listSrv.Shutdown(ctx)
|
|
}
|
|
if g.httpSrv != nil {
|
|
_ = g.httpSrv.Shutdown(ctx)
|
|
}
|
|
if g.httpsSrv != nil {
|
|
_ = g.httpsSrv.Shutdown(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *Gateway) handleDispatch(w http.ResponseWriter, r *http.Request) {
|
|
urlPath := strings.ToLower(r.URL.Path)
|
|
host := strings.ToLower(r.Host)
|
|
|
|
// Clean up double slashes
|
|
for strings.Contains(urlPath, "//") {
|
|
urlPath = strings.ReplaceAll(urlPath, "//", "/")
|
|
}
|
|
|
|
// 1. LoginList (Port 5801 or via Host /LoginList)
|
|
if strings.HasSuffix(urlPath, "/loginlist") || urlPath == "/loginlist" {
|
|
g.serveLoginList(w, r)
|
|
return
|
|
}
|
|
|
|
// 2. ServerList (Contract MD5 7-column bypass)
|
|
if strings.HasSuffix(urlPath, "/serverlist.txt") || strings.HasSuffix(urlPath, "/serverlist_review.txt") {
|
|
g.serveServerList(w)
|
|
return
|
|
}
|
|
|
|
// 3. MPay / UniSDK WebView Auth Bypass
|
|
if urlPath == "/local-mpay-login" {
|
|
g.serveMpayLogin(w)
|
|
return
|
|
}
|
|
|
|
if urlPath == "/local-empty" {
|
|
g.serveJSON(w, map[string]interface{}{
|
|
"code": 0, "ret": 0, "result": 0, "errno": 0, "msg": "ok",
|
|
})
|
|
return
|
|
}
|
|
|
|
// 4. UniSDK Check Enter & Gray Release
|
|
if strings.Contains(urlPath, "/sdk/check_enter") {
|
|
g.serveJSON(w, map[string]interface{}{
|
|
"code": 200, "status": 0, "success": true, "message": "ok", "result": 0, "ret": 0, "subcode": 0,
|
|
})
|
|
return
|
|
}
|
|
|
|
if strings.Contains(urlPath, "/sdk/get_gray_release_info") {
|
|
g.serveJSON(w, map[string]interface{}{
|
|
"code": 200, "status": 0, "success": true, "message": "ok", "result": 0, "ret": 0, "subcode": 0,
|
|
})
|
|
return
|
|
}
|
|
|
|
if strings.Contains(urlPath, "/sdk/uni_sauth") {
|
|
g.serveJSON(w, map[string]interface{}{
|
|
"code": 200, "status": 0, "success": true, "message": "ok", "result": 0, "ret": 0, "subcode": 0,
|
|
"data": map[string]interface{}{
|
|
"uid": g.cfg.Player.AccountId,
|
|
"token": g.cfg.Player.LoginToken,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// 5. ConstConfig.cfg
|
|
if strings.HasSuffix(urlPath, "/constconfig.cfg") {
|
|
g.serveFile(w, filepath.Join(g.publicDir, "OuterTest1_ConstConfig.cfg"), "text/plain")
|
|
return
|
|
}
|
|
|
|
// 6. pc_boot.config (Disable shader warmup)
|
|
if strings.HasSuffix(urlPath, "/pc_boot.config") {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("enable-cs-warmup=0\nenable-ecs-shader-warmup=0\n"))
|
|
return
|
|
}
|
|
|
|
// 7. pc_netease_version.json (Disable triggerWarmupBelowVersion)
|
|
if strings.HasSuffix(urlPath, "/pc_netease_version.json") {
|
|
g.serveFile(w, filepath.Join(g.publicDir, "pc_netease_version.json"), "application/json")
|
|
return
|
|
}
|
|
|
|
// 8. device_check_pc.json
|
|
if strings.HasSuffix(urlPath, "/device_check_pc.json") {
|
|
g.serveJSON(w, map[string]interface{}{})
|
|
return
|
|
}
|
|
|
|
// 9. Startup Patch Info
|
|
if strings.HasSuffix(urlPath, "/trunk-client_startup_patch_info.txt") {
|
|
g.serveFile(w, filepath.Join(g.publicDir, "trunk-client_startup_patch_info.txt"), "text/plain")
|
|
return
|
|
}
|
|
|
|
// 10. FastPatch ZIP
|
|
if strings.HasPrefix(urlPath, "/fastpatch/") && strings.HasSuffix(urlPath, "/fastpatch.zip") {
|
|
g.serveFile(w, filepath.Join(g.publicDir, "fastpatch_4229938.zip"), "application/zip")
|
|
return
|
|
}
|
|
|
|
// 11. Resource Patch Info
|
|
if strings.HasSuffix(urlPath, "/patch.info") {
|
|
// Prefer latest Patch.info
|
|
patchFile := filepath.Join(g.publicDir, "Patch_20260805061659654p0.info")
|
|
if _, err := os.Stat(patchFile); err != nil {
|
|
patchFile = filepath.Join(g.publicDir, "Patch_20260707064825384p0.info")
|
|
}
|
|
g.serveFile(w, patchFile, "application/octet-stream")
|
|
return
|
|
}
|
|
|
|
// 12. vfs_res_version
|
|
if strings.Contains(urlPath, "vfs_res_version") {
|
|
g.serveFile(w, filepath.Join(g.publicDir, "vfs_res_version"), "application/octet-stream")
|
|
return
|
|
}
|
|
|
|
// 13. download_header.ehd
|
|
if strings.Contains(urlPath, "download_header.ehd") {
|
|
g.serveFile(w, filepath.Join(g.publicDir, "Blocks", "download_header.ehd"), "application/octet-stream")
|
|
return
|
|
}
|
|
|
|
// 14. stage0 patch
|
|
if strings.HasSuffix(urlPath, ".stage0.patch.bytes.standalone") {
|
|
g.serveFile(w, filepath.Join(g.publicDir, "stage0_patch.bytes.standalone"), "application/octet-stream")
|
|
return
|
|
}
|
|
|
|
// 15. Audit & Login.bin
|
|
if urlPath == "/audit.txt" {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("8000000\nlocal private server compatibility stub\nok\n\n"))
|
|
return
|
|
}
|
|
|
|
if urlPath == "/game_notice/login.bin" {
|
|
loginBin := filepath.Join(g.publicDir, "Login.bin")
|
|
if _, err := os.Stat(loginBin); err == nil {
|
|
g.serveFile(w, loginBin, "application/octet-stream")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Generic handler for all NetEase matrix / uniSDK subdomains
|
|
if strings.Contains(host, "netease.com") || strings.Contains(host, "163.com") {
|
|
g.serveJSON(w, map[string]interface{}{
|
|
"code": 200,
|
|
"subcode": 0,
|
|
"ret": 0,
|
|
"result": 0,
|
|
"errno": 0,
|
|
"status": 0,
|
|
"success": true,
|
|
"msg": "ok",
|
|
"message": "ok",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Default fallback
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("Ananta Private Server Gateway Running"))
|
|
}
|
|
|
|
func (g *Gateway) serveLoginList(w http.ResponseWriter, r *http.Request) {
|
|
login0 := g.cfg.Network.LoginPorts[0]
|
|
login1 := login0
|
|
if len(g.cfg.Network.LoginPorts) > 1 {
|
|
login1 = g.cfg.Network.LoginPorts[1]
|
|
}
|
|
|
|
host := g.cfg.Network.AdvertisedHost
|
|
body := fmt.Sprintf("%s:%d\n%s:%d\n", host, login0, host, login1)
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(body))
|
|
}
|
|
|
|
func (g *Gateway) serveServerList(w http.ResponseWriter) {
|
|
loginListUrl := fmt.Sprintf("http://%s:%d/LoginList", g.cfg.Network.AdvertisedHost, g.cfg.Network.Proxy.LoginListPort)
|
|
// Exactly 7 basic columns:
|
|
// *,801,QA101,Outer,http://127.0.0.1:5801/LoginList,1,14
|
|
body := fmt.Sprintf("*,%d,QA101,Outer,%s,1,14\n", g.cfg.Client.ServerID, loginListUrl)
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(body))
|
|
}
|
|
|
|
func (g *Gateway) serveMpayLogin(w http.ResponseWriter) {
|
|
html := fmt.Sprintf(`<!doctype html>
|
|
<html>
|
|
<head><meta charset="utf-8"><title>MPay Login Bypass</title></head>
|
|
<body>
|
|
<script>
|
|
const user = {
|
|
account: "%s",
|
|
user_id: "%s",
|
|
uid: "%s",
|
|
aid: "%d",
|
|
token: "%s",
|
|
access_token: "%s",
|
|
login_ticket: "%s",
|
|
device_id: "ananta-pc-client",
|
|
deviceid: "ananta-pc-client",
|
|
udid: "ananta-pc-client",
|
|
server_id: "%d",
|
|
code: 200,
|
|
subcode: 0,
|
|
msg: "ok"
|
|
};
|
|
|
|
const payload = {
|
|
methodId: "ngwebview_notify_native",
|
|
reqData: {
|
|
methodId: "onUserLogin",
|
|
device_id: user.device_id,
|
|
deviceid: user.deviceid,
|
|
udid: user.udid,
|
|
user: user
|
|
}
|
|
};
|
|
|
|
function sendLogin() {
|
|
try {
|
|
if (window.NeteaseMpayJSBridge && window.NeteaseMpayJSBridge.Common && window.NeteaseMpayJSBridge.Common.onUserLogin) {
|
|
window.NeteaseMpayJSBridge.Common.onUserLogin(user);
|
|
}
|
|
} catch (e) {}
|
|
try {
|
|
if (window.UniSDKJSBridge && window.UniSDKJSBridge.postMsgToNative) {
|
|
window.UniSDKJSBridge.postMsgToNative(payload);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
window.addEventListener("NeteaseMpayJSBridgeReady", sendLogin);
|
|
setTimeout(sendLogin, 100);
|
|
setTimeout(sendLogin, 300);
|
|
setTimeout(sendLogin, 1000);
|
|
</script>
|
|
<h3>Authenticating Ananta...</h3>
|
|
</body>
|
|
</html>`, g.cfg.Player.AccountId, g.cfg.Player.AccountId, g.cfg.Player.AccountId, g.cfg.Client.Aid, g.cfg.Player.LoginToken, g.cfg.Player.LoginToken, g.cfg.Player.LoginToken, g.cfg.Client.ServerID)
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(html))
|
|
}
|
|
|
|
func (g *Gateway) serveJSON(w http.ResponseWriter, v interface{}) {
|
|
data, _ := json.Marshal(v)
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(data)
|
|
}
|
|
|
|
func (g *Gateway) serveFile(w http.ResponseWriter, filePath, contentType string) {
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
if contentType != "" {
|
|
w.Header().Set("Content-Type", contentType)
|
|
}
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(data)
|
|
}
|